Skip to main content

fallow_output/
root_envelopes.rs

1//! Root JSON output envelopes shared by CLI and programmatic consumers.
2
3use fallow_types::envelope::{ElapsedMs, Meta, SchemaVersion, TelemetryMeta, ToolVersion};
4use fallow_types::output::NextStep;
5use fallow_types::workspace::WorkspaceDiagnostic;
6use serde::Serialize;
7
8/// Current schema version for `fallow audit --format json`.
9pub const AUDIT_SCHEMA_VERSION: u32 = 10;
10
11/// Current schema version for bare combined JSON output.
12///
13/// Version 11 tracks the expanded required semantic omission reason-code enum
14/// embedded by its analysis subreports.
15pub const COMBINED_SCHEMA_VERSION: u32 = 11;
16
17/// Schema projection for the audit envelope's exact version.
18#[cfg(feature = "schema")]
19#[allow(dead_code, reason = "schema-only type used by the field projection")]
20#[derive(schemars::JsonSchema)]
21#[schemars(extend("const" = AUDIT_SCHEMA_VERSION))]
22struct AuditSchemaVersion(u32);
23
24/// Schema projection for the combined envelope's exact version.
25#[cfg(feature = "schema")]
26#[allow(dead_code, reason = "schema-only type used by the field projection")]
27#[derive(schemars::JsonSchema)]
28#[schemars(extend("const" = COMBINED_SCHEMA_VERSION))]
29struct CombinedSchemaVersion(u32);
30
31/// JSON root envelope discriminator policy.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum RootEnvelopeMode {
34    /// Emit a top-level `kind` discriminator on the JSON root.
35    Tagged,
36}
37
38/// Serialize a typed fallow root envelope with the requested discriminator
39/// mode.
40///
41/// # Errors
42///
43/// Returns a serde error when the provided envelope cannot be converted to a
44/// JSON value.
45pub fn serialize_json_root_output<T: Serialize>(
46    output: T,
47    mode: RootEnvelopeMode,
48) -> Result<serde_json::Value, serde_json::Error> {
49    let _ = mode;
50    serde_json::to_value(output)
51}
52
53/// Serialize an output envelope and apply an explicit root discriminator.
54///
55/// Use this for command surfaces whose runtime shape is already a typed
56/// envelope struct and does not need to pass through the schema-only
57/// [`FallowOutput`] enum just to get a top-level `kind`.
58///
59/// # Errors
60///
61/// Returns a serde error when the provided envelope cannot be converted to a
62/// JSON value.
63pub fn serialize_named_json_output<T: Serialize>(
64    output: T,
65    kind: &'static str,
66    mode: RootEnvelopeMode,
67) -> Result<serde_json::Value, serde_json::Error> {
68    let mut value = serde_json::to_value(output)?;
69    apply_root_kind(&mut value, kind, mode);
70    Ok(value)
71}
72
73/// Serialize a typed `fallow audit --format json` envelope with the standard
74/// root discriminator policy.
75///
76/// # Errors
77///
78/// Returns a serde error when the provided envelope cannot be converted to a
79/// JSON value.
80pub fn serialize_audit_json_output<
81    Verdict,
82    Summary,
83    Attribution,
84    DeadCode,
85    Duplication,
86    Complexity,
87>(
88    output: AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity>,
89    mode: RootEnvelopeMode,
90    analysis_run_id: Option<&str>,
91) -> Result<serde_json::Value, serde_json::Error>
92where
93    Verdict: Serialize,
94    Summary: Serialize,
95    Attribution: Serialize,
96    DeadCode: Serialize,
97    Duplication: Serialize,
98    Complexity: Serialize,
99{
100    let mut value = serde_json::to_value(output)?;
101    apply_root_kind(&mut value, "audit", mode);
102    attach_telemetry_meta(&mut value, analysis_run_id);
103    Ok(value)
104}
105
106/// Serialize a typed bare `fallow --format json` combined envelope with the
107/// standard root discriminator policy.
108///
109/// # Errors
110///
111/// Returns a serde error when the provided envelope cannot be converted to a
112/// JSON value.
113pub fn serialize_combined_json_output<Check, Dupes, Health>(
114    output: CombinedOutput<Check, Dupes, Health>,
115    mode: RootEnvelopeMode,
116    analysis_run_id: Option<&str>,
117) -> Result<serde_json::Value, serde_json::Error>
118where
119    Check: Serialize,
120    Dupes: Serialize,
121    Health: Serialize,
122{
123    let mut value = serde_json::to_value(output)?;
124    apply_root_kind(&mut value, "combined", mode);
125    attach_telemetry_meta(&mut value, analysis_run_id);
126    Ok(value)
127}
128
129/// Apply a document-root discriminator.
130pub fn apply_root_kind(value: &mut serde_json::Value, kind: &'static str, mode: RootEnvelopeMode) {
131    let _ = mode;
132    if let serde_json::Value::Object(map) = value {
133        let previous = map.shift_insert(
134            0,
135            "kind".to_string(),
136            serde_json::Value::String(kind.to_string()),
137        );
138        if let Some(previous) = previous
139            && let Some(current) = map.get_mut("kind")
140        {
141            *current = previous;
142        }
143    }
144}
145
146/// Attach telemetry metadata to a JSON root object when a run id is available.
147pub fn attach_telemetry_meta(value: &mut serde_json::Value, analysis_run_id: Option<&str>) {
148    let Some(analysis_run_id) = analysis_run_id else {
149        return;
150    };
151    let serde_json::Value::Object(map) = value else {
152        return;
153    };
154    let meta = map
155        .entry("_meta".to_string())
156        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
157    if !meta.is_object() {
158        *meta = serde_json::Value::Object(serde_json::Map::new());
159    }
160    if let serde_json::Value::Object(meta_map) = meta {
161        meta_map.insert(
162            "telemetry".to_string(),
163            serde_json::json!({ "analysis_run_id": analysis_run_id }),
164        );
165    }
166}
167
168/// `fallow audit --format json` envelope.
169#[derive(Debug, Clone, Serialize)]
170#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
171#[cfg_attr(feature = "schema", schemars(title = "fallow audit --format json"))]
172pub struct AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity> {
173    /// Audit output schema version.
174    #[cfg_attr(feature = "schema", schemars(with = "AuditSchemaVersion"))]
175    pub schema_version: SchemaVersion,
176    /// Fallow CLI version that produced this output.
177    pub version: ToolVersion,
178    /// Command discriminator singleton: always `audit`.
179    pub command: AuditCommand,
180    /// Gate verdict for the audited change.
181    pub verdict: Verdict,
182    /// Number of changed files in the audit scope.
183    pub changed_files_count: u32,
184    /// Git ref the change was diffed against.
185    pub base_ref: String,
186    /// Human-readable provenance of `base_ref`, e.g. `merge-base with
187    /// origin/main`, `local main`, or `FALLOW_AUDIT_BASE=upstream/main`.
188    /// Present when the base was auto-detected or set via `FALLOW_AUDIT_BASE`;
189    /// absent for an explicit `--base` (the ref the user typed is already
190    /// self-describing).
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub base_description: Option<String>,
193    /// Commit SHA of the audited head tree, when resolvable.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub head_sha: Option<String>,
196    /// Wall-clock analysis duration in milliseconds.
197    pub elapsed_ms: ElapsedMs,
198    /// True when base-snapshot analysis was skipped, so new-vs-inherited
199    /// attribution could not run.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub base_snapshot_skipped: Option<bool>,
202    /// Aggregate finding counts for the audited change.
203    pub summary: Summary,
204    /// New-vs-inherited attribution of findings against the base.
205    pub attribution: Attribution,
206    /// `_meta` block with metric / rule definitions, when `--explain` was
207    /// passed.
208    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
209    pub meta: Option<Meta>,
210    /// Dead-code findings scoped to the audit changeset.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub dead_code: Option<DeadCode>,
213    /// Duplication findings scoped to the audit changeset.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub duplication: Option<Duplication>,
216    /// Complexity findings scoped to the audit changeset.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub complexity: Option<Complexity>,
219    /// Read-only follow-up commands computed from this run's findings. See
220    /// `CheckOutput::next_steps` for the contract.
221    #[serde(default, skip_serializing_if = "Vec::is_empty")]
222    pub next_steps: Vec<NextStep>,
223}
224
225/// Audit command singleton carried by [`AuditOutput`].
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
227#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
228#[serde(rename_all = "lowercase")]
229pub enum AuditCommand {
230    /// The only value: `audit`.
231    Audit,
232}
233
234/// Bare `fallow --format json` envelope.
235#[derive(Debug, Clone, Serialize)]
236#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
237#[cfg_attr(
238    feature = "schema",
239    schemars(title = "fallow --format json (bare, combined)")
240)]
241pub struct CombinedOutput<Check, Dupes, Health> {
242    /// Combined output schema version.
243    #[cfg_attr(feature = "schema", schemars(with = "CombinedSchemaVersion"))]
244    pub schema_version: SchemaVersion,
245    /// Fallow CLI version that produced this output.
246    pub version: ToolVersion,
247    /// Wall-clock analysis duration in milliseconds.
248    pub elapsed_ms: ElapsedMs,
249    /// Per-section `_meta` blocks, when `--explain` was passed.
250    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
251    pub meta: Option<CombinedMeta>,
252    /// Dead-code section of the combined run.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub check: Option<Check>,
255    /// Duplication section of the combined run.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub dupes: Option<Dupes>,
258    /// Health section of the combined run.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub health: Option<Health>,
261    /// Workspace-discovery, source-discovery, and analysis-stage diagnostics
262    /// for the run (issue #2366). See `CheckOutput::workspace_diagnostics` for
263    /// the full contract: root-relative paths, omitted when empty. The
264    /// combined envelope carries them here rather than inside a section, so a
265    /// run that skips a section (`--skip check`, `--only health`,
266    /// `--only dupes`) still reports every diagnostic its analyses recorded.
267    #[serde(default, skip_serializing_if = "Vec::is_empty")]
268    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
269    /// Read-only follow-up commands aggregated across the combined run's
270    /// findings. See `CheckOutput::next_steps` for the contract.
271    #[serde(default, skip_serializing_if = "Vec::is_empty")]
272    pub next_steps: Vec<NextStep>,
273}
274
275/// Optional `_meta` block for [`CombinedOutput`].
276#[derive(Debug, Clone, Serialize)]
277#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
278pub struct CombinedMeta {
279    /// `_meta` block for the dead-code section.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub check: Option<Meta>,
282    /// `_meta` block for the duplication section.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub dupes: Option<Meta>,
285    /// `_meta` block for the health section.
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub health: Option<Meta>,
288    /// Telemetry identifiers for the run.
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub telemetry: Option<TelemetryMeta>,
291}
292
293/// Typed root of every fallow JSON envelope shape that serializes as a JSON
294/// object and participates in the documented `FallowOutput` contract. The
295/// schema derived from this enum drives the document-root `oneOf` in
296/// `docs/output-schema.json`.
297///
298/// The wire shape carries a top-level `kind` discriminator so agents and
299/// schema-validating clients can select the variant in O(1) instead of probing
300/// for unique field presence.
301///
302/// One envelope is intentionally NOT in this enum:
303/// - `CodeClimateOutput` serializes as a bare JSON array
304///   (`#[serde(transparent)]`) per the Code Climate / GitLab Code Quality
305///   spec; `#[serde(tag = ...)]` cannot internally tag a non-object
306///   variant and wrapping the array would break the spec. The root schema
307///   carries it as a sibling `oneOf` branch alongside `FallowOutput`.
308#[derive(Debug, Clone, Serialize)]
309#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
310#[cfg_attr(
311    feature = "schema",
312    schemars(title = "fallow --format json (typed root)")
313)]
314#[serde(tag = "kind")]
315#[allow(
316    dead_code,
317    reason = "some variants are schema-emit only, but runtime roots serialize through this enum where practical"
318)]
319pub enum FallowOutput<
320    Audit,
321    Explain,
322    Inspect,
323    Trace,
324    ReviewEnvelope,
325    ReviewReconcile,
326    CoverageSetup,
327    CoverageAnalyze,
328    ListBoundaries,
329    Workspaces,
330    Health,
331    Dupes,
332    CheckGrouped,
333    Impact,
334    ImpactCrossRepo,
335    SecuritySummary,
336    Security,
337    SecuritySurvivors,
338    SecurityBlindSpots,
339    Check,
340    Combined,
341    FeatureFlags,
342    AuditBrief,
343    DecisionSurface,
344    WalkthroughGuide,
345    WalkthroughValidation,
346    SuppressionInventory,
347    TypeAwareStatus,
348    SimilarCode,
349    SimilarCodeInspect,
350    SimilarCodeReview,
351    SimilarCodeStatus,
352    SimilarCodeCacheClear,
353> {
354    /// `fallow audit --format json`.
355    #[serde(rename = "audit")]
356    Audit(Audit),
357    /// `fallow explain <issue-type> --format json`.
358    #[serde(rename = "explain")]
359    Explain(Explain),
360    /// `fallow inspect --format json`.
361    #[serde(rename = "inspect_target")]
362    Inspect(Inspect),
363    /// `fallow trace <symbol> --format json`.
364    #[serde(rename = "trace")]
365    Trace(Trace),
366    /// `fallow --format review-github` / `--format review-gitlab`.
367    #[serde(rename = "review-envelope")]
368    ReviewEnvelope(ReviewEnvelope),
369    /// `fallow ci reconcile-review --format json`.
370    #[serde(rename = "review-reconcile")]
371    ReviewReconcile(ReviewReconcile),
372    /// `fallow coverage setup --json`.
373    #[serde(rename = "coverage-setup")]
374    CoverageSetup(CoverageSetup),
375    /// `fallow coverage analyze --format json`.
376    #[serde(rename = "coverage-analyze")]
377    CoverageAnalyze(CoverageAnalyze),
378    /// `fallow list --boundaries --format json`.
379    #[serde(rename = "list-boundaries")]
380    ListBoundaries(ListBoundaries),
381    /// `fallow workspaces --format json`.
382    #[serde(rename = "list-workspaces")]
383    Workspaces(Workspaces),
384    /// `fallow health --format json`.
385    #[serde(rename = "health")]
386    Health(Health),
387    /// `fallow dupes --format json`.
388    #[serde(rename = "dupes")]
389    Dupes(Dupes),
390    /// `fallow dead-code --format json --group-by <mode>`.
391    #[serde(rename = "dead-code-grouped")]
392    CheckGrouped(CheckGrouped),
393    /// `fallow impact --format json`.
394    #[serde(rename = "impact")]
395    Impact(Impact),
396    /// `fallow impact --all --format json`.
397    #[serde(rename = "impact-cross-repo")]
398    ImpactCrossRepo(ImpactCrossRepo),
399    /// `fallow security --summary --format json`.
400    #[serde(rename = "security")]
401    SecuritySummary(SecuritySummary),
402    /// `fallow security --format json`.
403    #[serde(rename = "security")]
404    Security(Security),
405    /// `fallow security survivors --format json`.
406    #[serde(rename = "security-survivors")]
407    SecuritySurvivors(SecuritySurvivors),
408    /// `fallow security blind-spots --format json`.
409    #[serde(rename = "security-blind-spots")]
410    SecurityBlindSpots(SecurityBlindSpots),
411    /// `fallow dead-code --format json`.
412    #[serde(rename = "dead-code")]
413    Check(Check),
414    /// Bare `fallow --format json`.
415    #[serde(rename = "combined")]
416    Combined(Combined),
417    /// `fallow flags --format json`.
418    #[serde(rename = "feature-flags")]
419    FeatureFlags(FeatureFlags),
420    /// `fallow audit --brief --format json`.
421    #[serde(rename = "audit-brief")]
422    AuditBrief(AuditBrief),
423    /// `fallow decision-surface --format json`.
424    #[serde(rename = "decision-surface")]
425    DecisionSurface(DecisionSurface),
426    /// `fallow review --walkthrough-guide --format json`.
427    #[serde(rename = "review-walkthrough-guide")]
428    WalkthroughGuide(WalkthroughGuide),
429    /// `fallow review --walkthrough-file --format json`.
430    #[serde(rename = "review-walkthrough-validation")]
431    WalkthroughValidation(WalkthroughValidation),
432    /// `fallow suppressions --format json`.
433    #[serde(rename = "suppression-inventory")]
434    SuppressionInventory(SuppressionInventory),
435    /// `fallow type-aware status --format json`.
436    #[serde(rename = "type-aware-status")]
437    TypeAwareStatus(TypeAwareStatus),
438    /// `fallow similar-code --format json`.
439    #[serde(rename = "similar-code")]
440    SimilarCode(SimilarCode),
441    /// `fallow similar-code inspect --format json`.
442    #[serde(rename = "similar-code-inspect")]
443    SimilarCodeInspect(SimilarCodeInspect),
444    /// `fallow similar-code review --format json`.
445    #[serde(rename = "similar-code-review")]
446    SimilarCodeReview(SimilarCodeReview),
447    /// `fallow similar-code status --format json` and successful setup output.
448    #[serde(rename = "similar-code-status")]
449    SimilarCodeStatus(SimilarCodeStatus),
450    /// `fallow similar-code cache clear --format json`.
451    #[serde(rename = "similar-code-cache-clear")]
452    SimilarCodeCacheClear(SimilarCodeCacheClear),
453}
454
455#[cfg(test)]
456mod tests {
457    use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
458    use serde_json::json;
459
460    use super::*;
461
462    #[test]
463    fn apply_root_kind_sets_tagged_mode() {
464        let mut value = json!({});
465
466        apply_root_kind(&mut value, "dead_code", RootEnvelopeMode::Tagged);
467
468        assert_eq!(value["kind"], "dead_code");
469    }
470
471    #[test]
472    fn apply_root_kind_prepends_without_reordering_existing_fields() {
473        let mut value = json!({ "schema_version": 1, "summary": { "total": 0 } });
474
475        apply_root_kind(&mut value, "example", RootEnvelopeMode::Tagged);
476
477        assert_eq!(
478            serde_json::to_string(&value).expect("root output should serialize"),
479            r#"{"kind":"example","schema_version":1,"summary":{"total":0}}"#
480        );
481    }
482
483    #[test]
484    fn apply_root_kind_preserves_existing_value_and_moves_it_first() {
485        let mut value = json!({ "before": 1, "kind": "custom", "after": 2 });
486
487        apply_root_kind(&mut value, "replacement", RootEnvelopeMode::Tagged);
488
489        assert_eq!(
490            serde_json::to_string(&value).expect("root output should serialize"),
491            r#"{"kind":"custom","before":1,"after":2}"#
492        );
493    }
494
495    #[test]
496    fn apply_root_kind_preserves_non_object_roots() {
497        let mut value = json!(["not", "an", "object"]);
498
499        apply_root_kind(&mut value, "example", RootEnvelopeMode::Tagged);
500
501        assert_eq!(value, json!(["not", "an", "object"]));
502    }
503
504    #[test]
505    fn attach_telemetry_meta_sets_analysis_run_id() {
506        let mut value = json!({});
507
508        attach_telemetry_meta(&mut value, Some("run-123"));
509
510        assert_eq!(
511            value["_meta"]["telemetry"]["analysis_run_id"],
512            json!("run-123")
513        );
514    }
515
516    #[test]
517    fn attach_telemetry_meta_preserves_non_object_roots() {
518        let mut value = json!(["not", "an", "object"]);
519
520        attach_telemetry_meta(&mut value, Some("run-123"));
521
522        assert_eq!(value, json!(["not", "an", "object"]));
523    }
524
525    #[test]
526    fn serialize_named_json_output_applies_explicit_kind() {
527        let value = serialize_named_json_output(
528            json!({
529                "schema_version": 1,
530                "summary": { "total": 0 }
531            }),
532            "example",
533            RootEnvelopeMode::Tagged,
534        )
535        .expect("named output should serialize");
536
537        assert_eq!(value["kind"], "example");
538        assert_eq!(value["summary"]["total"], 0);
539    }
540
541    #[test]
542    fn serialize_audit_json_output_applies_audit_kind() {
543        let value = serialize_audit_json_output(
544            AuditOutput {
545                schema_version: SchemaVersion(7),
546                version: ToolVersion("1.2.3".to_string()),
547                command: AuditCommand::Audit,
548                verdict: "pass",
549                changed_files_count: 2,
550                base_ref: "origin/main".to_string(),
551                base_description: Some("merge-base with origin/main".to_string()),
552                head_sha: Some("abc123".to_string()),
553                elapsed_ms: ElapsedMs(42),
554                base_snapshot_skipped: Some(false),
555                summary: json!({ "dead_code_issues": 0 }),
556                attribution: json!({ "gate": "new_only" }),
557                meta: None,
558                dead_code: Some(json!({ "summary": { "total_issues": 0 } })),
559                duplication: None::<serde_json::Value>,
560                complexity: None::<serde_json::Value>,
561                next_steps: Vec::new(),
562            },
563            RootEnvelopeMode::Tagged,
564            Some("run-audit"),
565        )
566        .expect("audit output should serialize");
567
568        assert_eq!(value["kind"], "audit");
569        assert_eq!(value["command"], "audit");
570        assert_eq!(value["dead_code"]["summary"]["total_issues"], 0);
571        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-audit");
572    }
573
574    #[test]
575    fn serialize_combined_json_output_applies_combined_kind() {
576        let value = serialize_combined_json_output(
577            CombinedOutput {
578                schema_version: SchemaVersion(7),
579                version: ToolVersion("1.2.3".to_string()),
580                elapsed_ms: ElapsedMs(42),
581                meta: None,
582                check: Some(json!({ "summary": { "total_issues": 0 } })),
583                dupes: None::<serde_json::Value>,
584                health: None::<serde_json::Value>,
585                workspace_diagnostics: Vec::new(),
586                next_steps: Vec::new(),
587            },
588            RootEnvelopeMode::Tagged,
589            Some("run-combined"),
590        )
591        .expect("combined output should serialize");
592
593        assert_eq!(value["kind"], "combined");
594        assert_eq!(value["check"]["summary"]["total_issues"], 0);
595        assert_eq!(
596            value["_meta"]["telemetry"]["analysis_run_id"],
597            "run-combined"
598        );
599    }
600}