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    pub dry_run: bool,
10    pub fixes: &'a [Value],
11    pub skipped_content_changed: usize,
12    pub skipped_mixed_line_endings: usize,
13    pub skipped_low_confidence_exports: usize,
14}
15
16/// JSON root emitted by `fallow fix --format json`.
17#[derive(Debug, Clone, Serialize)]
18#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
19#[cfg_attr(feature = "schema", schemars(title = "fallow fix --format json"))]
20pub struct FixJsonOutput<'a> {
21    pub dry_run: bool,
22    pub fixes: &'a [Value],
23    pub total_fixed: usize,
24    pub skipped: usize,
25    pub skipped_content_changed: usize,
26    pub skipped_mixed_line_endings: usize,
27    pub skipped_low_confidence_exports: usize,
28}
29
30/// Count fix entries whose `applied` flag is true.
31#[must_use]
32pub fn count_applied_fixes(fixes: &[Value]) -> usize {
33    fixes
34        .iter()
35        .filter(|fix| fix.get("applied").and_then(Value::as_bool).unwrap_or(false))
36        .count()
37}
38
39/// Count user-facing skipped entries, excluding plan-level skip diagnostics.
40#[must_use]
41pub fn count_reported_fix_skips(fixes: &[Value]) -> usize {
42    fixes
43        .iter()
44        .filter(|fix| {
45            let is_skipped = fix.get("skipped").and_then(Value::as_bool).unwrap_or(false);
46            let reason = fix.get("skip_reason").and_then(Value::as_str);
47            let is_plan_skip = matches!(
48                reason,
49                Some(
50                    "content_changed"
51                        | "mixed_line_endings"
52                        | "low_confidence_off_graph"
53                        | "low_confidence_unresolved_imports"
54                )
55            );
56            is_skipped && !is_plan_skip
57        })
58        .count()
59}
60
61/// Build the typed fix JSON root.
62#[must_use]
63pub fn build_fix_json_output(input: FixJsonOutputInput<'_>) -> FixJsonOutput<'_> {
64    FixJsonOutput {
65        dry_run: input.dry_run,
66        fixes: input.fixes,
67        total_fixed: count_applied_fixes(input.fixes),
68        skipped: count_reported_fix_skips(input.fixes),
69        skipped_content_changed: input.skipped_content_changed,
70        skipped_mixed_line_endings: input.skipped_mixed_line_endings,
71        skipped_low_confidence_exports: input.skipped_low_confidence_exports,
72    }
73}
74
75/// Serialize the typed fix JSON root.
76///
77/// # Errors
78///
79/// Returns a serde error when a fix entry cannot be converted to JSON.
80pub fn serialize_fix_json_output(
81    input: FixJsonOutputInput<'_>,
82) -> Result<Value, serde_json::Error> {
83    serde_json::to_value(build_fix_json_output(input))
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use serde_json::json;
90
91    #[test]
92    fn fix_output_counts_applied_and_user_skips() {
93        let fixes = vec![
94            json!({"applied": true}),
95            json!({"applied": false, "skipped": true, "skip_reason": "manual"}),
96            json!({"skipped": true, "skip_reason": "content_changed"}),
97            json!({"skipped": true, "skip_reason": "low_confidence_unresolved_imports"}),
98        ];
99
100        let output = build_fix_json_output(FixJsonOutputInput {
101            dry_run: true,
102            fixes: &fixes,
103            skipped_content_changed: 1,
104            skipped_mixed_line_endings: 2,
105            skipped_low_confidence_exports: 3,
106        });
107
108        assert!(output.dry_run);
109        assert_eq!(output.total_fixed, 1);
110        assert_eq!(output.skipped, 1);
111        assert_eq!(output.skipped_content_changed, 1);
112        assert_eq!(output.skipped_mixed_line_endings, 2);
113        assert_eq!(output.skipped_low_confidence_exports, 3);
114    }
115
116    #[test]
117    fn fix_output_serializes_expected_root_keys() {
118        let fixes = vec![json!({"type": "unused-export", "applied": true})];
119        let value = serialize_fix_json_output(FixJsonOutputInput {
120            dry_run: false,
121            fixes: &fixes,
122            skipped_content_changed: 0,
123            skipped_mixed_line_endings: 0,
124            skipped_low_confidence_exports: 0,
125        })
126        .expect("fix output serializes");
127
128        assert_eq!(value["dry_run"], false);
129        assert_eq!(value["total_fixed"], 1);
130        assert_eq!(value["skipped"], 0);
131        assert_eq!(value["fixes"][0]["type"], "unused-export");
132    }
133}