Skip to main content

fallow_output/
fix.rs

1//! Fix JSON output contract.
2
3use serde::Serialize;
4use serde_json::Value;
5
6/// Inputs for building `fallow fix --format json`.
7#[derive(Clone, Copy)]
8pub struct FixJsonOutputInput<'a> {
9    /// True when `--dry-run` was passed and no files were modified.
10    pub dry_run: bool,
11    /// Per-fix entries as prepared by the fix planner.
12    pub fixes: &'a [Value],
13    /// Fixes skipped because file content changed since analysis.
14    pub skipped_content_changed: usize,
15    /// Fixes skipped because the file mixes line endings.
16    pub skipped_mixed_line_endings: usize,
17    /// Export removals skipped because confidence was below the threshold.
18    pub skipped_low_confidence_exports: usize,
19    /// Dependency removals withheld because the finding carried a
20    /// reachability caveat.
21    pub skipped_low_confidence_dependencies: usize,
22    /// Enum- and class-member removals withheld because the finding carried a
23    /// reachability caveat.
24    pub skipped_low_confidence_members: usize,
25}
26
27/// JSON root emitted by `fallow fix --format json`.
28#[derive(Debug, Clone, Serialize)]
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
30#[cfg_attr(feature = "schema", schemars(title = "fallow fix --format json"))]
31pub struct FixJsonOutput<'a> {
32    /// True when `--dry-run` was passed and no files were modified.
33    pub dry_run: bool,
34    /// Per-fix entries, applied and skipped alike.
35    pub fixes: &'a [Value],
36    /// Number of entries in `fixes` whose `applied` flag is true.
37    pub total_fixed: usize,
38    /// User-facing skipped entries; excludes plan-level skip diagnostics,
39    /// which are reported through the dedicated `skipped_*` counters.
40    pub skipped: usize,
41    /// Fixes skipped because file content changed since analysis.
42    pub skipped_content_changed: usize,
43    /// Fixes skipped because the file mixes line endings.
44    pub skipped_mixed_line_endings: usize,
45    /// Export removals skipped because confidence was below the threshold.
46    pub skipped_low_confidence_exports: usize,
47    /// Dependency removals withheld because the finding carried a
48    /// reachability caveat. Counted separately from the export withholding
49    /// so the two names stay honest; both are intentional and neither moves
50    /// the exit code.
51    pub skipped_low_confidence_dependencies: usize,
52    /// Enum- and class-member removals withheld because the finding carried a
53    /// reachability caveat. A third counter for the same reason the second
54    /// one exists: a member is neither an export nor a dependency, and
55    /// folding it into either name would make that name lie. Intentional,
56    /// exit-code-neutral, and `0` on a run that read every file it found.
57    pub skipped_low_confidence_members: usize,
58}
59
60/// Count fix entries whose `applied` flag is true.
61#[must_use]
62pub fn count_applied_fixes(fixes: &[Value]) -> usize {
63    fixes
64        .iter()
65        .filter(|fix| fix.get("applied").and_then(Value::as_bool).unwrap_or(false))
66        .count()
67}
68
69/// Count user-facing skipped entries, excluding plan-level skip diagnostics.
70#[must_use]
71pub fn count_reported_fix_skips(fixes: &[Value]) -> usize {
72    fixes
73        .iter()
74        .filter(|fix| {
75            let is_skipped = fix.get("skipped").and_then(Value::as_bool).unwrap_or(false);
76            let reason = fix.get("skip_reason").and_then(Value::as_str);
77            let is_plan_skip = matches!(
78                reason,
79                Some(
80                    "content_changed"
81                        | "mixed_line_endings"
82                        | "low_confidence_off_graph"
83                        | "low_confidence_unresolved_imports"
84                        | "low_confidence_incomplete_analysis"
85                )
86            );
87            is_skipped && !is_plan_skip
88        })
89        .count()
90}
91
92/// Build the typed fix JSON root.
93#[must_use]
94pub fn build_fix_json_output(input: FixJsonOutputInput<'_>) -> FixJsonOutput<'_> {
95    FixJsonOutput {
96        dry_run: input.dry_run,
97        fixes: input.fixes,
98        total_fixed: count_applied_fixes(input.fixes),
99        skipped: count_reported_fix_skips(input.fixes),
100        skipped_content_changed: input.skipped_content_changed,
101        skipped_mixed_line_endings: input.skipped_mixed_line_endings,
102        skipped_low_confidence_exports: input.skipped_low_confidence_exports,
103        skipped_low_confidence_dependencies: input.skipped_low_confidence_dependencies,
104        skipped_low_confidence_members: input.skipped_low_confidence_members,
105    }
106}
107
108/// Serialize the typed fix JSON root.
109///
110/// # Errors
111///
112/// Returns a serde error when a fix entry cannot be converted to JSON.
113pub fn serialize_fix_json_output(
114    input: FixJsonOutputInput<'_>,
115) -> Result<Value, serde_json::Error> {
116    serde_json::to_value(build_fix_json_output(input))
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use serde_json::json;
123
124    #[test]
125    fn fix_output_counts_applied_and_user_skips() {
126        let fixes = vec![
127            json!({"applied": true}),
128            json!({"applied": false, "skipped": true, "skip_reason": "manual"}),
129            json!({"skipped": true, "skip_reason": "content_changed"}),
130            json!({"skipped": true, "skip_reason": "low_confidence_unresolved_imports"}),
131            json!({
132                "type": "remove_dependency",
133                "skipped": true,
134                "skip_reason": "low_confidence_incomplete_analysis",
135            }),
136            json!({
137                "type": "remove_enum_member",
138                "skipped": true,
139                "skip_reason": "low_confidence_incomplete_analysis",
140            }),
141        ];
142
143        let output = build_fix_json_output(FixJsonOutputInput {
144            dry_run: true,
145            fixes: &fixes,
146            skipped_content_changed: 1,
147            skipped_mixed_line_endings: 2,
148            skipped_low_confidence_exports: 3,
149            skipped_low_confidence_dependencies: 4,
150            skipped_low_confidence_members: 5,
151        });
152
153        assert!(output.dry_run);
154        assert_eq!(output.total_fixed, 1);
155        assert_eq!(output.skipped, 1);
156        assert_eq!(output.skipped_content_changed, 1);
157        assert_eq!(output.skipped_mixed_line_endings, 2);
158        assert_eq!(output.skipped_low_confidence_exports, 3);
159        assert_eq!(
160            output.skipped, 1,
161            "a withheld dependency belongs to its own counter, not the generic skip count"
162        );
163        assert_eq!(output.skipped_low_confidence_dependencies, 4);
164        assert_eq!(
165            output.skipped, 1,
166            "a withheld enum member belongs to its own counter too"
167        );
168        assert_eq!(output.skipped_low_confidence_members, 5);
169    }
170
171    #[test]
172    fn fix_output_serializes_expected_root_keys() {
173        let fixes = vec![json!({"type": "unused-export", "applied": true})];
174        let value = serialize_fix_json_output(FixJsonOutputInput {
175            dry_run: false,
176            fixes: &fixes,
177            skipped_content_changed: 0,
178            skipped_mixed_line_endings: 0,
179            skipped_low_confidence_exports: 0,
180            skipped_low_confidence_dependencies: 0,
181            skipped_low_confidence_members: 0,
182        })
183        .expect("fix output serializes");
184
185        assert_eq!(value["dry_run"], false);
186        assert_eq!(value["total_fixed"], 1);
187        assert_eq!(value["skipped"], 0);
188        assert_eq!(value["fixes"][0]["type"], "unused-export");
189    }
190}