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 serde::Serialize;
6
7/// Whether a JSON root envelope keeps the top-level `kind` discriminator.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum RootEnvelopeMode {
10    Tagged,
11    Legacy,
12}
13
14impl RootEnvelopeMode {
15    /// Convert a legacy-envelope flag into the root envelope mode.
16    #[must_use]
17    pub const fn from_legacy(legacy_envelope: bool) -> Self {
18        if legacy_envelope {
19            Self::Legacy
20        } else {
21            Self::Tagged
22        }
23    }
24}
25
26/// Serialize a typed fallow root envelope with the requested discriminator
27/// mode.
28///
29/// # Errors
30///
31/// Returns a serde error when the provided envelope cannot be converted to a
32/// JSON value.
33pub fn serialize_json_root_output<T: Serialize>(
34    output: T,
35    mode: RootEnvelopeMode,
36) -> Result<serde_json::Value, serde_json::Error> {
37    let mut value = serde_json::to_value(output)?;
38    if mode == RootEnvelopeMode::Legacy {
39        remove_root_kind(&mut value);
40    }
41    Ok(value)
42}
43
44/// Serialize an output envelope and apply an explicit root discriminator.
45///
46/// Use this for command surfaces whose runtime shape is already a typed
47/// envelope struct and does not need to pass through the schema-only
48/// [`FallowOutput`] enum just to get a top-level `kind`.
49///
50/// # Errors
51///
52/// Returns a serde error when the provided envelope cannot be converted to a
53/// JSON value.
54pub fn serialize_named_json_output<T: Serialize>(
55    output: T,
56    kind: &'static str,
57    mode: RootEnvelopeMode,
58) -> Result<serde_json::Value, serde_json::Error> {
59    let mut value = serde_json::to_value(output)?;
60    apply_root_kind(&mut value, kind, mode);
61    Ok(value)
62}
63
64/// Serialize a typed `fallow audit --format json` envelope with the standard
65/// root discriminator policy.
66///
67/// # Errors
68///
69/// Returns a serde error when the provided envelope cannot be converted to a
70/// JSON value.
71pub fn serialize_audit_json_output<
72    Verdict,
73    Summary,
74    Attribution,
75    DeadCode,
76    Duplication,
77    Complexity,
78>(
79    output: AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity>,
80    mode: RootEnvelopeMode,
81    analysis_run_id: Option<&str>,
82) -> Result<serde_json::Value, serde_json::Error>
83where
84    Verdict: Serialize,
85    Summary: Serialize,
86    Attribution: Serialize,
87    DeadCode: Serialize,
88    Duplication: Serialize,
89    Complexity: Serialize,
90{
91    let mut value = serde_json::to_value(output)?;
92    apply_root_kind(&mut value, "audit", mode);
93    attach_telemetry_meta(&mut value, analysis_run_id);
94    Ok(value)
95}
96
97/// Serialize a typed bare `fallow --format json` combined envelope with the
98/// standard root discriminator policy.
99///
100/// # Errors
101///
102/// Returns a serde error when the provided envelope cannot be converted to a
103/// JSON value.
104pub fn serialize_combined_json_output<Check, Dupes, Health>(
105    output: CombinedOutput<Check, Dupes, Health>,
106    mode: RootEnvelopeMode,
107    analysis_run_id: Option<&str>,
108) -> Result<serde_json::Value, serde_json::Error>
109where
110    Check: Serialize,
111    Dupes: Serialize,
112    Health: Serialize,
113{
114    let mut value = serde_json::to_value(output)?;
115    apply_root_kind(&mut value, "combined", mode);
116    attach_telemetry_meta(&mut value, analysis_run_id);
117    Ok(value)
118}
119
120/// Remove only the document-root discriminator. Nested objects may carry their
121/// own meaningful `kind` fields, so this intentionally does not recurse.
122pub fn remove_root_kind(value: &mut serde_json::Value) {
123    if let serde_json::Value::Object(map) = value {
124        map.remove("kind");
125    }
126}
127
128/// Apply a document-root discriminator unless the caller requested the legacy
129/// envelope shape.
130pub fn apply_root_kind(value: &mut serde_json::Value, kind: &'static str, mode: RootEnvelopeMode) {
131    if mode == RootEnvelopeMode::Tagged
132        && let serde_json::Value::Object(map) = value
133    {
134        let existing = std::mem::take(map);
135        map.insert(
136            "kind".to_string(),
137            serde_json::Value::String(kind.to_string()),
138        );
139        map.extend(existing);
140    }
141}
142
143/// Attach telemetry metadata to a JSON root object when a run id is available.
144pub fn attach_telemetry_meta(value: &mut serde_json::Value, analysis_run_id: Option<&str>) {
145    let Some(analysis_run_id) = analysis_run_id else {
146        return;
147    };
148    let serde_json::Value::Object(map) = value else {
149        return;
150    };
151    let meta = map
152        .entry("_meta".to_string())
153        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
154    if !meta.is_object() {
155        *meta = serde_json::Value::Object(serde_json::Map::new());
156    }
157    if let serde_json::Value::Object(meta_map) = meta {
158        meta_map.insert(
159            "telemetry".to_string(),
160            serde_json::json!({ "analysis_run_id": analysis_run_id }),
161        );
162    }
163}
164
165/// `fallow audit --format json` envelope.
166#[derive(Debug, Clone, Serialize)]
167#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
168#[cfg_attr(feature = "schema", schemars(title = "fallow audit --format json"))]
169pub struct AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity> {
170    pub schema_version: SchemaVersion,
171    pub version: ToolVersion,
172    pub command: AuditCommand,
173    pub verdict: Verdict,
174    pub changed_files_count: u32,
175    pub base_ref: String,
176    /// Human-readable provenance of `base_ref`, e.g. `merge-base with
177    /// origin/main`, `local main`, or `FALLOW_AUDIT_BASE=upstream/main`.
178    /// Present when the base was auto-detected or set via `FALLOW_AUDIT_BASE`;
179    /// absent for an explicit `--base` (the ref the user typed is already
180    /// self-describing).
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub base_description: Option<String>,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub head_sha: Option<String>,
185    pub elapsed_ms: ElapsedMs,
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub base_snapshot_skipped: Option<bool>,
188    pub summary: Summary,
189    pub attribution: Attribution,
190    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
191    pub meta: Option<Meta>,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub dead_code: Option<DeadCode>,
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub duplication: Option<Duplication>,
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub complexity: Option<Complexity>,
198    /// Read-only follow-up commands computed from this run's findings. See
199    /// `CheckOutput::next_steps` for the contract.
200    #[serde(default, skip_serializing_if = "Vec::is_empty")]
201    pub next_steps: Vec<NextStep>,
202}
203
204/// Audit command singleton carried by [`AuditOutput`].
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
206#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
207#[serde(rename_all = "lowercase")]
208pub enum AuditCommand {
209    Audit,
210}
211
212/// Bare `fallow --format json` envelope.
213#[derive(Debug, Clone, Serialize)]
214#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
215#[cfg_attr(
216    feature = "schema",
217    schemars(title = "fallow --format json (bare, combined)")
218)]
219pub struct CombinedOutput<Check, Dupes, Health> {
220    pub schema_version: SchemaVersion,
221    pub version: ToolVersion,
222    pub elapsed_ms: ElapsedMs,
223    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
224    pub meta: Option<CombinedMeta>,
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub check: Option<Check>,
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub dupes: Option<Dupes>,
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub health: Option<Health>,
231    /// Read-only follow-up commands aggregated across the combined run's
232    /// findings. See `CheckOutput::next_steps` for the contract.
233    #[serde(default, skip_serializing_if = "Vec::is_empty")]
234    pub next_steps: Vec<NextStep>,
235}
236
237/// Optional `_meta` block for [`CombinedOutput`].
238#[derive(Debug, Clone, Serialize)]
239#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
240pub struct CombinedMeta {
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub check: Option<Meta>,
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub dupes: Option<Meta>,
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub health: Option<Meta>,
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub telemetry: Option<TelemetryMeta>,
249}
250
251/// Typed root of every fallow JSON envelope shape that serializes as a JSON
252/// object and participates in the documented `FallowOutput` contract. The
253/// schema derived from this enum drives the document-root `oneOf` in
254/// `docs/output-schema.json`.
255///
256/// The default wire shape now carries a top-level `kind` discriminator so
257/// agents and schema-validating clients can select the variant in O(1) instead
258/// of probing for unique field presence. `--legacy-envelope` is a one-cycle
259/// compatibility flag that removes only this document-root `kind` field from
260/// CLI JSON output; nested report objects are not rewritten.
261///
262/// One envelope is intentionally NOT in this enum:
263/// - `CodeClimateOutput` serializes as a bare JSON array
264///   (`#[serde(transparent)]`) per the Code Climate / GitLab Code Quality
265///   spec; `#[serde(tag = ...)]` cannot internally tag a non-object
266///   variant and wrapping the array would break the spec. The root schema
267///   carries it as a sibling `oneOf` branch alongside `FallowOutput`.
268#[derive(Debug, Clone, Serialize)]
269#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
270#[cfg_attr(
271    feature = "schema",
272    schemars(title = "fallow --format json (typed root)")
273)]
274#[serde(tag = "kind")]
275#[allow(
276    dead_code,
277    reason = "some variants are schema-emit only, but runtime roots serialize through this enum where practical"
278)]
279pub enum FallowOutput<
280    Audit,
281    Explain,
282    Inspect,
283    Trace,
284    ReviewEnvelope,
285    ReviewReconcile,
286    CoverageSetup,
287    CoverageAnalyze,
288    ListBoundaries,
289    Workspaces,
290    Health,
291    Dupes,
292    CheckGrouped,
293    Impact,
294    ImpactCrossRepo,
295    SecuritySummary,
296    Security,
297    SecuritySurvivors,
298    SecurityBlindSpots,
299    Check,
300    Combined,
301    AuditBrief,
302    DecisionSurface,
303    WalkthroughGuide,
304    WalkthroughValidation,
305> {
306    /// `fallow audit --format json`.
307    #[serde(rename = "audit")]
308    Audit(Audit),
309    /// `fallow explain <issue-type> --format json`.
310    #[serde(rename = "explain")]
311    Explain(Explain),
312    /// `fallow inspect --format json`.
313    #[serde(rename = "inspect_target")]
314    Inspect(Inspect),
315    /// `fallow trace <symbol> --format json`.
316    #[serde(rename = "trace")]
317    Trace(Trace),
318    /// `fallow --format review-github` / `--format review-gitlab`.
319    #[serde(rename = "review-envelope")]
320    ReviewEnvelope(ReviewEnvelope),
321    /// `fallow ci reconcile-review --format json`.
322    #[serde(rename = "review-reconcile")]
323    ReviewReconcile(ReviewReconcile),
324    /// `fallow coverage setup --json`.
325    #[serde(rename = "coverage-setup")]
326    CoverageSetup(CoverageSetup),
327    /// `fallow coverage analyze --format json`.
328    #[serde(rename = "coverage-analyze")]
329    CoverageAnalyze(CoverageAnalyze),
330    /// `fallow list --boundaries --format json`.
331    #[serde(rename = "list-boundaries")]
332    ListBoundaries(ListBoundaries),
333    /// `fallow workspaces --format json`.
334    #[serde(rename = "list-workspaces")]
335    Workspaces(Workspaces),
336    /// `fallow health --format json`.
337    #[serde(rename = "health")]
338    Health(Health),
339    /// `fallow dupes --format json`.
340    #[serde(rename = "dupes")]
341    Dupes(Dupes),
342    /// `fallow dead-code --format json --group-by <mode>`.
343    #[serde(rename = "dead-code-grouped")]
344    CheckGrouped(CheckGrouped),
345    /// `fallow impact --format json`.
346    #[serde(rename = "impact")]
347    Impact(Impact),
348    /// `fallow impact --all --format json`.
349    #[serde(rename = "impact-cross-repo")]
350    ImpactCrossRepo(ImpactCrossRepo),
351    /// `fallow security --summary --format json`.
352    #[serde(rename = "security")]
353    SecuritySummary(SecuritySummary),
354    /// `fallow security --format json`.
355    #[serde(rename = "security")]
356    Security(Security),
357    /// `fallow security survivors --format json`.
358    #[serde(rename = "security-survivors")]
359    SecuritySurvivors(SecuritySurvivors),
360    /// `fallow security blind-spots --format json`.
361    #[serde(rename = "security-blind-spots")]
362    SecurityBlindSpots(SecurityBlindSpots),
363    /// `fallow dead-code --format json`.
364    #[serde(rename = "dead-code")]
365    Check(Check),
366    /// Bare `fallow --format json`.
367    #[serde(rename = "combined")]
368    Combined(Combined),
369    /// `fallow audit --brief --format json`.
370    #[serde(rename = "audit-brief")]
371    AuditBrief(AuditBrief),
372    /// `fallow decision-surface --format json`.
373    #[serde(rename = "decision-surface")]
374    DecisionSurface(DecisionSurface),
375    /// `fallow review --walkthrough-guide --format json`.
376    #[serde(rename = "review-walkthrough-guide")]
377    WalkthroughGuide(WalkthroughGuide),
378    /// `fallow review --walkthrough-file --format json`.
379    #[serde(rename = "review-walkthrough-validation")]
380    WalkthroughValidation(WalkthroughValidation),
381}
382
383#[cfg(test)]
384mod tests {
385    use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
386    use serde_json::json;
387
388    use super::*;
389
390    #[test]
391    fn root_envelope_mode_maps_legacy_flag() {
392        assert_eq!(
393            RootEnvelopeMode::from_legacy(false),
394            RootEnvelopeMode::Tagged
395        );
396        assert_eq!(
397            RootEnvelopeMode::from_legacy(true),
398            RootEnvelopeMode::Legacy
399        );
400    }
401
402    #[test]
403    fn legacy_mode_removes_only_root_kind() {
404        let mut value = json!({
405            "kind": "root",
406            "action": {
407                "kind": "suppress"
408            }
409        });
410
411        remove_root_kind(&mut value);
412
413        assert!(value.get("kind").is_none());
414        assert_eq!(value["action"]["kind"], "suppress");
415    }
416
417    #[test]
418    fn apply_root_kind_respects_legacy_mode() {
419        let mut value = json!({});
420
421        apply_root_kind(&mut value, "dead_code", RootEnvelopeMode::Legacy);
422
423        assert!(value.get("kind").is_none());
424    }
425
426    #[test]
427    fn apply_root_kind_sets_tagged_mode() {
428        let mut value = json!({});
429
430        apply_root_kind(&mut value, "dead_code", RootEnvelopeMode::Tagged);
431
432        assert_eq!(value["kind"], "dead_code");
433    }
434
435    #[test]
436    fn attach_telemetry_meta_sets_analysis_run_id() {
437        let mut value = json!({});
438
439        attach_telemetry_meta(&mut value, Some("run-123"));
440
441        assert_eq!(
442            value["_meta"]["telemetry"]["analysis_run_id"],
443            json!("run-123")
444        );
445    }
446
447    #[test]
448    fn attach_telemetry_meta_preserves_non_object_roots() {
449        let mut value = json!(["not", "an", "object"]);
450
451        attach_telemetry_meta(&mut value, Some("run-123"));
452
453        assert_eq!(value, json!(["not", "an", "object"]));
454    }
455
456    #[test]
457    fn serialize_json_root_output_removes_root_kind_in_legacy_mode() {
458        let value = serialize_json_root_output(
459            json!({
460                "kind": "combined",
461                "schema_version": 1
462            }),
463            RootEnvelopeMode::Legacy,
464        )
465        .expect("root should serialize");
466
467        assert!(value.get("kind").is_none());
468        assert_eq!(value["schema_version"], 1);
469    }
470
471    #[test]
472    fn serialize_named_json_output_applies_explicit_kind() {
473        let value = serialize_named_json_output(
474            json!({
475                "schema_version": 1,
476                "summary": { "total": 0 }
477            }),
478            "example",
479            RootEnvelopeMode::Tagged,
480        )
481        .expect("named output should serialize");
482
483        assert_eq!(value["kind"], "example");
484        assert_eq!(value["summary"]["total"], 0);
485    }
486
487    #[test]
488    fn serialize_audit_json_output_applies_audit_kind() {
489        let value = serialize_audit_json_output(
490            AuditOutput {
491                schema_version: SchemaVersion(7),
492                version: ToolVersion("1.2.3".to_string()),
493                command: AuditCommand::Audit,
494                verdict: "pass",
495                changed_files_count: 2,
496                base_ref: "origin/main".to_string(),
497                base_description: Some("merge-base with origin/main".to_string()),
498                head_sha: Some("abc123".to_string()),
499                elapsed_ms: ElapsedMs(42),
500                base_snapshot_skipped: Some(false),
501                summary: json!({ "dead_code_issues": 0 }),
502                attribution: json!({ "gate": "new_only" }),
503                meta: None,
504                dead_code: Some(json!({ "summary": { "total_issues": 0 } })),
505                duplication: None::<serde_json::Value>,
506                complexity: None::<serde_json::Value>,
507                next_steps: Vec::new(),
508            },
509            RootEnvelopeMode::Tagged,
510            Some("run-audit"),
511        )
512        .expect("audit output should serialize");
513
514        assert_eq!(value["kind"], "audit");
515        assert_eq!(value["command"], "audit");
516        assert_eq!(value["dead_code"]["summary"]["total_issues"], 0);
517        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-audit");
518    }
519
520    #[test]
521    fn serialize_combined_json_output_applies_combined_kind() {
522        let value = serialize_combined_json_output(
523            CombinedOutput {
524                schema_version: SchemaVersion(7),
525                version: ToolVersion("1.2.3".to_string()),
526                elapsed_ms: ElapsedMs(42),
527                meta: None,
528                check: Some(json!({ "summary": { "total_issues": 0 } })),
529                dupes: None::<serde_json::Value>,
530                health: None::<serde_json::Value>,
531                next_steps: Vec::new(),
532            },
533            RootEnvelopeMode::Tagged,
534            Some("run-combined"),
535        )
536        .expect("combined output should serialize");
537
538        assert_eq!(value["kind"], "combined");
539        assert_eq!(value["check"]["summary"]["total_issues"], 0);
540        assert_eq!(
541            value["_meta"]["telemetry"]["analysis_run_id"],
542            "run-combined"
543        );
544    }
545}