1use std::path::{Component, Path};
4
5pub fn hash_bytes(content: &[u8]) -> String {
7 format!("b3:{}", blake3::hash(content).to_hex())
8}
9
10pub 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
31pub 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
57pub 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 if parts.first().is_some_and(|p| p != "..") {
80 return format!("./{}", parts.join("/"));
81 }
82 parts.join("/")
83}
84
85pub 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 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 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}