1use crate::error::Result;
8use crate::storage::Database;
9use crate::types::{Edge, Node, NodeType};
10use chrono::Utc;
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13use std::path::{Path, PathBuf};
14
15pub const REL_AUTO_FILENAME: &str = "auto_filename";
17pub const REL_AUTO_TAG: &str = "auto_tag";
19
20pub const WEIGHT_AUTO_FILENAME: f32 = 0.40;
22pub const WEIGHT_AUTO_TAG: f32 = 0.25;
24
25pub fn is_auto_relation(relation_type: &str) -> bool {
27 relation_type.starts_with("auto_")
28}
29
30pub fn is_explicit_relation(relation_type: &str) -> bool {
32 !is_auto_relation(relation_type)
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct OrphanNote {
38 pub id: String,
40 pub node_type: String,
42 pub title: String,
44 pub file_path: Option<String>,
46 pub suggestions: Vec<AutoLinkSuggestion>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct AutoLinkSuggestion {
53 pub target_id: String,
55 pub target_title: String,
57 pub target_path: Option<String>,
59 pub relation_type: String,
61 pub weight: f32,
63 pub reason: String,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct AutoLinkReport {
70 pub edges_upserted: usize,
72 pub pairs_considered: usize,
74 pub scope: String,
76 pub applied: Vec<AutoLinkSuggestion>,
78}
79
80pub fn path_stem(file_path: &str) -> Option<String> {
82 let p = Path::new(file_path);
83 let stem = p.file_stem()?.to_str()?.to_ascii_lowercase();
84 if stem.is_empty() || stem == "readme" || stem == "template" {
85 return None;
86 }
87 if stem.contains("generated") || stem == "from-readme" || stem == "bootstrap-checklist" {
89 return None;
90 }
91 Some(stem)
92}
93
94fn trivial_tag(tag: &str) -> bool {
95 matches!(
96 tag.to_ascii_lowercase().as_str(),
97 "goal"
98 | "adr"
99 | "concept"
100 | "symbol"
101 | "reference"
102 | "edge_case"
103 | "alternative"
104 | "readme"
105 | "generated"
106 | "index"
107 )
108}
109
110pub fn list_orphan_notes(db: &Database) -> Result<Vec<OrphanNote>> {
112 let notes = list_note_nodes(db)?;
113 let explicit = explicit_degree(db)?;
114 let mut out = Vec::new();
115 for n in ¬es {
116 let deg = explicit.get(&n.id).copied().unwrap_or(0);
117 if deg > 0 {
118 continue;
119 }
120 if is_noise_orphan_id(&n.id) {
122 continue;
123 }
124 let suggestions = suggest_for_node(db, n, ¬es)?;
125 out.push(OrphanNote {
126 id: n.id.clone(),
127 node_type: n.node_type.as_str().to_string(),
128 title: n.title.clone(),
129 file_path: n.file_path.clone(),
130 suggestions,
131 });
132 }
133 out.sort_by(|a, b| a.id.cmp(&b.id));
134 Ok(out)
135}
136
137fn is_noise_orphan_id(id: &str) -> bool {
138 let id = id.to_lowercase();
139 id.contains("template")
140 || id.contains("bootstrap")
141 || id.contains("module-map.generated")
142 || id == "agents"
143 || id.ends_with("/agents")
144 || id == "docs/goals/readme"
145}
146
147fn list_note_nodes(db: &Database) -> Result<Vec<Node>> {
148 let mut nodes = Vec::new();
149 for ty in [
150 NodeType::Goal,
151 NodeType::Adr,
152 NodeType::Concept,
153 NodeType::EdgeCase,
154 NodeType::Reference,
155 NodeType::Alternative,
156 ] {
157 for id in db.list_node_ids_by_type(ty.as_str())? {
158 if let Some(n) = db.get_node(&id)? {
159 nodes.push(n);
160 }
161 }
162 }
163 if let Some(n) = db.get_node("readme")? {
165 if !nodes.iter().any(|x| x.id == "readme") {
166 nodes.push(n);
167 }
168 }
169 Ok(nodes)
170}
171
172fn explicit_degree(db: &Database) -> Result<HashMap<String, usize>> {
173 let edges = db.get_all_edges()?;
174 let mut deg: HashMap<String, usize> = HashMap::new();
175 for e in edges {
176 if !is_explicit_relation(&e.relation_type) {
177 continue;
178 }
179 *deg.entry(e.source_id).or_default() += 1;
180 *deg.entry(e.target_id).or_default() += 1;
181 }
182 Ok(deg)
183}
184
185fn suggest_for_node(db: &Database, node: &Node, all: &[Node]) -> Result<Vec<AutoLinkSuggestion>> {
186 let mut suggestions = Vec::new();
187 let mut seen = HashSet::new();
188
189 if let Some(path) = &node.file_path {
191 if let Some(stem) = path_stem(path) {
192 for other in all {
193 if other.id == node.id {
194 continue;
195 }
196 let Some(op) = other.file_path.as_ref() else {
197 continue;
198 };
199 let Some(ostem) = path_stem(op) else {
200 continue;
201 };
202 if ostem != stem {
203 continue;
204 }
205 let key = format!("fn:{}:{}", node.id, other.id);
206 if !seen.insert(key) {
207 continue;
208 }
209 suggestions.push(AutoLinkSuggestion {
210 target_id: other.id.clone(),
211 target_title: other.title.clone(),
212 target_path: other.file_path.clone(),
213 relation_type: REL_AUTO_FILENAME.into(),
214 weight: WEIGHT_AUTO_FILENAME,
215 reason: format!("same filename stem `{stem}`"),
216 });
217 }
218 }
219 }
220
221 let tags: HashSet<String> = db
223 .get_tags_for(&node.id)?
224 .into_iter()
225 .filter(|t| !trivial_tag(t))
226 .map(|t| t.to_ascii_lowercase())
227 .collect();
228 if !tags.is_empty() {
229 for other in all {
230 if other.id == node.id {
231 continue;
232 }
233 let otags = db.get_tags_for(&other.id)?;
234 let shared: Vec<String> = otags
235 .into_iter()
236 .filter(|t| !trivial_tag(t) && tags.contains(&t.to_ascii_lowercase()))
237 .collect();
238 if shared.is_empty() {
239 continue;
240 }
241 let key = format!("tag:{}:{}", node.id, other.id);
242 if !seen.insert(key) {
243 continue;
244 }
245 suggestions.push(AutoLinkSuggestion {
246 target_id: other.id.clone(),
247 target_title: other.title.clone(),
248 target_path: other.file_path.clone(),
249 relation_type: REL_AUTO_TAG.into(),
250 weight: WEIGHT_AUTO_TAG,
251 reason: format!("shared tag(s): {}", shared.join(", ")),
252 });
253 }
254 }
255
256 suggestions.sort_by(|a, b| {
257 b.weight
258 .partial_cmp(&a.weight)
259 .unwrap_or(std::cmp::Ordering::Equal)
260 .then_with(|| a.target_id.cmp(&b.target_id))
261 });
262 suggestions.truncate(24);
263 Ok(suggestions)
264}
265
266pub fn run_auto_link(db: &Database, target: Option<&Path>) -> Result<AutoLinkReport> {
272 let all = list_note_nodes(db)?;
273 let now = Utc::now().timestamp();
274 let mut applied = Vec::new();
275 let mut edges_upserted = 0usize;
276 let mut pairs = HashSet::new();
277
278 let scope = if let Some(t) = target {
279 let focus = resolve_target_node(db, t, &all)?;
280 db.clear_auto_edges_involving(&focus.id)?;
282 let suggestions = suggest_for_node(db, &focus, &all)?;
283 for s in &suggestions {
284 edges_upserted += upsert_auto_pair(
285 db,
286 &focus.id,
287 &s.target_id,
288 &s.relation_type,
289 s.weight,
290 now,
291 &mut pairs,
292 )?;
293 applied.push(s.clone());
294 }
295 format!("node {}", focus.id)
296 } else {
297 db.clear_all_auto_edges()?;
299 let mut by_stem: HashMap<String, Vec<&Node>> = HashMap::new();
301 for n in &all {
302 if let Some(p) = &n.file_path {
303 if let Some(stem) = path_stem(p) {
304 by_stem.entry(stem).or_default().push(n);
305 }
306 }
307 }
308 for (stem, group) in &by_stem {
309 if group.len() < 2 {
310 continue;
311 }
312 for i in 0..group.len() {
313 for j in (i + 1)..group.len() {
314 let a = group[i];
315 let b = group[j];
316 edges_upserted += upsert_auto_pair(
317 db,
318 &a.id,
319 &b.id,
320 REL_AUTO_FILENAME,
321 WEIGHT_AUTO_FILENAME,
322 now,
323 &mut pairs,
324 )?;
325 if applied.len() < 50 {
326 applied.push(AutoLinkSuggestion {
327 target_id: b.id.clone(),
328 target_title: b.title.clone(),
329 target_path: b.file_path.clone(),
330 relation_type: REL_AUTO_FILENAME.into(),
331 weight: WEIGHT_AUTO_FILENAME,
332 reason: format!("same filename stem `{stem}` ({} ↔ {})", a.id, b.id),
333 });
334 }
335 }
336 }
337 }
338 let mut tag_index: HashMap<String, Vec<String>> = HashMap::new();
340 for n in &all {
341 for t in db.get_tags_for(&n.id)? {
342 if trivial_tag(&t) {
343 continue;
344 }
345 tag_index
346 .entry(t.to_ascii_lowercase())
347 .or_default()
348 .push(n.id.clone());
349 }
350 }
351 for (tag, ids) in &tag_index {
352 let mut uniq = ids.clone();
353 uniq.sort();
354 uniq.dedup();
355 if uniq.len() < 2 {
356 continue;
357 }
358 for i in 0..uniq.len() {
359 for j in (i + 1)..uniq.len() {
360 let a = &uniq[i];
361 let b = &uniq[j];
362 edges_upserted += upsert_auto_pair(
363 db,
364 a,
365 b,
366 REL_AUTO_TAG,
367 WEIGHT_AUTO_TAG,
368 now,
369 &mut pairs,
370 )?;
371 if applied.len() < 50 {
372 let bt = all
373 .iter()
374 .find(|n| n.id == *b)
375 .map(|n| n.title.clone())
376 .unwrap_or_else(|| b.clone());
377 let bp = all
378 .iter()
379 .find(|n| n.id == *b)
380 .and_then(|n| n.file_path.clone());
381 applied.push(AutoLinkSuggestion {
382 target_id: b.clone(),
383 target_title: bt,
384 target_path: bp,
385 relation_type: REL_AUTO_TAG.into(),
386 weight: WEIGHT_AUTO_TAG,
387 reason: format!("shared tag `{tag}` ({a} ↔ {b})"),
388 });
389 }
390 }
391 }
392 }
393 "all notes (filename stems + shared tags)".into()
394 };
395
396 Ok(AutoLinkReport {
397 edges_upserted,
398 pairs_considered: pairs.len(),
399 scope,
400 applied,
401 })
402}
403
404fn upsert_auto_pair(
405 db: &Database,
406 a: &str,
407 b: &str,
408 rel: &str,
409 weight: f32,
410 now: i64,
411 pairs: &mut HashSet<String>,
412) -> Result<usize> {
413 if a == b {
414 return Ok(0);
415 }
416 let (lo, hi) = if a < b { (a, b) } else { (b, a) };
418 let key = format!("{rel}:{lo}:{hi}");
419 if !pairs.insert(key) {
420 return Ok(0);
421 }
422 let mut n = 0;
424 for (src, tgt) in [(lo, hi), (hi, lo)] {
425 db.insert_edge(&Edge {
426 source_id: src.to_string(),
427 target_id: tgt.to_string(),
428 relation_type: rel.to_string(),
429 weight,
430 decay_rate: 0.0,
431 created_at: now,
432 })?;
433 n += 1;
434 }
435 Ok(n)
436}
437
438fn resolve_target_node(db: &Database, target: &Path, all: &[Node]) -> Result<Node> {
439 let raw = target.to_string_lossy().replace('\\', "/");
440 let raw = raw.trim_start_matches("./");
441
442 if let Some(n) = db.get_node(raw)? {
444 return Ok(n);
445 }
446
447 let candidates: Vec<&Node> = all
449 .iter()
450 .filter(|n| {
451 n.file_path
452 .as_ref()
453 .map(|p| {
454 let p = p.replace('\\', "/");
455 p == raw || p.ends_with(raw) || p.ends_with(&format!("/{raw}"))
456 })
457 .unwrap_or(false)
458 })
459 .collect();
460 if candidates.len() == 1 {
461 return Ok(candidates[0].clone());
462 }
463 if candidates.len() > 1 {
464 return Err(crate::error::BrainError::other(format!(
465 "ambiguous path `{raw}` matches {} nodes",
466 candidates.len()
467 )));
468 }
469
470 let with_docs = if raw.starts_with("docs/") {
472 raw.to_string()
473 } else {
474 format!("docs/{raw}")
475 };
476 if let Some(n) = all.iter().find(|n| {
477 n.file_path
478 .as_ref()
479 .map(|p| p.replace('\\', "/") == with_docs)
480 .unwrap_or(false)
481 }) {
482 return Ok(n.clone());
483 }
484
485 Err(crate::error::BrainError::other(format!(
486 "no note node found for `{raw}` — pass a path like docs/goals/foo.md or a node id"
487 )))
488}
489
490pub fn normalize_target_arg(s: &str) -> PathBuf {
492 PathBuf::from(s.trim().trim_start_matches("./"))
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use crate::types::NodeType;
499
500 fn note(id: &str, ty: NodeType, path: &str, title: &str) -> Node {
501 Node {
502 id: id.into(),
503 node_type: ty,
504 title: title.into(),
505 file_path: Some(path.into()),
506 symbol_hash: None,
507 summary: Some(title.into()),
508 content_hash: None,
509 created_at: 1,
510 updated_at: 1,
511 }
512 }
513
514 #[test]
515 fn path_stem_extracts_filename() {
516 assert_eq!(
517 path_stem("docs/goals/rust-fluency.md").as_deref(),
518 Some("rust-fluency")
519 );
520 assert_eq!(
521 path_stem("docs/concepts/rust-fluency.md").as_deref(),
522 Some("rust-fluency")
523 );
524 assert!(path_stem("docs/adr/TEMPLATE.md").is_none());
525 }
526
527 #[test]
528 fn filename_stem_auto_links_and_orphans() {
529 let db = Database::open_in_memory().unwrap();
530 let g = note(
531 "docs/goals/rust-fluency",
532 NodeType::Goal,
533 "docs/goals/rust-fluency.md",
534 "Rust fluency goal",
535 );
536 let c = note(
537 "docs/concepts/rust-fluency",
538 NodeType::Concept,
539 "docs/concepts/rust-fluency.md",
540 "Rust fluency deep dive",
541 );
542 db.insert_node(&g).unwrap();
543 db.insert_node(&c).unwrap();
544 db.index_fts(&g.id, &g.title, "goal body", "goal").unwrap();
545 db.index_fts(&c.id, &c.title, "concept body", "concept")
546 .unwrap();
547
548 let orphans = list_orphan_notes(&db).unwrap();
549 assert_eq!(orphans.len(), 2);
550 assert!(orphans
551 .iter()
552 .any(|o| o.suggestions.iter().any(|s| s.relation_type == REL_AUTO_FILENAME)));
553
554 let report = run_auto_link(&db, None).unwrap();
555 assert!(report.edges_upserted >= 2);
556 let edges = db.get_all_edges().unwrap();
557 assert!(edges.iter().any(|e| e.relation_type == REL_AUTO_FILENAME));
558
559 let orphans2 = list_orphan_notes(&db).unwrap();
561 assert_eq!(
562 orphans2.len(),
563 2,
564 "auto edges must not count as explicit"
565 );
566 }
567
568 #[test]
569 fn targeted_auto_link_by_path() {
570 let db = Database::open_in_memory().unwrap();
571 let g = note(
572 "docs/goals/foo",
573 NodeType::Goal,
574 "docs/goals/foo.md",
575 "Foo goal",
576 );
577 let c = note(
578 "docs/concepts/foo",
579 NodeType::Concept,
580 "docs/concepts/foo.md",
581 "Foo concept",
582 );
583 db.insert_node(&g).unwrap();
584 db.insert_node(&c).unwrap();
585 let report = run_auto_link(&db, Some(Path::new("docs/goals/foo.md"))).unwrap();
586 assert!(report.edges_upserted >= 2);
587 assert!(report.scope.contains("docs/goals/foo"));
588 }
589}