Skip to main content

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