1use 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
46const MAX_ITERS: usize = 1000;
50
51#[derive(Debug, Clone, PartialEq, Serialize)]
53pub struct AppliedFix {
54 pub rule: LintRuleId,
56 pub file: LintFile,
58 pub description: String,
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize)]
65pub struct SkippedFix {
66 pub rule: LintRuleId,
68 pub file: LintFile,
70 pub reason: String,
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize)]
76pub struct FixOutcome {
77 pub applied: Vec<AppliedFix>,
79 pub skipped: Vec<SkippedFix>,
81 pub remaining: LintReport,
89 pub changed_files: Vec<LintFile>,
91 pub errors: Vec<(LintFile, String)>,
94}
95
96impl FixOutcome {
97 pub fn is_noop(&self) -> bool {
99 self.applied.is_empty() && self.changed_files.is_empty()
100 }
101
102 pub fn has_errors(&self) -> bool {
104 !self.errors.is_empty()
105 }
106}
107
108pub 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 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 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
168type ParseResult = Result<(Manifest, ParseReport), ParseReport>;
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173enum AliasField {
174 WhyFailed,
176 Rationale,
178}
179
180struct Applier {
182 tree: String,
184 claims: Option<String>,
186 applied: Vec<AppliedFix>,
188 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 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 self.failed.clear();
223 }
224 }
225 }
226
227 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 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 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 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 let Ok((mc, _)) = cand else {
303 return false;
304 };
305 if !errors_subset(cand, base) {
308 return false;
309 }
310
311 let Some(base_claims) = claims_only(self.claims.as_deref()) else {
314 return false;
315 };
316 let Some((rec_id, rec_title)) = header_at(new_claims, diag_line(diag)) else {
318 return false;
319 };
320
321 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 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 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 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 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 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
378fn guard_ara001(base: &ParseResult, cand: &ParseResult) -> bool {
382 match (base, cand) {
383 (Ok((mb, _)), Ok((mc, _))) => mc == mb,
384 _ => false,
385 }
386}
387
388fn 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 if field_is_some(&mb.nodes[i], field) || !field_is_some(&mc.nodes[i], field) {
411 return false;
412 }
413
414 let mut mc2 = (*mc).clone();
417 clear_field(&mut mc2.nodes[i], field);
418 mc2 == *mb
419}
420
421fn 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
430fn 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
439fn 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
446fn errors_of(result: &ParseResult) -> &[Diagnostic] {
448 match result {
449 Ok((_, report)) => report.errors(),
450 Err(report) => report.errors(),
451 }
452}
453
454fn 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
465fn 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
484fn 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
504fn 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 {
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 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
549fn leading_spaces(s: &str) -> usize {
551 s.len() - s.trim_start_matches(' ').len()
552}
553
554fn 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
571fn 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
580fn 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
596fn 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 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 #[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 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 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 #[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 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 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 #[test]
767 fn ara004_dash_header_recovers_claim() {
768 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 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 #[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 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 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 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 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 assert!(!outcome.changed_files.contains(&LintFile::Tree));
940 assert_eq!(read_tree(&dir), yaml, "on-disk file must be untouched");
941
942 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 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}