Skip to main content

eska_presentation/
path_output.rs

1//! Safe local filesystem links for interactive human output.
2
3use std::{fmt::Write as _, path::Path};
4
5/// Link a path to itself only when its output stream is a terminal.
6#[must_use]
7pub fn render(path: &Path, hyperlink: bool) -> String {
8    render_link(path, path, hyperlink)
9}
10
11/// Keep the visible label separate from the target, including build directory links.
12#[must_use]
13pub fn render_link(label: &Path, target: &Path, hyperlink: bool) -> String {
14    let label = display_path(label);
15    let Some(target) = hyperlink.then(|| file_uri(target)).flatten() else {
16        return label;
17    };
18    format!("\x1b]8;;{target}\x1b\\{label}\x1b]8;;\x1b\\")
19}
20
21/// Escape control characters so filesystem names cannot inject terminal commands.
22#[must_use]
23pub fn display_path(path: &Path) -> String {
24    let mut display = String::new();
25    for character in path.to_string_lossy().chars() {
26        if character.is_control() {
27            display.extend(character.escape_default());
28        } else {
29            display.push(character);
30        }
31    }
32    display
33}
34
35/// Resolve relative or missing targets without requiring the linked file to exist.
36fn file_uri(path: &Path) -> Option<String> {
37    let path = std::path::absolute(path).ok()?;
38    #[cfg(unix)]
39    let normalized = {
40        use std::os::unix::ffi::OsStrExt;
41        path.as_os_str().as_bytes().to_vec()
42    };
43    #[cfg(not(unix))]
44    let normalized = {
45        let path = path.to_str()?.replace('\\', "/");
46        // Win32 verbatim prefixes are filesystem syntax, not part of a file URI.
47        let path = path.strip_prefix("//?/UNC/").map_or_else(
48            || path.strip_prefix("//?/").unwrap_or(&path).to_owned(),
49            |unc| format!("//{unc}"),
50        );
51        path.into_bytes()
52    };
53    let prefix = if normalized.starts_with(b"//") {
54        "file:"
55    } else if normalized.starts_with(b"/") {
56        "file://"
57    } else {
58        "file:///"
59    };
60    let mut uri = prefix.to_owned();
61    for byte in normalized {
62        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/' | b':') {
63            uri.push(char::from(byte));
64        } else {
65            write!(uri, "%{byte:02X}").expect("writing to String cannot fail");
66        }
67    }
68    Some(uri)
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    /// Encode URI delimiters and Unicode while keeping the readable path label intact.
76    #[test]
77    fn links_paths_without_confusing_uri_delimiters() {
78        let path = std::env::current_dir().unwrap().join("Проект #1%?.cf");
79        let linked = render(&path, true);
80        assert!(linked.starts_with("\x1b]8;;file:"));
81        assert!(linked.contains("%D0%9F%D1%80%D0%BE%D0%B5%D0%BA%D1%82%20%231%25%3F.cf"));
82        assert!(linked.ends_with(&format!("\x1b\\{}\x1b]8;;\x1b\\", path.display())));
83        assert_eq!(render(&path, false), path.to_string_lossy());
84    }
85
86    /// A relative error path still points to an absolute target even before creation.
87    #[test]
88    fn resolves_relative_missing_targets() {
89        let path = Path::new("missing project/src");
90        assert_eq!(
91            file_uri(path),
92            file_uri(&std::env::current_dir().unwrap().join(path))
93        );
94        assert!(render(path, true).ends_with("\x1b\\missing project/src\x1b]8;;\x1b\\"));
95    }
96
97    /// Preserve Unix path bytes and backslashes instead of linking a lossy replacement.
98    #[cfg(unix)]
99    #[test]
100    fn encodes_non_unicode_and_control_bytes() {
101        use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
102        let path = Path::new(OsStr::from_bytes(b"/tmp/name\\\xff\x1b\n.cf"));
103        let linked = render(path, true);
104        assert!(linked.starts_with("\x1b]8;;file:///tmp/name%5C%FF%1B%0A.cf\x1b\\"));
105        let label = display_path(path);
106        assert!(!label.contains('\x1b'));
107        assert!(!label.contains('\n'));
108        assert!(label.contains("\\u{1b}\\n"));
109    }
110}