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