Skip to main content

rustbrain_core/
id.rs

1//! Stable node ID scheme based on repository-relative path slugs.
2//!
3//! Collision-resistant across directories:
4//!
5//! | Relative path | Node id |
6//! |---------------|---------|
7//! | `docs/concepts/raft.md` | `docs/concepts/raft` |
8//! | `Notes/Foo Bar.md` | `notes/foo-bar` |
9//!
10//! IDs are lowercase, `/`-separated, and extension-stripped. They are the
11//! primary key in SQLite and the string key in the CSR id table.
12
13use std::path::{Component, Path};
14
15/// Build a stable node id from a workspace-relative file path.
16///
17/// Rules:
18/// - strip extension
19/// - lowercase
20/// - normalize path separators to `/`
21/// - collapse `//`, reject parent-dir escapes
22/// - replace whitespace runs with `-`
23pub 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            // Drop prefix / root / parent — callers should pass relative paths.
36            _ => {}
37        }
38    }
39
40    if parts.is_empty() {
41        return "untitled".to_string();
42    }
43
44    // Drop final extension if present on last segment only when it looks like a file.
45    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
56/// Sanitize a single path segment for use in an id.
57fn 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            // drop other punctuation
72        }
73    }
74    while out.ends_with('-') {
75        out.pop();
76    }
77    out
78}
79
80/// Resolve a WikiLink target string against known node ids / aliases / titles.
81///
82/// Matching order (first hit wins):
83/// 1. exact id
84/// 2. exact alias
85/// 3. case-insensitive title
86/// 4. id suffix match (`raft` → `docs/concepts/raft`) when unique
87pub 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    // Also try as-is for ids that already contain slashes in lowercase form.
104    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    // Unique suffix match: "raft" matches "docs/concepts/raft" if only one ends with /raft or == raft
117    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
134/// Compute a hex BLAKE3 content hash for change detection.
135pub fn content_hash(bytes: &[u8]) -> String {
136    blake3::hash(bytes).to_hex().to_string()
137}
138
139/// Make a path relative to workspace, falling back to the path itself.
140pub 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    // Try canonicalize both sides.
145    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}