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 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 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}