Skip to main content

todo_tree/printer/
utils.rs

1//! Path formatting, terminal hyperlink, and tag-coloring helpers.
2
3use super::options::PrintOptions;
4use crate::core::TodoPriority;
5use crate::display::priority_to_color;
6use colored::Colorize;
7use std::path::Path;
8
9/// Formats `path` per `options`: absolute if `options.full_paths`,
10/// relative to `options.base_path` if set, else as-is.
11pub fn format_path(path: &Path, options: &PrintOptions) -> String {
12    if options.full_paths {
13        path.display().to_string()
14    } else if let Some(base) = &options.base_path {
15        path.strip_prefix(base)
16            .map(|p| p.display().to_string())
17            .unwrap_or_else(|_| path.display().to_string())
18    } else {
19        path.display().to_string()
20    }
21}
22
23/// Builds an OSC 8 terminal hyperlink to `path` at `line`, showing the
24/// formatted path as link text. Returns `None` if `options.clickable_links`
25/// is off or the terminal doesn't advertise hyperlink support.
26pub fn make_clickable_link(path: &Path, line: usize, options: &PrintOptions) -> Option<String> {
27    if !options.clickable_links || !hyperlinks_supported() {
28        return None;
29    }
30
31    let display_path = format_path(path, options);
32    let abs_path = path.canonicalize().ok()?;
33    let file_url = format!("file://{}:{}", abs_path.display(), line);
34
35    let link = format!(
36        "\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\",
37        file_url,
38        if options.colored {
39            display_path.bold().to_string()
40        } else {
41            display_path
42        }
43    );
44
45    Some(link)
46}
47
48/// Builds an OSC 8 terminal hyperlink to `path` at `line`, showing
49/// `"L{line}"` as link text. Returns `None` under the same conditions as
50/// [`make_clickable_link`].
51pub fn make_line_link(path: &Path, line: usize, options: &PrintOptions) -> Option<String> {
52    if !options.clickable_links || !hyperlinks_supported() {
53        return None;
54    }
55
56    let abs_path = path.canonicalize().ok()?;
57    let file_url = format!("file://{}:{}", abs_path.display(), line);
58    let display = format!("L{}", line);
59
60    let link = format!(
61        "\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\",
62        file_url,
63        if options.colored {
64            display.cyan().to_string()
65        } else {
66            display
67        }
68    );
69
70    Some(link)
71}
72
73/// Colors `tag` by its derived [`TodoPriority`], or returns it unchanged if
74/// `options.colored` is off.
75pub fn colorize_tag(tag: &str, options: &PrintOptions) -> String {
76    if !options.colored {
77        return tag.to_string();
78    }
79
80    let color = priority_to_color(TodoPriority::from_tag(tag));
81    tag.color(color).bold().to_string()
82}
83
84/// Whether `stdout` is a hyperlink-capable terminal: a TTY (or
85/// `FORCE_HYPERLINK` set) whose type is known to render OSC 8 links.
86fn hyperlinks_supported() -> bool {
87    supports_hyperlinks::on(supports_hyperlinks::Stream::Stdout)
88}
89
90#[cfg(test)]
91static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
92
93/// Forces [`hyperlinks_supported`]'s result via `FORCE_HYPERLINK`
94/// (bypassing the TTY check, which is always false under `cargo test`'s
95/// captured stdout), restoring the prior value on drop.
96#[cfg(test)]
97struct ForceHyperlinkGuard(Option<std::ffi::OsString>);
98
99#[cfg(test)]
100impl ForceHyperlinkGuard {
101    fn set(value: &str) -> Self {
102        let saved = std::env::var_os("FORCE_HYPERLINK");
103        unsafe {
104            std::env::set_var("FORCE_HYPERLINK", value);
105        }
106        Self(saved)
107    }
108}
109
110#[cfg(test)]
111impl Drop for ForceHyperlinkGuard {
112    fn drop(&mut self) {
113        unsafe {
114            match &self.0 {
115                Some(v) => std::env::set_var("FORCE_HYPERLINK", v),
116                None => std::env::remove_var("FORCE_HYPERLINK"),
117            }
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use std::path::PathBuf;
126
127    fn options() -> PrintOptions {
128        PrintOptions::default()
129    }
130
131    #[test]
132    fn format_path_uses_full_path_when_requested() {
133        let opts = PrintOptions {
134            full_paths: true,
135            ..options()
136        };
137        let path = PathBuf::from("src/main.rs");
138        assert_eq!(format_path(&path, &opts), path.display().to_string());
139    }
140
141    #[test]
142    fn format_path_strips_base_path_when_set() {
143        let opts = PrintOptions {
144            base_path: Some(PathBuf::from("/repo")),
145            ..options()
146        };
147        let path = PathBuf::from("/repo/src/main.rs");
148        assert_eq!(format_path(&path, &opts), "src/main.rs");
149    }
150
151    #[test]
152    fn format_path_falls_back_when_strip_prefix_fails() {
153        let opts = PrintOptions {
154            base_path: Some(PathBuf::from("/other")),
155            ..options()
156        };
157        let path = PathBuf::from("/repo/src/main.rs");
158        assert_eq!(format_path(&path, &opts), path.display().to_string());
159    }
160
161    #[test]
162    fn format_path_uses_display_when_no_base_path() {
163        let path = PathBuf::from("src/main.rs");
164        assert_eq!(format_path(&path, &options()), path.display().to_string());
165    }
166
167    #[test]
168    fn colorize_tag_returns_plain_text_when_uncolored() {
169        let opts = PrintOptions {
170            colored: false,
171            ..options()
172        };
173        assert_eq!(colorize_tag("TODO", &opts), "TODO");
174    }
175
176    #[test]
177    fn colorize_tag_includes_tag_text_when_colored() {
178        let opts = PrintOptions {
179            colored: true,
180            ..options()
181        };
182        assert!(colorize_tag("TODO", &opts).contains("TODO"));
183    }
184
185    #[test]
186    fn make_clickable_link_none_when_disabled() {
187        let opts = PrintOptions {
188            clickable_links: false,
189            ..options()
190        };
191        assert!(make_clickable_link(Path::new("src/main.rs"), 1, &opts).is_none());
192    }
193
194    #[test]
195    fn make_line_link_none_when_disabled() {
196        let opts = PrintOptions {
197            clickable_links: false,
198            ..options()
199        };
200        assert!(make_line_link(Path::new("src/main.rs"), 1, &opts).is_none());
201    }
202
203    #[test]
204    fn hyperlinks_supported_false_when_forced_off() {
205        let _lock = ENV_LOCK.lock().unwrap();
206        let _guard = ForceHyperlinkGuard::set("0");
207
208        assert!(!hyperlinks_supported());
209    }
210
211    #[test]
212    fn hyperlinks_supported_true_when_forced_on() {
213        let _lock = ENV_LOCK.lock().unwrap();
214        let _guard = ForceHyperlinkGuard::set("1");
215
216        assert!(hyperlinks_supported());
217    }
218
219    #[test]
220    fn make_clickable_link_none_when_terminal_unsupported() {
221        let _lock = ENV_LOCK.lock().unwrap();
222        let _guard = ForceHyperlinkGuard::set("0");
223
224        let opts = options();
225        assert!(make_clickable_link(Path::new("src/main.rs"), 1, &opts).is_none());
226    }
227
228    #[test]
229    fn make_clickable_link_none_when_path_does_not_exist() {
230        let _lock = ENV_LOCK.lock().unwrap();
231        let _guard = ForceHyperlinkGuard::set("1");
232
233        let opts = options();
234        let missing = Path::new("/definitely/not/a/real/path/hopefully.rs");
235        assert!(make_clickable_link(missing, 1, &opts).is_none());
236    }
237
238    #[test]
239    fn make_clickable_link_some_for_existing_path_on_supported_terminal() {
240        let _lock = ENV_LOCK.lock().unwrap();
241        let _guard = ForceHyperlinkGuard::set("1");
242
243        let opts = PrintOptions {
244            colored: true,
245            ..options()
246        };
247        let link = make_clickable_link(Path::new("Cargo.toml"), 1, &opts);
248        assert!(link.is_some());
249        assert!(link.unwrap().contains("\x1b]8;;file://"));
250    }
251
252    #[test]
253    fn make_clickable_link_uncolored_variant() {
254        let _lock = ENV_LOCK.lock().unwrap();
255        let _guard = ForceHyperlinkGuard::set("1");
256
257        let opts = PrintOptions {
258            colored: false,
259            ..options()
260        };
261        let link = make_clickable_link(Path::new("Cargo.toml"), 1, &opts);
262        assert!(link.is_some());
263    }
264
265    #[test]
266    fn make_line_link_some_for_existing_path_on_supported_terminal() {
267        let _lock = ENV_LOCK.lock().unwrap();
268        let _guard = ForceHyperlinkGuard::set("1");
269
270        let opts = PrintOptions {
271            colored: true,
272            ..options()
273        };
274        let link = make_line_link(Path::new("Cargo.toml"), 42, &opts);
275        assert!(link.is_some());
276        assert!(link.unwrap().contains("L42"));
277    }
278
279    #[test]
280    fn make_line_link_uncolored_variant() {
281        let _lock = ENV_LOCK.lock().unwrap();
282        let _guard = ForceHyperlinkGuard::set("1");
283
284        let opts = PrintOptions {
285            colored: false,
286            ..options()
287        };
288        let link = make_line_link(Path::new("Cargo.toml"), 42, &opts);
289        assert!(link.is_some());
290    }
291
292    #[test]
293    fn make_line_link_none_when_path_does_not_exist() {
294        let _lock = ENV_LOCK.lock().unwrap();
295        let _guard = ForceHyperlinkGuard::set("1");
296
297        let opts = options();
298        let missing = Path::new("/definitely/not/a/real/path/hopefully.rs");
299        assert!(make_line_link(missing, 1, &opts).is_none());
300    }
301}