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/// Resolve a link target relative to a source file, producing a path relative to
58/// the graph root.
59pub fn resolve_link(source_file: &str, raw_target: &str) -> String {
60    let source_path = Path::new(source_file);
61    let source_dir = source_path.parent().unwrap_or(Path::new(""));
62    let joined = source_dir.join(raw_target);
63    normalize_relative_path(&joined.to_string_lossy())
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn normalize_simple() {
72        assert_eq!(normalize_relative_path("a/b/c"), "a/b/c");
73    }
74
75    #[test]
76    fn normalize_dot_and_dotdot() {
77        assert_eq!(normalize_relative_path("./a/./b"), "a/b");
78        assert_eq!(normalize_relative_path("a/b/../c"), "a/c");
79    }
80
81    #[test]
82    fn normalize_preserves_leading_dotdot() {
83        assert_eq!(normalize_relative_path("../a"), "../a");
84        assert_eq!(normalize_relative_path("../../a"), "../../a");
85        assert_eq!(
86            normalize_relative_path("guides/../../README.md"),
87            "../README.md"
88        );
89    }
90
91    #[test]
92    fn normalize_preserves_absolute_root() {
93        // An absolute target must stay absolute, not collapse into an in-graph
94        // relative path that could falsely resolve against the fs graph.
95        assert_eq!(normalize_relative_path("/docs/x.md"), "/docs/x.md");
96        assert_eq!(normalize_relative_path("/a/../b"), "/b");
97    }
98
99    #[test]
100    fn resolve_relative_to_source() {
101        assert_eq!(resolve_link("index.md", "setup.md"), "setup.md");
102        assert_eq!(
103            resolve_link("guides/intro.md", "setup.md"),
104            "guides/setup.md"
105        );
106        assert_eq!(resolve_link("guides/intro.md", "../config.md"), "config.md");
107    }
108
109    #[test]
110    fn is_uri_detects_schemes() {
111        assert!(is_uri("http://example.com"));
112        assert!(is_uri("https://example.com"));
113        assert!(is_uri("mailto:user@example.com"));
114        assert!(is_uri("tel:+1234567890"));
115        assert!(is_uri("ssh://git@github.com"));
116    }
117
118    #[test]
119    fn is_uri_rejects_paths_and_bare_schemes() {
120        assert!(!is_uri("setup.md"));
121        assert!(!is_uri("./relative/path.md"));
122        assert!(!is_uri("../parent.md"));
123        assert!(!is_uri(""));
124        assert!(!is_uri("path/with:colon.md"));
125        assert!(!is_uri("name: foo bar bazz"));
126        assert!(!is_uri("status: draft"));
127    }
128
129    #[test]
130    fn hash_has_prefix() {
131        assert!(hash_bytes(b"hello").starts_with("b3:"));
132    }
133}