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)]
32pub struct ApplyOptions {
33 pub write: bool,
35 pub dry_run: bool,
37 pub discover: bool,
39 pub force_generated: bool,
41 pub limit: usize,
43 pub target: Option<String>,
45 pub sync_after: bool,
47 pub report_suggest: bool,
49}
50
51impl Default for ApplyOptions {
52 fn default() -> Self {
53 Self {
54 write: false,
55 dry_run: true,
56 discover: false,
57 force_generated: false,
58 limit: 200,
59 target: None,
60 sync_after: true,
61 report_suggest: true,
62 }
63 }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum ApplyTier {
70 Auto,
72 Suggest,
74 Skip,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum ApplyKind {
82 PendingWikiNormalize,
84 PendingResolvedNoEdit,
86 PendingUnresolved,
88 DiscoverWrap,
90 DiscoverSkip,
92 FileSkip,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct ApplyEdit {
99 pub tier: ApplyTier,
101 pub kind: ApplyKind,
103 pub source_id: String,
105 pub file_path: Option<String>,
107 pub target_id: Option<String>,
109 pub before: String,
111 pub after: Option<String>,
113 pub reason: String,
115 pub written: bool,
117 pub span: Option<(usize, usize)>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ApplyReport {
124 pub write_requested: bool,
126 pub dry_run: bool,
128 pub discover: bool,
130 pub written: usize,
132 pub auto_planned: usize,
134 pub suggest_planned: usize,
136 pub skipped: usize,
138 pub files_written: Vec<String>,
140 pub files_planned: Vec<String>,
142 pub edits: Vec<ApplyEdit>,
144 pub warnings: Vec<String>,
146 pub recommend_sync: bool,
148 pub ambiguous_surfaces: usize,
150}
151
152impl ApplyReport {
153 pub fn to_text(&self) -> String {
155 let mut out = String::new();
156 out.push_str(&format!(
157 "links apply: dry_run={} write_requested={} discover={}\n",
158 self.dry_run, self.write_requested, self.discover
159 ));
160 out.push_str(&format!(
161 " auto={} suggest={} skipped={} written={} files={}\n",
162 self.auto_planned,
163 self.suggest_planned,
164 self.skipped,
165 self.written,
166 if self.dry_run {
167 self.files_planned.len()
168 } else {
169 self.files_written.len()
170 }
171 ));
172 let show = self.edits.iter().take(80);
173 for e in show {
174 let status = if e.written {
175 "WRITTEN"
176 } else {
177 match e.tier {
178 ApplyTier::Auto => "AUTO",
179 ApplyTier::Suggest => "SUGGEST",
180 ApplyTier::Skip => "SKIP",
181 }
182 };
183 let after = e.after.as_deref().unwrap_or("-");
184 let path = e.file_path.as_deref().unwrap_or("-");
185 out.push_str(&format!(
186 " [{status}] {} {} → {} ({}) {}\n",
187 path, e.before, after, e.source_id, e.reason
188 ));
189 }
190 if self.edits.len() > 80 {
191 out.push_str(&format!(" … {} more (use --json)\n", self.edits.len() - 80));
192 }
193 for w in &self.warnings {
194 out.push_str(&format!(" warning: {w}\n"));
195 }
196 if self.dry_run && self.auto_planned > 0 {
197 out.push_str(
198 "tip: re-run with `rustbrain links --apply --write` to apply AUTO edits\n",
199 );
200 } else if !self.dry_run && self.written > 0 && self.recommend_sync {
201 out.push_str("tip: run `rustbrain sync` (or rely on auto-sync) so pending/edges refresh\n");
202 } else if self.auto_planned == 0 && self.suggest_planned == 0 && self.skipped > 0 {
203 out.push_str(
204 "tip: pending targets may not resolve yet — create notes or fix titles, then sync\n",
205 );
206 }
207 if !self.discover {
208 out.push_str(
209 "tip: `links --apply --discover --dry-run` finds unmarked mentions (Phase 1)\n",
210 );
211 }
212 out
213 }
214}
215
216pub fn apply_links(
220 workspace: &Path,
221 db: &Database,
222 opts: &ApplyOptions,
223) -> Result<ApplyReport> {
224 let dry_run = !opts.write || opts.dry_run;
225 let limit = opts.limit.max(1);
226
227 let (ids, aliases, titles) = db.link_resolution_maps()?;
228 let symbol_ids: HashSet<String> = ids
229 .iter()
230 .filter(|id| id.starts_with("symbol/"))
231 .cloned()
232 .collect();
233
234 let all_nodes = db.get_all_nodes()?;
235 let node_by_id: HashMap<String, Node> =
236 all_nodes.into_iter().map(|n| (n.id.clone(), n)).collect();
237
238 let mut pending = db.list_pending_links()?;
239 if let Some(t) = &opts.target {
240 let focus = resolve_source_filter(db, t, &node_by_id)?;
241 pending.retain(|p| focus.contains(&p.source_id));
242 }
243
244 let mut edits: Vec<ApplyEdit> = Vec::new();
245 let mut warnings: Vec<String> = Vec::new();
246
247 let mut by_source: HashMap<String, Vec<PendingLink>> = HashMap::new();
249 for p in pending {
250 by_source.entry(p.source_id.clone()).or_default().push(p);
251 }
252
253 let mut file_ops: HashMap<PathBuf, Vec<SpanReplace>> = HashMap::new();
255
256 for (source_id, pendings) in &by_source {
257 let Some(source) = node_by_id.get(source_id) else {
258 for p in pendings {
259 edits.push(ApplyEdit {
260 tier: ApplyTier::Skip,
261 kind: ApplyKind::FileSkip,
262 source_id: source_id.clone(),
263 file_path: None,
264 target_id: None,
265 before: p.raw_target.clone(),
266 after: None,
267 reason: "source node missing from nodes table".into(),
268 written: false,
269 span: None,
270 });
271 }
272 continue;
273 };
274
275 let Some(rel) = source.file_path.as_deref() else {
276 for p in pendings {
277 plan_pending_no_file(
279 p,
280 source_id,
281 &ids,
282 &aliases,
283 &titles,
284 &symbol_ids,
285 &mut edits,
286 );
287 }
288 continue;
289 };
290
291 let abs = workspace.join(rel);
292 if !abs.is_file() {
293 for p in pendings {
294 edits.push(ApplyEdit {
295 tier: ApplyTier::Skip,
296 kind: ApplyKind::FileSkip,
297 source_id: source_id.clone(),
298 file_path: Some(rel.to_string()),
299 target_id: None,
300 before: p.raw_target.clone(),
301 after: None,
302 reason: format!("file missing on disk: {}", abs.display()),
303 written: false,
304 span: None,
305 });
306 }
307 continue;
308 }
309
310 let content = match fs::read_to_string(&abs) {
311 Ok(c) => c,
312 Err(e) => {
313 warnings.push(format!("read {}: {e}", abs.display()));
314 for p in pendings {
315 edits.push(ApplyEdit {
316 tier: ApplyTier::Skip,
317 kind: ApplyKind::FileSkip,
318 source_id: source_id.clone(),
319 file_path: Some(rel.to_string()),
320 target_id: None,
321 before: p.raw_target.clone(),
322 after: None,
323 reason: format!("read error: {e}"),
324 written: false,
325 span: None,
326 });
327 }
328 continue;
329 }
330 };
331
332 if is_generated_file(rel, &content) && !opts.force_generated {
333 for p in pendings {
334 edits.push(ApplyEdit {
335 tier: ApplyTier::Skip,
336 kind: ApplyKind::FileSkip,
337 source_id: source_id.clone(),
338 file_path: Some(rel.to_string()),
339 target_id: None,
340 before: p.raw_target.clone(),
341 after: None,
342 reason: "generated file skipped (pass --force to override)".into(),
343 written: false,
344 span: None,
345 });
346 }
347 continue;
348 }
349
350 plan_pending_for_file(
351 source_id,
352 rel,
353 &content,
354 pendings,
355 &ids,
356 &aliases,
357 &titles,
358 &symbol_ids,
359 &mut edits,
360 &mut file_ops,
361 &abs,
362 );
363 }
364
365 let mut ambiguous_surfaces = 0usize;
367 if opts.discover {
368 let lexicon = LinkLexicon::compile(&node_by_id, &aliases)?;
369 ambiguous_surfaces = lexicon.ambiguous.len();
370 if ambiguous_surfaces > 0 {
371 warnings.push(format!(
372 "discover: {ambiguous_surfaces} surface(s) excluded as ambiguous (unique match required)"
373 ));
374 }
375 if let Some(ac) = lexicon.build_automaton() {
376 let sources: Vec<&Node> = if let Some(t) = &opts.target {
377 let focus = resolve_source_filter(db, t, &node_by_id)?;
378 node_by_id
379 .values()
380 .filter(|n| focus.contains(&n.id) && n.node_type != NodeType::Symbol)
381 .collect()
382 } else {
383 node_by_id
384 .values()
385 .filter(|n| n.node_type != NodeType::Symbol && n.file_path.is_some())
386 .collect()
387 };
388
389 for source in sources {
390 let Some(rel) = source.file_path.as_deref() else {
391 continue;
392 };
393 let abs = workspace.join(rel);
394 if !abs.is_file() {
395 continue;
396 }
397 let content = match fs::read_to_string(&abs) {
398 Ok(c) => c,
399 Err(e) => {
400 warnings.push(format!("discover read {}: {e}", abs.display()));
401 continue;
402 }
403 };
404 if is_generated_file(rel, &content) && !opts.force_generated {
405 continue;
406 }
407 plan_discover_for_file(
408 source,
409 rel,
410 &content,
411 &lexicon,
412 &ac,
413 opts.report_suggest,
414 &mut edits,
415 &mut file_ops,
416 &abs,
417 );
418 }
419 } else {
420 warnings.push("discover: empty lexicon (no eligible patterns)".into());
421 }
422 }
423
424 prioritize_and_cap_ops(&mut file_ops, &mut edits, limit);
426
427 let mut files_written = Vec::new();
429 let mut files_planned = Vec::new();
430 let mut written_count = 0usize;
431
432 for (abs, ops) in &file_ops {
433 if ops.is_empty() {
434 continue;
435 }
436 let rel = abs
437 .strip_prefix(workspace)
438 .unwrap_or(abs.as_path())
439 .to_string_lossy()
440 .replace('\\', "/");
441 files_planned.push(rel.clone());
442
443 if dry_run {
444 continue;
445 }
446
447 let original = match fs::read_to_string(abs) {
448 Ok(c) => c,
449 Err(e) => {
450 warnings.push(format!("write aborted, re-read {}: {e}", abs.display()));
451 continue;
452 }
453 };
454
455 match apply_span_replaces(&original, ops) {
456 Ok(new_content) => {
457 if new_content == original {
458 continue;
459 }
460 match atomic_write(abs, &new_content) {
461 Ok(()) => {
462 files_written.push(rel);
463 written_count += ops.len();
464 for op in ops {
466 for e in edits.iter_mut() {
467 if e.span == Some((op.start, op.end))
468 && e.file_path.as_deref()
469 == Some(
470 abs.strip_prefix(workspace)
471 .unwrap_or(abs)
472 .to_string_lossy()
473 .replace('\\', "/")
474 .as_str(),
475 )
476 && e.tier == ApplyTier::Auto
477 {
478 e.written = true;
479 }
480 }
481 }
482 }
483 Err(e) => warnings.push(format!("atomic write {}: {e}", abs.display())),
484 }
485 }
486 Err(e) => warnings.push(format!("apply plan {}: {e}", abs.display())),
487 }
488 }
489
490 if !dry_run {
492 let written_set: HashSet<String> = files_written.iter().cloned().collect();
493 for e in edits.iter_mut() {
494 if e.tier == ApplyTier::Auto
495 && e.after.is_some()
496 && e.file_path
497 .as_ref()
498 .is_some_and(|p| written_set.contains(p))
499 && matches!(
500 e.kind,
501 ApplyKind::PendingWikiNormalize | ApplyKind::DiscoverWrap
502 )
503 {
504 e.written = true;
505 }
506 }
507 written_count = edits.iter().filter(|e| e.written).count();
508 }
509
510 let auto_planned = edits
511 .iter()
512 .filter(|e| e.tier == ApplyTier::Auto && e.after.is_some())
513 .count();
514 let suggest_planned = edits.iter().filter(|e| e.tier == ApplyTier::Suggest).count();
515 let skipped = edits.iter().filter(|e| e.tier == ApplyTier::Skip).count();
516
517 files_planned.sort();
518 files_planned.dedup();
519 files_written.sort();
520 files_written.dedup();
521
522 Ok(ApplyReport {
523 write_requested: opts.write,
524 dry_run,
525 discover: opts.discover,
526 written: written_count,
527 auto_planned,
528 suggest_planned,
529 skipped,
530 files_written,
531 files_planned,
532 edits,
533 warnings,
534 recommend_sync: !dry_run && written_count > 0 && opts.sync_after,
535 ambiguous_surfaces,
536 })
537}
538
539#[derive(Debug, Clone)]
542struct SpanReplace {
543 start: usize,
544 end: usize,
545 replacement: String,
546 priority: u8,
548}
549
550fn apply_span_replaces(content: &str, ops: &[SpanReplace]) -> Result<String> {
551 let mut ops: Vec<&SpanReplace> = ops.iter().collect();
552 ops.sort_by(|a, b| b.start.cmp(&a.start).then_with(|| b.end.cmp(&a.end)));
553
554 let mut ordered = ops.clone();
556 ordered.sort_by(|a, b| a.start.cmp(&b.start).then_with(|| a.end.cmp(&b.end)));
557 for w in ordered.windows(2) {
558 if w[0].end > w[1].start {
559 return Err(BrainError::other(format!(
560 "overlapping edits at {}..{} and {}..{}",
561 w[0].start, w[0].end, w[1].start, w[1].end
562 )));
563 }
564 }
565
566 let mut out = content.to_string();
567 for op in ops {
568 if op.start > op.end || op.end > out.len() {
569 return Err(BrainError::other(format!(
570 "invalid span {}..{} (len {})",
571 op.start,
572 op.end,
573 out.len()
574 )));
575 }
576 if !out.is_char_boundary(op.start) || !out.is_char_boundary(op.end) {
577 return Err(BrainError::other("edit span not on UTF-8 boundary"));
578 }
579 out.replace_range(op.start..op.end, &op.replacement);
580 }
581 Ok(out)
582}
583
584fn atomic_write(path: &Path, content: &str) -> Result<()> {
585 let parent = path.parent().unwrap_or_else(|| Path::new("."));
586 fs::create_dir_all(parent)?;
587 let tmp = parent.join(format!(
588 ".{}.rb-apply.{}.tmp",
589 path.file_name()
590 .and_then(|s| s.to_str())
591 .unwrap_or("note.md"),
592 std::process::id()
593 ));
594 fs::write(&tmp, content.as_bytes())?;
595 if let Ok(f) = fs::File::open(&tmp) {
596 let _ = f.sync_all();
597 }
598 fs::rename(&tmp, path).map_err(|e| {
599 let _ = fs::remove_file(&tmp);
600 BrainError::from(e)
601 })?;
602 Ok(())
603}
604
605fn prioritize_and_cap_ops(
606 file_ops: &mut HashMap<PathBuf, Vec<SpanReplace>>,
607 edits: &mut [ApplyEdit],
608 limit: usize,
609) {
610 let mut all: Vec<(PathBuf, SpanReplace)> = Vec::new();
612 for (p, ops) in file_ops.iter() {
613 for op in ops {
614 all.push((p.clone(), op.clone()));
615 }
616 }
617 all.sort_by(|a, b| {
618 a.1.priority
619 .cmp(&b.1.priority)
620 .then_with(|| a.0.cmp(&b.0))
621 .then_with(|| a.1.start.cmp(&b.1.start))
622 });
623
624 if all.len() <= limit {
625 return;
626 }
627
628 let keep: HashSet<(PathBuf, usize, usize)> = all
629 .iter()
630 .take(limit)
631 .map(|(p, op)| (p.clone(), op.start, op.end))
632 .collect();
633
634 for (p, ops) in file_ops.iter_mut() {
635 ops.retain(|op| keep.contains(&(p.clone(), op.start, op.end)));
636 }
637
638 for e in edits.iter_mut() {
640 if e.tier == ApplyTier::Auto && e.after.is_some() {
641 if let (Some(fp), Some((s, en))) = (&e.file_path, e.span) {
642 let abs_match = keep.iter().any(|(p, a, b)| {
643 *a == s
644 && *b == en
645 && p.to_string_lossy().replace('\\', "/").ends_with(fp.as_str())
646 });
647 if !abs_match {
648 let still = file_ops.values().flatten().any(|op| {
650 op.start == s && op.end == en && op.replacement == e.after.as_deref().unwrap_or("")
651 });
652 if !still {
653 e.tier = ApplyTier::Skip;
654 e.kind = ApplyKind::FileSkip;
655 e.reason = format!("capped by --limit {limit}");
656 e.after = None;
657 }
658 }
659 }
660 }
661 }
662}
663
664fn plan_pending_no_file(
667 p: &PendingLink,
668 source_id: &str,
669 ids: &HashSet<String>,
670 aliases: &HashMap<String, String>,
671 titles: &HashMap<String, String>,
672 symbol_ids: &HashSet<String>,
673 edits: &mut Vec<ApplyEdit>,
674) {
675 match resolve_pending_target(&p.raw_target, ids, aliases, titles, symbol_ids) {
676 Some(tid) => edits.push(ApplyEdit {
677 tier: ApplyTier::Skip,
678 kind: ApplyKind::PendingResolvedNoEdit,
679 source_id: source_id.into(),
680 file_path: None,
681 target_id: Some(tid),
682 before: p.raw_target.clone(),
683 after: None,
684 reason: "no markdown file for source — edge will resolve on sync, no text rewrite"
685 .into(),
686 written: false,
687 span: None,
688 }),
689 None => edits.push(ApplyEdit {
690 tier: ApplyTier::Skip,
691 kind: ApplyKind::PendingUnresolved,
692 source_id: source_id.into(),
693 file_path: None,
694 target_id: None,
695 before: p.raw_target.clone(),
696 after: None,
697 reason: "target still unresolved (and no source file)".into(),
698 written: false,
699 span: None,
700 }),
701 }
702}
703
704fn plan_pending_for_file(
705 source_id: &str,
706 rel: &str,
707 content: &str,
708 pendings: &[PendingLink],
709 ids: &HashSet<String>,
710 aliases: &HashMap<String, String>,
711 titles: &HashMap<String, String>,
712 symbol_ids: &HashSet<String>,
713 edits: &mut Vec<ApplyEdit>,
714 file_ops: &mut HashMap<PathBuf, Vec<SpanReplace>>,
715 abs: &Path,
716) {
717 let spans = extract_wikilink_spans(content);
718
719 for p in pendings {
720 let resolved =
721 resolve_pending_target(&p.raw_target, ids, aliases, titles, symbol_ids);
722
723 let Some(target_id) = resolved else {
724 edits.push(ApplyEdit {
725 tier: ApplyTier::Skip,
726 kind: ApplyKind::PendingUnresolved,
727 source_id: source_id.into(),
728 file_path: Some(rel.into()),
729 target_id: None,
730 before: p.raw_target.clone(),
731 after: None,
732 reason: "target does not uniquely resolve — create the note or disambiguate"
733 .into(),
734 written: false,
735 span: None,
736 });
737 continue;
738 };
739
740 let wiki_raw = p
742 .raw_target
743 .strip_prefix("symbol:")
744 .map(|s| format!("symbol:{s}"))
745 .unwrap_or_else(|| p.raw_target.clone());
746
747 let mut matched = 0usize;
748 for sp in &spans {
749 if !wikilink_matches_pending(&sp.link, &p.raw_target) {
750 continue;
751 }
752 matched += 1;
753 let new_link = normalize_wikilink(&sp.link, &target_id, &p.raw_target);
754 let after = new_link.to_markdown();
755 let before = content[sp.start..sp.end].to_string();
756 if before == after {
757 edits.push(ApplyEdit {
758 tier: ApplyTier::Skip,
759 kind: ApplyKind::PendingResolvedNoEdit,
760 source_id: source_id.into(),
761 file_path: Some(rel.into()),
762 target_id: Some(target_id.clone()),
763 before,
764 after: None,
765 reason: "already canonical; edge will clear on sync".into(),
766 written: false,
767 span: Some((sp.start, sp.end)),
768 });
769 continue;
770 }
771 file_ops.entry(abs.to_path_buf()).or_default().push(SpanReplace {
772 start: sp.start,
773 end: sp.end,
774 replacement: after.clone(),
775 priority: 0,
776 });
777 edits.push(ApplyEdit {
778 tier: ApplyTier::Auto,
779 kind: ApplyKind::PendingWikiNormalize,
780 source_id: source_id.into(),
781 file_path: Some(rel.into()),
782 target_id: Some(target_id.clone()),
783 before,
784 after: Some(after),
785 reason: format!("pending `{}` → unique node `{target_id}`", p.raw_target),
786 written: false,
787 span: Some((sp.start, sp.end)),
788 });
789 }
790
791 if matched == 0 && p.raw_target.starts_with("symbol:") {
793 if let Some((start, end, before)) = find_symbol_token(content, &p.raw_target) {
794 edits.push(ApplyEdit {
796 tier: ApplyTier::Skip,
797 kind: ApplyKind::PendingResolvedNoEdit,
798 source_id: source_id.into(),
799 file_path: Some(rel.into()),
800 target_id: Some(target_id),
801 before,
802 after: None,
803 reason: "symbol: ref resolves; edge created on sync without rewrite".into(),
804 written: false,
805 span: Some((start, end)),
806 });
807 continue;
808 }
809 }
810
811 if matched == 0 {
812 edits.push(ApplyEdit {
813 tier: ApplyTier::Skip,
814 kind: ApplyKind::PendingResolvedNoEdit,
815 source_id: source_id.into(),
816 file_path: Some(rel.into()),
817 target_id: Some(target_id),
818 before: p.raw_target.clone(),
819 after: None,
820 reason: format!(
821 "resolves to target but `{wiki_raw}` not found as WikiLink in file (edge on sync)"
822 ),
823 written: false,
824 span: None,
825 });
826 }
827 }
828}
829
830fn wikilink_matches_pending(link: &WikiLink, raw_target: &str) -> bool {
831 let t = link.target_node.as_str();
832 if t == raw_target {
833 return true;
834 }
835 if let Some(rest) = raw_target.strip_prefix("symbol:") {
837 if t == raw_target || t.strip_prefix("symbol:") == Some(rest) || t == rest {
838 return true;
839 }
840 }
841 t.eq_ignore_ascii_case(raw_target)
843}
844
845fn normalize_wikilink(old: &WikiLink, canonical_id: &str, raw_pending: &str) -> WikiLink {
846 let display = old.display_alias.clone().or_else(|| {
848 if old.target_node != canonical_id && !raw_pending.starts_with("symbol:") {
849 Some(old.target_node.clone())
850 } else {
851 None
852 }
853 });
854 let target_node = if canonical_id.starts_with("symbol/") {
856 if old.target_node.starts_with("symbol:") {
857 old.target_node.clone()
858 } else if raw_pending.starts_with("symbol:") {
859 raw_pending.to_string()
860 } else {
861 canonical_id.to_string()
862 }
863 } else {
864 canonical_id.to_string()
865 };
866 let display_alias = display.filter(|d| d != &target_node);
867 WikiLink {
868 target_node,
869 section: old.section.clone(),
870 display_alias,
871 }
872}
873
874fn resolve_pending_target(
875 raw_target: &str,
876 ids: &HashSet<String>,
877 aliases: &HashMap<String, String>,
878 titles: &HashMap<String, String>,
879 symbol_ids: &HashSet<String>,
880) -> Option<String> {
881 if let Some(sym_path) = raw_target.strip_prefix("symbol:") {
882 parse_symbol_path(sym_path)
883 .and_then(|s| resolve_symbol_ref(&s, symbol_ids))
884 .or_else(|| {
885 parse_symbol_path(sym_path)
886 .and_then(|s| resolve_link_target(&s.symbol_name, ids, aliases, titles))
887 })
888 } else {
889 resolve_link_target(raw_target, ids, aliases, titles)
890 }
891}
892
893fn find_symbol_token(content: &str, raw_target: &str) -> Option<(usize, usize, String)> {
894 let needle = raw_target;
896 let bytes = content.as_bytes();
897 let mut i = 0;
898 let mut in_fence = false;
899 while i + needle.len() <= bytes.len() {
900 if is_line_start_bytes(bytes, i) && i + 2 < bytes.len() && &bytes[i..i + 3] == b"```" {
901 in_fence = !in_fence;
902 i += 3;
903 continue;
904 }
905 if in_fence {
906 i += 1;
907 continue;
908 }
909 if bytes[i] == b'`' {
910 i += 1;
911 while i < bytes.len() && bytes[i] != b'`' {
912 i += 1;
913 }
914 if i < bytes.len() {
915 i += 1;
916 }
917 continue;
918 }
919 if content.is_char_boundary(i)
920 && content[i..].starts_with(needle)
921 && content.is_char_boundary(i + needle.len())
922 {
923 return Some((i, i + needle.len(), needle.to_string()));
924 }
925 i += 1;
926 }
927 None
928}
929
930struct LinkLexicon {
934 surfaces: Vec<(String, String, PatternKind)>,
936 ambiguous: Vec<String>,
938}
939
940#[derive(Debug, Clone, Copy)]
941enum PatternKind {
942 Title,
943 Alias,
944 Stem,
945 SymbolName,
946}
947
948impl LinkLexicon {
949 fn compile(
950 nodes: &HashMap<String, Node>,
951 aliases: &HashMap<String, String>,
952 ) -> Result<Self> {
953 let mut map: HashMap<String, HashSet<String>> = HashMap::new();
955 let mut original_case: HashMap<String, String> = HashMap::new();
956 let mut kinds: HashMap<String, PatternKind> = HashMap::new();
957
958 for n in nodes.values() {
959 if n.node_type == NodeType::Symbol {
960 if let Some(name) = n.id.rsplit('/').next() {
962 let short = name.rsplit("::").next().unwrap_or(name);
964 add_surface(&mut map, &mut original_case, &mut kinds, short, &n.id, PatternKind::SymbolName);
965 }
966 continue;
967 }
968 add_surface(
969 &mut map,
970 &mut original_case,
971 &mut kinds,
972 &n.title,
973 &n.id,
974 PatternKind::Title,
975 );
976 if let Some(path) = &n.file_path {
977 if let Some(stem) = Path::new(path).file_stem().and_then(|s| s.to_str()) {
978 add_surface(
979 &mut map,
980 &mut original_case,
981 &mut kinds,
982 stem,
983 &n.id,
984 PatternKind::Stem,
985 );
986 }
987 }
988 }
989 for (alias, id) in aliases {
990 add_surface(
991 &mut map,
992 &mut original_case,
993 &mut kinds,
994 alias,
995 id,
996 PatternKind::Alias,
997 );
998 }
999
1000 let mut surfaces = Vec::new();
1001 let mut ambiguous = Vec::new();
1002 for (lower, ids) in &map {
1003 if is_stop_surface(lower) {
1004 continue;
1005 }
1006 let min_len = match kinds.get(lower) {
1007 Some(PatternKind::SymbolName) => 3,
1008 Some(PatternKind::Alias) => 3,
1009 _ => 4,
1010 };
1011 if lower.chars().count() < min_len {
1012 continue;
1013 }
1014 if ids.len() != 1 {
1015 ambiguous.push(original_case.get(lower).cloned().unwrap_or_else(|| lower.clone()));
1016 continue;
1017 }
1018 let id = ids.iter().next().unwrap().clone();
1019 let surface = original_case
1020 .get(lower)
1021 .cloned()
1022 .unwrap_or_else(|| lower.clone());
1023 let kind = kinds.get(lower).copied().unwrap_or(PatternKind::Title);
1024 surfaces.push((surface, id, kind));
1025 }
1026
1027 surfaces.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
1029 ambiguous.sort();
1030 ambiguous.dedup();
1031
1032 Ok(Self {
1033 surfaces,
1034 ambiguous,
1035 })
1036 }
1037
1038 fn build_automaton(&self) -> Option<AhoCorasick> {
1039 if self.surfaces.is_empty() {
1040 return None;
1041 }
1042 let patterns: Vec<&str> = self.surfaces.iter().map(|(s, _, _)| s.as_str()).collect();
1043 AhoCorasickBuilder::new()
1044 .ascii_case_insensitive(true)
1045 .match_kind(MatchKind::LeftmostLongest)
1046 .build(patterns)
1047 .ok()
1048 }
1049
1050 fn node_for_pattern_index(&self, idx: usize) -> Option<(&str, PatternKind)> {
1051 self.surfaces
1052 .get(idx)
1053 .map(|(s, id, k)| {
1054 let _ = s;
1055 (id.as_str(), *k)
1056 })
1057 }
1058}
1059
1060fn add_surface(
1061 map: &mut HashMap<String, HashSet<String>>,
1062 original_case: &mut HashMap<String, String>,
1063 kinds: &mut HashMap<String, PatternKind>,
1064 surface: &str,
1065 node_id: &str,
1066 kind: PatternKind,
1067) {
1068 let s = surface.trim();
1069 if s.is_empty() {
1070 return;
1071 }
1072 let lower = s.to_ascii_lowercase();
1073 map.entry(lower.clone()).or_default().insert(node_id.to_string());
1074 original_case.entry(lower.clone()).or_insert_with(|| s.to_string());
1075 kinds.entry(lower).or_insert(kind);
1076}
1077
1078fn is_stop_surface(lower: &str) -> bool {
1079 matches!(
1081 lower,
1082 "the"
1083 | "and"
1084 | "for"
1085 | "with"
1086 | "from"
1087 | "this"
1088 | "that"
1089 | "into"
1090 | "over"
1091 | "under"
1092 | "about"
1093 | "after"
1094 | "before"
1095 | "data"
1096 | "file"
1097 | "files"
1098 | "path"
1099 | "type"
1100 | "name"
1101 | "value"
1102 | "error"
1103 | "errors"
1104 | "state"
1105 | "config"
1106 | "default"
1107 | "result"
1108 | "option"
1109 | "string"
1110 | "number"
1111 | "index"
1112 | "list"
1113 | "item"
1114 | "test"
1115 | "tests"
1116 | "main"
1117 | "lib"
1118 | "mod"
1119 | "use"
1120 | "impl"
1121 | "self"
1122 | "super"
1123 | "crate"
1124 | "pub"
1125 | "fn"
1126 | "struct"
1127 | "enum"
1128 | "trait"
1129 | "const"
1130 | "static"
1131 | "true"
1132 | "false"
1133 | "none"
1134 | "some"
1135 | "ok"
1136 | "err"
1137 | "note"
1138 | "notes"
1139 | "docs"
1140 | "doc"
1141 | "readme"
1142 | "todo"
1143 | "fixme"
1144 | "open"
1145 | "read"
1146 | "write"
1147 | "load"
1148 | "save"
1149 | "run"
1150 | "new"
1151 | "get"
1152 | "set"
1153 | "add"
1154 | "remove"
1155 | "create"
1156 | "update"
1157 | "delete"
1158 | "code"
1159 | "text"
1160 | "body"
1161 | "title"
1162 | "link"
1163 | "links"
1164 | "node"
1165 | "nodes"
1166 | "edge"
1167 | "edges"
1168 | "graph"
1169 | "query"
1170 | "context"
1171 | "sync"
1172 | "build"
1173 | "cargo"
1174 | "rust"
1175 | "http"
1176 | "https"
1177 | "json"
1178 | "yaml"
1179 | "markdown"
1180 | "generated"
1181 | "implementation"
1182 | "concept"
1183 | "concepts"
1184 | "goal"
1185 | "goals"
1186 | "analysis"
1187 | "reference"
1188 | "alternative"
1189 | "edge_case"
1190 | "edge-case"
1191 | "adr"
1192 | "symbol"
1193 | "module"
1194 | "modules"
1195 | "function"
1196 | "functions"
1197 | "method"
1198 | "methods"
1199 | "field"
1200 | "fields"
1201 | "param"
1202 | "params"
1203 | "return"
1204 | "returns"
1205 | "input"
1206 | "output"
1207 | "user"
1208 | "users"
1209 | "app"
1210 | "application"
1211 | "system"
1212 | "service"
1213 | "server"
1214 | "client"
1215 | "api"
1216 | "id"
1217 | "ids"
1218 | "key"
1219 | "keys"
1220 | "map"
1221 | "vec"
1222 | "vector"
1223 | "array"
1224 | "table"
1225 | "row"
1226 | "column"
1227 | "page"
1228 | "home"
1229 | "info"
1230 | "debug"
1231 | "trace"
1232 | "warn"
1233 | "warning"
1234 | "log"
1235 | "logs"
1236 | "time"
1237 | "date"
1238 | "version"
1239 | "v1"
1240 | "v2"
1241 | "see"
1242 | "also"
1243 | "related"
1244 | "summary"
1245 | "status"
1246 | "decision"
1247 | "consequences"
1248 | "checklist"
1249 | "bootstrap"
1250 | "template"
1251 | "scaffold"
1252 | "example"
1253 | "examples"
1254 | "usage"
1255 | "install"
1256 | "license"
1257 | "mit"
1258 | "apache"
1259 ) || lower.len() <= 2
1260}
1261
1262fn plan_discover_for_file(
1263 source: &Node,
1264 rel: &str,
1265 content: &str,
1266 lexicon: &LinkLexicon,
1267 ac: &AhoCorasick,
1268 report_suggest: bool,
1269 edits: &mut Vec<ApplyEdit>,
1270 file_ops: &mut HashMap<PathBuf, Vec<SpanReplace>>,
1271 abs: &Path,
1272) {
1273 let mask = build_unlinkable_mask(content);
1274 let existing_targets: HashSet<String> = extract_wikilink_spans(content)
1275 .into_iter()
1276 .map(|s| s.link.target_node.to_ascii_lowercase())
1277 .collect();
1278
1279 let mut linked_targets: HashSet<String> = HashSet::new();
1281
1282 for mat in ac.find_iter(content) {
1283 let start = mat.start();
1284 let end = mat.end();
1285 if !content.is_char_boundary(start) || !content.is_char_boundary(end) {
1286 continue;
1287 }
1288 if region_blocked(&mask, start, end) {
1289 continue;
1290 }
1291 if !identifier_boundaries(content, start, end) {
1292 continue;
1293 }
1294 let Some((node_id, kind)) = lexicon.node_for_pattern_index(mat.pattern().as_usize()) else {
1295 continue;
1296 };
1297 if node_id == source.id {
1298 continue;
1299 }
1300 if linked_targets.contains(node_id) {
1301 continue;
1302 }
1303 if existing_targets.contains(&node_id.to_ascii_lowercase()) {
1304 continue;
1305 }
1306 let surface = &content[start..end];
1308 if existing_targets.contains(&surface.to_ascii_lowercase()) {
1309 continue;
1310 }
1311
1312 let (tier, reason) = match kind {
1313 PatternKind::Title | PatternKind::Alias if surface.chars().count() >= 5 => (
1314 ApplyTier::Auto,
1315 format!("discover unique {:?} mention → `{node_id}`", kind_name(kind)),
1316 ),
1317 PatternKind::SymbolName if surface.chars().any(|c| c.is_ascii_uppercase()) => (
1318 ApplyTier::Auto,
1319 format!("discover unique symbol-like mention → `{node_id}`"),
1320 ),
1321 PatternKind::Title | PatternKind::Alias | PatternKind::Stem | PatternKind::SymbolName => (
1322 ApplyTier::Suggest,
1323 format!(
1324 "discover weak {:?} mention → `{node_id}` (suggest only)",
1325 kind_name(kind)
1326 ),
1327 ),
1328 };
1329
1330 if tier == ApplyTier::Suggest && !report_suggest {
1331 continue;
1332 }
1333
1334 let after = format!("[[{node_id}|{surface}]]");
1335 let before = surface.to_string();
1336
1337 if tier == ApplyTier::Auto {
1338 let ops = file_ops.entry(abs.to_path_buf()).or_default();
1340 if ops.iter().any(|o| spans_overlap(o.start, o.end, start, end)) {
1341 edits.push(ApplyEdit {
1342 tier: ApplyTier::Skip,
1343 kind: ApplyKind::DiscoverSkip,
1344 source_id: source.id.clone(),
1345 file_path: Some(rel.into()),
1346 target_id: Some(node_id.into()),
1347 before,
1348 after: None,
1349 reason: "overlaps another planned edit".into(),
1350 written: false,
1351 span: Some((start, end)),
1352 });
1353 continue;
1354 }
1355 ops.push(SpanReplace {
1356 start,
1357 end,
1358 replacement: after.clone(),
1359 priority: 1,
1360 });
1361 linked_targets.insert(node_id.to_string());
1362 edits.push(ApplyEdit {
1363 tier: ApplyTier::Auto,
1364 kind: ApplyKind::DiscoverWrap,
1365 source_id: source.id.clone(),
1366 file_path: Some(rel.into()),
1367 target_id: Some(node_id.into()),
1368 before,
1369 after: Some(after),
1370 reason,
1371 written: false,
1372 span: Some((start, end)),
1373 });
1374 } else {
1375 edits.push(ApplyEdit {
1376 tier: ApplyTier::Suggest,
1377 kind: ApplyKind::DiscoverWrap,
1378 source_id: source.id.clone(),
1379 file_path: Some(rel.into()),
1380 target_id: Some(node_id.into()),
1381 before,
1382 after: Some(after),
1383 reason,
1384 written: false,
1385 span: Some((start, end)),
1386 });
1387 linked_targets.insert(node_id.to_string());
1388 }
1389 }
1390
1391 let _ = &lexicon.ambiguous; }
1393
1394fn kind_name(k: PatternKind) -> &'static str {
1395 match k {
1396 PatternKind::Title => "title",
1397 PatternKind::Alias => "alias",
1398 PatternKind::Stem => "stem",
1399 PatternKind::SymbolName => "symbol",
1400 }
1401}
1402
1403fn spans_overlap(a0: usize, a1: usize, b0: usize, b1: usize) -> bool {
1404 a0 < b1 && b0 < a1
1405}
1406
1407fn build_unlinkable_mask(content: &str) -> Vec<u8> {
1409 let mut mask = vec![0u8; content.len()];
1410 let bytes = content.as_bytes();
1411 let len = bytes.len();
1412
1413 if let Some(body_start) = frontmatter_body_start(content) {
1415 for b in mask.iter_mut().take(body_start) {
1416 *b = 1;
1417 }
1418 }
1419
1420 let mut i = 0;
1421 let mut in_fence = false;
1422 while i < len {
1423 if is_line_start_bytes(bytes, i) && i + 2 < len && &bytes[i..i + 3] == b"```" {
1424 let fence_start = i;
1426 in_fence = !in_fence;
1427 i += 3;
1428 if in_fence {
1429 while i < len {
1431 if is_line_start_bytes(bytes, i) && i + 2 < len && &bytes[i..i + 3] == b"```" {
1432 for b in mask.iter_mut().take(i + 3).skip(fence_start) {
1433 *b = 1;
1434 }
1435 i += 3;
1436 in_fence = false;
1437 break;
1438 }
1439 i += 1;
1440 }
1441 if in_fence {
1442 for b in mask.iter_mut().skip(fence_start) {
1443 *b = 1;
1444 }
1445 }
1446 }
1447 continue;
1448 }
1449 if bytes[i] == b'`' {
1450 let s = i;
1451 i += 1;
1452 while i < len && bytes[i] != b'`' {
1453 i += 1;
1454 }
1455 if i < len {
1456 i += 1;
1457 }
1458 for b in mask.iter_mut().take(i).skip(s) {
1459 *b = 1;
1460 }
1461 continue;
1462 }
1463 if i + 1 < len && bytes[i] == b'[' && bytes[i + 1] == b'[' {
1464 if let Some(rel) = content[i + 2..].find("]]") {
1465 let end = i + 2 + rel + 2;
1466 for b in mask.iter_mut().take(end).skip(i) {
1467 *b = 1;
1468 }
1469 i = end;
1470 continue;
1471 }
1472 }
1473 i += 1;
1474 }
1475 mask
1476}
1477
1478fn frontmatter_body_start(content: &str) -> Option<usize> {
1479 let trimmed = content.trim_start();
1480 let trim_off = content.len() - trimmed.len();
1481 if !trimmed.starts_with("---") {
1482 return None;
1483 }
1484 let rest = &trimmed[3..];
1485 let rest = rest.strip_prefix('\n').unwrap_or(rest);
1486 let rest_off = content.len() - rest.len();
1487 if let Some(end_idx) = rest.find("\n---") {
1488 let after = end_idx + 4;
1489 let body_rel = rest[after..].trim_start_matches('\n');
1490 let body_off = rest_off + after + (rest[after..].len() - body_rel.len());
1491 return Some(body_off.max(trim_off));
1492 }
1493 None
1494}
1495
1496fn region_blocked(mask: &[u8], start: usize, end: usize) -> bool {
1497 if end > mask.len() {
1498 return true;
1499 }
1500 mask[start..end].iter().any(|&b| b != 0)
1501}
1502
1503fn identifier_boundaries(content: &str, start: usize, end: usize) -> bool {
1504 let bytes = content.as_bytes();
1505 if start > 0 {
1506 let c = bytes[start - 1];
1507 if c.is_ascii_alphanumeric() || c == b'_' {
1508 return false;
1509 }
1510 if content.is_char_boundary(start) {
1512 if let Some(ch) = content[..start].chars().last() {
1513 if ch.is_alphanumeric() || ch == '_' {
1514 return false;
1515 }
1516 }
1517 }
1518 }
1519 if end < bytes.len() {
1520 let c = bytes[end];
1521 if c.is_ascii_alphanumeric() || c == b'_' {
1522 return false;
1523 }
1524 if content.is_char_boundary(end) {
1525 if let Some(ch) = content[end..].chars().next() {
1526 if ch.is_alphanumeric() || ch == '_' {
1527 return false;
1528 }
1529 }
1530 }
1531 }
1532 true
1533}
1534
1535fn is_line_start_bytes(bytes: &[u8], i: usize) -> bool {
1536 i == 0 || bytes[i - 1] == b'\n'
1537}
1538
1539fn is_generated_file(rel: &str, content: &str) -> bool {
1540 let path = rel.replace('\\', "/");
1541 if path.contains(".generated.")
1542 || path.ends_with("module-map.generated.md")
1543 || path.contains("/generated/")
1544 {
1545 return true;
1546 }
1547 let (fm, _) = parse_frontmatter(content);
1548 if let Some(fm) = fm {
1549 if let Some(v) = fm.extra.get("generated") {
1550 match v {
1551 serde_yaml_ng::Value::Bool(true) => return true,
1552 serde_yaml_ng::Value::String(s) if s == "true" || s == "yes" => return true,
1553 _ => {}
1554 }
1555 }
1556 }
1557 content.lines().take(20).any(|l| {
1558 let t = l.trim();
1559 t == "generated: true" || t == "generated: yes"
1560 })
1561}
1562
1563fn resolve_source_filter(
1564 db: &Database,
1565 target: &str,
1566 nodes: &HashMap<String, Node>,
1567) -> Result<HashSet<String>> {
1568 match crate::graph::resolve_graph_target(db, target) {
1570 Ok(n) => Ok(HashSet::from([n.id])),
1571 Err(_) => {
1572 let raw = target.trim().trim_start_matches("./").replace('\\', "/");
1574 let hits: Vec<_> = nodes
1575 .values()
1576 .filter(|n| {
1577 n.id == raw
1578 || n.file_path
1579 .as_ref()
1580 .is_some_and(|p| p == &raw || p.ends_with(&raw))
1581 })
1582 .map(|n| n.id.clone())
1583 .collect();
1584 if hits.is_empty() {
1585 Err(BrainError::other(format!(
1586 "apply target `{target}` not found — pass a source path or node id"
1587 )))
1588 } else {
1589 Ok(hits.into_iter().collect())
1590 }
1591 }
1592 }
1593}
1594
1595#[cfg(test)]
1596mod tests {
1597 use super::*;
1598 use crate::types::{Node, NodeType};
1599 use tempfile::tempdir;
1600
1601 fn setup() -> (tempfile::TempDir, Database, PathBuf) {
1602 let dir = tempdir().unwrap();
1603 let ws = dir.path().to_path_buf();
1604 let docs = ws.join("docs/concepts");
1605 fs::create_dir_all(&docs).unwrap();
1606 fs::write(
1607 docs.join("raft.md"),
1608 "---\nnode_type: concept\naliases: [RaftConsensus]\n---\n# Raft\n\nSee [[LogCompaction]] and the StorageEngine path.\n",
1609 )
1610 .unwrap();
1611 fs::write(
1612 docs.join("logcompaction.md"),
1613 "---\nnode_type: concept\n---\n# Log Compaction\n\nDetails.\n",
1614 )
1615 .unwrap();
1616
1617 let db = Database::open(ws.join("db.sqlite")).unwrap();
1618 let now = 1_700_000_000i64;
1619 let raft = Node {
1620 id: "docs/concepts/raft".into(),
1621 node_type: NodeType::Concept,
1622 title: "Raft".into(),
1623 file_path: Some("docs/concepts/raft.md".into()),
1624 symbol_hash: None,
1625 summary: None,
1626 content_hash: None,
1627 created_at: now,
1628 updated_at: now,
1629 };
1630 let logc = Node {
1631 id: "docs/concepts/logcompaction".into(),
1632 node_type: NodeType::Concept,
1633 title: "Log Compaction".into(),
1634 file_path: Some("docs/concepts/logcompaction.md".into()),
1635 symbol_hash: None,
1636 summary: None,
1637 content_hash: None,
1638 created_at: now,
1639 updated_at: now,
1640 };
1641 db.insert_node(&raft).unwrap();
1642 db.insert_node(&logc).unwrap();
1643 db.replace_node_aliases(
1644 "docs/concepts/raft",
1645 &["RaftConsensus".into(), "Raft".into()],
1646 )
1647 .unwrap();
1648 db.replace_node_aliases(
1649 "docs/concepts/logcompaction",
1650 &["LogCompaction".into(), "Log Compaction".into()],
1651 )
1652 .unwrap();
1653 db.insert_pending_link(
1654 "docs/concepts/raft",
1655 "LogCompaction",
1656 "relates_to",
1657 now,
1658 )
1659 .unwrap();
1660 (dir, db, ws)
1661 }
1662
1663 #[test]
1664 fn phase0_dry_run_plans_wiki_normalize() {
1665 let (_dir, db, ws) = setup();
1666 let report = apply_links(
1667 &ws,
1668 &db,
1669 &ApplyOptions {
1670 write: false,
1671 dry_run: true,
1672 ..ApplyOptions::default()
1673 },
1674 )
1675 .unwrap();
1676 assert!(report.dry_run);
1677 assert!(
1678 report.auto_planned >= 1,
1679 "expected auto plan, got {:?}",
1680 report.edits
1681 );
1682 let e = report
1683 .edits
1684 .iter()
1685 .find(|e| e.kind == ApplyKind::PendingWikiNormalize)
1686 .expect("normalize edit");
1687 assert!(e.after.as_ref().unwrap().contains("docs/concepts/logcompaction"));
1688 let body = fs::read_to_string(ws.join("docs/concepts/raft.md")).unwrap();
1690 assert!(body.contains("[[LogCompaction]]"));
1691 }
1692
1693 #[test]
1694 fn phase0_write_rewrites_and_preserves_display() {
1695 let (_dir, db, ws) = setup();
1696 let report = apply_links(
1697 &ws,
1698 &db,
1699 &ApplyOptions {
1700 write: true,
1701 dry_run: false,
1702 sync_after: false,
1703 ..ApplyOptions::default()
1704 },
1705 )
1706 .unwrap();
1707 assert!(report.written >= 1);
1708 let body = fs::read_to_string(ws.join("docs/concepts/raft.md")).unwrap();
1709 assert!(
1710 body.contains("[[docs/concepts/logcompaction|LogCompaction]]")
1711 || body.contains("[[docs/concepts/logcompaction]]"),
1712 "body={body}"
1713 );
1714 assert!(!body.contains("[[LogCompaction]]"));
1715 }
1716
1717 #[test]
1718 fn phase0_skips_unresolved() {
1719 let (_dir, db, ws) = setup();
1720 db.insert_pending_link("docs/concepts/raft", "NoSuchNote", "relates_to", 1)
1721 .unwrap();
1722 let report = apply_links(
1723 &ws,
1724 &db,
1725 &ApplyOptions {
1726 write: false,
1727 ..ApplyOptions::default()
1728 },
1729 )
1730 .unwrap();
1731 assert!(report
1732 .edits
1733 .iter()
1734 .any(|e| e.kind == ApplyKind::PendingUnresolved && e.before == "NoSuchNote"));
1735 }
1736
1737 #[test]
1738 fn phase0_skips_generated_without_force() {
1739 let dir = tempdir().unwrap();
1740 let ws = dir.path().to_path_buf();
1741 let path = ws.join("docs/implementation");
1742 fs::create_dir_all(&path).unwrap();
1743 fs::write(
1744 path.join("module-map.generated.md"),
1745 "---\ngenerated: true\n---\n# Map\n[[LogCompaction]]\n",
1746 )
1747 .unwrap();
1748 let db = Database::open(ws.join("db.sqlite")).unwrap();
1749 let now = 1i64;
1750 db.insert_node(&Node {
1751 id: "docs/implementation/module-map.generated".into(),
1752 node_type: NodeType::Concept,
1753 title: "Map".into(),
1754 file_path: Some("docs/implementation/module-map.generated.md".into()),
1755 symbol_hash: None,
1756 summary: None,
1757 content_hash: None,
1758 created_at: now,
1759 updated_at: now,
1760 })
1761 .unwrap();
1762 db.insert_node(&Node {
1763 id: "docs/concepts/logcompaction".into(),
1764 node_type: NodeType::Concept,
1765 title: "Log Compaction".into(),
1766 file_path: Some("docs/concepts/logcompaction.md".into()),
1767 symbol_hash: None,
1768 summary: None,
1769 content_hash: None,
1770 created_at: now,
1771 updated_at: now,
1772 })
1773 .unwrap();
1774 db.insert_pending_link(
1775 "docs/implementation/module-map.generated",
1776 "LogCompaction",
1777 "relates_to",
1778 now,
1779 )
1780 .unwrap();
1781 let report = apply_links(&ws, &db, &ApplyOptions::default()).unwrap();
1782 assert!(report.edits.iter().any(|e| e.reason.contains("generated")));
1783 }
1784
1785 #[test]
1786 fn phase1_discover_wraps_title_mention() {
1787 let (_dir, db, ws) = setup();
1788 fs::write(
1790 ws.join("docs/concepts/logcompaction.md"),
1791 "---\nnode_type: concept\n---\n# Log Compaction\n\nRaft is related here.\n",
1792 )
1793 .unwrap();
1794 let report = apply_links(
1795 &ws,
1796 &db,
1797 &ApplyOptions {
1798 write: true,
1799 dry_run: false,
1800 discover: true,
1801 sync_after: false,
1802 ..ApplyOptions::default()
1804 },
1805 )
1806 .unwrap();
1807 let body = fs::read_to_string(ws.join("docs/concepts/logcompaction.md")).unwrap();
1808 assert!(
1809 body.contains("[[docs/concepts/raft|Raft]]") || report.suggest_planned + report.auto_planned > 0,
1810 "body={body} report={:?}",
1811 report.edits
1812 );
1813 }
1814
1815 #[test]
1816 fn apply_span_replaces_from_end() {
1817 let s = "aaa bbb ccc";
1818 let ops = vec![
1819 SpanReplace {
1820 start: 0,
1821 end: 3,
1822 replacement: "AAA".into(),
1823 priority: 0,
1824 },
1825 SpanReplace {
1826 start: 4,
1827 end: 7,
1828 replacement: "BBB".into(),
1829 priority: 0,
1830 },
1831 ];
1832 let out = apply_span_replaces(s, &ops).unwrap();
1833 assert_eq!(out, "AAA BBB ccc");
1834 }
1835
1836 #[test]
1837 fn mask_blocks_wikilinks_and_code() {
1838 let s = "See [[Raft]] and `Raft` and Raft.";
1839 let mask = build_unlinkable_mask(s);
1840 let idx = s.rfind("Raft").unwrap();
1842 assert_eq!(mask[idx], 0);
1843 let wiki = s.find("[[").unwrap();
1844 assert_eq!(mask[wiki], 1);
1845 }
1846}