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 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}