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> {
279    /// `fallow audit --format json`.
280    #[serde(rename = "audit")]
281    Audit(Audit),
282    /// `fallow explain <issue-type> --format json`.
283    #[serde(rename = "explain")]
284    Explain(Explain),
285    /// `fallow inspect --format json`.
286    #[serde(rename = "inspect_target")]
287    Inspect(Inspect),
288    /// `fallow trace <symbol> --format json`.
289    #[serde(rename = "trace")]
290    Trace(Trace),
291    /// `fallow --format review-github` / `--format review-gitlab`.
292    #[serde(rename = "review-envelope")]
293    ReviewEnvelope(ReviewEnvelope),
294    /// `fallow ci reconcile-review --format json`.
295    #[serde(rename = "review-reconcile")]
296    ReviewReconcile(ReviewReconcile),
297    /// `fallow coverage setup --json`.
298    #[serde(rename = "coverage-setup")]
299    CoverageSetup(CoverageSetup),
300    /// `fallow coverage analyze --format json`.
301    #[serde(rename = "coverage-analyze")]
302    CoverageAnalyze(CoverageAnalyze),
303    /// `fallow list --boundaries --format json`.
304    #[serde(rename = "list-boundaries")]
305    ListBoundaries(ListBoundaries),
306    /// `fallow workspaces --format json`.
307    #[serde(rename = "list-workspaces")]
308    Workspaces(Workspaces),
309    /// `fallow health --format json`.
310    #[serde(rename = "health")]
311    Health(Health),
312    /// `fallow dupes --format json`.
313    #[serde(rename = "dupes")]
314    Dupes(Dupes),
315    /// `fallow dead-code --format json --group-by <mode>`.
316    #[serde(rename = "dead-code-grouped")]
317    CheckGrouped(CheckGrouped),
318    /// `fallow impact --format json`.
319    #[serde(rename = "impact")]
320    Impact(Impact),
321    /// `fallow impact --all --format json`.
322    #[serde(rename = "impact-cross-repo")]
323    ImpactCrossRepo(ImpactCrossRepo),
324    /// `fallow security --summary --format json`.
325    #[serde(rename = "security")]
326    SecuritySummary(SecuritySummary),
327    /// `fallow security --format json`.
328    #[serde(rename = "security")]
329    Security(Security),
330    /// `fallow security survivors --format json`.
331    #[serde(rename = "security-survivors")]
332    SecuritySurvivors(SecuritySurvivors),
333    /// `fallow security blind-spots --format json`.
334    #[serde(rename = "security-blind-spots")]
335    SecurityBlindSpots(SecurityBlindSpots),
336    /// `fallow dead-code --format json`.
337    #[serde(rename = "dead-code")]
338    Check(Check),
339    /// Bare `fallow --format json`.
340    #[serde(rename = "combined")]
341    Combined(Combined),
342    /// `fallow flags --format json`.
343    #[serde(rename = "feature-flags")]
344    FeatureFlags(FeatureFlags),
345    /// `fallow audit --brief --format json`.
346    #[serde(rename = "audit-brief")]
347    AuditBrief(AuditBrief),
348    /// `fallow decision-surface --format json`.
349    #[serde(rename = "decision-surface")]
350    DecisionSurface(DecisionSurface),
351    /// `fallow review --walkthrough-guide --format json`.
352    #[serde(rename = "review-walkthrough-guide")]
353    WalkthroughGuide(WalkthroughGuide),
354    /// `fallow review --walkthrough-file --format json`.
355    #[serde(rename = "review-walkthrough-validation")]
356    WalkthroughValidation(WalkthroughValidation),
357}
358
359#[cfg(test)]
360mod tests {
361    use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
362    use serde_json::json;
363
364    use super::*;
365
366    #[test]
367    fn apply_root_kind_sets_tagged_mode() {
368        let mut value = json!({});
369
370        apply_root_kind(&mut value, "dead_code", RootEnvelopeMode::Tagged);
371
372        assert_eq!(value["kind"], "dead_code");
373    }
374
375    #[test]
376    fn attach_telemetry_meta_sets_analysis_run_id() {
377        let mut value = json!({});
378
379        attach_telemetry_meta(&mut value, Some("run-123"));
380
381        assert_eq!(
382            value["_meta"]["telemetry"]["analysis_run_id"],
383            json!("run-123")
384        );
385    }
386
387    #[test]
388    fn attach_telemetry_meta_preserves_non_object_roots() {
389        let mut value = json!(["not", "an", "object"]);
390
391        attach_telemetry_meta(&mut value, Some("run-123"));
392
393        assert_eq!(value, json!(["not", "an", "object"]));
394    }
395
396    #[test]
397    fn serialize_named_json_output_applies_explicit_kind() {
398        let value = serialize_named_json_output(
399            json!({
400                "schema_version": 1,
401                "summary": { "total": 0 }
402            }),
403            "example",
404            RootEnvelopeMode::Tagged,
405        )
406        .expect("named output should serialize");
407
408        assert_eq!(value["kind"], "example");
409        assert_eq!(value["summary"]["total"], 0);
410    }
411
412    #[test]
413    fn serialize_audit_json_output_applies_audit_kind() {
414        let value = serialize_audit_json_output(
415            AuditOutput {
416                schema_version: SchemaVersion(7),
417                version: ToolVersion("1.2.3".to_string()),
418                command: AuditCommand::Audit,
419                verdict: "pass",
420                changed_files_count: 2,
421                base_ref: "origin/main".to_string(),
422                base_description: Some("merge-base with origin/main".to_string()),
423                head_sha: Some("abc123".to_string()),
424                elapsed_ms: ElapsedMs(42),
425                base_snapshot_skipped: Some(false),
426                summary: json!({ "dead_code_issues": 0 }),
427                attribution: json!({ "gate": "new_only" }),
428                meta: None,
429                dead_code: Some(json!({ "summary": { "total_issues": 0 } })),
430                duplication: None::<serde_json::Value>,
431                complexity: None::<serde_json::Value>,
432                next_steps: Vec::new(),
433            },
434            RootEnvelopeMode::Tagged,
435            Some("run-audit"),
436        )
437        .expect("audit output should serialize");
438
439        assert_eq!(value["kind"], "audit");
440        assert_eq!(value["command"], "audit");
441        assert_eq!(value["dead_code"]["summary"]["total_issues"], 0);
442        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-audit");
443    }
444
445    #[test]
446    fn serialize_combined_json_output_applies_combined_kind() {
447        let value = serialize_combined_json_output(
448            CombinedOutput {
449                schema_version: SchemaVersion(7),
450                version: ToolVersion("1.2.3".to_string()),
451                elapsed_ms: ElapsedMs(42),
452                meta: None,
453                check: Some(json!({ "summary": { "total_issues": 0 } })),
454                dupes: None::<serde_json::Value>,
455                health: None::<serde_json::Value>,
456                next_steps: Vec::new(),
457            },
458            RootEnvelopeMode::Tagged,
459            Some("run-combined"),
460        )
461        .expect("combined output should serialize");
462
463        assert_eq!(value["kind"], "combined");
464        assert_eq!(value["check"]["summary"]["total_issues"], 0);
465        assert_eq!(
466            value["_meta"]["telemetry"]["analysis_run_id"],
467            "run-combined"
468        );
469    }
470}