diff --git a/app/components/crate-sidebar.gjs b/app/components/crate-sidebar.gjs
index 5de1723a116..2f691091bc0 100644
--- a/app/components/crate-sidebar.gjs
+++ b/app/components/crate-sidebar.gjs
@@ -162,7 +162,7 @@ export default class CrateSidebar extends Component {
{{/unless}}
- {{#if (or this.showHomepage @version.documentationLink @crate.repository)}}
+ {{#if (or this.showHomepage @version.documentationLink @version.sourceLink @crate.repository)}}
{{#if this.showHomepage}}
@@ -172,6 +172,10 @@ export default class CrateSidebar extends Component {
{{/if}}
+ {{#if @version.sourceLink}}
+
+ {{/if}}
+
{{#if @crate.repository}}
{{/if}}
diff --git a/app/models/version.js b/app/models/version.js
index 29ca11b2177..9da57cc430a 100644
--- a/app/models/version.js
+++ b/app/models/version.js
@@ -228,6 +228,22 @@ export default class Version extends Model {
return null;
}
+ get docsRsSourceLink() {
+ if (this.hasDocsRsLink) {
+ return `https://docs.rs/crate/${this.crateName}/${this.num}/source/`;
+ }
+ }
+
+ get sourceLink() {
+ // if we know about a successful docs.rs build, we'll return a link to that
+ let { docsRsSourceLink } = this;
+ if (docsRsSourceLink) {
+ return docsRsSourceLink;
+ }
+
+ return null;
+ }
+
yankTask = keepLatestTask(async () => {
let data = { version: { yanked: true } };
let payload = await waitForPromise(apiAction(this, { method: 'PATCH', data }));
diff --git a/e2e/routes/crate/version/source-link.spec.ts b/e2e/routes/crate/version/source-link.spec.ts
new file mode 100644
index 00000000000..5f083b5eb45
--- /dev/null
+++ b/e2e/routes/crate/version/source-link.spec.ts
@@ -0,0 +1,98 @@
+import { expect, test } from '@/e2e/helper';
+import { http, HttpResponse } from 'msw';
+
+test.describe('Route | crate.version | source link', { tag: '@routes' }, () => {
+ test('show docs.rs source link even if non-docs.rs documentation link is specified', async ({ page, msw }) => {
+ let crate = await msw.db.crate.create({ name: 'foo', documentation: 'https://foo.io/docs' });
+ await msw.db.version.create({ crate, num: '1.0.0' });
+
+ await page.goto('/crates/foo');
+ await expect(page.locator('[data-test-source-link] a')).toHaveAttribute('href', 'https://docs.rs/crate/foo/1.0.0/source/');
+ });
+
+ test('show no source link if there are no related docs.rs builds', async ({
+ page,
+ msw,
+ }) => {
+ let crate = await msw.db.crate.create({ name: 'foo' });
+ await msw.db.version.create({ crate, num: '1.0.0' });
+
+ let error = HttpResponse.text('not found', { status: 404 });
+ msw.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => error));
+
+ await page.goto('/crates/foo');
+ await expect(page.getByRole('link', { name: 'crates.io', exact: true })).toHaveCount(1);
+
+ await expect(page.locator('[data-test-source-link] a')).toHaveCount(0);
+ });
+
+ test('show source link if `documentation` is unspecified and there are related docs.rs builds', async ({
+ page,
+ msw,
+ }) => {
+ let crate = await msw.db.crate.create({ name: 'foo' });
+ await msw.db.version.create({ crate, num: '1.0.0' });
+
+ let response = HttpResponse.json({
+ doc_status: true,
+ version: '1.0.0',
+ });
+ msw.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => response));
+
+ await page.goto('/crates/foo');
+ await expect(page.locator('[data-test-source-link] a')).toHaveAttribute('href', 'https://docs.rs/crate/foo/1.0.0/source/');
+ });
+
+ test('show no source link if `documentation` points to docs.rs and there are no related docs.rs builds', async ({
+ page,
+ msw,
+ }) => {
+ let crate = await msw.db.crate.create({ name: 'foo', documentation: 'https://docs.rs/foo/0.6.2' });
+ await msw.db.version.create({ crate, num: '1.0.0' });
+
+ let error = HttpResponse.text('not found', { status: 404 });
+ msw.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => error));
+
+ await page.goto('/crates/foo');
+ await expect(page.locator('[data-test-source-link] a')).toHaveCount(0);
+ });
+
+ test('show source link if `documentation` points to docs.rs and there are related docs.rs builds', async ({
+ page,
+ msw,
+ }) => {
+ let crate = await msw.db.crate.create({ name: 'foo', documentation: 'https://docs.rs/foo/0.6.2' });
+ await msw.db.version.create({ crate, num: '1.0.0' });
+
+ let response = HttpResponse.json({
+ doc_status: true,
+ version: '1.0.0',
+ });
+ msw.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => response));
+
+ await page.goto('/crates/foo');
+ await expect(page.locator('[data-test-source-link] a')).toHaveAttribute('href', 'https://docs.rs/crate/foo/1.0.0/source');
+ });
+
+ test('ajax errors are ignored, but show no source link', async ({ page, msw }) => {
+ let crate = await msw.db.crate.create({ name: 'foo', documentation: 'https://docs.rs/foo/0.6.2' });
+ await msw.db.version.create({ crate, num: '1.0.0' });
+
+ let error = HttpResponse.text('error', { status: 500 });
+ msw.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => error));
+
+ await page.goto('/crates/foo');
+ await expect(page.locator('[data-test-source-link] a')).toHaveCount(0);
+ });
+
+ test('empty docs.rs responses are ignored, still show source link', async ({ page, msw }) => {
+ let crate = await msw.db.crate.create({ name: 'foo', documentation: 'https://docs.rs/foo/0.6.2' });
+ await msw.db.version.create({ crate, num: '0.6.2' });
+
+ let response = HttpResponse.json({});
+ msw.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => response));
+
+ await page.goto('/crates/foo');
+ await expect(page.locator('[data-test-source-link] a')).toHaveAttribute('href', 'https://docs.rs/crate/foo/0.6.2/source/');
+ });
+});
diff --git a/tests/routes/crate/version/source-link-test.js b/tests/routes/crate/version/source-link-test.js
new file mode 100644
index 00000000000..0ae5715d4a3
--- /dev/null
+++ b/tests/routes/crate/version/source-link-test.js
@@ -0,0 +1,84 @@
+import { visit } from '@ember/test-helpers';
+import { module, test } from 'qunit';
+
+import { http, HttpResponse } from 'msw';
+
+import { setupApplicationTest } from 'crates-io/tests/helpers';
+
+module('Route | crate.version | source link', function (hooks) {
+ setupApplicationTest(hooks);
+
+ test('shows docs.rs source link even if non-docs.rs documentation link is specified', async function (assert) {
+ let crate = await this.db.crate.create({ name: 'foo', documentation: 'https://foo.io/docs' });
+ await this.db.version.create({ crate, num: '1.0.0' });
+
+ await visit('/crates/foo');
+ assert.dom('[data-test-source-link] a').hasAttribute('href', 'https://docs.rs/crate/foo/1.0.0/source/');
+ });
+
+ test('show no source link if there are no related docs.rs builds', async function (assert) {
+ let crate = await this.db.crate.create({ name: 'foo' });
+ await this.db.version.create({ crate, num: '1.0.0' });
+
+ let error = HttpResponse.text('not found', { status: 404 });
+ this.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => error));
+
+ await visit('/crates/foo');
+ assert.dom('[data-test-source-link] a').doesNotExist();
+ });
+
+ test('show source link if `documentation` is unspecified and there are related docs.rs builds', async function (assert) {
+ let crate = await this.db.crate.create({ name: 'foo' });
+ await this.db.version.create({ crate, num: '1.0.0' });
+
+ let response = HttpResponse.json({ doc_status: true, version: '1.0.0' });
+ this.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => response));
+
+ await visit('/crates/foo');
+ assert.dom('[data-test-source-link] a').hasAttribute('href', 'https://docs.rs/crate/foo/1.0.0/source/');
+ });
+
+ test('show no source link if `documentation` points to docs.rs and there are no related docs.rs builds', async function (assert) {
+ let crate = await this.db.crate.create({ name: 'foo', documentation: 'https://docs.rs/foo/0.6.2' });
+ await this.db.version.create({ crate, num: '1.0.0' });
+
+ let error = HttpResponse.text('not found', { status: 404 });
+ this.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => error));
+
+ await visit('/crates/foo');
+ assert.dom('[data-test-source-link] a').doesNotExist();
+ });
+
+ test('show source link if `documentation` points to docs.rs and there are related docs.rs builds', async function (assert) {
+ let crate = await this.db.crate.create({ name: 'foo', documentation: 'https://docs.rs/foo/0.6.2' });
+ await this.db.version.create({ crate, num: '1.0.0' });
+
+ let response = HttpResponse.json({ doc_status: true, version: '1.0.0' });
+ this.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => response));
+
+ await visit('/crates/foo');
+ assert.dom('[data-test-source-link] a').hasAttribute('href', 'https://docs.rs/crate/foo/1.0.0/source/');
+ });
+
+ test('ajax errors are ignored, but show no source link', async function (assert) {
+ let crate = await this.db.crate.create({ name: 'foo', documentation: 'https://docs.rs/foo/0.6.2' });
+ await this.db.version.create({ crate, num: '1.0.0' });
+
+ let error = HttpResponse.text('error', { status: 500 });
+ this.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => error));
+
+ await visit('/crates/foo');
+ assert.dom('[data-test-source-link] a').doesNotExist();
+ });
+
+ test('empty docs.rs responses are ignored, still show source link', async function (assert) {
+ let crate = await this.db.crate.create({ name: 'foo', documentation: 'https://docs.rs/foo/0.6.2' });
+ await this.db.version.create({ crate, num: '0.6.2' });
+
+ let response = HttpResponse.json({});
+ this.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => response));
+
+ await visit('/crates/foo');
+ assert.dom('[data-test-source-link] a').hasAttribute('href', 'https://docs.rs/crate/foo/0.6.2/source/');
+ });
+});