Skip to main content

rustbrain_core/
apply_links.rs

1//! Safe Markdown link application: pending normalize (Phase 0) + AC discover (Phase 1).
2//!
3//! # Design
4//!
5//! - **Closed world:** only rewrite toward nodes that already exist in the brain.
6//! - **Unique or skip:** ambiguous resolutions never mutate files.
7//! - **Atomic writes:** temp file + rename per path; plan-then-apply with offset-safe edits.
8//! - **Tiers:** `auto` may write; `suggest` is dry-run / report only unless opted in later.
9//! - **No invention:** never create stub notes or free-form prose.
10//!
11//! Phase 0 closes the `pending_links` ledger when targets now resolve uniquely.
12//! Phase 1 (`discover`) compiles a [`LinkLexicon`] and scans note bodies with
13//! Aho–Corasick for unmarked entity mentions.
14
15use 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// ── public options / report ─────────────────────────────────────────────────
29
30/// How discover mentions are materialised in Markdown.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
32#[serde(rename_all = "snake_case")]
33pub enum ApplyStyle {
34    /// Wrap the mention in-place: `Raft` → `[[docs/concepts/raft|Raft]]`.
35    #[default]
36    Wrap,
37    /// Leave the mention text; append `- [[id|surface]]` under a `## Related` section.
38    Related,
39}
40
41impl ApplyStyle {
42    /// Parse `wrap` / `related` (case-insensitive).
43    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/// Options for [`apply_links`].
53#[derive(Debug, Clone)]
54pub struct ApplyOptions {
55    /// When true, write files (requires explicit opt-in from CLI).
56    pub write: bool,
57    /// When true with `write`, still plan but do not write (redundant safety).
58    pub dry_run: bool,
59    /// Phase 1: scan for unmarked entity mentions via Aho–Corasick.
60    pub discover: bool,
61    /// Allow rewriting files marked `generated: true` or `.generated.` paths.
62    pub force_generated: bool,
63    /// Max edits that may be applied (or planned as auto when dry-run).
64    pub limit: usize,
65    /// Optional focus: path, node id, or title of **source** notes only.
66    pub target: Option<String>,
67    /// Run `sync` after successful writes (caller handles sync; flag is advisory in report).
68    pub sync_after: bool,
69    /// Include `suggest`-tier discover hits in the report (always; never auto-written).
70    pub report_suggest: bool,
71    /// Discover materialisation style (default [`ApplyStyle::Wrap`]).
72    pub style: ApplyStyle,
73    /// Prefer graph-neighbor targets (1-hop SQLite adjacency) when scoring discover hits.
74    pub graph_priors: bool,
75    /// Directory for lexicon cache (usually `workspace/.brain`). When `None`, no disk cache.
76    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/// Confidence / disposition of a planned edit.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum ApplyTier {
101    /// Safe to write under `--write`.
102    Auto,
103    /// Report only (discover weak hits).
104    Suggest,
105    /// Will not write.
106    Skip,
107}
108
109/// Kind of planned change.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum ApplyKind {
113    /// Rewrite an existing WikiLink target to a canonical node id (pending).
114    PendingWikiNormalize,
115    /// Pending resolved in DB without needing a text change.
116    PendingResolvedNoEdit,
117    /// Pending could not be resolved uniquely.
118    PendingUnresolved,
119    /// Wrap an unmarked mention in a WikiLink (discover + style=wrap).
120    DiscoverWrap,
121    /// Append a WikiLink under `## Related` (discover + style=related).
122    DiscoverRelated,
123    /// Discover hit filtered / not applied.
124    DiscoverSkip,
125    /// File-level skip (missing path, generated, …).
126    FileSkip,
127}
128
129/// One planned or applied edit (or skip record).
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct ApplyEdit {
132    /// Disposition.
133    pub tier: ApplyTier,
134    /// Edit kind.
135    pub kind: ApplyKind,
136    /// Source node id.
137    pub source_id: String,
138    /// Repo-relative file path when known.
139    pub file_path: Option<String>,
140    /// Target node id when known.
141    pub target_id: Option<String>,
142    /// Original surface (wiki target, symbol raw, or mention text).
143    pub before: String,
144    /// Replacement text when an edit is planned.
145    pub after: Option<String>,
146    /// Human-readable reason.
147    pub reason: String,
148    /// True when this edit was written to disk.
149    pub written: bool,
150    /// Byte span in the **original** file when applicable (`start`, `end`).
151    pub span: Option<(usize, usize)>,
152}
153
154/// Aggregate report from [`apply_links`].
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct ApplyReport {
157    /// Whether writes were requested.
158    pub write_requested: bool,
159    /// Whether this run was dry-run (no mutations).
160    pub dry_run: bool,
161    /// Whether discover mode was enabled.
162    pub discover: bool,
163    /// Edits actually written.
164    pub written: usize,
165    /// Auto-tier plans (written or would write).
166    pub auto_planned: usize,
167    /// Suggest-tier plans (report only).
168    pub suggest_planned: usize,
169    /// Skips (all kinds).
170    pub skipped: usize,
171    /// Files that changed on disk.
172    pub files_written: Vec<String>,
173    /// Files that would change (dry-run).
174    pub files_planned: Vec<String>,
175    /// Detail rows (capped in text display; full in JSON).
176    pub edits: Vec<ApplyEdit>,
177    /// Non-fatal issues.
178    pub warnings: Vec<String>,
179    /// Whether caller should sync after write.
180    pub recommend_sync: bool,
181    /// Surfaces excluded from discover because they map to multiple nodes.
182    pub ambiguous_surfaces: usize,
183    /// Whether the LinkLexicon was loaded from `.brain/link_lexicon.json` cache.
184    pub lexicon_cache_hit: bool,
185    /// Discover style used.
186    pub style: ApplyStyle,
187}
188
189impl ApplyReport {
190    /// Human-readable summary for CLI.
191    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
253// ── entry point ─────────────────────────────────────────────────────────────
254
255/// Plan (and optionally apply) link normalizations and discoveries.
256pub 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    // Group pending by source for efficient file reads.
285    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    // File plans: path -> list of (start, end, replacement, edit_index scaffolding)
291    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                // DB-only pending (e.g. canvas-origin): try resolve without file rewrite.
315                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    // Phase 1: discover unmarked mentions.
403    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    // 1-hop undirected adjacency for graph priors (SQLite edges; always available).
408    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    // Enforce limit on auto-tier ops (prefer pending over discover by sort).
482    prioritize_and_cap_ops(&mut file_ops, &mut related_ops, &mut edits, limit);
483
484    // Apply writes.
485    let mut files_written = Vec::new();
486    let mut files_planned = Vec::new();
487    let mut written_count = 0usize;
488
489    // Union of files with any planned mutation.
490    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    // Fix written flags more reliably by path + before/after
542    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// ── span replace plumbing ───────────────────────────────────────────────────
595
596#[derive(Debug, Clone)]
597struct SpanReplace {
598    start: usize,
599    end: usize,
600    replacement: String,
601    /// Prefer pending (0) over discover (1) when capping.
602    priority: u8,
603}
604
605#[derive(Debug, Clone)]
606struct RelatedInsert {
607    target_id: String,
608    surface: String,
609    /// Prefer pending (0) over discover (1) when capping.
610    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    // Overlap check (after sorting by start desc, check neighbors in original order)
618    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
775/// Cache schema for LinkLexicon surfaces (AC is rebuilt from surfaces; fast).
776const 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
905/// Append unique related links under `## Related` (create section if missing).
906fn 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        // Also skip if the exact markdown already appears
923        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    // Find ## Related (any trailing junk after Related)
935    let mut out = content.to_string();
936    let lower = out.to_ascii_lowercase();
937    if let Some(idx) = lower.find("\n## related") {
938        // Find end of heading line
939        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        // Heading at start of file
947        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
962// ── Phase 0: pending ────────────────────────────────────────────────────────
963
964fn 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        // Match WikiLinks whose target equals pending raw (wiki) or symbol form.
1039        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        // Bare `symbol:…` in body (not necessarily a wiki).
1090        if matched == 0 && p.raw_target.starts_with("symbol:") {
1091            if let Some((start, end, before)) = find_symbol_token(content, &p.raw_target) {
1092                // Leave token as-is when it resolves — no markdown rewrite needed.
1093                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    // pending stores symbol:… ; wiki may be symbol:… without extra prefix issues
1134    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    // case-insensitive target match for user-facing titles used as links
1140    t.eq_ignore_ascii_case(raw_target)
1141}
1142
1143fn normalize_wikilink(old: &WikiLink, canonical_id: &str, raw_pending: &str) -> WikiLink {
1144    // Prefer keeping display alias; else preserve original target as display when it differs.
1145    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    // For symbol anchors, keep a readable symbol: surface when canonical is symbol/…
1153    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    // raw_target like "symbol:StorageEngine"
1193    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
1228// ── Phase 1: lexicon + AC ───────────────────────────────────────────────────
1229
1230/// Closed-world surface → node dictionary for discover.
1231struct LinkLexicon {
1232    /// pattern string → node_id (only unambiguous surfaces)
1233    surfaces: Vec<(String, String, PatternKind)>,
1234    /// surfaces dropped as ambiguous
1235    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        // surface_lower → set of node ids (for ambiguity)
1252        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                // symbol short name
1259                if let Some(name) = n.id.rsplit('/').next() {
1260                    // Prefer Type::method last segment after ::
1261                    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        // Prefer longer patterns first for AC leftmost-longest
1326        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    // High-DF English / Rust noise — never auto-link.
1378    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    // One materialisation per target node per file (avoid spam).
1582    let mut linked_targets: HashSet<String> = HashSet::new();
1583
1584    // Collect candidates first so graph priors can re-rank before materialising.
1585    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    // Graph-boosted first, then longer surfaces.
1636    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    // Reset linked_targets for materialisation pass (still one per target).
1644    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            // Exact unique titles/aliases: 4+ chars is enough (e.g. "Raft").
1654            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        // Graph prior: promote weak neighbor hits to AUTO.
1661        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
1791/// Bit mask: 1 = not eligible for discover wrap.
1792fn 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    // Frontmatter
1798    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            // mark whole fence line start; toggle and mark until end fence
1809            let fence_start = i;
1810            in_fence = !in_fence;
1811            i += 3;
1812            if in_fence {
1813                // mark until closing fence
1814                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        // Don't match mid-word for UTF-8 letters before
1895        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    // Reuse graph resolver if available
1953    match crate::graph::resolve_graph_target(db, target) {
1954        Ok(n) => Ok(HashSet::from([n.id])),
1955        Err(_) => {
1956            // path suffix soft match
1957            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        // file unchanged
2073        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        // Add mention of full title in logcompaction body
2173        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                // no pending needed for discover on logcompaction
2187                ..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        // last Raft should be free
2225        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        // Original prose left unmarked
2263        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    /// End-to-end: pending WikiLink + target node present → apply rewrites → reindex clears pending.
2303    ///
2304    /// We insert the target **without** running resolve_pending so the pending row remains
2305    /// (mirrors "target now exists, Markdown still short-form").
2306    #[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        // Target appears: file on disk + node in DB, but leave pending row intact.
2333        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        // Full reindex: short-name pending is gone (canonical wiki resolves).
2385        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        // Edge raft ↔ logcompaction already from pending setup? only pending, no edge.
2402        // Insert explicit edge so graph prior fires for a weak stem-like mention.
2403        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}