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