Skip to main content

drft/
util.rs

1//! Path, URI, and hashing utilities shared by the sources, builders, and rules.
2
3use std::path::{Component, Path};
4
5/// Hash content with BLAKE3, returning `b3:<hex>`.
6pub fn hash_bytes(content: &[u8]) -> String {
7    format!("b3:{}", blake3::hash(content).to_hex())
8}
9
10/// Check whether a target string is a URI.
11///
12/// Uses the `url` crate (WHATWG URL Standard) for parsing, then filters to URIs
13/// that either have authority (`://`) or use a known opaque scheme. Without this
14/// filter, any `word:stuff` parses as a URL — e.g. a YAML value like `name: foo`
15/// would be treated as a URI with scheme `name`.
16pub fn is_uri(target: &str) -> bool {
17    match url::Url::parse(target) {
18        Ok(url) => {
19            if url.has_authority() {
20                return true;
21            }
22            matches!(
23                url.scheme(),
24                "mailto" | "tel" | "data" | "urn" | "javascript"
25            )
26        }
27        Err(_) => false,
28    }
29}
30
31/// Normalize a relative path by resolving `.` and `..` components using path
32/// APIs. Does not touch the filesystem. Always returns forward-slash separated
33/// paths. Preserves leading `..` that escape above the root, and preserves an
34/// absolute root/prefix (`/foo`, `C:\foo`) verbatim — both indicate graph
35/// escape and must not be silently rewritten into in-graph relative paths.
36pub fn normalize_relative_path(path: &str) -> String {
37    let mut prefix = String::new();
38    let mut parts: Vec<String> = Vec::new();
39    for component in Path::new(path).components() {
40        match component {
41            Component::Prefix(p) => prefix.push_str(&p.as_os_str().to_string_lossy()),
42            Component::RootDir => prefix.push('/'),
43            Component::CurDir => {}
44            Component::ParentDir => {
45                if parts.last().is_some_and(|p| p != "..") {
46                    parts.pop();
47                } else {
48                    parts.push("..".to_string());
49                }
50            }
51            Component::Normal(c) => parts.push(c.to_string_lossy().to_string()),
52        }
53    }
54    format!("{prefix}{}", parts.join("/"))
55}
56
57/// Rewrite a root-relative `target` as a link relative to `source_file` — the
58/// inverse of [`resolve_link`], for suggesting the path an author meant to
59/// write. `resolve_link(source, relative_from(source, target)) == target`.
60pub fn relative_from(source_file: &str, target: &str) -> String {
61    let source_dir: Vec<&str> = source_file
62        .split('/')
63        .filter(|s| !s.is_empty())
64        .collect::<Vec<_>>()
65        .split_last()
66        .map(|(_, dir)| dir.to_vec())
67        .unwrap_or_default();
68    let target_parts: Vec<&str> = target.split('/').filter(|s| !s.is_empty()).collect();
69
70    let shared = source_dir
71        .iter()
72        .zip(&target_parts)
73        .take_while(|(a, b)| a == b)
74        .count();
75
76    let mut parts: Vec<String> = vec!["..".to_string(); source_dir.len() - shared];
77    parts.extend(target_parts[shared..].iter().map(|s| s.to_string()));
78    // A target inside the source's own directory needs `./` to stay a path.
79    if parts.first().is_some_and(|p| p != "..") {
80        return format!("./{}", parts.join("/"));
81    }
82    parts.join("/")
83}
84
85/// Resolve a link target relative to a source file, producing a path relative to
86/// the graph root.
87pub fn resolve_link(source_file: &str, raw_target: &str) -> String {
88    let source_path = Path::new(source_file);
89    let source_dir = source_path.parent().unwrap_or(Path::new(""));
90    let joined = source_dir.join(raw_target);
91    normalize_relative_path(&joined.to_string_lossy())
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn normalize_simple() {
100        assert_eq!(normalize_relative_path("a/b/c"), "a/b/c");
101    }
102
103    #[test]
104    fn normalize_dot_and_dotdot() {
105        assert_eq!(normalize_relative_path("./a/./b"), "a/b");
106        assert_eq!(normalize_relative_path("a/b/../c"), "a/c");
107    }
108
109    #[test]
110    fn relative_from_walks_up_to_shared_root() {
111        assert_eq!(
112            relative_from("docs/taxonomy.md", "predicated/artifact/src/lib.rs"),
113            "../predicated/artifact/src/lib.rs"
114        );
115        assert_eq!(relative_from("a/b/c.md", "a/d.md"), "../d.md");
116        assert_eq!(relative_from("a/b/c.md", "x.md"), "../../x.md");
117    }
118
119    #[test]
120    fn relative_from_same_directory_keeps_dot_prefix() {
121        assert_eq!(relative_from("docs/a.md", "docs/b.md"), "./b.md");
122        assert_eq!(relative_from("a.md", "b.md"), "./b.md");
123        assert_eq!(relative_from("docs/a.md", "docs/sub/b.md"), "./sub/b.md");
124    }
125
126    #[test]
127    fn relative_from_round_trips_through_resolve_link() {
128        // The suggestion must actually resolve to the target it names.
129        for (source, target) in [
130            ("docs/taxonomy.md", "predicated/artifact/src/lib.rs"),
131            ("docs/a.md", "docs/b.md"),
132            ("a/b/c.md", "x.md"),
133            ("README.md", "src/lib.rs"),
134            ("a/b/c.md", "a/b/d/e.md"),
135        ] {
136            let suggestion = relative_from(source, target);
137            assert_eq!(
138                resolve_link(source, &suggestion),
139                target,
140                "{source} + {suggestion} should resolve to {target}"
141            );
142        }
143    }
144
145    #[test]
146    fn normalize_preserves_leading_dotdot() {
147        assert_eq!(normalize_relative_path("../a"), "../a");
148        assert_eq!(normalize_relative_path("../../a"), "../../a");
149        assert_eq!(
150            normalize_relative_path("guides/../../README.md"),
151            "../README.md"
152        );
153    }
154
155    #[test]
156    fn normalize_preserves_absolute_root() {
157        // An absolute target must stay absolute, not collapse into an in-graph
158        // relative path that could falsely resolve against the fs graph.
159        assert_eq!(normalize_relative_path("/docs/x.md"), "/docs/x.md");
160        assert_eq!(normalize_relative_path("/a/../b"), "/b");
161    }
162
163    #[test]
164    fn resolve_relative_to_source() {
165        assert_eq!(resolve_link("index.md", "setup.md"), "setup.md");
166        assert_eq!(
167            resolve_link("guides/intro.md", "setup.md"),
168            "guides/setup.md"
169        );
170        assert_eq!(resolve_link("guides/intro.md", "../config.md"), "config.md");
171    }
172
173    #[test]
174    fn is_uri_detects_schemes() {
175        assert!(is_uri("http://example.com"));
176        assert!(is_uri("https://example.com"));
177        assert!(is_uri("mailto:user@example.com"));
178        assert!(is_uri("tel:+1234567890"));
179        assert!(is_uri("ssh://git@github.com"));
180    }
181
182    #[test]
183    fn is_uri_rejects_paths_and_bare_schemes() {
184        assert!(!is_uri("setup.md"));
185        assert!(!is_uri("./relative/path.md"));
186        assert!(!is_uri("../parent.md"));
187        assert!(!is_uri(""));
188        assert!(!is_uri("path/with:colon.md"));
189        assert!(!is_uri("name: foo bar bazz"));
190        assert!(!is_uri("status: draft"));
191    }
192
193    #[test]
194    fn hash_has_prefix() {
195        assert!(hash_bytes(b"hello").starts_with("b3:"));
196    }
197}