1use std::path::{Component, Path};
14
15pub fn node_id_from_rel_path(rel_path: &Path) -> String {
24 let mut parts: Vec<String> = Vec::new();
25 for comp in rel_path.components() {
26 match comp {
27 Component::Normal(os) => {
28 let s = os.to_string_lossy();
29 let cleaned = sanitize_segment(&s);
30 if !cleaned.is_empty() {
31 parts.push(cleaned);
32 }
33 }
34 Component::CurDir => {}
35 _ => {}
37 }
38 }
39
40 if parts.is_empty() {
41 return "untitled".to_string();
42 }
43
44 if let Some(last) = parts.last_mut() {
46 if let Some((stem, _ext)) = last.rsplit_once('.') {
47 if !stem.is_empty() {
48 *last = stem.to_string();
49 }
50 }
51 }
52
53 parts.join("/")
54}
55
56fn sanitize_segment(s: &str) -> String {
58 let lower = s.to_lowercase();
59 let mut out = String::with_capacity(lower.len());
60 let mut prev_dash = false;
61 for c in lower.chars() {
62 if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
63 out.push(c);
64 prev_dash = c == '-';
65 } else if c.is_whitespace() || c == '/' || c == '\\' {
66 if !prev_dash && !out.is_empty() {
67 out.push('-');
68 prev_dash = true;
69 }
70 } else {
71 }
73 }
74 while out.ends_with('-') {
75 out.pop();
76 }
77 out
78}
79
80pub fn resolve_link_target(
88 raw: &str,
89 ids: &std::collections::HashSet<String>,
90 aliases: &std::collections::HashMap<String, String>,
91 titles: &std::collections::HashMap<String, String>,
92) -> Option<String> {
93 let trimmed = raw.trim();
94 if trimmed.is_empty() {
95 return None;
96 }
97
98 let key = trimmed.to_lowercase();
99
100 if ids.contains(&key) {
101 return Some(key);
102 }
103 if ids.contains(trimmed) {
105 return Some(trimmed.to_string());
106 }
107
108 if let Some(id) = aliases.get(&key) {
109 return Some(id.clone());
110 }
111
112 if let Some(id) = titles.get(&key) {
113 return Some(id.clone());
114 }
115
116 let mut matches: Vec<&String> = ids
118 .iter()
119 .filter(|id| {
120 id.as_str() == key
121 || id.ends_with(&format!("/{key}"))
122 || id.rsplit('/').next() == Some(key.as_str())
123 })
124 .collect();
125 matches.sort();
126 matches.dedup();
127 if matches.len() == 1 {
128 return Some(matches[0].clone());
129 }
130
131 None
132}
133
134pub fn content_hash(bytes: &[u8]) -> String {
136 blake3::hash(bytes).to_hex().to_string()
137}
138
139pub fn rel_path_from_workspace(workspace: &Path, file: &Path) -> std::path::PathBuf {
141 if let Ok(rel) = file.strip_prefix(workspace) {
142 return rel.to_path_buf();
143 }
144 if let (Ok(ws), Ok(fp)) = (workspace.canonicalize(), file.canonicalize()) {
146 if let Ok(rel) = fp.strip_prefix(&ws) {
147 return rel.to_path_buf();
148 }
149 }
150 file.to_path_buf()
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use std::collections::{HashMap, HashSet};
157 use std::path::PathBuf;
158
159 #[test]
160 fn slug_from_nested_path() {
161 let id = node_id_from_rel_path(Path::new("docs/concepts/Raft Consensus.md"));
162 assert_eq!(id, "docs/concepts/raft-consensus");
163 }
164
165 #[test]
166 fn slug_strips_extension() {
167 assert_eq!(
168 node_id_from_rel_path(Path::new("notes/foo.md")),
169 "notes/foo"
170 );
171 }
172
173 #[test]
174 fn resolve_suffix_unique() {
175 let mut ids = HashSet::new();
176 ids.insert("docs/concepts/raft".to_string());
177 ids.insert("docs/other/log".to_string());
178 let aliases = HashMap::new();
179 let titles = HashMap::new();
180 assert_eq!(
181 resolve_link_target("raft", &ids, &aliases, &titles).as_deref(),
182 Some("docs/concepts/raft")
183 );
184 }
185
186 #[test]
187 fn resolve_ambiguous_suffix_none() {
188 let mut ids = HashSet::new();
189 ids.insert("a/raft".to_string());
190 ids.insert("b/raft".to_string());
191 let aliases = HashMap::new();
192 let titles = HashMap::new();
193 assert!(resolve_link_target("raft", &ids, &aliases, &titles).is_none());
194 }
195
196 #[test]
197 fn rel_path_strips_workspace() {
198 let ws = PathBuf::from("/proj");
199 let f = PathBuf::from("/proj/docs/a.md");
200 assert_eq!(
201 rel_path_from_workspace(&ws, &f),
202 PathBuf::from("docs/a.md")
203 );
204 }
205}