1use crate::error::{BrainError, Result};
16use crate::id::resolve_link_target;
17use crate::obsidian::{extract_wikilink_spans, parse_frontmatter, WikiLink};
18use crate::query::PendingLink;
19use crate::storage::Database;
20use crate::symbols::{parse_symbol_path, resolve_symbol_ref};
21use crate::types::{Node, NodeType};
22use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind};
23use serde::{Deserialize, Serialize};
24use std::collections::{HashMap, HashSet};
25use std::fs;
26use std::path::{Path, PathBuf};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
32#[serde(rename_all = "snake_case")]
33pub enum ApplyStyle {
34 #[default]
36 Wrap,
37 Related,
39}
40
41impl ApplyStyle {
42 pub fn parse(s: &str) -> Option<Self> {
44 match s.trim().to_ascii_lowercase().as_str() {
45 "wrap" | "inline" => Some(Self::Wrap),
46 "related" | "section" => Some(Self::Related),
47 _ => None,
48 }
49 }
50}
51
52#[derive(Debug, Clone)]
54pub struct ApplyOptions {
55 pub write: bool,
57 pub dry_run: bool,
59 pub discover: bool,
61 pub force_generated: bool,
63 pub limit: usize,
65 pub target: Option<String>,
67 pub sync_after: bool,
69 pub report_suggest: bool,
71 pub style: ApplyStyle,
73 pub graph_priors: bool,
75 pub cache_dir: Option<PathBuf>,
77}
78
79impl Default for ApplyOptions {
80 fn default() -> Self {
81 Self {
82 write: false,
83 dry_run: true,
84 discover: false,
85 force_generated: false,
86 limit: 200,
87 target: None,
88 sync_after: true,
89 report_suggest: true,
90 style: ApplyStyle::Wrap,
91 graph_priors: true,
92 cache_dir: None,
93 }
94 }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum ApplyTier {
101 Auto,
103 Suggest,
105 Skip,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum ApplyKind {
113 PendingWikiNormalize,
115 PendingResolvedNoEdit,
117 PendingUnresolved,
119 DiscoverWrap,
121 DiscoverRelated,
123 DiscoverSkip,
125 FileSkip,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct ApplyEdit {
132 pub tier: ApplyTier,
134 pub kind: ApplyKind,
136 pub source_id: String,
138 pub file_path: Option<String>,
140 pub target_id: Option<String>,
142 pub before: String,
144 pub after: Option<String>,
146 pub reason: String,
148 pub written: bool,
150 pub span: Option<(usize, usize)>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct ApplyReport {
157 pub write_requested: bool,
159 pub dry_run: bool,
161 pub discover: bool,
163 pub written: usize,
165 pub auto_planned: usize,
167 pub suggest_planned: usize,
169 pub skipped: usize,
171 pub files_written: Vec<String>,
173 pub files_planned: Vec<String>,
175 pub edits: Vec<ApplyEdit>,
177 pub warnings: Vec<String>,
179 pub recommend_sync: bool,
181 pub ambiguous_surfaces: usize,
183 pub lexicon_cache_hit: bool,
185 pub style: ApplyStyle,
187}
188
189impl ApplyReport {
190 pub fn to_text(&self) -> String {
192 let mut out = String::new();
193 out.push_str(&format!(
194 "links apply: dry_run={} write_requested={} discover={}\n",
195 self.dry_run, self.write_requested, self.discover
196 ));
197 out.push_str(&format!(
198 " auto={} suggest={} skipped={} written={} files={}\n",
199 self.auto_planned,
200 self.suggest_planned,
201 self.skipped,
202 self.written,
203 if self.dry_run {
204 self.files_planned.len()
205 } else {
206 self.files_written.len()
207 }
208 ));
209 let show = self.edits.iter().take(80);
210 for e in show {
211 let status = if e.written {
212 "WRITTEN"
213 } else {
214 match e.tier {
215 ApplyTier::Auto => "AUTO",
216 ApplyTier::Suggest => "SUGGEST",
217 ApplyTier::Skip => "SKIP",
218 }
219 };
220 let after = e.after.as_deref().unwrap_or("-");
221 let path = e.file_path.as_deref().unwrap_or("-");
222 out.push_str(&format!(
223 " [{status}] {} {} → {} ({}) {}\n",
224 path, e.before, after, e.source_id, e.reason
225 ));
226 }
227 if self.edits.len() > 80 {
228 out.push_str(&format!(" … {} more (use --json)\n", self.edits.len() - 80));
229 }
230 for w in &self.warnings {
231 out.push_str(&format!(" warning: {w}\n"));
232 }
233 if self.dry_run && self.auto_planned > 0 {
234 out.push_str(
235 "tip: re-run with `rustbrain links --apply --write` to apply AUTO edits\n",
236 );
237 } else if !self.dry_run && self.written > 0 && self.recommend_sync {
238 out.push_str("tip: run `rustbrain sync` (or rely on auto-sync) so pending/edges refresh\n");
239 } else if self.auto_planned == 0 && self.suggest_planned == 0 && self.skipped > 0 {
240 out.push_str(
241 "tip: pending targets may not resolve yet — create notes or fix titles, then sync\n",
242 );
243 }
244 if !self.discover {
245 out.push_str(
246 "tip: `links --apply --discover --dry-run` finds unmarked mentions (Phase 1)\n",
247 );
248 }
249 out
250 }
251}
252
253pub fn apply_links(
257 workspace: &Path,
258 db: &Database,
259 opts: &ApplyOptions,
260) -> Result<ApplyReport> {
261 let dry_run = !opts.write || opts.dry_run;
262 let limit = opts.limit.max(1);
263
264 let (ids, aliases, titles) = db.link_resolution_maps()?;
265 let symbol_ids: HashSet<String> = ids
266 .iter()
267 .filter(|id| id.starts_with("symbol/"))
268 .cloned()
269 .collect();
270
271 let all_nodes = db.get_all_nodes()?;
272 let node_by_id: HashMap<String, Node> =
273 all_nodes.into_iter().map(|n| (n.id.clone(), n)).collect();
274
275 let mut pending = db.list_pending_links()?;
276 if let Some(t) = &opts.target {
277 let focus = resolve_source_filter(db, t, &node_by_id)?;
278 pending.retain(|p| focus.contains(&p.source_id));
279 }
280
281 let mut edits: Vec<ApplyEdit> = Vec::new();
282 let mut warnings: Vec<String> = Vec::new();
283
284 let mut by_source: HashMap<String, Vec<PendingLink>> = HashMap::new();
286 for p in pending {
287 by_source.entry(p.source_id.clone()).or_default().push(p);
288 }
289
290 let mut file_ops: HashMap<PathBuf, Vec<SpanReplace>> = HashMap::new();
292
293 for (source_id, pendings) in &by_source {
294 let Some(source) = node_by_id.get(source_id) else {
295 for p in pendings {
296 edits.push(ApplyEdit {
297 tier: ApplyTier::Skip,
298 kind: ApplyKind::FileSkip,
299 source_id: source_id.clone(),
300 file_path: None,
301 target_id: None,
302 before: p.raw_target.clone(),
303 after: None,
304 reason: "source node missing from nodes table".into(),
305 written: false,
306 span: None,
307 });
308 }
309 continue;
310 };
311
312 let Some(rel) = source.file_path.as_deref() else {
313 for p in pendings {
314 plan_pending_no_file(
316 p,
317 source_id,
318 &ids,
319 &aliases,
320 &titles,
321 &symbol_ids,
322 &mut edits,
323 );
324 }
325 continue;
326 };
327
328 let abs = workspace.join(rel);
329 if !abs.is_file() {
330 for p in pendings {
331 edits.push(ApplyEdit {
332 tier: ApplyTier::Skip,
333 kind: ApplyKind::FileSkip,
334 source_id: source_id.clone(),
335 file_path: Some(rel.to_string()),
336 target_id: None,
337 before: p.raw_target.clone(),
338 after: None,
339 reason: format!("file missing on disk: {}", abs.display()),
340 written: false,
341 span: None,
342 });
343 }
344 continue;
345 }
346
347 let content = match fs::read_to_string(&abs) {
348 Ok(c) => c,
349 Err(e) => {
350 warnings.push(format!("read {}: {e}", abs.display()));
351 for p in pendings {
352 edits.push(ApplyEdit {
353 tier: ApplyTier::Skip,
354 kind: ApplyKind::FileSkip,
355 source_id: source_id.clone(),
356 file_path: Some(rel.to_string()),
357 target_id: None,
358 before: p.raw_target.clone(),
359 after: None,
360 reason: format!("read error: {e}"),
361 written: false,
362 span: None,
363 });
364 }
365 continue;
366 }
367 };
368
369 if is_generated_file(rel, &content) && !opts.force_generated {
370 for p in pendings {
371 edits.push(ApplyEdit {
372 tier: ApplyTier::Skip,
373 kind: ApplyKind::FileSkip,
374 source_id: source_id.clone(),
375 file_path: Some(rel.to_string()),
376 target_id: None,
377 before: p.raw_target.clone(),
378 after: None,
379 reason: "generated file skipped (pass --force to override)".into(),
380 written: false,
381 span: None,
382 });
383 }
384 continue;
385 }
386
387 plan_pending_for_file(
388 source_id,
389 rel,
390 &content,
391 pendings,
392 &ids,
393 &aliases,
394 &titles,
395 &symbol_ids,
396 &mut edits,
397 &mut file_ops,
398 &abs,
399 );
400 }
401
402 let mut ambiguous_surfaces = 0usize;
404 let mut lexicon_cache_hit = false;
405 let mut related_ops: HashMap<PathBuf, Vec<RelatedInsert>> = HashMap::new();
406
407 let adjacency = if opts.discover && opts.graph_priors {
409 build_adjacency(db)?
410 } else {
411 HashMap::new()
412 };
413
414 if opts.discover {
415 let (lexicon, cache_hit) =
416 load_or_compile_lexicon(opts.cache_dir.as_deref(), &node_by_id, &aliases)?;
417 lexicon_cache_hit = cache_hit;
418 ambiguous_surfaces = lexicon.ambiguous.len();
419 if ambiguous_surfaces > 0 {
420 warnings.push(format!(
421 "discover: {ambiguous_surfaces} surface(s) excluded as ambiguous (unique match required)"
422 ));
423 }
424 if cache_hit {
425 warnings.push("discover: LinkLexicon loaded from cache".into());
426 }
427 if let Some(ac) = lexicon.build_automaton() {
428 let sources: Vec<&Node> = if let Some(t) = &opts.target {
429 let focus = resolve_source_filter(db, t, &node_by_id)?;
430 node_by_id
431 .values()
432 .filter(|n| focus.contains(&n.id) && n.node_type != NodeType::Symbol)
433 .collect()
434 } else {
435 node_by_id
436 .values()
437 .filter(|n| n.node_type != NodeType::Symbol && n.file_path.is_some())
438 .collect()
439 };
440
441 for source in sources {
442 let Some(rel) = source.file_path.as_deref() else {
443 continue;
444 };
445 let abs = workspace.join(rel);
446 if !abs.is_file() {
447 continue;
448 }
449 let content = match fs::read_to_string(&abs) {
450 Ok(c) => c,
451 Err(e) => {
452 warnings.push(format!("discover read {}: {e}", abs.display()));
453 continue;
454 }
455 };
456 if is_generated_file(rel, &content) && !opts.force_generated {
457 continue;
458 }
459 let neighbors = adjacency.get(&source.id).cloned().unwrap_or_default();
460 plan_discover_for_file(
461 source,
462 rel,
463 &content,
464 &lexicon,
465 &ac,
466 opts.report_suggest,
467 opts.style,
468 opts.graph_priors,
469 &neighbors,
470 &mut edits,
471 &mut file_ops,
472 &mut related_ops,
473 &abs,
474 );
475 }
476 } else {
477 warnings.push("discover: empty lexicon (no eligible patterns)".into());
478 }
479 }
480
481 prioritize_and_cap_ops(&mut file_ops, &mut related_ops, &mut edits, limit);
483
484 let mut files_written = Vec::new();
486 let mut files_planned = Vec::new();
487 let mut written_count = 0usize;
488
489 let mut all_paths: HashSet<PathBuf> = file_ops.keys().cloned().collect();
491 all_paths.extend(related_ops.keys().cloned());
492
493 for abs in &all_paths {
494 let span_ops = file_ops.get(abs).map(|v| v.as_slice()).unwrap_or(&[]);
495 let rel_ops = related_ops.get(abs).map(|v| v.as_slice()).unwrap_or(&[]);
496 if span_ops.is_empty() && rel_ops.is_empty() {
497 continue;
498 }
499 let rel = abs
500 .strip_prefix(workspace)
501 .unwrap_or(abs.as_path())
502 .to_string_lossy()
503 .replace('\\', "/");
504 files_planned.push(rel.clone());
505
506 if dry_run {
507 continue;
508 }
509
510 let original = match fs::read_to_string(abs) {
511 Ok(c) => c,
512 Err(e) => {
513 warnings.push(format!("write aborted, re-read {}: {e}", abs.display()));
514 continue;
515 }
516 };
517
518 let after_spans = match apply_span_replaces(&original, span_ops) {
519 Ok(c) => c,
520 Err(e) => {
521 warnings.push(format!("apply plan {}: {e}", abs.display()));
522 continue;
523 }
524 };
525 let new_content = if rel_ops.is_empty() {
526 after_spans
527 } else {
528 insert_related_links(&after_spans, rel_ops)
529 };
530 if new_content == original {
531 continue;
532 }
533 match atomic_write(abs, &new_content) {
534 Ok(()) => {
535 files_written.push(rel);
536 }
537 Err(e) => warnings.push(format!("atomic write {}: {e}", abs.display())),
538 }
539 }
540
541 if !dry_run {
543 let written_set: HashSet<String> = files_written.iter().cloned().collect();
544 for e in edits.iter_mut() {
545 if e.tier == ApplyTier::Auto
546 && e.after.is_some()
547 && e.file_path
548 .as_ref()
549 .is_some_and(|p| written_set.contains(p))
550 && matches!(
551 e.kind,
552 ApplyKind::PendingWikiNormalize
553 | ApplyKind::DiscoverWrap
554 | ApplyKind::DiscoverRelated
555 )
556 {
557 e.written = true;
558 }
559 }
560 written_count = edits.iter().filter(|e| e.written).count();
561 }
562
563 let auto_planned = edits
564 .iter()
565 .filter(|e| e.tier == ApplyTier::Auto && e.after.is_some())
566 .count();
567 let suggest_planned = edits.iter().filter(|e| e.tier == ApplyTier::Suggest).count();
568 let skipped = edits.iter().filter(|e| e.tier == ApplyTier::Skip).count();
569
570 files_planned.sort();
571 files_planned.dedup();
572 files_written.sort();
573 files_written.dedup();
574
575 Ok(ApplyReport {
576 write_requested: opts.write,
577 dry_run,
578 discover: opts.discover,
579 written: written_count,
580 auto_planned,
581 suggest_planned,
582 skipped,
583 files_written,
584 files_planned,
585 edits,
586 warnings,
587 recommend_sync: !dry_run && written_count > 0 && opts.sync_after,
588 ambiguous_surfaces,
589 lexicon_cache_hit,
590 style: opts.style,
591 })
592}
593
594#[derive(Debug, Clone)]
597struct SpanReplace {
598 start: usize,
599 end: usize,
600 replacement: String,
601 priority: u8,
603}
604
605#[derive(Debug, Clone)]
606struct RelatedInsert {
607 target_id: String,
608 surface: String,
609 priority: u8,
611}
612
613fn apply_span_replaces(content: &str, ops: &[SpanReplace]) -> Result<String> {
614 let mut ops: Vec<&SpanReplace> = ops.iter().collect();
615 ops.sort_by(|a, b| b.start.cmp(&a.start).then_with(|| b.end.cmp(&a.end)));
616
617 let mut ordered = ops.clone();
619 ordered.sort_by(|a, b| a.start.cmp(&b.start).then_with(|| a.end.cmp(&b.end)));
620 for w in ordered.windows(2) {
621 if w[0].end > w[1].start {
622 return Err(BrainError::other(format!(
623 "overlapping edits at {}..{} and {}..{}",
624 w[0].start, w[0].end, w[1].start, w[1].end
625 )));
626 }
627 }
628
629 let mut out = content.to_string();
630 for op in ops {
631 if op.start > op.end || op.end > out.len() {
632 return Err(BrainError::other(format!(
633 "invalid span {}..{} (len {})",
634 op.start,
635 op.end,
636 out.len()
637 )));
638 }
639 if !out.is_char_boundary(op.start) || !out.is_char_boundary(op.end) {
640 return Err(BrainError::other("edit span not on UTF-8 boundary"));
641 }
642 out.replace_range(op.start..op.end, &op.replacement);
643 }
644 Ok(out)
645}
646
647fn atomic_write(path: &Path, content: &str) -> Result<()> {
648 let parent = path.parent().unwrap_or_else(|| Path::new("."));
649 fs::create_dir_all(parent)?;
650 let tmp = parent.join(format!(
651 ".{}.rb-apply.{}.tmp",
652 path.file_name()
653 .and_then(|s| s.to_str())
654 .unwrap_or("note.md"),
655 std::process::id()
656 ));
657 fs::write(&tmp, content.as_bytes())?;
658 if let Ok(f) = fs::File::open(&tmp) {
659 let _ = f.sync_all();
660 }
661 fs::rename(&tmp, path).map_err(|e| {
662 let _ = fs::remove_file(&tmp);
663 BrainError::from(e)
664 })?;
665 Ok(())
666}
667
668fn prioritize_and_cap_ops(
669 file_ops: &mut HashMap<PathBuf, Vec<SpanReplace>>,
670 related_ops: &mut HashMap<PathBuf, Vec<RelatedInsert>>,
671 edits: &mut [ApplyEdit],
672 limit: usize,
673) {
674 enum CapKind {
675 Span { path: PathBuf, start: usize, end: usize, priority: u8 },
676 Related { path: PathBuf, target: String, priority: u8 },
677 }
678
679 let mut all: Vec<CapKind> = Vec::new();
680 for (p, ops) in file_ops.iter() {
681 for op in ops {
682 all.push(CapKind::Span {
683 path: p.clone(),
684 start: op.start,
685 end: op.end,
686 priority: op.priority,
687 });
688 }
689 }
690 for (p, ops) in related_ops.iter() {
691 for op in ops {
692 all.push(CapKind::Related {
693 path: p.clone(),
694 target: op.target_id.clone(),
695 priority: op.priority,
696 });
697 }
698 }
699 all.sort_by(|a, b| {
700 let (pa, path_a) = match a {
701 CapKind::Span { path, priority, .. } => (*priority, path),
702 CapKind::Related { path, priority, .. } => (*priority, path),
703 };
704 let (pb, path_b) = match b {
705 CapKind::Span { path, priority, .. } => (*priority, path),
706 CapKind::Related { path, priority, .. } => (*priority, path),
707 };
708 pa.cmp(&pb).then_with(|| path_a.cmp(path_b))
709 });
710
711 if all.len() <= limit {
712 return;
713 }
714
715 let mut keep_spans: HashSet<(PathBuf, usize, usize)> = HashSet::new();
716 let mut keep_related: HashSet<(PathBuf, String)> = HashSet::new();
717 for item in all.into_iter().take(limit) {
718 match item {
719 CapKind::Span {
720 path, start, end, ..
721 } => {
722 keep_spans.insert((path, start, end));
723 }
724 CapKind::Related { path, target, .. } => {
725 keep_related.insert((path, target));
726 }
727 }
728 }
729
730 for (p, ops) in file_ops.iter_mut() {
731 ops.retain(|op| keep_spans.contains(&(p.clone(), op.start, op.end)));
732 }
733 for (p, ops) in related_ops.iter_mut() {
734 ops.retain(|op| keep_related.contains(&(p.clone(), op.target_id.clone())));
735 }
736
737 for e in edits.iter_mut() {
738 if e.tier != ApplyTier::Auto || e.after.is_none() {
739 continue;
740 }
741 let still = match e.kind {
742 ApplyKind::DiscoverRelated => e
743 .target_id
744 .as_ref()
745 .is_some_and(|tid| related_ops.values().flatten().any(|r| &r.target_id == tid)),
746 _ => e.span.is_some_and(|(s, en)| {
747 file_ops
748 .values()
749 .flatten()
750 .any(|op| op.start == s && op.end == en)
751 }),
752 };
753 if !still {
754 e.tier = ApplyTier::Skip;
755 e.kind = ApplyKind::FileSkip;
756 e.reason = format!("capped by --limit {limit}");
757 e.after = None;
758 }
759 }
760}
761
762fn build_adjacency(db: &Database) -> Result<HashMap<String, HashSet<String>>> {
763 let mut adj: HashMap<String, HashSet<String>> = HashMap::new();
764 for e in db.get_all_edges()? {
765 adj.entry(e.source_id.clone())
766 .or_default()
767 .insert(e.target_id.clone());
768 adj.entry(e.target_id)
769 .or_default()
770 .insert(e.source_id);
771 }
772 Ok(adj)
773}
774
775const LEXICON_CACHE_VERSION: u32 = 1;
777const LEXICON_CACHE_NAME: &str = "link_lexicon.json";
778
779#[derive(Debug, Serialize, Deserialize)]
780struct LexiconCacheFile {
781 version: u32,
782 fingerprint: String,
783 surfaces: Vec<CachedSurface>,
784 ambiguous: Vec<String>,
785}
786
787#[derive(Debug, Serialize, Deserialize)]
788struct CachedSurface {
789 surface: String,
790 node_id: String,
791 kind: u8,
792}
793
794fn pattern_kind_to_u8(k: PatternKind) -> u8 {
795 match k {
796 PatternKind::Title => 0,
797 PatternKind::Alias => 1,
798 PatternKind::Stem => 2,
799 PatternKind::SymbolName => 3,
800 }
801}
802
803fn pattern_kind_from_u8(v: u8) -> PatternKind {
804 match v {
805 1 => PatternKind::Alias,
806 2 => PatternKind::Stem,
807 3 => PatternKind::SymbolName,
808 _ => PatternKind::Title,
809 }
810}
811
812fn lexicon_fingerprint(
813 nodes: &HashMap<String, Node>,
814 aliases: &HashMap<String, String>,
815) -> String {
816 let mut lines: Vec<String> = nodes
817 .values()
818 .map(|n| {
819 format!(
820 "n|{}|{}|{}|{}",
821 n.id,
822 n.title,
823 n.node_type.as_str(),
824 n.file_path.as_deref().unwrap_or("")
825 )
826 })
827 .collect();
828 lines.sort();
829 let mut alines: Vec<String> = aliases
830 .iter()
831 .map(|(a, id)| format!("a|{a}|{id}"))
832 .collect();
833 alines.sort();
834 lines.extend(alines);
835 let blob = lines.join("\n");
836 blake3::hash(blob.as_bytes()).to_hex().to_string()
837}
838
839fn load_or_compile_lexicon(
840 cache_dir: Option<&Path>,
841 nodes: &HashMap<String, Node>,
842 aliases: &HashMap<String, String>,
843) -> Result<(LinkLexicon, bool)> {
844 let fp = lexicon_fingerprint(nodes, aliases);
845 if let Some(dir) = cache_dir {
846 let path = dir.join(LEXICON_CACHE_NAME);
847 if path.is_file() {
848 if let Ok(text) = fs::read_to_string(&path) {
849 if let Ok(cached) = serde_json::from_str::<LexiconCacheFile>(&text) {
850 if cached.version == LEXICON_CACHE_VERSION && cached.fingerprint == fp {
851 let surfaces = cached
852 .surfaces
853 .into_iter()
854 .map(|s| {
855 (
856 s.surface,
857 s.node_id,
858 pattern_kind_from_u8(s.kind),
859 )
860 })
861 .collect();
862 return Ok((
863 LinkLexicon {
864 surfaces,
865 ambiguous: cached.ambiguous,
866 },
867 true,
868 ));
869 }
870 }
871 }
872 }
873 }
874
875 let lexicon = LinkLexicon::compile(nodes, aliases)?;
876 if let Some(dir) = cache_dir {
877 let _ = fs::create_dir_all(dir);
878 let cached = LexiconCacheFile {
879 version: LEXICON_CACHE_VERSION,
880 fingerprint: fp,
881 surfaces: lexicon
882 .surfaces
883 .iter()
884 .map(|(s, id, k)| CachedSurface {
885 surface: s.clone(),
886 node_id: id.clone(),
887 kind: pattern_kind_to_u8(*k),
888 })
889 .collect(),
890 ambiguous: lexicon.ambiguous.clone(),
891 };
892 if let Ok(json) = serde_json::to_string_pretty(&cached) {
893 let path = dir.join(LEXICON_CACHE_NAME);
894 let tmp = dir.join(format!(".{LEXICON_CACHE_NAME}.{}.tmp", std::process::id()));
895 if fs::write(&tmp, json).is_ok() {
896 let _ = fs::rename(&tmp, path);
897 } else {
898 let _ = fs::remove_file(&tmp);
899 }
900 }
901 }
902 Ok((lexicon, false))
903}
904
905fn insert_related_links(content: &str, links: &[RelatedInsert]) -> String {
907 if links.is_empty() {
908 return content.to_string();
909 }
910 let existing_targets: HashSet<String> = extract_wikilink_spans(content)
911 .into_iter()
912 .map(|s| s.link.target_node.to_ascii_lowercase())
913 .collect();
914
915 let mut lines_to_add: Vec<String> = Vec::new();
916 let mut seen = HashSet::new();
917 for l in links {
918 let key = l.target_id.to_ascii_lowercase();
919 if existing_targets.contains(&key) || !seen.insert(key) {
920 continue;
921 }
922 let md = format!("[[{}|{}]]", l.target_id, l.surface);
924 let md2 = format!("[[{}]]", l.target_id);
925 if content.contains(&md) || content.contains(&md2) {
926 continue;
927 }
928 lines_to_add.push(format!("- [[{}|{}]]", l.target_id, l.surface));
929 }
930 if lines_to_add.is_empty() {
931 return content.to_string();
932 }
933
934 let mut out = content.to_string();
936 let lower = out.to_ascii_lowercase();
937 if let Some(idx) = lower.find("\n## related") {
938 let after_nl = idx + 1;
940 let rest = &out[after_nl..];
941 let line_end = rest.find('\n').map(|i| after_nl + i + 1).unwrap_or(out.len());
942 let insert_at = line_end;
943 let block = format!("{}\n", lines_to_add.join("\n"));
944 out.insert_str(insert_at, &block);
945 } else if let Some(idx) = lower.find("## related") {
946 let rest = &out[idx..];
948 let line_end = rest.find('\n').map(|i| idx + i + 1).unwrap_or(out.len());
949 let block = format!("{}\n", lines_to_add.join("\n"));
950 out.insert_str(line_end, &block);
951 } else {
952 if !out.ends_with('\n') {
953 out.push('\n');
954 }
955 out.push_str("\n## Related\n\n");
956 out.push_str(&lines_to_add.join("\n"));
957 out.push('\n');
958 }
959 out
960}
961
962fn plan_pending_no_file(
965 p: &PendingLink,
966 source_id: &str,
967 ids: &HashSet<String>,
968 aliases: &HashMap<String, String>,
969 titles: &HashMap<String, String>,
970 symbol_ids: &HashSet<String>,
971 edits: &mut Vec<ApplyEdit>,
972) {
973 match resolve_pending_target(&p.raw_target, ids, aliases, titles, symbol_ids) {
974 Some(tid) => edits.push(ApplyEdit {
975 tier: ApplyTier::Skip,
976 kind: ApplyKind::PendingResolvedNoEdit,
977 source_id: source_id.into(),
978 file_path: None,
979 target_id: Some(tid),
980 before: p.raw_target.clone(),
981 after: None,
982 reason: "no markdown file for source — edge will resolve on sync, no text rewrite"
983 .into(),
984 written: false,
985 span: None,
986 }),
987 None => edits.push(ApplyEdit {
988 tier: ApplyTier::Skip,
989 kind: ApplyKind::PendingUnresolved,
990 source_id: source_id.into(),
991 file_path: None,
992 target_id: None,
993 before: p.raw_target.clone(),
994 after: None,
995 reason: "target still unresolved (and no source file)".into(),
996 written: false,
997 span: None,
998 }),
999 }
1000}
1001
1002fn plan_pending_for_file(
1003 source_id: &str,
1004 rel: &str,
1005 content: &str,
1006 pendings: &[PendingLink],
1007 ids: &HashSet<String>,
1008 aliases: &HashMap<String, String>,
1009 titles: &HashMap<String, String>,
1010 symbol_ids: &HashSet<String>,
1011 edits: &mut Vec<ApplyEdit>,
1012 file_ops: &mut HashMap<PathBuf, Vec<SpanReplace>>,
1013 abs: &Path,
1014) {
1015 let spans = extract_wikilink_spans(content);
1016
1017 for p in pendings {
1018 let resolved =
1019 resolve_pending_target(&p.raw_target, ids, aliases, titles, symbol_ids);
1020
1021 let Some(target_id) = resolved else {
1022 edits.push(ApplyEdit {
1023 tier: ApplyTier::Skip,
1024 kind: ApplyKind::PendingUnresolved,
1025 source_id: source_id.into(),
1026 file_path: Some(rel.into()),
1027 target_id: None,
1028 before: p.raw_target.clone(),
1029 after: None,
1030 reason: "target does not uniquely resolve — create the note or disambiguate"
1031 .into(),
1032 written: false,
1033 span: None,
1034 });
1035 continue;
1036 };
1037
1038 let wiki_raw = p
1040 .raw_target
1041 .strip_prefix("symbol:")
1042 .map(|s| format!("symbol:{s}"))
1043 .unwrap_or_else(|| p.raw_target.clone());
1044
1045 let mut matched = 0usize;
1046 for sp in &spans {
1047 if !wikilink_matches_pending(&sp.link, &p.raw_target) {
1048 continue;
1049 }
1050 matched += 1;
1051 let new_link = normalize_wikilink(&sp.link, &target_id, &p.raw_target);
1052 let after = new_link.to_markdown();
1053 let before = content[sp.start..sp.end].to_string();
1054 if before == after {
1055 edits.push(ApplyEdit {
1056 tier: ApplyTier::Skip,
1057 kind: ApplyKind::PendingResolvedNoEdit,
1058 source_id: source_id.into(),
1059 file_path: Some(rel.into()),
1060 target_id: Some(target_id.clone()),
1061 before,
1062 after: None,
1063 reason: "already canonical; edge will clear on sync".into(),
1064 written: false,
1065 span: Some((sp.start, sp.end)),
1066 });
1067 continue;
1068 }
1069 file_ops.entry(abs.to_path_buf()).or_default().push(SpanReplace {
1070 start: sp.start,
1071 end: sp.end,
1072 replacement: after.clone(),
1073 priority: 0,
1074 });
1075 edits.push(ApplyEdit {
1076 tier: ApplyTier::Auto,
1077 kind: ApplyKind::PendingWikiNormalize,
1078 source_id: source_id.into(),
1079 file_path: Some(rel.into()),
1080 target_id: Some(target_id.clone()),
1081 before,
1082 after: Some(after),
1083 reason: format!("pending `{}` → unique node `{target_id}`", p.raw_target),
1084 written: false,
1085 span: Some((sp.start, sp.end)),
1086 });
1087 }
1088
1089 if matched == 0 && p.raw_target.starts_with("symbol:") {
1091 if let Some((start, end, before)) = find_symbol_token(content, &p.raw_target) {
1092 edits.push(ApplyEdit {
1094 tier: ApplyTier::Skip,
1095 kind: ApplyKind::PendingResolvedNoEdit,
1096 source_id: source_id.into(),
1097 file_path: Some(rel.into()),
1098 target_id: Some(target_id),
1099 before,
1100 after: None,
1101 reason: "symbol: ref resolves; edge created on sync without rewrite".into(),
1102 written: false,
1103 span: Some((start, end)),
1104 });
1105 continue;
1106 }
1107 }
1108
1109 if matched == 0 {
1110 edits.push(ApplyEdit {
1111 tier: ApplyTier::Skip,
1112 kind: ApplyKind::PendingResolvedNoEdit,
1113 source_id: source_id.into(),
1114 file_path: Some(rel.into()),
1115 target_id: Some(target_id),
1116 before: p.raw_target.clone(),
1117 after: None,
1118 reason: format!(
1119 "resolves to target but `{wiki_raw}` not found as WikiLink in file (edge on sync)"
1120 ),
1121 written: false,
1122 span: None,
1123 });
1124 }
1125 }
1126}
1127
1128fn wikilink_matches_pending(link: &WikiLink, raw_target: &str) -> bool {
1129 let t = link.target_node.as_str();
1130 if t == raw_target {
1131 return true;
1132 }
1133 if let Some(rest) = raw_target.strip_prefix("symbol:") {
1135 if t == raw_target || t.strip_prefix("symbol:") == Some(rest) || t == rest {
1136 return true;
1137 }
1138 }
1139 t.eq_ignore_ascii_case(raw_target)
1141}
1142
1143fn normalize_wikilink(old: &WikiLink, canonical_id: &str, raw_pending: &str) -> WikiLink {
1144 let display = old.display_alias.clone().or_else(|| {
1146 if old.target_node != canonical_id && !raw_pending.starts_with("symbol:") {
1147 Some(old.target_node.clone())
1148 } else {
1149 None
1150 }
1151 });
1152 let target_node = if canonical_id.starts_with("symbol/") {
1154 if old.target_node.starts_with("symbol:") {
1155 old.target_node.clone()
1156 } else if raw_pending.starts_with("symbol:") {
1157 raw_pending.to_string()
1158 } else {
1159 canonical_id.to_string()
1160 }
1161 } else {
1162 canonical_id.to_string()
1163 };
1164 let display_alias = display.filter(|d| d != &target_node);
1165 WikiLink {
1166 target_node,
1167 section: old.section.clone(),
1168 display_alias,
1169 }
1170}
1171
1172fn resolve_pending_target(
1173 raw_target: &str,
1174 ids: &HashSet<String>,
1175 aliases: &HashMap<String, String>,
1176 titles: &HashMap<String, String>,
1177 symbol_ids: &HashSet<String>,
1178) -> Option<String> {
1179 if let Some(sym_path) = raw_target.strip_prefix("symbol:") {
1180 parse_symbol_path(sym_path)
1181 .and_then(|s| resolve_symbol_ref(&s, symbol_ids))
1182 .or_else(|| {
1183 parse_symbol_path(sym_path)
1184 .and_then(|s| resolve_link_target(&s.symbol_name, ids, aliases, titles))
1185 })
1186 } else {
1187 resolve_link_target(raw_target, ids, aliases, titles)
1188 }
1189}
1190
1191fn find_symbol_token(content: &str, raw_target: &str) -> Option<(usize, usize, String)> {
1192 let needle = raw_target;
1194 let bytes = content.as_bytes();
1195 let mut i = 0;
1196 let mut in_fence = false;
1197 while i + needle.len() <= bytes.len() {
1198 if is_line_start_bytes(bytes, i) && i + 2 < bytes.len() && &bytes[i..i + 3] == b"```" {
1199 in_fence = !in_fence;
1200 i += 3;
1201 continue;
1202 }
1203 if in_fence {
1204 i += 1;
1205 continue;
1206 }
1207 if bytes[i] == b'`' {
1208 i += 1;
1209 while i < bytes.len() && bytes[i] != b'`' {
1210 i += 1;
1211 }
1212 if i < bytes.len() {
1213 i += 1;
1214 }
1215 continue;
1216 }
1217 if content.is_char_boundary(i)
1218 && content[i..].starts_with(needle)
1219 && content.is_char_boundary(i + needle.len())
1220 {
1221 return Some((i, i + needle.len(), needle.to_string()));
1222 }
1223 i += 1;
1224 }
1225 None
1226}
1227
1228struct LinkLexicon {
1232 surfaces: Vec<(String, String, PatternKind)>,
1234 ambiguous: Vec<String>,
1236}
1237
1238#[derive(Debug, Clone, Copy)]
1239enum PatternKind {
1240 Title,
1241 Alias,
1242 Stem,
1243 SymbolName,
1244}
1245
1246impl LinkLexicon {
1247 fn compile(
1248 nodes: &HashMap<String, Node>,
1249 aliases: &HashMap<String, String>,
1250 ) -> Result<Self> {
1251 let mut map: HashMap<String, HashSet<String>> = HashMap::new();
1253 let mut original_case: HashMap<String, String> = HashMap::new();
1254 let mut kinds: HashMap<String, PatternKind> = HashMap::new();
1255
1256 for n in nodes.values() {
1257 if n.node_type == NodeType::Symbol {
1258 if let Some(name) = n.id.rsplit('/').next() {
1260 let short = name.rsplit("::").next().unwrap_or(name);
1262 add_surface(&mut map, &mut original_case, &mut kinds, short, &n.id, PatternKind::SymbolName);
1263 }
1264 continue;
1265 }
1266 add_surface(
1267 &mut map,
1268 &mut original_case,
1269 &mut kinds,
1270 &n.title,
1271 &n.id,
1272 PatternKind::Title,
1273 );
1274 if let Some(path) = &n.file_path {
1275 if let Some(stem) = Path::new(path).file_stem().and_then(|s| s.to_str()) {
1276 add_surface(
1277 &mut map,
1278 &mut original_case,
1279 &mut kinds,
1280 stem,
1281 &n.id,
1282 PatternKind::Stem,
1283 );
1284 }
1285 }
1286 }
1287 for (alias, id) in aliases {
1288 add_surface(
1289 &mut map,
1290 &mut original_case,
1291 &mut kinds,
1292 alias,
1293 id,
1294 PatternKind::Alias,
1295 );
1296 }
1297
1298 let mut surfaces = Vec::new();
1299 let mut ambiguous = Vec::new();
1300 for (lower, ids) in &map {
1301 if is_stop_surface(lower) {
1302 continue;
1303 }
1304 let min_len = match kinds.get(lower) {
1305 Some(PatternKind::SymbolName) => 3,
1306 Some(PatternKind::Alias) => 3,
1307 _ => 4,
1308 };
1309 if lower.chars().count() < min_len {
1310 continue;
1311 }
1312 if ids.len() != 1 {
1313 ambiguous.push(original_case.get(lower).cloned().unwrap_or_else(|| lower.clone()));
1314 continue;
1315 }
1316 let id = ids.iter().next().unwrap().clone();
1317 let surface = original_case
1318 .get(lower)
1319 .cloned()
1320 .unwrap_or_else(|| lower.clone());
1321 let kind = kinds.get(lower).copied().unwrap_or(PatternKind::Title);
1322 surfaces.push((surface, id, kind));
1323 }
1324
1325 surfaces.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
1327 ambiguous.sort();
1328 ambiguous.dedup();
1329
1330 Ok(Self {
1331 surfaces,
1332 ambiguous,
1333 })
1334 }
1335
1336 fn build_automaton(&self) -> Option<AhoCorasick> {
1337 if self.surfaces.is_empty() {
1338 return None;
1339 }
1340 let patterns: Vec<&str> = self.surfaces.iter().map(|(s, _, _)| s.as_str()).collect();
1341 AhoCorasickBuilder::new()
1342 .ascii_case_insensitive(true)
1343 .match_kind(MatchKind::LeftmostLongest)
1344 .build(patterns)
1345 .ok()
1346 }
1347
1348 fn node_for_pattern_index(&self, idx: usize) -> Option<(&str, PatternKind)> {
1349 self.surfaces
1350 .get(idx)
1351 .map(|(s, id, k)| {
1352 let _ = s;
1353 (id.as_str(), *k)
1354 })
1355 }
1356}
1357
1358fn add_surface(
1359 map: &mut HashMap<String, HashSet<String>>,
1360 original_case: &mut HashMap<String, String>,
1361 kinds: &mut HashMap<String, PatternKind>,
1362 surface: &str,
1363 node_id: &str,
1364 kind: PatternKind,
1365) {
1366 let s = surface.trim();
1367 if s.is_empty() {
1368 return;
1369 }
1370 let lower = s.to_ascii_lowercase();
1371 map.entry(lower.clone()).or_default().insert(node_id.to_string());
1372 original_case.entry(lower.clone()).or_insert_with(|| s.to_string());
1373 kinds.entry(lower).or_insert(kind);
1374}
1375
1376fn is_stop_surface(lower: &str) -> bool {
1377 matches!(
1379 lower,
1380 "the"
1381 | "and"
1382 | "for"
1383 | "with"
1384 | "from"
1385 | "this"
1386 | "that"
1387 | "into"
1388 | "over"
1389 | "under"
1390 | "about"
1391 | "after"
1392 | "before"
1393 | "data"
1394 | "file"
1395 | "files"
1396 | "path"
1397 | "type"
1398 | "name"
1399 | "value"
1400 | "error"
1401 | "errors"
1402 | "state"
1403 | "config"
1404 | "default"
1405 | "result"
1406 | "option"
1407 | "string"
1408 | "number"
1409 | "index"
1410 | "list"
1411 | "item"
1412 | "test"
1413 | "tests"
1414 | "main"
1415 | "lib"
1416 | "mod"
1417 | "use"
1418 | "impl"
1419 | "self"
1420 | "super"
1421 | "crate"
1422 | "pub"
1423 | "fn"
1424 | "struct"
1425 | "enum"
1426 | "trait"
1427 | "const"
1428 | "static"
1429 | "true"
1430 | "false"
1431 | "none"
1432 | "some"
1433 | "ok"
1434 | "err"
1435 | "note"
1436 | "notes"
1437 | "docs"
1438 | "doc"
1439 | "readme"
1440 | "todo"
1441 | "fixme"
1442 | "open"
1443 | "read"
1444 | "write"
1445 | "load"
1446 | "save"
1447 | "run"
1448 | "new"
1449 | "get"
1450 | "set"
1451 | "add"
1452 | "remove"
1453 | "create"
1454 | "update"
1455 | "delete"
1456 | "code"
1457 | "text"
1458 | "body"
1459 | "title"
1460 | "link"
1461 | "links"
1462 | "node"
1463 | "nodes"
1464 | "edge"
1465 | "edges"
1466 | "graph"
1467 | "query"
1468 | "context"
1469 | "sync"
1470 | "build"
1471 | "cargo"
1472 | "rust"
1473 | "http"
1474 | "https"
1475 | "json"
1476 | "yaml"
1477 | "markdown"
1478 | "generated"
1479 | "implementation"
1480 | "concept"
1481 | "concepts"
1482 | "goal"
1483 | "goals"
1484 | "analysis"
1485 | "reference"
1486 | "alternative"
1487 | "edge_case"
1488 | "edge-case"
1489 | "adr"
1490 | "symbol"
1491 | "module"
1492 | "modules"
1493 | "function"
1494 | "functions"
1495 | "method"
1496 | "methods"
1497 | "field"
1498 | "fields"
1499 | "param"
1500 | "params"
1501 | "return"
1502 | "returns"
1503 | "input"
1504 | "output"
1505 | "user"
1506 | "users"
1507 | "app"
1508 | "application"
1509 | "system"
1510 | "service"
1511 | "server"
1512 | "client"
1513 | "api"
1514 | "id"
1515 | "ids"
1516 | "key"
1517 | "keys"
1518 | "map"
1519 | "vec"
1520 | "vector"
1521 | "array"
1522 | "table"
1523 | "row"
1524 | "column"
1525 | "page"
1526 | "home"
1527 | "info"
1528 | "debug"
1529 | "trace"
1530 | "warn"
1531 | "warning"
1532 | "log"
1533 | "logs"
1534 | "time"
1535 | "date"
1536 | "version"
1537 | "v1"
1538 | "v2"
1539 | "see"
1540 | "also"
1541 | "related"
1542 | "summary"
1543 | "status"
1544 | "decision"
1545 | "consequences"
1546 | "checklist"
1547 | "bootstrap"
1548 | "template"
1549 | "scaffold"
1550 | "example"
1551 | "examples"
1552 | "usage"
1553 | "install"
1554 | "license"
1555 | "mit"
1556 | "apache"
1557 ) || lower.len() <= 2
1558}
1559
1560fn plan_discover_for_file(
1561 source: &Node,
1562 rel: &str,
1563 content: &str,
1564 lexicon: &LinkLexicon,
1565 ac: &AhoCorasick,
1566 report_suggest: bool,
1567 style: ApplyStyle,
1568 use_graph_priors: bool,
1569 neighbors: &HashSet<String>,
1570 edits: &mut Vec<ApplyEdit>,
1571 file_ops: &mut HashMap<PathBuf, Vec<SpanReplace>>,
1572 related_ops: &mut HashMap<PathBuf, Vec<RelatedInsert>>,
1573 abs: &Path,
1574) {
1575 let mask = build_unlinkable_mask(content);
1576 let existing_targets: HashSet<String> = extract_wikilink_spans(content)
1577 .into_iter()
1578 .map(|s| s.link.target_node.to_ascii_lowercase())
1579 .collect();
1580
1581 let mut linked_targets: HashSet<String> = HashSet::new();
1583
1584 struct Cand {
1586 start: usize,
1587 end: usize,
1588 node_id: String,
1589 kind: PatternKind,
1590 surface: String,
1591 graph_boost: bool,
1592 }
1593 let mut cands: Vec<Cand> = Vec::new();
1594
1595 for mat in ac.find_iter(content) {
1596 let start = mat.start();
1597 let end = mat.end();
1598 if !content.is_char_boundary(start) || !content.is_char_boundary(end) {
1599 continue;
1600 }
1601 if region_blocked(&mask, start, end) {
1602 continue;
1603 }
1604 if !identifier_boundaries(content, start, end) {
1605 continue;
1606 }
1607 let Some((node_id, kind)) = lexicon.node_for_pattern_index(mat.pattern().as_usize()) else {
1608 continue;
1609 };
1610 if node_id == source.id {
1611 continue;
1612 }
1613 if linked_targets.contains(node_id) {
1614 continue;
1615 }
1616 if existing_targets.contains(&node_id.to_ascii_lowercase()) {
1617 continue;
1618 }
1619 let surface = content[start..end].to_string();
1620 if existing_targets.contains(&surface.to_ascii_lowercase()) {
1621 continue;
1622 }
1623 let graph_boost = use_graph_priors && neighbors.contains(node_id);
1624 cands.push(Cand {
1625 start,
1626 end,
1627 node_id: node_id.to_string(),
1628 kind,
1629 surface,
1630 graph_boost,
1631 });
1632 linked_targets.insert(node_id.to_string());
1633 }
1634
1635 cands.sort_by(|a, b| {
1637 b.graph_boost
1638 .cmp(&a.graph_boost)
1639 .then_with(|| b.surface.len().cmp(&a.surface.len()))
1640 .then_with(|| a.start.cmp(&b.start))
1641 });
1642
1643 linked_targets.clear();
1645
1646 for c in cands {
1647 if !linked_targets.insert(c.node_id.clone()) {
1648 continue;
1649 }
1650
1651 let surface_len = c.surface.chars().count();
1652 let mut tier = match c.kind {
1653 PatternKind::Title | PatternKind::Alias if surface_len >= 4 => ApplyTier::Auto,
1655 PatternKind::SymbolName if c.surface.chars().any(|ch| ch.is_ascii_uppercase()) => {
1656 ApplyTier::Auto
1657 }
1658 _ => ApplyTier::Suggest,
1659 };
1660 if c.graph_boost && tier == ApplyTier::Suggest {
1662 tier = ApplyTier::Auto;
1663 }
1664
1665 let mut reason = match c.kind {
1666 PatternKind::Title | PatternKind::Alias if surface_len >= 4 => {
1667 format!(
1668 "discover unique {} mention → `{}`",
1669 kind_name(c.kind),
1670 c.node_id
1671 )
1672 }
1673 PatternKind::SymbolName if c.surface.chars().any(|ch| ch.is_ascii_uppercase()) => {
1674 format!("discover unique symbol-like mention → `{}`", c.node_id)
1675 }
1676 _ => format!(
1677 "discover weak {} mention → `{}` (suggest only)",
1678 kind_name(c.kind),
1679 c.node_id
1680 ),
1681 };
1682 if c.graph_boost {
1683 reason.push_str(" [graph-neighbor boost]");
1684 }
1685
1686 if tier == ApplyTier::Suggest && !report_suggest {
1687 continue;
1688 }
1689
1690 let after = format!("[[{}|{}]]", c.node_id, c.surface);
1691 let before = c.surface.clone();
1692
1693 if tier != ApplyTier::Auto {
1694 edits.push(ApplyEdit {
1695 tier: ApplyTier::Suggest,
1696 kind: match style {
1697 ApplyStyle::Wrap => ApplyKind::DiscoverWrap,
1698 ApplyStyle::Related => ApplyKind::DiscoverRelated,
1699 },
1700 source_id: source.id.clone(),
1701 file_path: Some(rel.into()),
1702 target_id: Some(c.node_id.clone()),
1703 before,
1704 after: Some(after),
1705 reason,
1706 written: false,
1707 span: Some((c.start, c.end)),
1708 });
1709 continue;
1710 }
1711
1712 match style {
1713 ApplyStyle::Wrap => {
1714 let ops = file_ops.entry(abs.to_path_buf()).or_default();
1715 if ops
1716 .iter()
1717 .any(|o| spans_overlap(o.start, o.end, c.start, c.end))
1718 {
1719 edits.push(ApplyEdit {
1720 tier: ApplyTier::Skip,
1721 kind: ApplyKind::DiscoverSkip,
1722 source_id: source.id.clone(),
1723 file_path: Some(rel.into()),
1724 target_id: Some(c.node_id),
1725 before,
1726 after: None,
1727 reason: "overlaps another planned edit".into(),
1728 written: false,
1729 span: Some((c.start, c.end)),
1730 });
1731 continue;
1732 }
1733 ops.push(SpanReplace {
1734 start: c.start,
1735 end: c.end,
1736 replacement: after.clone(),
1737 priority: if c.graph_boost { 1 } else { 2 },
1738 });
1739 edits.push(ApplyEdit {
1740 tier: ApplyTier::Auto,
1741 kind: ApplyKind::DiscoverWrap,
1742 source_id: source.id.clone(),
1743 file_path: Some(rel.into()),
1744 target_id: Some(c.node_id),
1745 before,
1746 after: Some(after),
1747 reason,
1748 written: false,
1749 span: Some((c.start, c.end)),
1750 });
1751 }
1752 ApplyStyle::Related => {
1753 related_ops
1754 .entry(abs.to_path_buf())
1755 .or_default()
1756 .push(RelatedInsert {
1757 target_id: c.node_id.clone(),
1758 surface: c.surface.clone(),
1759 priority: if c.graph_boost { 1 } else { 2 },
1760 });
1761 edits.push(ApplyEdit {
1762 tier: ApplyTier::Auto,
1763 kind: ApplyKind::DiscoverRelated,
1764 source_id: source.id.clone(),
1765 file_path: Some(rel.into()),
1766 target_id: Some(c.node_id),
1767 before,
1768 after: Some(after),
1769 reason: format!("{reason} (style=related)"),
1770 written: false,
1771 span: None,
1772 });
1773 }
1774 }
1775 }
1776}
1777
1778fn kind_name(k: PatternKind) -> &'static str {
1779 match k {
1780 PatternKind::Title => "title",
1781 PatternKind::Alias => "alias",
1782 PatternKind::Stem => "stem",
1783 PatternKind::SymbolName => "symbol",
1784 }
1785}
1786
1787fn spans_overlap(a0: usize, a1: usize, b0: usize, b1: usize) -> bool {
1788 a0 < b1 && b0 < a1
1789}
1790
1791fn build_unlinkable_mask(content: &str) -> Vec<u8> {
1793 let mut mask = vec![0u8; content.len()];
1794 let bytes = content.as_bytes();
1795 let len = bytes.len();
1796
1797 if let Some(body_start) = frontmatter_body_start(content) {
1799 for b in mask.iter_mut().take(body_start) {
1800 *b = 1;
1801 }
1802 }
1803
1804 let mut i = 0;
1805 let mut in_fence = false;
1806 while i < len {
1807 if is_line_start_bytes(bytes, i) && i + 2 < len && &bytes[i..i + 3] == b"```" {
1808 let fence_start = i;
1810 in_fence = !in_fence;
1811 i += 3;
1812 if in_fence {
1813 while i < len {
1815 if is_line_start_bytes(bytes, i) && i + 2 < len && &bytes[i..i + 3] == b"```" {
1816 for b in mask.iter_mut().take(i + 3).skip(fence_start) {
1817 *b = 1;
1818 }
1819 i += 3;
1820 in_fence = false;
1821 break;
1822 }
1823 i += 1;
1824 }
1825 if in_fence {
1826 for b in mask.iter_mut().skip(fence_start) {
1827 *b = 1;
1828 }
1829 }
1830 }
1831 continue;
1832 }
1833 if bytes[i] == b'`' {
1834 let s = i;
1835 i += 1;
1836 while i < len && bytes[i] != b'`' {
1837 i += 1;
1838 }
1839 if i < len {
1840 i += 1;
1841 }
1842 for b in mask.iter_mut().take(i).skip(s) {
1843 *b = 1;
1844 }
1845 continue;
1846 }
1847 if i + 1 < len && bytes[i] == b'[' && bytes[i + 1] == b'[' {
1848 if let Some(rel) = content[i + 2..].find("]]") {
1849 let end = i + 2 + rel + 2;
1850 for b in mask.iter_mut().take(end).skip(i) {
1851 *b = 1;
1852 }
1853 i = end;
1854 continue;
1855 }
1856 }
1857 i += 1;
1858 }
1859 mask
1860}
1861
1862fn frontmatter_body_start(content: &str) -> Option<usize> {
1863 let trimmed = content.trim_start();
1864 let trim_off = content.len() - trimmed.len();
1865 if !trimmed.starts_with("---") {
1866 return None;
1867 }
1868 let rest = &trimmed[3..];
1869 let rest = rest.strip_prefix('\n').unwrap_or(rest);
1870 let rest_off = content.len() - rest.len();
1871 if let Some(end_idx) = rest.find("\n---") {
1872 let after = end_idx + 4;
1873 let body_rel = rest[after..].trim_start_matches('\n');
1874 let body_off = rest_off + after + (rest[after..].len() - body_rel.len());
1875 return Some(body_off.max(trim_off));
1876 }
1877 None
1878}
1879
1880fn region_blocked(mask: &[u8], start: usize, end: usize) -> bool {
1881 if end > mask.len() {
1882 return true;
1883 }
1884 mask[start..end].iter().any(|&b| b != 0)
1885}
1886
1887fn identifier_boundaries(content: &str, start: usize, end: usize) -> bool {
1888 let bytes = content.as_bytes();
1889 if start > 0 {
1890 let c = bytes[start - 1];
1891 if c.is_ascii_alphanumeric() || c == b'_' {
1892 return false;
1893 }
1894 if content.is_char_boundary(start) {
1896 if let Some(ch) = content[..start].chars().last() {
1897 if ch.is_alphanumeric() || ch == '_' {
1898 return false;
1899 }
1900 }
1901 }
1902 }
1903 if end < bytes.len() {
1904 let c = bytes[end];
1905 if c.is_ascii_alphanumeric() || c == b'_' {
1906 return false;
1907 }
1908 if content.is_char_boundary(end) {
1909 if let Some(ch) = content[end..].chars().next() {
1910 if ch.is_alphanumeric() || ch == '_' {
1911 return false;
1912 }
1913 }
1914 }
1915 }
1916 true
1917}
1918
1919fn is_line_start_bytes(bytes: &[u8], i: usize) -> bool {
1920 i == 0 || bytes[i - 1] == b'\n'
1921}
1922
1923fn is_generated_file(rel: &str, content: &str) -> bool {
1924 let path = rel.replace('\\', "/");
1925 if path.contains(".generated.")
1926 || path.ends_with("module-map.generated.md")
1927 || path.contains("/generated/")
1928 {
1929 return true;
1930 }
1931 let (fm, _) = parse_frontmatter(content);
1932 if let Some(fm) = fm {
1933 if let Some(v) = fm.extra.get("generated") {
1934 match v {
1935 serde_yaml_ng::Value::Bool(true) => return true,
1936 serde_yaml_ng::Value::String(s) if s == "true" || s == "yes" => return true,
1937 _ => {}
1938 }
1939 }
1940 }
1941 content.lines().take(20).any(|l| {
1942 let t = l.trim();
1943 t == "generated: true" || t == "generated: yes"
1944 })
1945}
1946
1947fn resolve_source_filter(
1948 db: &Database,
1949 target: &str,
1950 nodes: &HashMap<String, Node>,
1951) -> Result<HashSet<String>> {
1952 match crate::graph::resolve_graph_target(db, target) {
1954 Ok(n) => Ok(HashSet::from([n.id])),
1955 Err(_) => {
1956 let raw = target.trim().trim_start_matches("./").replace('\\', "/");
1958 let hits: Vec<_> = nodes
1959 .values()
1960 .filter(|n| {
1961 n.id == raw
1962 || n.file_path
1963 .as_ref()
1964 .is_some_and(|p| p == &raw || p.ends_with(&raw))
1965 })
1966 .map(|n| n.id.clone())
1967 .collect();
1968 if hits.is_empty() {
1969 Err(BrainError::other(format!(
1970 "apply target `{target}` not found — pass a source path or node id"
1971 )))
1972 } else {
1973 Ok(hits.into_iter().collect())
1974 }
1975 }
1976 }
1977}
1978
1979#[cfg(test)]
1980mod tests {
1981 use super::*;
1982 use crate::types::{Node, NodeType};
1983 use tempfile::tempdir;
1984
1985 fn setup() -> (tempfile::TempDir, Database, PathBuf) {
1986 let dir = tempdir().unwrap();
1987 let ws = dir.path().to_path_buf();
1988 let docs = ws.join("docs/concepts");
1989 fs::create_dir_all(&docs).unwrap();
1990 fs::write(
1991 docs.join("raft.md"),
1992 "---\nnode_type: concept\naliases: [RaftConsensus]\n---\n# Raft\n\nSee [[LogCompaction]] and the StorageEngine path.\n",
1993 )
1994 .unwrap();
1995 fs::write(
1996 docs.join("logcompaction.md"),
1997 "---\nnode_type: concept\n---\n# Log Compaction\n\nDetails.\n",
1998 )
1999 .unwrap();
2000
2001 let db = Database::open(ws.join("db.sqlite")).unwrap();
2002 let now = 1_700_000_000i64;
2003 let raft = Node {
2004 id: "docs/concepts/raft".into(),
2005 node_type: NodeType::Concept,
2006 title: "Raft".into(),
2007 file_path: Some("docs/concepts/raft.md".into()),
2008 symbol_hash: None,
2009 summary: None,
2010 content_hash: None,
2011 created_at: now,
2012 updated_at: now,
2013 };
2014 let logc = Node {
2015 id: "docs/concepts/logcompaction".into(),
2016 node_type: NodeType::Concept,
2017 title: "Log Compaction".into(),
2018 file_path: Some("docs/concepts/logcompaction.md".into()),
2019 symbol_hash: None,
2020 summary: None,
2021 content_hash: None,
2022 created_at: now,
2023 updated_at: now,
2024 };
2025 db.insert_node(&raft).unwrap();
2026 db.insert_node(&logc).unwrap();
2027 db.replace_node_aliases(
2028 "docs/concepts/raft",
2029 &["RaftConsensus".into(), "Raft".into()],
2030 )
2031 .unwrap();
2032 db.replace_node_aliases(
2033 "docs/concepts/logcompaction",
2034 &["LogCompaction".into(), "Log Compaction".into()],
2035 )
2036 .unwrap();
2037 db.insert_pending_link(
2038 "docs/concepts/raft",
2039 "LogCompaction",
2040 "relates_to",
2041 now,
2042 )
2043 .unwrap();
2044 (dir, db, ws)
2045 }
2046
2047 #[test]
2048 fn phase0_dry_run_plans_wiki_normalize() {
2049 let (_dir, db, ws) = setup();
2050 let report = apply_links(
2051 &ws,
2052 &db,
2053 &ApplyOptions {
2054 write: false,
2055 dry_run: true,
2056 ..ApplyOptions::default()
2057 },
2058 )
2059 .unwrap();
2060 assert!(report.dry_run);
2061 assert!(
2062 report.auto_planned >= 1,
2063 "expected auto plan, got {:?}",
2064 report.edits
2065 );
2066 let e = report
2067 .edits
2068 .iter()
2069 .find(|e| e.kind == ApplyKind::PendingWikiNormalize)
2070 .expect("normalize edit");
2071 assert!(e.after.as_ref().unwrap().contains("docs/concepts/logcompaction"));
2072 let body = fs::read_to_string(ws.join("docs/concepts/raft.md")).unwrap();
2074 assert!(body.contains("[[LogCompaction]]"));
2075 }
2076
2077 #[test]
2078 fn phase0_write_rewrites_and_preserves_display() {
2079 let (_dir, db, ws) = setup();
2080 let report = apply_links(
2081 &ws,
2082 &db,
2083 &ApplyOptions {
2084 write: true,
2085 dry_run: false,
2086 sync_after: false,
2087 ..ApplyOptions::default()
2088 },
2089 )
2090 .unwrap();
2091 assert!(report.written >= 1);
2092 let body = fs::read_to_string(ws.join("docs/concepts/raft.md")).unwrap();
2093 assert!(
2094 body.contains("[[docs/concepts/logcompaction|LogCompaction]]")
2095 || body.contains("[[docs/concepts/logcompaction]]"),
2096 "body={body}"
2097 );
2098 assert!(!body.contains("[[LogCompaction]]"));
2099 }
2100
2101 #[test]
2102 fn phase0_skips_unresolved() {
2103 let (_dir, db, ws) = setup();
2104 db.insert_pending_link("docs/concepts/raft", "NoSuchNote", "relates_to", 1)
2105 .unwrap();
2106 let report = apply_links(
2107 &ws,
2108 &db,
2109 &ApplyOptions {
2110 write: false,
2111 ..ApplyOptions::default()
2112 },
2113 )
2114 .unwrap();
2115 assert!(report
2116 .edits
2117 .iter()
2118 .any(|e| e.kind == ApplyKind::PendingUnresolved && e.before == "NoSuchNote"));
2119 }
2120
2121 #[test]
2122 fn phase0_skips_generated_without_force() {
2123 let dir = tempdir().unwrap();
2124 let ws = dir.path().to_path_buf();
2125 let path = ws.join("docs/implementation");
2126 fs::create_dir_all(&path).unwrap();
2127 fs::write(
2128 path.join("module-map.generated.md"),
2129 "---\ngenerated: true\n---\n# Map\n[[LogCompaction]]\n",
2130 )
2131 .unwrap();
2132 let db = Database::open(ws.join("db.sqlite")).unwrap();
2133 let now = 1i64;
2134 db.insert_node(&Node {
2135 id: "docs/implementation/module-map.generated".into(),
2136 node_type: NodeType::Concept,
2137 title: "Map".into(),
2138 file_path: Some("docs/implementation/module-map.generated.md".into()),
2139 symbol_hash: None,
2140 summary: None,
2141 content_hash: None,
2142 created_at: now,
2143 updated_at: now,
2144 })
2145 .unwrap();
2146 db.insert_node(&Node {
2147 id: "docs/concepts/logcompaction".into(),
2148 node_type: NodeType::Concept,
2149 title: "Log Compaction".into(),
2150 file_path: Some("docs/concepts/logcompaction.md".into()),
2151 symbol_hash: None,
2152 summary: None,
2153 content_hash: None,
2154 created_at: now,
2155 updated_at: now,
2156 })
2157 .unwrap();
2158 db.insert_pending_link(
2159 "docs/implementation/module-map.generated",
2160 "LogCompaction",
2161 "relates_to",
2162 now,
2163 )
2164 .unwrap();
2165 let report = apply_links(&ws, &db, &ApplyOptions::default()).unwrap();
2166 assert!(report.edits.iter().any(|e| e.reason.contains("generated")));
2167 }
2168
2169 #[test]
2170 fn phase1_discover_wraps_title_mention() {
2171 let (_dir, db, ws) = setup();
2172 fs::write(
2174 ws.join("docs/concepts/logcompaction.md"),
2175 "---\nnode_type: concept\n---\n# Log Compaction\n\nRaft is related here.\n",
2176 )
2177 .unwrap();
2178 let report = apply_links(
2179 &ws,
2180 &db,
2181 &ApplyOptions {
2182 write: true,
2183 dry_run: false,
2184 discover: true,
2185 sync_after: false,
2186 ..ApplyOptions::default()
2188 },
2189 )
2190 .unwrap();
2191 let body = fs::read_to_string(ws.join("docs/concepts/logcompaction.md")).unwrap();
2192 assert!(
2193 body.contains("[[docs/concepts/raft|Raft]]") || report.suggest_planned + report.auto_planned > 0,
2194 "body={body} report={:?}",
2195 report.edits
2196 );
2197 }
2198
2199 #[test]
2200 fn apply_span_replaces_from_end() {
2201 let s = "aaa bbb ccc";
2202 let ops = vec![
2203 SpanReplace {
2204 start: 0,
2205 end: 3,
2206 replacement: "AAA".into(),
2207 priority: 0,
2208 },
2209 SpanReplace {
2210 start: 4,
2211 end: 7,
2212 replacement: "BBB".into(),
2213 priority: 0,
2214 },
2215 ];
2216 let out = apply_span_replaces(s, &ops).unwrap();
2217 assert_eq!(out, "AAA BBB ccc");
2218 }
2219
2220 #[test]
2221 fn mask_blocks_wikilinks_and_code() {
2222 let s = "See [[Raft]] and `Raft` and Raft.";
2223 let mask = build_unlinkable_mask(s);
2224 let idx = s.rfind("Raft").unwrap();
2226 assert_eq!(mask[idx], 0);
2227 let wiki = s.find("[[").unwrap();
2228 assert_eq!(mask[wiki], 1);
2229 }
2230
2231 #[test]
2232 fn related_style_appends_section() {
2233 let (_dir, db, ws) = setup();
2234 fs::write(
2235 ws.join("docs/concepts/logcompaction.md"),
2236 "---\nnode_type: concept\n---\n# Log Compaction\n\nRaft is related here.\n",
2237 )
2238 .unwrap();
2239 let brain_dir = ws.join(".brain");
2240 fs::create_dir_all(&brain_dir).unwrap();
2241 let report = apply_links(
2242 &ws,
2243 &db,
2244 &ApplyOptions {
2245 write: true,
2246 dry_run: false,
2247 discover: true,
2248 style: ApplyStyle::Related,
2249 sync_after: false,
2250 cache_dir: Some(brain_dir),
2251 graph_priors: false,
2252 ..ApplyOptions::default()
2253 },
2254 )
2255 .unwrap();
2256 let body = fs::read_to_string(ws.join("docs/concepts/logcompaction.md")).unwrap();
2257 assert!(
2258 body.contains("## Related") && body.contains("docs/concepts/raft"),
2259 "body={body} edits={:?}",
2260 report.edits
2261 );
2262 assert!(body.contains("Raft is related here"));
2264 assert!(!body.contains("[[docs/concepts/raft|Raft]] is related"));
2265 }
2266
2267 #[test]
2268 fn lexicon_cache_hit_on_second_compile() {
2269 let (_dir, db, ws) = setup();
2270 let cache = ws.join(".brain");
2271 fs::create_dir_all(&cache).unwrap();
2272 let nodes: HashMap<_, _> = db
2273 .get_all_nodes()
2274 .unwrap()
2275 .into_iter()
2276 .map(|n| (n.id.clone(), n))
2277 .collect();
2278 let (_, aliases, _) = db.link_resolution_maps().unwrap();
2279 let (_lex, hit1) = load_or_compile_lexicon(Some(&cache), &nodes, &aliases).unwrap();
2280 assert!(!hit1);
2281 let (_lex, hit2) = load_or_compile_lexicon(Some(&cache), &nodes, &aliases).unwrap();
2282 assert!(hit2);
2283 assert!(cache.join("link_lexicon.json").is_file());
2284 }
2285
2286 #[test]
2287 fn insert_related_idempotent() {
2288 let base = "# Note\n\nBody.\n";
2289 let links = vec![RelatedInsert {
2290 target_id: "docs/concepts/raft".into(),
2291 surface: "Raft".into(),
2292 priority: 1,
2293 }];
2294 let once = insert_related_links(base, &links);
2295 let twice = insert_related_links(&once, &links);
2296 assert_eq!(
2297 once.matches("docs/concepts/raft").count(),
2298 twice.matches("docs/concepts/raft").count()
2299 );
2300 }
2301
2302 #[test]
2307 fn integration_pending_apply_clears_ledger() {
2308 use crate::indexer::WorkspaceIndexer;
2309 use crate::types::{Node, NodeType};
2310
2311 let dir = tempdir().unwrap();
2312 let ws = dir.path().to_path_buf();
2313 let docs = ws.join("docs/concepts");
2314 fs::create_dir_all(&docs).unwrap();
2315 fs::write(
2316 docs.join("raft.md"),
2317 "---\nnode_type: concept\naliases: [Raft]\n---\n# Raft\n\nSee [[LogCompaction]].\n",
2318 )
2319 .unwrap();
2320 let brain = ws.join(".brain");
2321 fs::create_dir_all(&brain).unwrap();
2322 let db = Database::open(brain.join("db.sqlite")).unwrap();
2323 let indexer = WorkspaceIndexer::new(db, &ws);
2324 indexer.index_workspace().unwrap();
2325
2326 let db = Database::open(brain.join("db.sqlite")).unwrap();
2327 assert!(
2328 db.count_pending_links().unwrap() >= 1,
2329 "expected pending after index without target"
2330 );
2331
2332 fs::write(
2334 docs.join("logcompaction.md"),
2335 "---\nnode_type: concept\naliases: [LogCompaction]\n---\n# Log Compaction\n\nBody.\n",
2336 )
2337 .unwrap();
2338 let now = 1_700_000_000i64;
2339 db.insert_node(&Node {
2340 id: "docs/concepts/logcompaction".into(),
2341 node_type: NodeType::Concept,
2342 title: "Log Compaction".into(),
2343 file_path: Some("docs/concepts/logcompaction.md".into()),
2344 symbol_hash: None,
2345 summary: None,
2346 content_hash: None,
2347 created_at: now,
2348 updated_at: now,
2349 })
2350 .unwrap();
2351 db.replace_node_aliases(
2352 "docs/concepts/logcompaction",
2353 &["LogCompaction".into(), "Log Compaction".into()],
2354 )
2355 .unwrap();
2356 assert!(
2357 db.count_pending_links().unwrap() >= 1,
2358 "pending should still be present before apply"
2359 );
2360
2361 let report = apply_links(
2362 &ws,
2363 &db,
2364 &ApplyOptions {
2365 write: true,
2366 dry_run: false,
2367 sync_after: false,
2368 cache_dir: Some(brain.clone()),
2369 ..ApplyOptions::default()
2370 },
2371 )
2372 .unwrap();
2373 assert!(
2374 report.written >= 1,
2375 "expected rewrite written: {:?}",
2376 report.edits
2377 );
2378 let raft_body = fs::read_to_string(docs.join("raft.md")).unwrap();
2379 assert!(
2380 raft_body.contains("docs/concepts/logcompaction"),
2381 "body={raft_body}"
2382 );
2383
2384 let db = Database::open(brain.join("db.sqlite")).unwrap();
2386 let indexer = WorkspaceIndexer::new(db, &ws);
2387 indexer.index_workspace().unwrap();
2388 let db = Database::open(brain.join("db.sqlite")).unwrap();
2389 let still = db.list_pending_links().unwrap();
2390 assert!(
2391 !still
2392 .iter()
2393 .any(|p| p.source_id.contains("raft") && p.raw_target.contains("LogCompaction")),
2394 "still pending: {still:?}"
2395 );
2396 }
2397
2398 #[test]
2399 fn graph_prior_promotes_neighbor() {
2400 let (_dir, db, ws) = setup();
2401 db.insert_edge(&crate::types::Edge {
2404 source_id: "docs/concepts/logcompaction".into(),
2405 target_id: "docs/concepts/raft".into(),
2406 relation_type: "relates_to".into(),
2407 weight: 1.0,
2408 decay_rate: 0.0,
2409 created_at: 1,
2410 })
2411 .unwrap();
2412 fs::write(
2413 ws.join("docs/concepts/logcompaction.md"),
2414 "---\nnode_type: concept\n---\n# Log Compaction\n\nRaft matters.\n",
2415 )
2416 .unwrap();
2417 let report = apply_links(
2418 &ws,
2419 &db,
2420 &ApplyOptions {
2421 write: false,
2422 discover: true,
2423 graph_priors: true,
2424 ..ApplyOptions::default()
2425 },
2426 )
2427 .unwrap();
2428 let boosted = report.edits.iter().any(|e| {
2429 e.reason.contains("graph-neighbor")
2430 && e.target_id.as_deref() == Some("docs/concepts/raft")
2431 });
2432 assert!(boosted, "edits={:?}", report.edits);
2433 }
2434}