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/// Options for [`apply_links`].
31#[derive(Debug, Clone)]
32pub struct ApplyOptions {
33    /// When true, write files (requires explicit opt-in from CLI).
34    pub write: bool,
35    /// When true with `write`, still plan but do not write (redundant safety).
36    pub dry_run: bool,
37    /// Phase 1: scan for unmarked entity mentions via Aho–Corasick.
38    pub discover: bool,
39    /// Allow rewriting files marked `generated: true` or `.generated.` paths.
40    pub force_generated: bool,
41    /// Max edits that may be applied (or planned as auto when dry-run).
42    pub limit: usize,
43    /// Optional focus: path, node id, or title of **source** notes only.
44    pub target: Option<String>,
45    /// Run `sync` after successful writes (caller handles sync; flag is advisory in report).
46    pub sync_after: bool,
47    /// Include `suggest`-tier discover hits in the report (always; never auto-written).
48    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/// Confidence / disposition of a planned edit.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum ApplyTier {
70    /// Safe to write under `--write`.
71    Auto,
72    /// Report only (discover weak hits).
73    Suggest,
74    /// Will not write.
75    Skip,
76}
77
78/// Kind of planned change.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum ApplyKind {
82    /// Rewrite an existing WikiLink target to a canonical node id (pending).
83    PendingWikiNormalize,
84    /// Pending resolved in DB without needing a text change.
85    PendingResolvedNoEdit,
86    /// Pending could not be resolved uniquely.
87    PendingUnresolved,
88    /// Wrap an unmarked mention in a WikiLink (discover).
89    DiscoverWrap,
90    /// Discover hit filtered / not applied.
91    DiscoverSkip,
92    /// File-level skip (missing path, generated, …).
93    FileSkip,
94}
95
96/// One planned or applied edit (or skip record).
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct ApplyEdit {
99    /// Disposition.
100    pub tier: ApplyTier,
101    /// Edit kind.
102    pub kind: ApplyKind,
103    /// Source node id.
104    pub source_id: String,
105    /// Repo-relative file path when known.
106    pub file_path: Option<String>,
107    /// Target node id when known.
108    pub target_id: Option<String>,
109    /// Original surface (wiki target, symbol raw, or mention text).
110    pub before: String,
111    /// Replacement text when an edit is planned.
112    pub after: Option<String>,
113    /// Human-readable reason.
114    pub reason: String,
115    /// True when this edit was written to disk.
116    pub written: bool,
117    /// Byte span in the **original** file when applicable (`start`, `end`).
118    pub span: Option<(usize, usize)>,
119}
120
121/// Aggregate report from [`apply_links`].
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ApplyReport {
124    /// Whether writes were requested.
125    pub write_requested: bool,
126    /// Whether this run was dry-run (no mutations).
127    pub dry_run: bool,
128    /// Whether discover mode was enabled.
129    pub discover: bool,
130    /// Edits actually written.
131    pub written: usize,
132    /// Auto-tier plans (written or would write).
133    pub auto_planned: usize,
134    /// Suggest-tier plans (report only).
135    pub suggest_planned: usize,
136    /// Skips (all kinds).
137    pub skipped: usize,
138    /// Files that changed on disk.
139    pub files_written: Vec<String>,
140    /// Files that would change (dry-run).
141    pub files_planned: Vec<String>,
142    /// Detail rows (capped in text display; full in JSON).
143    pub edits: Vec<ApplyEdit>,
144    /// Non-fatal issues.
145    pub warnings: Vec<String>,
146    /// Whether caller should sync after write.
147    pub recommend_sync: bool,
148    /// Surfaces excluded from discover because they map to multiple nodes.
149    pub ambiguous_surfaces: usize,
150}
151
152impl ApplyReport {
153    /// Human-readable summary for CLI.
154    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
216// ── entry point ─────────────────────────────────────────────────────────────
217
218/// Plan (and optionally apply) link normalizations and discoveries.
219pub 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    // Group pending by source for efficient file reads.
248    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    // File plans: path -> list of (start, end, replacement, edit_index scaffolding)
254    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                // DB-only pending (e.g. canvas-origin): try resolve without file rewrite.
278                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    // Phase 1: discover unmarked mentions.
366    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    // Enforce limit on auto-tier span replaces (prefer pending over discover by sort).
425    prioritize_and_cap_ops(&mut file_ops, &mut edits, limit);
426
427    // Apply writes.
428    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                        // mark matching edits written
465                        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    // Fix written flags more reliably by path + before/after
491    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// ── span replace plumbing ───────────────────────────────────────────────────
540
541#[derive(Debug, Clone)]
542struct SpanReplace {
543    start: usize,
544    end: usize,
545    replacement: String,
546    /// Prefer pending (0) over discover (1) when capping.
547    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    // Overlap check (after sorting by start desc, check neighbors in original order)
555    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    // Collect all auto ops with global priority.
611    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    // Demote edits that lost their op
639    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                    // might still be kept — check loosely
649                    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
664// ── Phase 0: pending ────────────────────────────────────────────────────────
665
666fn 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        // Match WikiLinks whose target equals pending raw (wiki) or symbol form.
741        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        // Bare `symbol:…` in body (not necessarily a wiki).
792        if matched == 0 && p.raw_target.starts_with("symbol:") {
793            if let Some((start, end, before)) = find_symbol_token(content, &p.raw_target) {
794                // Leave token as-is when it resolves — no markdown rewrite needed.
795                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    // pending stores symbol:… ; wiki may be symbol:… without extra prefix issues
836    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    // case-insensitive target match for user-facing titles used as links
842    t.eq_ignore_ascii_case(raw_target)
843}
844
845fn normalize_wikilink(old: &WikiLink, canonical_id: &str, raw_pending: &str) -> WikiLink {
846    // Prefer keeping display alias; else preserve original target as display when it differs.
847    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    // For symbol anchors, keep a readable symbol: surface when canonical is symbol/…
855    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    // raw_target like "symbol:StorageEngine"
895    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
930// ── Phase 1: lexicon + AC ───────────────────────────────────────────────────
931
932/// Closed-world surface → node dictionary for discover.
933struct LinkLexicon {
934    /// pattern string → node_id (only unambiguous surfaces)
935    surfaces: Vec<(String, String, PatternKind)>,
936    /// surfaces dropped as ambiguous
937    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        // surface_lower → set of node ids (for ambiguity)
954        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                // symbol short name
961                if let Some(name) = n.id.rsplit('/').next() {
962                    // Prefer Type::method last segment after ::
963                    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        // Prefer longer patterns first for AC leftmost-longest
1028        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    // High-DF English / Rust noise — never auto-link.
1080    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    // One wrap per target node per file (avoid spam).
1280    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        // Already linked under some surface of this node
1307        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            // Avoid overlapping ops
1339            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; // reserved for future report field
1392}
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
1407/// Bit mask: 1 = not eligible for discover wrap.
1408fn 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    // Frontmatter
1414    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            // mark whole fence line start; toggle and mark until end fence
1425            let fence_start = i;
1426            in_fence = !in_fence;
1427            i += 3;
1428            if in_fence {
1429                // mark until closing fence
1430                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        // Don't match mid-word for UTF-8 letters before
1511        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    // Reuse graph resolver if available
1569    match crate::graph::resolve_graph_target(db, target) {
1570        Ok(n) => Ok(HashSet::from([n.id])),
1571        Err(_) => {
1572            // path suffix soft match
1573            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        // file unchanged
1689        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        // Add mention of full title in logcompaction body
1789        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                // no pending needed for discover on logcompaction
1803                ..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        // last Raft should be free
1841        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}