cargo_crap/report/
links.rs1use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
10use std::path::{Path, PathBuf};
11
12const PATH_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
14 .remove(b'-')
15 .remove(b'.')
16 .remove(b'_')
17 .remove(b'~')
18 .remove(b'/');
19
20#[derive(Clone, Debug)]
27pub struct SourceLinks {
28 repo_url: String,
29 commit_ref: String,
30}
31
32impl SourceLinks {
33 #[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 #[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
71fn 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
93pub(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 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}