Skip to main content

ara_core/
fix.rs

1//! Format-fix applier: turns the drift [`crate::lint`] detects into in-place
2//! rewrites of `trace/exploration_tree.yaml` / `logic/claims.md`, but applies an
3//! edit only after a per-rule **safety guard** proves it is semantically sound.
4//!
5//! # Design
6//!
7//! All edits and guard checks happen **in memory** on the file text; a file is
8//! written to disk only once its edits pass the guard, so a rejected fix can
9//! never leave a corrupted source file.
10//!
11//! The applier runs a **fixpoint loop**: detect → pick one fixable candidate →
12//! apply it to a copy of the text → re-parse and guard → commit or discard →
13//! re-detect on the (possibly edited) text and repeat. Running one candidate per
14//! iteration and re-detecting from scratch is what gives idempotence and lets
15//! later rules see the byte-offset / line shifts an earlier fix produced (e.g.
16//! an ARA001 root→tree rewrite re-indents the block, shifting the columns
17//! ARA002/ARA003 point at).
18//!
19//! # Guards
20//!
21//! The re-parse uses the crate's public [`parse_sources`]: the applier only ever
22//! edits `exploration_tree.yaml` / `claims.md`, and the extra `parse_dir` layers
23//! (logic sections, evidence) read *other* files these edits never touch, so
24//! comparing `parse_sources` output is sufficient to prove the edit's effect.
25//!
26//! - **ARA001** (structural, semantic no-op): accept only if the manifest is
27//!   *unchanged* (`mc == mb`). This is what protects the re-indent.
28//! - **ARA002 / ARA003** (alias rename, value-recovering): accept only if exactly
29//!   one node's target field goes `None → Some` and nothing else differs.
30//! - **ARA004** (claim-header rewrite, value-recovering): accept only if exactly
31//!   one claim appears (with the header's title), additively, and no node/link
32//!   changes.
33//!
34//! When a guard is ambiguous for an edge case the applier prefers the **safe**
35//! choice — discard and report the drift as detected-but-not-applied.
36
37use std::path::Path;
38
39use serde::Serialize;
40
41use crate::lint::{FixCandidate, LintDiagnostic, LintFile, LintReport, LintRuleId, check_sources};
42use crate::manifest::{Claim, Manifest, Node, NodeFields, is_canonical_id};
43use crate::parse::parse_sources;
44use crate::report::{Diagnostic, ParseReport};
45
46/// Safety backstop on the fixpoint loop. Each iteration applies or discards
47/// exactly one candidate; applies only ever *reduce* the remaining drift, so a
48/// real artifact terminates far below this bound.
49const MAX_ITERS: usize = 1000;
50
51/// A fix that was applied to a source file.
52#[derive(Debug, Clone, PartialEq, Serialize)]
53pub struct AppliedFix {
54    /// The rule whose drift was fixed.
55    pub rule: LintRuleId,
56    /// The file that was edited.
57    pub file: LintFile,
58    /// Short human-readable description of the edit.
59    pub description: String,
60}
61
62/// A fixable drift that was detected but deliberately **not** applied, because
63/// the safety guard rejected the edit (or it could not be rendered).
64#[derive(Debug, Clone, PartialEq, Serialize)]
65pub struct SkippedFix {
66    /// The rule whose drift was left in place.
67    pub rule: LintRuleId,
68    /// The file the drift lives in.
69    pub file: LintFile,
70    /// Why the fix was not applied.
71    pub reason: String,
72}
73
74/// The outcome of a [`fix_dir`] pass.
75#[derive(Debug, Clone, PartialEq, Serialize)]
76pub struct FixOutcome {
77    /// Fixes that were applied, in application order.
78    pub applied: Vec<AppliedFix>,
79    /// Fixable drift detected but discarded by a guard, with the reason.
80    pub skipped: Vec<SkippedFix>,
81    /// Format-lint report re-run on the post-fix text (what still remains).
82    ///
83    /// This reflects the **in-memory** post-fix text. When `errors` is non-empty
84    /// an intended write did not reach disk, so for those files the on-disk drift
85    /// still stands even though `remaining` shows it resolved — callers must treat
86    /// a non-empty `errors` as a failure (the CLI keys exit code 2 off it) rather
87    /// than trusting `remaining`/`applied` for the un-written files.
88    pub remaining: LintReport,
89    /// The files that were actually rewritten on disk.
90    pub changed_files: Vec<LintFile>,
91    /// I/O failures while writing fixes back: `(file, error message)`. Non-empty
92    /// ⇔ at least one intended write did not reach disk.
93    pub errors: Vec<(LintFile, String)>,
94}
95
96impl FixOutcome {
97    /// True when nothing was applied and no file changed.
98    pub fn is_noop(&self) -> bool {
99        self.applied.is_empty() && self.changed_files.is_empty()
100    }
101
102    /// True when an intended write failed to reach disk.
103    pub fn has_errors(&self) -> bool {
104        !self.errors.is_empty()
105    }
106}
107
108/// Detects fixable drift in the ARA artifact at `dir`, applies the **safe** fixes
109/// to `trace/exploration_tree.yaml` / `logic/claims.md` in place, and returns a
110/// [`FixOutcome`]. Native only.
111///
112/// Edits and guard validation run entirely in memory; a file is written only
113/// after its edits pass, so a rejected fix never corrupts a source file. Running
114/// `fix_dir` twice is a no-op the second time (idempotent).
115pub fn fix_dir(dir: &Path) -> FixOutcome {
116    let tree_path = dir.join("trace/exploration_tree.yaml");
117    let claims_path = dir.join("logic/claims.md");
118    let orig_tree = std::fs::read_to_string(&tree_path).unwrap_or_default();
119    let orig_claims = std::fs::read_to_string(&claims_path).ok();
120
121    let mut applier = Applier::new(orig_tree.clone(), orig_claims.clone());
122    applier.run();
123
124    // Write back only the files that actually changed. A successful write is
125    // recorded in `changed_files`; a failed write is recorded in `errors` so the
126    // caller never mistakes an un-written file for clean.
127    let mut changed_files = Vec::new();
128    let mut errors = Vec::new();
129    if applier.tree != orig_tree {
130        match std::fs::write(&tree_path, &applier.tree) {
131            Ok(()) => changed_files.push(LintFile::Tree),
132            Err(e) => errors.push((LintFile::Tree, e.to_string())),
133        }
134    }
135    if let Some(new_claims) = &applier.claims
136        && orig_claims.as_deref() != Some(new_claims.as_str())
137    {
138        match std::fs::write(&claims_path, new_claims) {
139            Ok(()) => changed_files.push(LintFile::Claims),
140            Err(e) => errors.push((LintFile::Claims, e.to_string())),
141        }
142    }
143
144    // Re-detect on the final in-memory text: what remains is exactly the fixable
145    // drift we chose not to apply (applied fixes are gone), so the skip list is
146    // built directly from it, annotated with the reason recorded during the run.
147    let remaining = check_sources(&applier.tree, applier.claims.as_deref());
148    let skipped = remaining
149        .diagnostics()
150        .iter()
151        .filter(|d| d.fixable)
152        .map(|d| SkippedFix {
153            rule: d.rule,
154            file: d.file,
155            reason: applier.reason_for(d),
156        })
157        .collect();
158
159    FixOutcome {
160        applied: applier.applied,
161        skipped,
162        remaining,
163        changed_files,
164        errors,
165    }
166}
167
168/// The result shape [`parse_sources`] returns; aliased for the guard helpers.
169type ParseResult = Result<(Manifest, ParseReport), ParseReport>;
170
171/// Which recovering alias field a targeted guard is validating.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173enum AliasField {
174    /// ARA002: `dead_end.why_failed`.
175    WhyFailed,
176    /// ARA003: `decision.rationale`.
177    Rationale,
178}
179
180/// In-memory applier state driving the fixpoint loop.
181struct Applier {
182    /// Current `exploration_tree.yaml` text.
183    tree: String,
184    /// Current `claims.md` text, when the file exists.
185    claims: Option<String>,
186    /// Applied fixes, in order.
187    applied: Vec<AppliedFix>,
188    /// Candidates rejected in the current pass: `(rule, file, line, reason)`. Line
189    /// numbers are stable across every fix kind (all edits are single-line or
190    /// keep the line count), so `(rule, file, line)` uniquely keys a candidate.
191    /// Cleared whenever a fix is applied, so previously-rejected candidates get
192    /// re-evaluated against the new state (e.g. an ARA004 claim recovery may
193    /// resolve the error that had blocked an ARA002 rename).
194    failed: Vec<(LintRuleId, LintFile, usize, String)>,
195}
196
197impl Applier {
198    fn new(tree: String, claims: Option<String>) -> Self {
199        Self {
200            tree,
201            claims,
202            applied: Vec::new(),
203            failed: Vec::new(),
204        }
205    }
206
207    /// Runs the detect → apply/discard → re-detect fixpoint to completion.
208    fn run(&mut self) {
209        for _ in 0..MAX_ITERS {
210            let report = check_sources(&self.tree, self.claims.as_deref());
211            let Some(diag) = report
212                .diagnostics()
213                .iter()
214                .find(|d| d.fixable && d.fix.is_some() && !self.is_failed(d))
215                .cloned()
216            else {
217                break;
218            };
219            if self.step(&diag) {
220                // A fix landed: text (and thus the parse baseline) changed, so
221                // reconsider anything we had rejected earlier.
222                self.failed.clear();
223            }
224        }
225    }
226
227    /// Attempts one candidate. Returns `true` iff it was applied.
228    fn step(&mut self, diag: &LintDiagnostic) -> bool {
229        let base = parse_sources(&self.tree, self.claims.as_deref());
230        let Some((new_tree, new_claims)) = self.render_candidate(diag) else {
231            self.fail(
232                diag,
233                "fix candidate could not be rendered onto the source text",
234            );
235            return false;
236        };
237        let cand = parse_sources(&new_tree, new_claims.as_deref());
238
239        let accept = match diag.rule {
240            LintRuleId::RootDialect => guard_ara001(&base, &cand),
241            LintRuleId::DeadEndReasonAlias => guard_alias(&base, &cand, AliasField::WhyFailed),
242            LintRuleId::DecisionRationaleAlias => guard_alias(&base, &cand, AliasField::Rationale),
243            LintRuleId::ClaimHeaderStyle => {
244                self.guard_ara004(diag, &base, &cand, new_claims.as_deref(), &new_tree)
245            }
246        };
247        if !accept {
248            self.fail(diag, guard_reason(diag.rule));
249            return false;
250        }
251
252        // Idempotence backstop: the same drift must not survive at this line, or
253        // the loop could re-detect and re-apply it forever.
254        let recheck = check_sources(&new_tree, new_claims.as_deref());
255        let line = diag_line(diag);
256        if recheck
257            .diagnostics()
258            .iter()
259            .any(|d| d.rule == diag.rule && diag_line(d) == line)
260        {
261            self.fail(diag, "fix did not eliminate the drift (non-idempotent)");
262            return false;
263        }
264
265        self.tree = new_tree;
266        self.claims = new_claims;
267        self.applied.push(AppliedFix {
268            rule: diag.rule,
269            file: diag.file,
270            description: applied_desc(diag.rule),
271        });
272        true
273    }
274
275    /// Renders `diag`'s fix candidate onto the current text, returning the edited
276    /// `(tree, claims)` pair. `None` if the offsets don't fit the text.
277    fn render_candidate(&self, diag: &LintDiagnostic) -> Option<(String, Option<String>)> {
278        let fix = diag.fix.as_ref()?;
279        match diag.file {
280            LintFile::Tree => Some((apply_fix_to_text(&self.tree, fix)?, self.claims.clone())),
281            LintFile::Claims => {
282                let claims = self.claims.as_deref()?;
283                Some((self.tree.clone(), Some(apply_fix_to_text(claims, fix)?)))
284            }
285        }
286    }
287
288    /// ARA004 targeted guard. See the module docs for the invariant it relies on:
289    /// the edit only changes one `## C\d+` header separator, so the parsed claim
290    /// set can only *grow* by that one claim (body boundaries are unchanged), the
291    /// tree is untouched (nodes/links identical), and bindings can only gain
292    /// edges to the recovered claim.
293    fn guard_ara004(
294        &self,
295        diag: &LintDiagnostic,
296        base: &ParseResult,
297        cand: &ParseResult,
298        new_claims: Option<&str>,
299        new_tree: &str,
300    ) -> bool {
301        // The edited artifact must be fully valid.
302        let Ok((mc, _)) = cand else {
303            return false;
304        };
305        // No new errors: fixing a separator may only resolve a dangling reference.
306        // (cand is Ok here, so this is trivially satisfied, but assert it anyway.)
307        if !errors_subset(cand, base) {
308            return false;
309        }
310
311        // The pre-fix claim set, isolated from the tree so a dangling reference in
312        // the full base parse (the main ARA004 case) can't hide it.
313        let Some(base_claims) = claims_only(self.claims.as_deref()) else {
314            return false;
315        };
316        // The recovered id/title, read from the rewritten header line.
317        let Some((rec_id, rec_title)) = header_at(new_claims, diag_line(diag)) else {
318            return false;
319        };
320
321        // Genuine recovery: absent before, present after with the header's title.
322        if base_claims.iter().any(|c| c.id.as_str() == rec_id) {
323            return false;
324        }
325        let Some(rc) = mc.claims.iter().find(|c| c.id.as_str() == rec_id) else {
326            return false;
327        };
328        if rc.title != rec_title {
329            return false;
330        }
331
332        // Additive: dropping the recovered claim reproduces the base set exactly
333        // (same order, nothing else changed).
334        let mc_minus: Vec<Claim> = mc
335            .claims
336            .iter()
337            .filter(|c| c.id.as_str() != rec_id)
338            .cloned()
339            .collect();
340        if mc_minus != base_claims {
341            return false;
342        }
343
344        // Nodes/links cannot change (the tree text is untouched); assert it
345        // against a claims-independent parse of the same tree.
346        let Ok((tb, _)) = parse_sources(new_tree, None) else {
347            return false;
348        };
349        mc.nodes == tb.nodes && mc.links == tb.links
350    }
351
352    /// True if `diag`'s candidate was already rejected in the current pass.
353    fn is_failed(&self, diag: &LintDiagnostic) -> bool {
354        let key = (diag.rule, diag.file, diag_line(diag));
355        self.failed.iter().any(|(r, f, l, _)| (*r, *f, *l) == key)
356    }
357
358    /// Records a rejection reason for `diag` (first reason per candidate wins).
359    fn fail(&mut self, diag: &LintDiagnostic, reason: impl Into<String>) {
360        if !self.is_failed(diag) {
361            self.failed
362                .push((diag.rule, diag.file, diag_line(diag), reason.into()));
363        }
364    }
365
366    /// Looks up the recorded rejection reason for a remaining diagnostic, falling
367    /// back to a generic per-rule reason.
368    fn reason_for(&self, diag: &LintDiagnostic) -> String {
369        let key = (diag.rule, diag.file, diag_line(diag));
370        self.failed
371            .iter()
372            .find(|(r, f, l, _)| (*r, *f, *l) == key)
373            .map(|(_, _, _, reason)| reason.clone())
374            .unwrap_or_else(|| guard_reason(diag.rule))
375    }
376}
377
378// ---- guards ---------------------------------------------------------------
379
380/// ARA001 structural guard: the root→tree rewrite must be a semantic no-op.
381fn guard_ara001(base: &ParseResult, cand: &ParseResult) -> bool {
382    match (base, cand) {
383        (Ok((mb, _)), Ok((mc, _))) => mc == mb,
384        _ => false,
385    }
386}
387
388/// ARA002/ARA003 targeted guard: exactly one node's target field goes
389/// `None → Some`, and nothing else differs.
390fn guard_alias(base: &ParseResult, cand: &ParseResult, field: AliasField) -> bool {
391    let (Ok((mb, _)), Ok((mc, _))) = (base, cand) else {
392        return false;
393    };
394    if mc.nodes.len() != mb.nodes.len() {
395        return false;
396    }
397    if mb.nodes.iter().zip(&mc.nodes).any(|(a, b)| a.id != b.id) {
398        return false;
399    }
400
401    let diffs: Vec<usize> = (0..mb.nodes.len())
402        .filter(|&i| mb.nodes[i] != mc.nodes[i])
403        .collect();
404    if diffs.len() != 1 {
405        return false;
406    }
407    let i = diffs[0];
408
409    // The value must be recovered: `None` in base, `Some` in cand.
410    if field_is_some(&mb.nodes[i], field) || !field_is_some(&mc.nodes[i], field) {
411        return false;
412    }
413
414    // Resetting that one recovered field to `None` must reproduce base exactly —
415    // proof that nothing else moved and the value landed in the right place.
416    let mut mc2 = (*mc).clone();
417    clear_field(&mut mc2.nodes[i], field);
418    mc2 == *mb
419}
420
421/// True when `node`'s `field` is populated.
422fn field_is_some(node: &Node, field: AliasField) -> bool {
423    match (field, &node.fields) {
424        (AliasField::WhyFailed, NodeFields::DeadEnd { why_failed, .. }) => why_failed.is_some(),
425        (AliasField::Rationale, NodeFields::Decision { rationale, .. }) => rationale.is_some(),
426        _ => false,
427    }
428}
429
430/// Clears `node`'s `field` (no-op if the node isn't the matching kind).
431fn clear_field(node: &mut Node, field: AliasField) {
432    match (field, &mut node.fields) {
433        (AliasField::WhyFailed, NodeFields::DeadEnd { why_failed, .. }) => *why_failed = None,
434        (AliasField::Rationale, NodeFields::Decision { rationale, .. }) => *rationale = None,
435        _ => {}
436    }
437}
438
439/// True iff every error in `cand` also appears in `base` (renames/recoveries may
440/// only resolve errors, never introduce one).
441fn errors_subset(cand: &ParseResult, base: &ParseResult) -> bool {
442    let be = errors_of(base);
443    errors_of(cand).iter().all(|e| be.contains(e))
444}
445
446/// The error diagnostics of a parse result (present on both `Ok` and `Err`).
447fn errors_of(result: &ParseResult) -> &[Diagnostic] {
448    match result {
449        Ok((_, report)) => report.errors(),
450        Err(report) => report.errors(),
451    }
452}
453
454/// Parses `claims` isolated from any tree (`tree: []`), returning just the claim
455/// set. This yields the claims even when the real artifact's tree references a
456/// not-yet-recovered claim (which would make the full parse error). `None` when
457/// the claims themselves fail to parse (e.g. a claim→claim dependency error).
458fn claims_only(claims: Option<&str>) -> Option<Vec<Claim>> {
459    match parse_sources("tree: []\n", claims) {
460        Ok((m, _)) => Some(m.claims),
461        Err(_) => None,
462    }
463}
464
465// ---- text edits -----------------------------------------------------------
466
467/// Applies a single [`FixCandidate`] to `text`, returning the edited text.
468fn apply_fix_to_text(text: &str, fix: &FixCandidate) -> Option<String> {
469    match fix {
470        FixCandidate::ReplaceInLine {
471            line,
472            start_col,
473            end_col,
474            replacement,
475        } => apply_replace_in_line(text, *line, *start_col, *end_col, replacement),
476        FixCandidate::RewriteRootToTree {
477            root_line,
478            root_indent,
479            block_end_line,
480        } => apply_root_to_tree(text, *root_line, *root_indent, *block_end_line),
481    }
482}
483
484/// Replaces the byte range `[start, end)` on 0-based `line` with `repl`.
485/// Splitting/joining on `'\n'` round-trips the exact text (including a trailing
486/// newline and any `\r` from CRLF, which sits past the edited span).
487fn apply_replace_in_line(
488    text: &str,
489    line: usize,
490    start: usize,
491    end: usize,
492    repl: &str,
493) -> Option<String> {
494    let mut segs: Vec<String> = text.split('\n').map(str::to_string).collect();
495    let seg = segs.get_mut(line)?;
496    if start > end || end > seg.len() || !seg.is_char_boundary(start) || !seg.is_char_boundary(end)
497    {
498        return None;
499    }
500    seg.replace_range(start..end, repl);
501    Some(segs.join("\n"))
502}
503
504/// Rewrites a top-level `root:` single-node map into a one-element `tree:` list:
505/// rename the key, add one indent level to every block line, and turn the first
506/// block content line into the list element with a `- ` marker.
507fn apply_root_to_tree(
508    text: &str,
509    root_line: usize,
510    root_indent: usize,
511    block_end_line: usize,
512) -> Option<String> {
513    let mut segs: Vec<String> = text.split('\n').map(str::to_string).collect();
514    if root_line >= segs.len() || block_end_line > segs.len() || block_end_line <= root_line {
515        return None;
516    }
517
518    // 1. `root` → `tree` at the key's indent.
519    {
520        let seg = &mut segs[root_line];
521        let end = root_indent + "root".len();
522        if end > seg.len() || !seg.is_char_boundary(root_indent) || &seg[root_indent..end] != "root"
523        {
524            return None;
525        }
526        seg.replace_range(root_indent..end, "tree");
527    }
528
529    // 2. Indent the block by one level; the first content line becomes the list
530    //    element (`- ` marker inserted after its existing indentation). Blank
531    //    lines are left untouched so no trailing whitespace is introduced.
532    let mut first_seen = false;
533    for seg in segs.iter_mut().take(block_end_line).skip(root_line + 1) {
534        if seg.trim().is_empty() {
535            continue;
536        }
537        if first_seen {
538            seg.insert_str(0, "  ");
539        } else {
540            first_seen = true;
541            let ws = leading_spaces(seg);
542            seg.insert_str(ws, "- ");
543        }
544    }
545
546    Some(segs.join("\n"))
547}
548
549/// Counts leading ASCII spaces.
550fn leading_spaces(s: &str) -> usize {
551    s.len() - s.trim_start_matches(' ').len()
552}
553
554/// The claim id+title of a canonical `## C\d+: title` header at 0-based `line`,
555/// mirroring the claims parser so a recovered title compares equal.
556fn header_at(claims: Option<&str>, line: usize) -> Option<(String, String)> {
557    let l = claims?.split('\n').nth(line)?;
558    let rest = l.trim_start().strip_prefix("## ")?;
559    let (raw_id, raw_title) = rest.split_once(':')?;
560    let id = raw_id.trim();
561    if !is_canonical_id(id, 'C') {
562        return None;
563    }
564    let title = raw_title.trim();
565    if title.is_empty() {
566        return None;
567    }
568    Some((id.to_string(), title.to_string()))
569}
570
571/// The 0-based source line a diagnostic's fix targets (used to key candidates).
572fn diag_line(diag: &LintDiagnostic) -> usize {
573    match &diag.fix {
574        Some(FixCandidate::ReplaceInLine { line, .. }) => *line,
575        Some(FixCandidate::RewriteRootToTree { root_line, .. }) => *root_line,
576        None => usize::MAX,
577    }
578}
579
580/// Human-readable description of an applied fix.
581fn applied_desc(rule: LintRuleId) -> String {
582    match rule {
583        LintRuleId::RootDialect => {
584            "rewrote top-level `root:` single node into a one-element `tree:` list".to_string()
585        }
586        LintRuleId::DeadEndReasonAlias => {
587            "renamed `reason:` to `why_failed:` on a dead_end node".to_string()
588        }
589        LintRuleId::DecisionRationaleAlias => {
590            "renamed `justification:` to `rationale:` on a decision node".to_string()
591        }
592        LintRuleId::ClaimHeaderStyle => "rewrote dash claim-header separator to `: `".to_string(),
593    }
594}
595
596/// Generic reason recorded when a rule's guard rejects a candidate.
597fn guard_reason(rule: LintRuleId) -> String {
598    match rule {
599        LintRuleId::RootDialect => {
600            "root→tree rewrite would change the parsed manifest; left unchanged".to_string()
601        }
602        LintRuleId::DeadEndReasonAlias | LintRuleId::DecisionRationaleAlias => {
603            "alias rename would change more than the recovered field; left unchanged".to_string()
604        }
605        LintRuleId::ClaimHeaderStyle => {
606            "claim-header rewrite would change more than the recovered claim; left unchanged"
607                .to_string()
608        }
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use crate::manifest::NodeId;
616
617    /// Builds a temp ARA artifact with the given tree YAML and optional claims.
618    fn artifact(tree_yaml: &str, claims_md: Option<&str>) -> tempfile::TempDir {
619        let dir = tempfile::TempDir::new().unwrap();
620        std::fs::create_dir_all(dir.path().join("trace")).unwrap();
621        std::fs::write(dir.path().join("trace/exploration_tree.yaml"), tree_yaml).unwrap();
622        if let Some(claims) = claims_md {
623            std::fs::create_dir_all(dir.path().join("logic")).unwrap();
624            std::fs::write(dir.path().join("logic/claims.md"), claims).unwrap();
625        }
626        dir
627    }
628
629    fn read_tree(dir: &tempfile::TempDir) -> String {
630        std::fs::read_to_string(dir.path().join("trace/exploration_tree.yaml")).unwrap()
631    }
632
633    fn read_claims(dir: &tempfile::TempDir) -> String {
634        std::fs::read_to_string(dir.path().join("logic/claims.md")).unwrap()
635    }
636
637    // ---- ARA001 -----------------------------------------------------------
638
639    #[test]
640    fn ara001_root_rewritten_to_tree_preserving_manifest() {
641        let yaml = "\
642root:
643  id: N01
644  type: question
645  title: q
646  children:
647    - id: N02
648      type: experiment
649      result: 28.4 BLEU
650";
651        let before = parse_sources(yaml, None).expect("root parses").0;
652        let dir = artifact(yaml, None);
653        let outcome = fix_dir(dir.path());
654
655        assert_eq!(outcome.applied.len(), 1);
656        assert_eq!(outcome.applied[0].rule, LintRuleId::RootDialect);
657        assert_eq!(outcome.changed_files, vec![LintFile::Tree]);
658        assert!(outcome.remaining.is_empty());
659
660        let after_text = read_tree(&dir);
661        assert!(after_text.starts_with("tree:\n"), "got: {after_text}");
662        // Rewritten YAML is well-formed and re-parses to the same manifest.
663        let after = parse_sources(&after_text, None)
664            .expect("rewritten parses")
665            .0;
666        assert_eq!(before.nodes, after.nodes);
667        assert_eq!(before.links, after.links);
668        assert_eq!(before, after);
669    }
670
671    #[test]
672    fn ara001_expected_reindented_text() {
673        let yaml = "root:\n  id: RQ\n  type: question\n  children:\n    - id: N02\n";
674        let dir = artifact(yaml, None);
675        fix_dir(dir.path());
676        assert_eq!(
677            read_tree(&dir),
678            "tree:\n  - id: RQ\n    type: question\n    children:\n      - id: N02\n"
679        );
680    }
681
682    #[test]
683    fn ara001_guard_discards_when_manifest_would_differ() {
684        // Directly exercise the load-bearing guard: two DIFFERENT valid manifests
685        // must be rejected, an identical one accepted.
686        let base = parse_sources("tree:\n  - id: N01\n    type: question\n", None);
687        let different = parse_sources("tree:\n  - id: N99\n    type: question\n", None);
688        let same = parse_sources("tree:\n  - id: N01\n    type: question\n", None);
689        assert!(!guard_ara001(&base, &different));
690        assert!(guard_ara001(&base, &same));
691    }
692
693    // ---- ARA002 / ARA003 --------------------------------------------------
694
695    #[test]
696    fn ara002_reason_recovered_as_why_failed() {
697        let yaml = "\
698tree:
699  - id: N01
700    type: dead_end
701    reason: it diverged
702";
703        let dir = artifact(yaml, None);
704        let outcome = fix_dir(dir.path());
705
706        assert_eq!(outcome.applied.len(), 1);
707        assert_eq!(outcome.applied[0].rule, LintRuleId::DeadEndReasonAlias);
708        assert!(read_tree(&dir).contains("why_failed: it diverged"));
709
710        let (m, _) = parse_sources(&read_tree(&dir), None).expect("ok");
711        match &m.nodes[0].fields {
712            NodeFields::DeadEnd { why_failed, .. } => {
713                assert_eq!(why_failed.as_deref(), Some("it diverged"));
714            }
715            other => panic!("expected DeadEnd fields, got {other:?}"),
716        }
717    }
718
719    #[test]
720    fn ara003_justification_recovered_as_rationale() {
721        let yaml = "\
722tree:
723  - id: N01
724    type: decision
725    justification: cheaper to train
726";
727        let dir = artifact(yaml, None);
728        let outcome = fix_dir(dir.path());
729
730        assert_eq!(outcome.applied.len(), 1);
731        assert_eq!(outcome.applied[0].rule, LintRuleId::DecisionRationaleAlias);
732
733        let (m, _) = parse_sources(&read_tree(&dir), None).expect("ok");
734        match &m.nodes[0].fields {
735            NodeFields::Decision { rationale, .. } => {
736                assert_eq!(rationale.as_deref(), Some("cheaper to train"));
737            }
738            other => panic!("expected Decision fields, got {other:?}"),
739        }
740    }
741
742    #[test]
743    fn alias_guard_discards_multi_node_change() {
744        // Two nodes' fields change → not "exactly one recovered field" → discard.
745        let base = parse_sources(
746            "tree:\n  - id: N01\n    type: dead_end\n  - id: N02\n    type: dead_end\n",
747            None,
748        );
749        let cand = parse_sources(
750            "tree:\n  - id: N01\n    type: dead_end\n    why_failed: a\n  - id: N02\n    type: dead_end\n    why_failed: b\n",
751            None,
752        );
753        assert!(!guard_alias(&base, &cand, AliasField::WhyFailed));
754
755        // A single recovered field is accepted.
756        let base1 = parse_sources("tree:\n  - id: N01\n    type: dead_end\n", None);
757        let cand1 = parse_sources(
758            "tree:\n  - id: N01\n    type: dead_end\n    why_failed: a\n",
759            None,
760        );
761        assert!(guard_alias(&base1, &cand1, AliasField::WhyFailed));
762    }
763
764    // ---- ARA004 -----------------------------------------------------------
765
766    #[test]
767    fn ara004_dash_header_recovers_claim() {
768        // Standalone claim (not referenced) that silently disappears today.
769        let yaml = "tree:\n  - id: N01\n    type: question\n";
770        let claims = "## C01 — Attention is all you need\n- **Statement**: yes\n";
771        let dir = artifact(yaml, Some(claims));
772
773        let before = parse_sources(yaml, Some(claims)).expect("ok").0;
774        assert!(before.claims.is_empty(), "dash header must not parse today");
775
776        let outcome = fix_dir(dir.path());
777        assert_eq!(outcome.applied.len(), 1);
778        assert_eq!(outcome.applied[0].rule, LintRuleId::ClaimHeaderStyle);
779        assert_eq!(outcome.changed_files, vec![LintFile::Claims]);
780
781        let after_claims = read_claims(&dir);
782        assert!(after_claims.starts_with("## C01: Attention is all you need\n"));
783        let (m, _) = parse_sources(&read_tree(&dir), Some(&after_claims)).expect("ok");
784        assert_eq!(m.claims.len(), 1);
785        assert_eq!(m.claims[0].id, crate::manifest::ClaimId::new("C01"));
786        assert_eq!(m.claims[0].title, "Attention is all you need");
787    }
788
789    #[test]
790    fn ara004_recovers_referenced_claim_and_resolves_dangling_error() {
791        // A node references C01 whose header is dash-separated → base parse errors
792        // (dangling reference). The fix recovers the claim and the binding.
793        let yaml = "\
794tree:
795  - id: N01
796    type: experiment
797    evidence: [C01]
798";
799        let claims = "## C01 - Faster training\n- **Statement**: yes\n";
800        let dir = artifact(yaml, Some(claims));
801
802        assert!(
803            parse_sources(yaml, Some(claims)).is_err(),
804            "dangling C01 must error before the fix"
805        );
806
807        let outcome = fix_dir(dir.path());
808        assert_eq!(outcome.applied.len(), 1);
809        assert_eq!(outcome.applied[0].rule, LintRuleId::ClaimHeaderStyle);
810
811        let (m, report) =
812            parse_sources(&read_tree(&dir), Some(&read_claims(&dir))).expect("ok now");
813        assert!(report.is_ok());
814        assert_eq!(m.claims.len(), 1);
815        assert_eq!(m.bindings.len(), 1);
816        assert_eq!(m.bindings[0].claim, crate::manifest::ClaimId::new("C01"));
817    }
818
819    // ---- idempotence / safety --------------------------------------------
820
821    #[test]
822    fn fix_dir_is_idempotent() {
823        let yaml = "\
824root:
825  id: N01
826  type: question
827  children:
828    - id: N02
829      type: dead_end
830      reason: diverged
831    - id: N03
832      type: decision
833      justification: cheaper
834";
835        let claims = "## C01 — A claim\n- **Statement**: yes\n";
836        let dir = artifact(yaml, Some(claims));
837
838        let first = fix_dir(dir.path());
839        assert!(!first.applied.is_empty());
840        let tree_after_first = read_tree(&dir);
841        let claims_after_first = read_claims(&dir);
842
843        let second = fix_dir(dir.path());
844        assert!(
845            second.applied.is_empty(),
846            "second run must apply nothing, got: {:?}",
847            second.applied
848        );
849        assert!(second.changed_files.is_empty());
850        assert_eq!(
851            read_tree(&dir),
852            tree_after_first,
853            "tree must be byte-identical"
854        );
855        assert_eq!(
856            read_claims(&dir),
857            claims_after_first,
858            "claims must be byte-identical"
859        );
860    }
861
862    #[test]
863    fn discarded_fix_leaves_file_unchanged_and_parseable() {
864        // A duplicate node id makes the base parse error, so the ARA002 alias
865        // guard (which requires a clean baseline) discards the rename. The file
866        // must be left byte-identical — no partial/corrupt write.
867        let yaml = "\
868tree:
869  - id: N01
870    type: dead_end
871    reason: x
872  - id: N01
873    type: insight
874";
875        let dir = artifact(yaml, None);
876        let outcome = fix_dir(dir.path());
877
878        assert!(outcome.applied.is_empty());
879        assert!(outcome.changed_files.is_empty());
880        assert!(
881            outcome
882                .skipped
883                .iter()
884                .any(|s| s.rule == LintRuleId::DeadEndReasonAlias)
885        );
886        assert_eq!(read_tree(&dir), yaml, "file must be untouched");
887        // Still valid text (the pre-existing duplicate-id error is unrelated).
888        assert_eq!(read_tree(&dir).lines().count(), yaml.lines().count());
889    }
890
891    #[test]
892    fn happy_path_reports_no_write_errors() {
893        let yaml = "root:\n  id: N01\n  type: question\n";
894        let dir = artifact(yaml, None);
895        let outcome = fix_dir(dir.path());
896        assert!(!outcome.applied.is_empty());
897        assert!(
898            outcome.errors.is_empty(),
899            "clean write must record no errors"
900        );
901        assert!(!outcome.has_errors());
902    }
903
904    #[cfg(unix)]
905    #[test]
906    fn write_failure_is_surfaced_in_errors() {
907        use std::os::unix::fs::PermissionsExt;
908
909        let yaml = "root:\n  id: N01\n  type: question\n";
910        let dir = artifact(yaml, None);
911        let tree_path = dir.path().join("trace/exploration_tree.yaml");
912
913        // Make the tree file read-only so the write-back fails (non-root).
914        let mut perms = std::fs::metadata(&tree_path).unwrap().permissions();
915        perms.set_mode(0o444);
916        std::fs::set_permissions(&tree_path, perms).unwrap();
917
918        // Probe whether we can still write despite the read-only bit (i.e. running
919        // as root, where the permission is bypassed); skip the assertion if so.
920        if std::fs::OpenOptions::new()
921            .write(true)
922            .open(&tree_path)
923            .is_ok()
924        {
925            eprintln!("skipping: write not denied (likely running as root)");
926            return;
927        }
928
929        let outcome = fix_dir(dir.path());
930
931        assert!(outcome.has_errors());
932        assert!(
933            outcome.errors.iter().any(|(f, _)| *f == LintFile::Tree),
934            "tree write failure must be surfaced, got: {:?}",
935            outcome.errors
936        );
937        // The write failed, so the file must NOT be marked changed and the drift
938        // is still on disk (no false "clean").
939        assert!(!outcome.changed_files.contains(&LintFile::Tree));
940        assert_eq!(read_tree(&dir), yaml, "on-disk file must be untouched");
941
942        // Restore write permission so TempDir cleanup succeeds.
943        let mut perms = std::fs::metadata(&tree_path).unwrap().permissions();
944        perms.set_mode(0o644);
945        std::fs::set_permissions(&tree_path, perms).unwrap();
946    }
947
948    #[test]
949    fn clean_artifact_is_a_noop() {
950        let yaml = "tree:\n  - id: N01\n    type: question\n";
951        let dir = artifact(yaml, None);
952        let outcome = fix_dir(dir.path());
953        assert!(outcome.is_noop());
954        assert!(outcome.applied.is_empty());
955        assert!(outcome.skipped.is_empty());
956        assert_eq!(read_tree(&dir), yaml);
957    }
958
959    #[test]
960    fn combined_ara001_and_alias_fixes_both_apply() {
961        // ARA001 re-indent shifts the `reason:` column; the fixpoint re-detects
962        // ARA002 on the rewritten text and still fixes it.
963        let yaml = "\
964root:
965  id: N01
966  type: question
967  children:
968    - id: N02
969      type: dead_end
970      reason: diverged
971";
972        let dir = artifact(yaml, None);
973        let outcome = fix_dir(dir.path());
974
975        let rules: Vec<LintRuleId> = outcome.applied.iter().map(|a| a.rule).collect();
976        assert!(rules.contains(&LintRuleId::RootDialect));
977        assert!(rules.contains(&LintRuleId::DeadEndReasonAlias));
978        assert!(outcome.remaining.is_empty());
979
980        let (m, _) = parse_sources(&read_tree(&dir), None).expect("ok");
981        assert_eq!(m.nodes[0].id, NodeId::new("N01"));
982        match &m.nodes[1].fields {
983            NodeFields::DeadEnd { why_failed, .. } => {
984                assert_eq!(why_failed.as_deref(), Some("diverged"));
985            }
986            other => panic!("expected DeadEnd, got {other:?}"),
987        }
988    }
989}