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