use std::path::{Component, Path};
pub fn node_id_from_rel_path(rel_path: &Path) -> String {
let mut parts: Vec<String> = Vec::new();
for comp in rel_path.components() {
match comp {
Component::Normal(os) => {
let s = os.to_string_lossy();
let cleaned = sanitize_segment(&s);
if !cleaned.is_empty() {
parts.push(cleaned);
}
}
Component::CurDir => {}
_ => {}
}
}
if parts.is_empty() {
return "untitled".to_string();
}
if let Some(last) = parts.last_mut() {
if let Some((stem, _ext)) = last.rsplit_once('.') {
if !stem.is_empty() {
*last = stem.to_string();
}
}
}
parts.join("/")
}
fn sanitize_segment(s: &str) -> String {
let lower = s.to_lowercase();
let mut out = String::with_capacity(lower.len());
let mut prev_dash = false;
for c in lower.chars() {
if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
out.push(c);
prev_dash = c == '-';
} else if c.is_whitespace() || c == '/' || c == '\\' {
if !prev_dash && !out.is_empty() {
out.push('-');
prev_dash = true;
}
} else {
}
}
while out.ends_with('-') {
out.pop();
}
out
}
pub fn resolve_link_target(
raw: &str,
ids: &std::collections::HashSet<String>,
aliases: &std::collections::HashMap<String, String>,
titles: &std::collections::HashMap<String, String>,
) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let key = trimmed.to_lowercase();
if ids.contains(&key) {
return Some(key);
}
if ids.contains(trimmed) {
return Some(trimmed.to_string());
}
if let Some(id) = aliases.get(&key) {
return Some(id.clone());
}
if let Some(id) = titles.get(&key) {
return Some(id.clone());
}
let mut matches: Vec<&String> = ids
.iter()
.filter(|id| {
id.as_str() == key
|| id.ends_with(&format!("/{key}"))
|| id.rsplit('/').next() == Some(key.as_str())
})
.collect();
matches.sort();
matches.dedup();
if matches.len() == 1 {
return Some(matches[0].clone());
}
None
}
pub fn content_hash(bytes: &[u8]) -> String {
blake3::hash(bytes).to_hex().to_string()
}
pub fn rel_path_from_workspace(workspace: &Path, file: &Path) -> std::path::PathBuf {
if let Ok(rel) = file.strip_prefix(workspace) {
return rel.to_path_buf();
}
if let (Ok(ws), Ok(fp)) = (workspace.canonicalize(), file.canonicalize()) {
if let Ok(rel) = fp.strip_prefix(&ws) {
return rel.to_path_buf();
}
}
file.to_path_buf()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
#[test]
fn slug_from_nested_path() {
let id = node_id_from_rel_path(Path::new("docs/concepts/Raft Consensus.md"));
assert_eq!(id, "docs/concepts/raft-consensus");
}
#[test]
fn slug_strips_extension() {
assert_eq!(
node_id_from_rel_path(Path::new("notes/foo.md")),
"notes/foo"
);
}
#[test]
fn resolve_suffix_unique() {
let mut ids = HashSet::new();
ids.insert("docs/concepts/raft".to_string());
ids.insert("docs/other/log".to_string());
let aliases = HashMap::new();
let titles = HashMap::new();
assert_eq!(
resolve_link_target("raft", &ids, &aliases, &titles).as_deref(),
Some("docs/concepts/raft")
);
}
#[test]
fn resolve_ambiguous_suffix_none() {
let mut ids = HashSet::new();
ids.insert("a/raft".to_string());
ids.insert("b/raft".to_string());
let aliases = HashMap::new();
let titles = HashMap::new();
assert!(resolve_link_target("raft", &ids, &aliases, &titles).is_none());
}
#[test]
fn rel_path_strips_workspace() {
let ws = PathBuf::from("/proj");
let f = PathBuf::from("/proj/docs/a.md");
assert_eq!(
rel_path_from_workspace(&ws, &f),
PathBuf::from("docs/a.md")
);
}
}