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