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