cargo_crap/report/links.rs
1//! Optional GitHub source-link wrapping for markdown / pr-comment output.
2//!
3//! When the user (or CI) provides a repo URL and a commit ref, Function and
4//! Location cells are wrapped in `[`text`](url#Lline)` markdown links.
5//! Otherwise the cells render as plain code spans — the renderers themselves
6//! never have to branch on link presence beyond passing `Option<&SourceLinks>`
7//! through to [`linkify`].
8
9use std::path::{Path, PathBuf};
10
11/// Repo URL + commit ref used by `markdown` / `pr-comment` renderers to wrap
12/// Function and Location cells in clickable source links.
13///
14/// Constructed once at the CLI layer (or by library callers) and threaded
15/// through as `Option<&SourceLinks>` — `None` means "no flags set, render
16/// plain code spans like before".
17#[derive(Clone, Debug)]
18pub struct SourceLinks {
19 repo_url: String,
20 commit_ref: String,
21}
22
23impl SourceLinks {
24 /// Trims a trailing `/` off `repo_url` so URL composition produces exactly
25 /// one slash between the base and `/blob/...`.
26 #[expect(
27 clippy::needless_pass_by_value,
28 reason = "callers always have fresh Strings (CLI flags / env vars); commit_ref is moved into self; taking `&str` would force `.to_string()` at every call site"
29 )]
30 #[must_use]
31 pub fn new(
32 repo_url: String,
33 commit_ref: String,
34 ) -> Self {
35 Self {
36 repo_url: repo_url.trim_end_matches('/').to_string(),
37 commit_ref,
38 }
39 }
40
41 /// Build a deep link to `file` at `line` on the configured ref.
42 ///
43 /// GitHub URLs require forward slashes regardless of host OS. On
44 /// Windows `Path::display()` emits `src\foo.rs`, which would land in
45 /// the URL verbatim and 404 on github.com — so we normalize backslashes
46 /// to forward slashes before composing the URL.
47 #[must_use]
48 pub fn url_for(
49 &self,
50 file: &Path,
51 line: usize,
52 ) -> String {
53 let path = file.to_string_lossy().replace('\\', "/");
54 format!(
55 "{}/blob/{}/{}#L{}",
56 self.repo_url, self.commit_ref, path, line
57 )
58 }
59}
60
61/// Decide what path to embed into a `/blob/<ref>/...` URL for `path`.
62///
63/// The URL prefix is **always the repo root (== CWD)**, never the LCP used
64/// for the visible Location text. If we shared the LCP, a rendered set that
65/// happens to live entirely under `src/` would strip `src/` from the URL
66/// too, yielding `host/repo/blob/<sha>/main.rs` which 404s.
67///
68/// Returns the repo-relative form when `path` is already relative (cargo
69/// crap reports relative paths when not invoked with `--workspace`) or
70/// absolute under CWD. Returns `None` otherwise — those rows fall back to
71/// plain code spans rather than emit a broken
72/// `host/repo/blob/<sha>//abs/...` URL.
73fn link_path(path: &Path) -> Option<PathBuf> {
74 if path.is_relative() {
75 return Some(path.to_path_buf());
76 }
77 let cwd = std::env::current_dir().ok()?;
78 path.strip_prefix(&cwd)
79 .ok()
80 .map(std::path::Path::to_path_buf)
81}
82
83/// Wrap `inner` (already-formatted, e.g. with backticks) in a markdown link
84/// iff both `links` and a usable (repo-relative) URL path are available;
85/// otherwise return `inner` unchanged.
86pub(crate) fn linkify(
87 inner: String,
88 links: Option<&SourceLinks>,
89 file: &Path,
90 line: usize,
91) -> String {
92 match (links, link_path(file)) {
93 (Some(l), Some(p)) => format!("[{inner}]({})", l.url_for(&p, line)),
94 _ => inner,
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 #[test]
103 fn source_links_url_for_joins_components_with_one_slash() {
104 let l = SourceLinks::new("https://github.com/owner/repo".into(), "abc123".into());
105 let url = l.url_for(Path::new("src/foo.rs"), 42);
106 assert_eq!(
107 url,
108 "https://github.com/owner/repo/blob/abc123/src/foo.rs#L42"
109 );
110 }
111
112 #[test]
113 fn source_links_strips_trailing_slash_from_repo_url() {
114 let l = SourceLinks::new("https://github.com/owner/repo/".into(), "abc123".into());
115 let url = l.url_for(Path::new("src/foo.rs"), 1);
116 assert!(
117 !url.contains("repo//blob"),
118 "trailing slash must be normalized: {url}"
119 );
120 assert!(url.contains("/repo/blob/abc123/"));
121 }
122
123 #[test]
124 fn source_links_url_uses_forward_slashes_even_for_windows_input() {
125 // GitHub URLs always use `/`. A Windows-style backslash path must
126 // be normalized before it lands in the URL, otherwise links break
127 // on github.com regardless of which OS rendered them.
128 let l = SourceLinks::new("https://github.com/o/r".into(), "sha".into());
129 let url = l.url_for(Path::new(r"src\foo.rs"), 1);
130 assert!(
131 !url.contains('\\'),
132 "URL must contain no backslashes, got: {url}"
133 );
134 assert_eq!(url, "https://github.com/o/r/blob/sha/src/foo.rs#L1");
135 }
136}