1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
//! `lint --fix`: the auto-fixers, and what they refuse.
//!
//! Split out of `lint.rs` when combining the #356 gate work with #359's
//! comment-preserving rewrite took that file past the repo's 500-line ceiling.
//! These belong together: `AutoFix` is the shape both halves report through,
//! and `sort_resources` is the only transformation that currently produces one.
use std::path::Path;
/// What `lint --fix` actually did, and what it refused to do.
///
/// paiml/forjar#359: the previous shape was a bare `Vec<String>` of "fixes
/// applied", and the one entry it could hold was pushed UNCONDITIONALLY —
/// whenever a `resources:` mapping existed, sorted or not. So `--fix` claimed
/// "sorted resource keys alphabetically" on an already-sorted file, and
/// rewrote the file to prove it. Separating what was applied from what was
/// refused is what makes both halves reportable without one lying about the
/// other.
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct AutoFix {
/// Transformations that changed the file. Empty means the file was not
/// written.
pub applied: Vec<String>,
/// Transformations forjar declined, each carrying why.
pub refused: Vec<String>,
}
/// The result of trying to sort the `resources:` mapping in place.
enum SortOutcome {
/// Nothing to do: no `resources:` mapping, or its keys are already sorted.
Unchanged,
/// The document with the mapping's entries reordered and nothing else
/// touched.
Sorted(String),
/// The reorder could not be proven sound. Carries the reason.
Refused(String),
}
/// Sort the entries of `resources:` by moving their source byte ranges.
///
/// paiml/forjar#359: this used to parse the whole document into
/// `serde_yaml_ng::Value`, rebuild the `resources` mapping in sorted order and
/// re-emit the file. `Value` does not carry comments, so every comment in the
/// user's config was deleted — silently, by a flag whose contract was to fix
/// lint findings. In an IaC config the comments are the operational reasoning:
/// why a host is pinned, why an ordering matters, which runbook depends on it.
///
/// Sorting does not need a re-serialisation. Each entry owns a contiguous run
/// of source lines, so this is a permutation of byte ranges: no byte is
/// rewritten, only moved, and a comment above an entry travels with it.
fn sort_resources(content: &str) -> SortOutcome {
use crate::core::yaml_edit::{blocks, verify, AnchorError};
let blocks = match blocks::key_blocks(content, &["resources"]) {
Ok(b) => b,
// No `resources:` mapping at all is not a refusal — there is nothing
// to sort, which is exactly the same outcome as "already sorted".
Err(AnchorError::NotFound) => return SortOutcome::Unchanged,
Err(e) => return SortOutcome::Refused(e.reason().to_string()),
};
if blocks::is_sorted(&blocks) {
return SortOutcome::Unchanged;
}
let sorted = match blocks::reorder(content, &blocks, &blocks::sorted_order(&blocks)) {
Ok(text) => text,
Err(e) => return SortOutcome::Refused(e.reason().to_string()),
};
// Fail closed. Reordering entries must change no value anywhere in the
// document; if the re-parse disagrees, the edit is discarded rather than
// written.
match verify::changed_paths_of_text(content, &sorted) {
Ok(changed) if changed.is_empty() => SortOutcome::Sorted(sorted),
Ok(_) => {
SortOutcome::Refused("the reorder changed a value, so it was discarded".to_string())
}
Err(e) => SortOutcome::Refused(e),
}
}
/// Apply every auto-fix `lint --fix` knows, writing the file only if one of
/// them actually changed something.
pub(crate) fn lint_auto_fix(file: &Path) -> Result<AutoFix, String> {
let content = std::fs::read_to_string(file)
.map_err(|e| format!("cannot read {}: {}", file.display(), e))?;
// Fail closed before touching anything: an auto-fixer must never rewrite a
// document it cannot parse. `cmd_lint` parses the config before it gets
// here, so this guard is for every other caller.
serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&content)
.map_err(|e| format!("YAML parse error: {e}"))?;
let mut out = AutoFix::default();
match sort_resources(&content) {
SortOutcome::Unchanged => {}
SortOutcome::Sorted(sorted) => {
std::fs::write(file, &sorted)
.map_err(|e| format!("cannot write {}: {}", file.display(), e))?;
out.applied
.push("sorted resource keys alphabetically".to_string());
}
SortOutcome::Refused(reason) => out
.refused
.push(format!("resource keys left unsorted: {reason}")),
}
Ok(out)
}