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