Skip to main content

fallow_api/
runtime_json.rs

1//! JSON protocol serializers for typed programmatic runtime output.
2//!
3//! Runtime entry points return typed output from [`crate::runtime`]. CLI, MCP,
4//! NAPI, and other protocol surfaces call these serializers at their JSON
5//! boundary.
6
7use crate::{
8    ProgrammaticError,
9    runtime::{
10        AuditProgrammaticOutput, BoundaryViolationsProgrammaticOutput,
11        CircularDependenciesProgrammaticOutput, CombinedProgrammaticOutput,
12        DeadCodeProgrammaticOutput, DecisionSurfaceProgrammaticOutput,
13        DuplicationProgrammaticOutput, FeatureFlagsProgrammaticOutput, HealthJsonReportInput,
14        HealthProgrammaticOutput, TraceCloneProgrammaticOutput, TraceDependencyProgrammaticOutput,
15        TraceExportProgrammaticOutput, TraceFileProgrammaticOutput, serialize_health_report_json,
16    },
17};
18use fallow_output::{
19    CHECK_SCHEMA_VERSION, CheckOutput, GroupByMode, RootEnvelopeMode,
20    build_decision_surface_output, serialize_check_json_output,
21    serialize_decision_surface_json_output, serialize_dupes_json_output,
22    serialize_feature_flags_json_output, strip_root_prefix,
23};
24use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
25use serde::Serialize;
26use std::path::Path;
27use std::time::Duration;
28
29type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
30
31/// Serialize typed combined output into the stable JSON compatibility contract.
32///
33/// # Errors
34///
35/// Returns a structured error if one of the combined sections cannot serialize.
36pub fn serialize_combined_programmatic_json(
37    output: CombinedProgrammaticOutput,
38) -> ProgrammaticResult<serde_json::Value> {
39    let CombinedProgrammaticOutput {
40        dead_code,
41        duplication,
42        health,
43        root,
44        elapsed,
45        explain,
46        next_steps,
47        envelope_mode,
48        telemetry_analysis_run_id,
49    } = output;
50    crate::serialize_combined_json(crate::CombinedJsonOutputInput {
51        check: dead_code
52            .as_ref()
53            .map(|dead_code| crate::CombinedCheckJsonSection {
54                results: &dead_code.output.results,
55                root: &dead_code.root,
56                elapsed: Duration::from_millis(dead_code.output.elapsed_ms.0),
57                config_fixable: dead_code.config_fixable,
58                extras: crate::CheckJsonExtraOutputs::default(),
59            }),
60        dupes: duplication
61            .as_ref()
62            .map(|duplication| &duplication.output.report),
63        health: health.as_ref().map(|health| &health.report),
64        root: &root,
65        elapsed,
66        explain,
67        next_steps,
68        envelope_mode,
69        telemetry_analysis_run_id: telemetry_analysis_run_id.as_deref(),
70    })
71    .map_err(|err| {
72        ProgrammaticError::new(format!("failed to serialize combined report: {err}"), 2)
73            .with_code("FALLOW_SERIALIZE_COMBINED_REPORT")
74            .with_context("combined")
75    })
76}
77
78/// Serialize typed decision-surface output into the stable JSON contract.
79///
80/// # Errors
81///
82/// Returns a structured error if the decision-surface payload cannot serialize.
83pub fn serialize_decision_surface_programmatic_json(
84    output: DecisionSurfaceProgrammaticOutput,
85) -> ProgrammaticResult<serde_json::Value> {
86    let DecisionSurfaceProgrammaticOutput {
87        surface,
88        elapsed: _,
89        envelope_mode,
90        telemetry_analysis_run_id,
91    } = output;
92    let payload = build_decision_surface_output(&surface);
93    serialize_decision_surface_json_output(
94        payload,
95        envelope_mode,
96        telemetry_analysis_run_id.as_deref(),
97    )
98    .map_err(|err| {
99        ProgrammaticError::new(format!("failed to serialize decision surface: {err}"), 2)
100            .with_code("FALLOW_SERIALIZE_DECISION_SURFACE")
101            .with_context("decision-surface")
102    })
103}
104
105/// Serialize typed audit output into the stable JSON compatibility contract.
106///
107/// # Errors
108///
109/// Returns a structured error if one of the audit sections cannot serialize.
110pub fn serialize_audit_programmatic_json(
111    output: AuditProgrammaticOutput,
112) -> ProgrammaticResult<serde_json::Value> {
113    let base_snapshot = output.base_snapshot.as_ref();
114    let dead_code = output
115        .dead_code
116        .as_ref()
117        .map(|dead_code| serialize_audit_dead_code(dead_code, base_snapshot))
118        .transpose()?;
119    let duplication = output
120        .duplication
121        .as_ref()
122        .map(|duplication| serialize_audit_duplication(duplication, base_snapshot))
123        .transpose()?;
124    let complexity = output
125        .complexity
126        .as_ref()
127        .map(|complexity| serialize_audit_complexity(complexity, base_snapshot))
128        .transpose()?;
129
130    crate::serialize_audit_json(
131        crate::AuditJsonOutputInput {
132            header: crate::AuditJsonHeaderInput {
133                schema_version: SchemaVersion(CHECK_SCHEMA_VERSION),
134                version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
135                verdict: output.verdict,
136                changed_files_count: u32::try_from(output.changed_files_count).unwrap_or(u32::MAX),
137                base_ref: output.base_ref,
138                base_description: output.base_description,
139                head_sha: output.head_sha,
140                elapsed_ms: ElapsedMs(
141                    u64::try_from(output.elapsed.as_millis()).unwrap_or(u64::MAX),
142                ),
143                base_snapshot_skipped: output.base_snapshot_skipped,
144                summary: output.summary,
145                attribution: output.attribution,
146            },
147            dead_code,
148            duplication,
149            complexity,
150            next_steps: output.next_steps,
151        },
152        output.envelope_mode,
153        output.telemetry_analysis_run_id.as_deref(),
154    )
155    .map_err(|err| {
156        ProgrammaticError::new(format!("failed to serialize audit report: {err}"), 2)
157            .with_code("FALLOW_SERIALIZE_AUDIT_REPORT")
158            .with_context("audit")
159    })
160}
161
162fn serialize_audit_dead_code(
163    output: &DeadCodeProgrammaticOutput,
164    base_snapshot: Option<&crate::AuditProgrammaticKeySnapshot>,
165) -> ProgrammaticResult<serde_json::Value> {
166    let mut json = crate::serialize_check_json_payload(crate::CheckJsonPayloadInput {
167        results: &output.output.results,
168        root: &output.root,
169        elapsed: Duration::from_millis(output.output.elapsed_ms.0),
170        config_fixable: output.config_fixable,
171        extras: crate::CheckJsonExtraOutputs::default(),
172        workspace_diagnostics: Vec::new(),
173    })
174    .map_err(|err| {
175        ProgrammaticError::new(format!("failed to serialize audit dead-code: {err}"), 2)
176            .with_code("FALLOW_SERIALIZE_AUDIT_DEAD_CODE")
177            .with_context("audit.deadCode")
178    })?;
179    if let Some(base) = base_snapshot {
180        if has_persisted_introduced_flags(&json) {
181            crate::audit_keys::annotate_stale_suppressions_json(
182                &mut json,
183                &output.output.results,
184                &output.root,
185                &base.dead_code,
186            );
187        } else {
188            crate::audit_keys::annotate_dead_code_json(
189                &mut json,
190                &output.output.results,
191                &output.root,
192                &base.dead_code,
193            );
194        }
195    }
196    Ok(json)
197}
198
199fn serialize_audit_duplication(
200    output: &DuplicationProgrammaticOutput,
201    base_snapshot: Option<&crate::AuditProgrammaticKeySnapshot>,
202) -> ProgrammaticResult<serde_json::Value> {
203    let mut json = serde_json::to_value(&output.output.report).map_err(|err| {
204        ProgrammaticError::new(format!("failed to serialize audit duplication: {err}"), 2)
205            .with_code("FALLOW_SERIALIZE_AUDIT_DUPLICATION")
206            .with_context("audit.duplication")
207    })?;
208    let root_prefix = format!("{}/", output.root.display());
209    strip_root_prefix(&mut json, &root_prefix);
210    if let Some(base) = base_snapshot
211        && !has_persisted_introduced_flags(&json)
212    {
213        annotate_audit_duplication_json(&mut json, output, &base.dupes);
214    }
215    Ok(json)
216}
217
218fn serialize_audit_complexity(
219    output: &HealthProgrammaticOutput,
220    base_snapshot: Option<&crate::AuditProgrammaticKeySnapshot>,
221) -> ProgrammaticResult<serde_json::Value> {
222    let mut json = serde_json::to_value(&output.report).map_err(|err| {
223        ProgrammaticError::new(format!("failed to serialize audit complexity: {err}"), 2)
224            .with_code("FALLOW_SERIALIZE_AUDIT_COMPLEXITY")
225            .with_context("audit.complexity")
226    })?;
227    let root_prefix = format!("{}/", output.root.display());
228    strip_root_prefix(&mut json, &root_prefix);
229    if let Some(base) = base_snapshot {
230        crate::audit_keys::annotate_health_json(
231            &mut json,
232            &output.report,
233            &output.root,
234            &base.health,
235        );
236    }
237    Ok(json)
238}
239
240fn has_persisted_introduced_flags(json: &serde_json::Value) -> bool {
241    json.as_object().is_some_and(|object| {
242        object.values().any(|value| {
243            value
244                .as_array()
245                .is_some_and(|items| items.iter().any(|item| item.get("introduced").is_some()))
246        })
247    })
248}
249
250fn annotate_audit_duplication_json(
251    json: &mut serde_json::Value,
252    output: &DuplicationProgrammaticOutput,
253    base: &rustc_hash::FxHashSet<String>,
254) {
255    let Some(items) = json
256        .get_mut("clone_groups")
257        .and_then(serde_json::Value::as_array_mut)
258    else {
259        return;
260    };
261    for (item, group) in items.iter_mut().zip(&output.output.report.clone_groups) {
262        if let serde_json::Value::Object(map) = item {
263            let key = crate::audit_keys::dupe_group_key(&group.group, &output.root);
264            map.insert(
265                "introduced".to_string(),
266                serde_json::json!(!base.contains(&key)),
267            );
268        }
269    }
270}
271
272/// Serialize typed dead-code output into the stable JSON compatibility contract.
273///
274/// # Errors
275///
276/// Returns a structured error if the output contract cannot be serialized.
277pub fn serialize_dead_code_programmatic_json(
278    output: DeadCodeProgrammaticOutput,
279) -> ProgrammaticResult<serde_json::Value> {
280    let DeadCodeProgrammaticOutput {
281        output,
282        root,
283        config_fixable: _,
284        envelope_mode,
285        telemetry_analysis_run_id,
286    } = output;
287    serialize_check_programmatic_output(
288        output,
289        &root,
290        envelope_mode,
291        telemetry_analysis_run_id.as_deref(),
292        "dead-code",
293        "FALLOW_SERIALIZE_DEAD_CODE_REPORT",
294    )
295}
296
297/// Serialize typed circular-dependency output into the JSON compatibility contract.
298///
299/// # Errors
300///
301/// Returns a structured error if the output contract cannot be serialized.
302pub fn serialize_circular_dependencies_programmatic_json(
303    output: CircularDependenciesProgrammaticOutput,
304) -> ProgrammaticResult<serde_json::Value> {
305    let CircularDependenciesProgrammaticOutput {
306        output,
307        root,
308        envelope_mode,
309        telemetry_analysis_run_id,
310    } = output;
311    serialize_check_programmatic_output(
312        output,
313        &root,
314        envelope_mode,
315        telemetry_analysis_run_id.as_deref(),
316        "circular-dependencies",
317        "FALLOW_SERIALIZE_CIRCULAR_DEPENDENCIES_REPORT",
318    )
319}
320
321/// Serialize typed boundary-family output into the JSON compatibility contract.
322///
323/// # Errors
324///
325/// Returns a structured error if the output contract cannot be serialized.
326pub fn serialize_boundary_violations_programmatic_json(
327    output: BoundaryViolationsProgrammaticOutput,
328) -> ProgrammaticResult<serde_json::Value> {
329    let BoundaryViolationsProgrammaticOutput {
330        output,
331        root,
332        envelope_mode,
333        telemetry_analysis_run_id,
334    } = output;
335    serialize_check_programmatic_output(
336        output,
337        &root,
338        envelope_mode,
339        telemetry_analysis_run_id.as_deref(),
340        "boundary-violations",
341        "FALLOW_SERIALIZE_BOUNDARY_VIOLATIONS_REPORT",
342    )
343}
344
345fn serialize_check_programmatic_output(
346    output: CheckOutput,
347    root: &Path,
348    envelope_mode: RootEnvelopeMode,
349    telemetry_analysis_run_id: Option<&str>,
350    context: &'static str,
351    code: &'static str,
352) -> ProgrammaticResult<serde_json::Value> {
353    let mut json = serialize_check_json_output(output, envelope_mode, telemetry_analysis_run_id)
354        .map_err(|err| {
355            ProgrammaticError::new(format!("failed to serialize {context} report: {err}"), 2)
356                .with_code(code)
357                .with_context(context)
358        })?;
359    let root_prefix = format!("{}/", root.display());
360    strip_root_prefix(&mut json, &root_prefix);
361    Ok(json)
362}
363
364/// Serialize typed duplication output into the JSON compatibility contract.
365///
366/// # Errors
367///
368/// Returns a structured error if the output contract cannot be serialized.
369pub fn serialize_duplication_programmatic_json(
370    output: DuplicationProgrammaticOutput,
371) -> ProgrammaticResult<serde_json::Value> {
372    let DuplicationProgrammaticOutput {
373        output,
374        root,
375        threshold: _,
376        envelope_mode,
377        telemetry_analysis_run_id,
378    } = output;
379    let mut json =
380        serialize_dupes_json_output(output, envelope_mode, telemetry_analysis_run_id.as_deref())
381            .map_err(|err| {
382                ProgrammaticError::new(format!("failed to serialize duplication report: {err}"), 2)
383                    .with_code("FALLOW_SERIALIZE_DUPLICATION_REPORT")
384                    .with_context("dupes")
385            })?;
386    let root_prefix = format!("{}/", root.display());
387    strip_root_prefix(&mut json, &root_prefix);
388    Ok(json)
389}
390
391/// Serialize typed feature-flag output into the JSON compatibility contract.
392///
393/// # Errors
394///
395/// Returns a structured error if the output contract cannot be serialized.
396pub fn serialize_feature_flags_programmatic_json(
397    output: FeatureFlagsProgrammaticOutput,
398) -> ProgrammaticResult<serde_json::Value> {
399    serialize_feature_flags_json_output(
400        output.output,
401        output.envelope_mode,
402        output.telemetry_analysis_run_id.as_deref(),
403    )
404    .map_err(|err| {
405        ProgrammaticError::new(
406            format!("failed to serialize feature flags report: {err}"),
407            2,
408        )
409        .with_code("FALLOW_SERIALIZE_FEATURE_FLAGS_REPORT")
410        .with_context("feature-flags")
411    })
412}
413
414/// Serialize typed export-trace output into the JSON compatibility contract.
415///
416/// # Errors
417///
418/// Returns a structured error if the trace output cannot be serialized.
419pub fn serialize_trace_export_programmatic_json(
420    output: TraceExportProgrammaticOutput,
421) -> ProgrammaticResult<serde_json::Value> {
422    serialize_trace_programmatic_output(
423        output.output,
424        "export trace",
425        "FALLOW_SERIALIZE_TRACE_EXPORT",
426        "trace_export",
427    )
428}
429
430/// Serialize typed file-trace output into the JSON compatibility contract.
431///
432/// # Errors
433///
434/// Returns a structured error if the trace output cannot be serialized.
435pub fn serialize_trace_file_programmatic_json(
436    output: TraceFileProgrammaticOutput,
437) -> ProgrammaticResult<serde_json::Value> {
438    serialize_trace_programmatic_output(
439        output.output,
440        "file trace",
441        "FALLOW_SERIALIZE_TRACE_FILE",
442        "trace_file",
443    )
444}
445
446/// Serialize typed dependency-trace output into the JSON compatibility contract.
447///
448/// # Errors
449///
450/// Returns a structured error if the trace output cannot be serialized.
451pub fn serialize_trace_dependency_programmatic_json(
452    output: TraceDependencyProgrammaticOutput,
453) -> ProgrammaticResult<serde_json::Value> {
454    serialize_trace_programmatic_output(
455        output.output,
456        "dependency trace",
457        "FALLOW_SERIALIZE_TRACE_DEPENDENCY",
458        "trace_dependency",
459    )
460}
461
462/// Serialize typed clone-trace output into the JSON compatibility contract.
463///
464/// # Errors
465///
466/// Returns a structured error if the trace output cannot be serialized.
467pub fn serialize_trace_clone_programmatic_json(
468    output: TraceCloneProgrammaticOutput,
469) -> ProgrammaticResult<serde_json::Value> {
470    serialize_trace_programmatic_output(
471        output.output,
472        "clone trace",
473        "FALLOW_SERIALIZE_TRACE_CLONE",
474        "trace_clone",
475    )
476}
477
478fn serialize_trace_programmatic_output<T: Serialize>(
479    output: T,
480    context: &'static str,
481    code: &'static str,
482    error_context: &'static str,
483) -> ProgrammaticResult<serde_json::Value> {
484    serde_json::to_value(output).map_err(|err| {
485        ProgrammaticError::new(format!("failed to serialize {context}: {err}"), 2)
486            .with_code(code)
487            .with_context(error_context)
488    })
489}
490
491/// Serialize typed health / complexity output into the JSON compatibility contract.
492///
493/// # Errors
494///
495/// Returns a structured error if the health output contract cannot be serialized.
496pub fn serialize_health_programmatic_json(
497    output: HealthProgrammaticOutput,
498) -> ProgrammaticResult<serde_json::Value> {
499    let HealthProgrammaticOutput {
500        report,
501        grouping,
502        root,
503        elapsed,
504        explain,
505        workspace_diagnostics,
506        next_steps,
507        envelope_mode,
508        telemetry_analysis_run_id,
509    } = output;
510    let (grouped_by, groups) = grouping.map_or((None, None), |grouping| {
511        (
512            group_by_mode_from_label(grouping.mode),
513            Some(grouping.groups),
514        )
515    });
516    serialize_health_report_json(HealthJsonReportInput {
517        report,
518        root: &root,
519        elapsed,
520        explain,
521        grouped_by,
522        groups,
523        workspace_diagnostics,
524        next_steps,
525        envelope_mode,
526        telemetry_analysis_run_id: telemetry_analysis_run_id.as_deref(),
527    })
528    .map_err(|err| {
529        ProgrammaticError::new(format!("failed to serialize health report: {err}"), 2)
530            .with_code("FALLOW_SERIALIZE_HEALTH_REPORT")
531            .with_context("health")
532    })
533}
534
535fn group_by_mode_from_label(label: &str) -> Option<GroupByMode> {
536    match label {
537        "owner" => Some(GroupByMode::Owner),
538        "directory" => Some(GroupByMode::Directory),
539        "package" => Some(GroupByMode::Package),
540        "section" => Some(GroupByMode::Section),
541        _ => None,
542    }
543}