Skip to main content

fallow_cli/
output_envelope.rs

1//! Typed envelope structs for the JSON output contract.
2//!
3//! This module is the schema-side source of truth for fallow's top-level JSON
4//! envelopes.
5
6use std::sync::Mutex;
7use std::sync::atomic::{AtomicBool, Ordering};
8
9use fallow_core::results::AnalysisResults;
10use fallow_types::envelope::{
11    BaselineDeltas, BaselineMatch, CheckSummary, ElapsedMs, EntryPoints, Meta, RegressionResult,
12    SchemaVersion, TelemetryMeta, ToolVersion,
13};
14use fallow_types::output::NextStep;
15use serde::Serialize;
16
17use crate::audit::{AuditAttribution, AuditSummary, AuditVerdict};
18use crate::health_types::{HealthGroup, HealthReport, RuntimeCoverageReport};
19use crate::output_dupes::DupesReportPayload;
20use crate::report::dupes_grouping::DuplicationGroup;
21
22static LEGACY_ENVELOPE: AtomicBool = AtomicBool::new(false);
23static TELEMETRY_ANALYSIS_RUN_ID: Mutex<Option<String>> = Mutex::new(None);
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum EnvelopeMode {
27    Tagged,
28    Legacy,
29}
30
31impl EnvelopeMode {
32    #[must_use]
33    pub fn current() -> Self {
34        if LEGACY_ENVELOPE.load(Ordering::Relaxed) {
35            Self::Legacy
36        } else {
37            Self::Tagged
38        }
39    }
40}
41
42pub fn set_legacy_envelope(enabled: bool) {
43    LEGACY_ENVELOPE.store(enabled, Ordering::Relaxed);
44}
45
46pub fn set_telemetry_analysis_run_id(run_id: Option<String>) {
47    if let Ok(mut current) = TELEMETRY_ANALYSIS_RUN_ID.lock() {
48        *current = run_id;
49    }
50}
51
52fn telemetry_analysis_run_id() -> Option<String> {
53    TELEMETRY_ANALYSIS_RUN_ID
54        .lock()
55        .ok()
56        .and_then(|id| id.clone())
57}
58
59pub fn serialize_root_output(output: FallowOutput) -> Result<serde_json::Value, serde_json::Error> {
60    serialize_root_output_with_mode(output, EnvelopeMode::current())
61}
62
63pub fn serialize_root_output_without_telemetry(
64    output: FallowOutput,
65) -> Result<serde_json::Value, serde_json::Error> {
66    let mut value = serde_json::to_value(output)?;
67    if EnvelopeMode::current() == EnvelopeMode::Legacy {
68        remove_root_kind(&mut value);
69    }
70    Ok(value)
71}
72
73pub fn serialize_root_output_with_mode(
74    output: FallowOutput,
75    mode: EnvelopeMode,
76) -> Result<serde_json::Value, serde_json::Error> {
77    let mut value = serde_json::to_value(output)?;
78    if mode == EnvelopeMode::Legacy {
79        remove_root_kind(&mut value);
80    }
81    attach_telemetry_meta(&mut value);
82    Ok(value)
83}
84
85pub fn attach_telemetry_meta(value: &mut serde_json::Value) {
86    let Some(run_id) = telemetry_analysis_run_id() else {
87        return;
88    };
89    let serde_json::Value::Object(map) = value else {
90        return;
91    };
92    let meta = map
93        .entry("_meta".to_string())
94        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
95    if !meta.is_object() {
96        *meta = serde_json::Value::Object(serde_json::Map::new());
97    }
98    if let serde_json::Value::Object(meta_map) = meta {
99        meta_map.insert(
100            "telemetry".to_string(),
101            serde_json::json!({ "analysis_run_id": run_id }),
102        );
103    }
104}
105
106/// Remove only the document-root discriminator for the one-cycle
107/// compatibility mode. Nested objects may carry their own meaningful `kind`
108/// fields, so this intentionally does not recurse.
109pub fn remove_root_kind(value: &mut serde_json::Value) {
110    if let serde_json::Value::Object(map) = value {
111        map.remove("kind");
112    }
113}
114
115pub fn apply_root_kind(value: &mut serde_json::Value, kind: &'static str) {
116    if EnvelopeMode::current() == EnvelopeMode::Tagged
117        && let serde_json::Value::Object(map) = value
118    {
119        map.insert(
120            "kind".to_string(),
121            serde_json::Value::String(kind.to_string()),
122        );
123    }
124}
125/// `fallow coverage setup --json` envelope.
126#[derive(Debug, Clone, Serialize)]
127#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
128#[cfg_attr(feature = "schema", schemars(title = "fallow coverage setup --json"))]
129pub struct CoverageSetupOutput {
130    pub schema_version: CoverageSetupSchemaVersion,
131    pub framework_detected: CoverageSetupFramework,
132    pub package_manager: Option<CoverageSetupPackageManager>,
133    pub runtime_targets: Vec<CoverageSetupRuntimeTarget>,
134    pub members: Vec<CoverageSetupMember>,
135    pub config_written: Option<serde_json::Value>,
136    pub commands: Vec<String>,
137    pub files_to_edit: Vec<CoverageSetupFileToEdit>,
138    pub snippets: Vec<CoverageSetupSnippet>,
139    pub dockerfile_snippet: Option<String>,
140    pub next_steps: Vec<String>,
141    pub warnings: Vec<String>,
142    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
143    pub meta: Option<serde_json::Value>,
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
147#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
148pub enum CoverageSetupSchemaVersion {
149    #[serde(rename = "1")]
150    V1,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155#[serde(rename_all = "snake_case")]
156pub enum CoverageSetupFramework {
157    #[serde(rename = "nextjs")]
158    NextJs,
159    #[serde(rename = "nestjs")]
160    NestJs,
161    Nuxt,
162    #[serde(rename = "sveltekit")]
163    SvelteKit,
164    Astro,
165    Remix,
166    Vite,
167    PlainNode,
168    Unknown,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
172#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
173#[serde(rename_all = "lowercase")]
174pub enum CoverageSetupPackageManager {
175    Npm,
176    Pnpm,
177    Yarn,
178    Bun,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
182#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
183#[serde(rename_all = "lowercase")]
184pub enum CoverageSetupRuntimeTarget {
185    Node,
186    Browser,
187}
188
189#[derive(Debug, Clone, Serialize)]
190#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
191pub struct CoverageSetupMember {
192    pub name: String,
193    pub path: String,
194    pub framework_detected: CoverageSetupFramework,
195    pub package_manager: Option<CoverageSetupPackageManager>,
196    pub runtime_targets: Vec<CoverageSetupRuntimeTarget>,
197    pub files_to_edit: Vec<CoverageSetupFileToEdit>,
198    pub snippets: Vec<CoverageSetupSnippet>,
199    pub dockerfile_snippet: Option<String>,
200    pub warnings: Vec<String>,
201}
202
203#[derive(Debug, Clone, Serialize)]
204#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
205pub struct CoverageSetupFileToEdit {
206    pub path: String,
207    pub reason: String,
208}
209
210#[derive(Debug, Clone, Serialize)]
211#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
212pub struct CoverageSetupSnippet {
213    pub label: String,
214    pub path: String,
215    pub content: String,
216}
217
218/// `fallow audit --format json` envelope.
219#[derive(Debug, Clone, Serialize)]
220#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
221#[cfg_attr(feature = "schema", schemars(title = "fallow audit --format json"))]
222#[allow(
223    dead_code,
224    reason = "schema-source-of-truth: audit.rs still builds the wire via serde_json::json!; this struct locks the schema shape via the drift gate. Migration is a follow-up to issue #384 items 3a/3b/3c."
225)]
226pub struct AuditOutput {
227    pub schema_version: SchemaVersion,
228    pub version: ToolVersion,
229    pub command: AuditCommand,
230    pub verdict: AuditVerdict,
231    pub changed_files_count: u32,
232    pub base_ref: String,
233    /// Human-readable provenance of `base_ref`, e.g. `merge-base with
234    /// origin/main`, `local main`, or `FALLOW_AUDIT_BASE=upstream/main`.
235    /// Present when the base was auto-detected or set via `FALLOW_AUDIT_BASE`;
236    /// absent for an explicit `--base` (the ref the user typed is already
237    /// self-describing).
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub base_description: Option<String>,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub head_sha: Option<String>,
242    pub elapsed_ms: ElapsedMs,
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub base_snapshot_skipped: Option<bool>,
245    pub summary: AuditSummary,
246    pub attribution: AuditAttribution,
247    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
248    pub meta: Option<Meta>,
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub dead_code: Option<CheckOutput>,
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub duplication: Option<DupesReportPayload>,
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub complexity: Option<HealthReport>,
255    /// Read-only follow-up commands computed from this run's findings. See
256    /// [`CheckOutput::next_steps`] for the contract.
257    #[serde(default, skip_serializing_if = "Vec::is_empty")]
258    pub next_steps: Vec<NextStep>,
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
262#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
263#[serde(rename_all = "lowercase")]
264#[allow(dead_code, reason = "schema-source-of-truth: see `AuditOutput`.")]
265pub enum AuditCommand {
266    Audit,
267}
268
269/// Bare `fallow --format json` envelope.
270#[derive(Debug, Clone, Serialize)]
271#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
272#[cfg_attr(
273    feature = "schema",
274    schemars(title = "fallow --format json (bare, combined)")
275)]
276pub struct CombinedOutput {
277    pub schema_version: SchemaVersion,
278    pub version: ToolVersion,
279    pub elapsed_ms: ElapsedMs,
280    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
281    pub meta: Option<CombinedMeta>,
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub check: Option<CheckOutput>,
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub dupes: Option<DupesReportPayload>,
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub health: Option<HealthReport>,
288    /// Read-only follow-up commands aggregated across the combined run's
289    /// findings. See [`CheckOutput::next_steps`] for the contract.
290    #[serde(default, skip_serializing_if = "Vec::is_empty")]
291    pub next_steps: Vec<NextStep>,
292}
293
294#[derive(Debug, Clone, Serialize)]
295#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
296pub struct CombinedMeta {
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub check: Option<Meta>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub dupes: Option<Meta>,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub health: Option<Meta>,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub telemetry: Option<TelemetryMeta>,
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
308#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
309pub enum CoverageAnalyzeSchemaVersion {
310    #[serde(rename = "1")]
311    V1,
312}
313
314#[derive(Debug, Clone, Serialize)]
315#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
316#[cfg_attr(
317    feature = "schema",
318    schemars(title = "fallow coverage analyze --format json")
319)]
320pub struct CoverageAnalyzeOutput {
321    pub schema_version: CoverageAnalyzeSchemaVersion,
322    pub version: ToolVersion,
323    pub elapsed_ms: ElapsedMs,
324    pub runtime_coverage: RuntimeCoverageReport,
325    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
326    pub meta: Option<Meta>,
327}
328
329#[derive(Debug, Clone, Serialize)]
330#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
331#[cfg_attr(feature = "schema", schemars(title = "fallow dupes --format json"))]
332pub struct DupesOutput {
333    pub schema_version: SchemaVersion,
334    pub version: ToolVersion,
335    pub elapsed_ms: ElapsedMs,
336    #[serde(flatten)]
337    pub report: DupesReportPayload,
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub grouped_by: Option<GroupByMode>,
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub total_issues: Option<usize>,
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub groups: Option<Vec<DuplicationGroup>>,
344    /// `_meta` block with metric / rule definitions, emitted when `--explain`
345    /// is passed (always present in MCP responses).
346    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
347    pub meta: Option<Meta>,
348    /// Workspace-discovery diagnostics surfaced during config load
349    /// (issue #473). See [`CheckOutput::workspace_diagnostics`] for the full
350    /// contract; the same list is repeated on each top-level command's
351    /// envelope so single-command consumers see it without having to look at
352    /// a separate top-level field.
353    #[serde(default, skip_serializing_if = "Vec::is_empty")]
354    pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
355    /// Read-only follow-up commands computed from this run's findings. See
356    /// [`CheckOutput::next_steps`] for the contract.
357    #[serde(default, skip_serializing_if = "Vec::is_empty")]
358    pub next_steps: Vec<NextStep>,
359}
360
361/// Envelope emitted by `fallow dead-code --format json` (plus the `check`
362/// block inside the combined and audit envelopes).
363///
364/// The body is the full `AnalysisResults` flattened into the envelope so
365/// every issue array (`unused_files`, `unused_exports`, ...) lives at the
366/// top level, matching the existing wire shape. `entry_points` lifts the
367/// otherwise `#[serde(skip)]`'d `AnalysisResults::entry_point_summary` back
368/// into the JSON output. `summary` carries the per-category counts the
369/// JSON layer always emits.
370#[derive(Debug, Clone, Serialize)]
371#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
372#[cfg_attr(feature = "schema", schemars(title = "fallow dead-code --format json"))]
373pub struct CheckOutput {
374    pub schema_version: SchemaVersion,
375    pub version: ToolVersion,
376    pub elapsed_ms: ElapsedMs,
377    pub total_issues: usize,
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub entry_points: Option<EntryPoints>,
380    pub summary: CheckSummary,
381    #[serde(flatten)]
382    pub results: AnalysisResults,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub baseline_deltas: Option<BaselineDeltas>,
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub baseline: Option<BaselineMatch>,
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub regression: Option<RegressionResult>,
389    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
390    pub meta: Option<Meta>,
391    #[serde(default, skip_serializing_if = "Vec::is_empty")]
392    pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
393    /// Read-only follow-up commands computed from this run's findings, emitted
394    /// at the JSON root so an agent acting on the output is pointed at fallow's
395    /// adjacent verification capabilities (trace, complexity breakdown, audit,
396    /// workspace scoping). Each command is runnable as-is and never mutating;
397    /// see [`NextStep`] for both contracts. Omitted when empty or when
398    /// `FALLOW_SUGGESTIONS=off`; does NOT contribute to `total_issues`.
399    #[serde(default, skip_serializing_if = "Vec::is_empty")]
400    pub next_steps: Vec<NextStep>,
401}
402
403/// Envelope emitted by `fallow dead-code --group-by ... --format json`.
404///
405/// Issues are partitioned into resolver buckets (CODEOWNERS team, directory
406/// prefix, workspace package, or GitLab CODEOWNERS section) instead of flat
407/// arrays. Each bucket carries the same issue-array shape as the ungrouped
408/// `CheckOutput` body, plus per-group `key` / `owners` / `total_issues`.
409#[derive(Debug, Clone, Serialize)]
410#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
411#[cfg_attr(
412    feature = "schema",
413    schemars(
414        title = "fallow dead-code --group-by <owner|directory|package|section> --format json"
415    )
416)]
417pub struct CheckGroupedOutput {
418    pub schema_version: SchemaVersion,
419    pub version: ToolVersion,
420    pub elapsed_ms: ElapsedMs,
421    pub grouped_by: GroupByMode,
422    pub total_issues: usize,
423    pub groups: Vec<CheckGroupedEntry>,
424    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
425    pub meta: Option<Meta>,
426    /// Read-only follow-up commands computed from the full (ungrouped) findings.
427    /// See [`CheckOutput::next_steps`] for the contract.
428    #[serde(default, skip_serializing_if = "Vec::is_empty")]
429    pub next_steps: Vec<NextStep>,
430}
431
432/// Single resolver bucket inside `CheckGroupedOutput`. Carries the group's
433/// identifier, optional section owners, and a per-group flattened
434/// `AnalysisResults`.
435#[derive(Debug, Clone, Serialize)]
436#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
437pub struct CheckGroupedEntry {
438    pub key: String,
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub owners: Option<Vec<String>>,
441    pub total_issues: usize,
442    #[serde(flatten)]
443    pub results: AnalysisResults,
444}
445
446/// Envelope emitted by `fallow health --format json` (plus the `health` block
447/// inside the combined and audit envelopes).
448///
449/// The body is `HealthReport` flattened into the envelope so every report
450/// field (`findings`, `summary`, `vital_signs`, `hotspots`, `actions_meta`,
451/// ...) lives at the top level. Grouped runs populate `grouped_by` +
452/// `groups` with per-bucket recomputed metrics. The `actions_meta`
453/// breadcrumb is modeled on `HealthReport` as an `Option<HealthActionsMeta>`
454/// and is set at construction time by the report builder when the active
455/// `HealthActionContext` requests suppress-line omission, so the schema
456/// documents the field and serde populates it natively.
457#[derive(Debug, Clone, Serialize)]
458#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
459#[cfg_attr(feature = "schema", schemars(title = "fallow health --format json"))]
460pub struct HealthOutput {
461    pub schema_version: SchemaVersion,
462    pub version: ToolVersion,
463    pub elapsed_ms: ElapsedMs,
464    #[serde(flatten)]
465    pub report: HealthReport,
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub grouped_by: Option<GroupByMode>,
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub groups: Option<Vec<HealthGroup>>,
470    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
471    pub meta: Option<Meta>,
472    #[serde(default, skip_serializing_if = "Vec::is_empty")]
473    pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
474    /// Read-only follow-up commands computed from this run's findings. See
475    /// [`CheckOutput::next_steps`] for the contract.
476    #[serde(default, skip_serializing_if = "Vec::is_empty")]
477    pub next_steps: Vec<NextStep>,
478}
479
480/// Envelope emitted by `fallow explain <issue-type> --format json`.
481///
482/// Standalone rule explanation. This command does not run project analysis
483/// and intentionally returns a compact object without `schema_version` /
484/// `version` metadata; consumers that need those should call any other
485/// fallow JSON-producing command.
486#[derive(Debug, Clone, Serialize)]
487#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
488#[cfg_attr(
489    feature = "schema",
490    schemars(title = "fallow explain <issue-type> --format json")
491)]
492pub struct ExplainOutput {
493    pub id: String,
494    pub name: String,
495    pub summary: String,
496    pub rationale: String,
497    pub example: String,
498    pub how_to_fix: String,
499    pub docs: String,
500}
501
502/// Envelope emitted by `fallow --format codeclimate` and
503/// `fallow --format gitlab-codequality`. GitLab Code Quality consumes the
504/// same shape. The wire form is a bare JSON array, not an object.
505#[derive(Debug, Clone, Serialize)]
506#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
507#[cfg_attr(
508    feature = "schema",
509    schemars(title = "fallow --format codeclimate / gitlab-codequality")
510)]
511#[serde(transparent)]
512#[allow(
513    dead_code,
514    reason = "schema-source-of-truth wrapper: runtime emits a `Vec<CodeClimateIssue>` directly via `codeclimate::issues_to_value`; this newtype exists so `schemars` can title and document the bare-array shape for the drift gate."
515)]
516pub struct CodeClimateOutput(pub Vec<CodeClimateIssue>);
517
518/// Single CodeClimate-compatible issue inside [`CodeClimateOutput`].
519#[derive(Debug, Clone, Serialize)]
520#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
521pub struct CodeClimateIssue {
522    #[serde(rename = "type")]
523    pub kind: CodeClimateIssueKind,
524    pub check_name: String,
525    pub description: String,
526    pub categories: Vec<String>,
527    pub severity: CodeClimateSeverity,
528    pub fingerprint: String,
529    pub location: CodeClimateLocation,
530}
531
532/// Discriminator value for [`CodeClimateIssue::kind`].
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
534#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
535#[serde(rename_all = "lowercase")]
536pub enum CodeClimateIssueKind {
537    /// The only valid CodeClimate type today.
538    Issue,
539}
540
541/// CodeClimate severity scale.
542#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
543#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
544#[serde(rename_all = "lowercase")]
545pub enum CodeClimateSeverity {
546    /// Informational. Reserved for future severity mappings; not produced
547    /// by the current runtime path (which only emits Minor / Major /
548    /// Critical via `severity_to_codeclimate` and the health / runtime-
549    /// coverage match arms).
550    #[allow(
551        dead_code,
552        reason = "schema-source-of-truth: documents the full CodeClimate severity spec; runtime never produces this variant today, but the schema needs it so consumers can validate against either fallow output or a third-party CodeClimate emitter without spec divergence."
553    )]
554    Info,
555    /// Minor finding.
556    Minor,
557    /// Major finding.
558    Major,
559    /// Critical finding.
560    Critical,
561    /// Blocker (highest severity). Reserved for future severity
562    /// mappings; not produced by the current runtime path.
563    #[allow(
564        dead_code,
565        reason = "schema-source-of-truth: documents the full CodeClimate severity spec; runtime never produces this variant today, but the schema needs it so consumers can validate against either fallow output or a third-party CodeClimate emitter without spec divergence."
566    )]
567    Blocker,
568}
569
570/// Location block inside [`CodeClimateIssue::location`].
571#[derive(Debug, Clone, Serialize)]
572#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
573pub struct CodeClimateLocation {
574    /// File path relative to the analysed root.
575    pub path: String,
576    /// Wrapper carrying the begin line so the schema lines up with
577    /// CodeClimate's spec.
578    pub lines: CodeClimateLines,
579}
580
581/// `lines.begin` for [`CodeClimateLocation`].
582#[derive(Debug, Clone, Copy, Serialize)]
583#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
584pub struct CodeClimateLines {
585    /// 1-based start line.
586    pub begin: u32,
587}
588
589/// Envelope emitted by `fallow --format review-github` / `review-gitlab`.
590#[derive(Debug, Clone, Serialize)]
591#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
592#[cfg_attr(
593    feature = "schema",
594    schemars(title = "fallow --format review-github / review-gitlab")
595)]
596pub struct ReviewEnvelopeOutput {
597    #[serde(default, skip_serializing_if = "Option::is_none")]
598    pub event: Option<ReviewEnvelopeEvent>,
599    pub body: String,
600    #[serde(default = "ReviewEnvelopeSummary::empty_default")]
601    pub summary: ReviewEnvelopeSummary,
602    pub comments: Vec<ReviewComment>,
603    #[serde(default = "default_marker_regex")]
604    pub marker_regex: String,
605    #[serde(default = "default_marker_regex_flags")]
606    pub marker_regex_flags: String,
607    pub meta: ReviewEnvelopeMeta,
608}
609
610/// Default for [`ReviewEnvelopeOutput::marker_regex`].
611#[must_use]
612pub fn default_marker_regex() -> String {
613    MARKER_REGEX_V2.to_owned()
614}
615
616/// Default for [`ReviewEnvelopeOutput::marker_regex_flags`].
617#[must_use]
618pub fn default_marker_regex_flags() -> String {
619    MARKER_REGEX_FLAGS_V2.to_owned()
620}
621
622/// Canonical v2 marker-regex literal.
623pub const MARKER_REGEX_V2: &str =
624    r"^<!-- fallow-fingerprint:v2: ((?:[a-z]+:)?[0-9a-f]{16}) -->\s*$";
625
626/// Canonical v2 marker-regex flags.
627pub const MARKER_REGEX_FLAGS_V2: &str = "m";
628
629/// Summary block on [`ReviewEnvelopeOutput`].
630#[derive(Debug, Clone, Serialize, Default)]
631#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
632pub struct ReviewEnvelopeSummary {
633    pub body: String,
634    pub fingerprint: String,
635}
636
637impl ReviewEnvelopeSummary {
638    /// Empty-default factory for [`ReviewEnvelopeOutput::summary`].
639    #[must_use]
640    #[allow(
641        dead_code,
642        reason = "referenced via serde default = \"...\" attr; no direct callsite until Deserialize is derived"
643    )]
644    pub fn empty_default() -> Self {
645        Self::default()
646    }
647}
648
649/// Singleton GitHub review-event marker.
650#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
651#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
652pub enum ReviewEnvelopeEvent {
653    #[serde(rename = "COMMENT")]
654    Comment,
655}
656
657/// Per-line review comment. Schema is an `anyOf` between GitHub and GitLab
658/// shapes; at runtime every entry in a single envelope comes from the same
659/// provider because the envelope is built from one provider's branch in
660/// `crates/cli/src/report/ci/review.rs::render_review_envelope`.
661#[derive(Debug, Clone, Serialize)]
662#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
663#[serde(untagged)]
664pub enum ReviewComment {
665    GitHub(GitHubReviewComment),
666    GitLab(GitLabReviewComment),
667}
668
669/// GitHub pull-request review comment.
670#[derive(Debug, Clone, Serialize)]
671#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
672pub struct GitHubReviewComment {
673    pub path: String,
674    pub line: u32,
675    pub side: GitHubReviewSide,
676    pub body: String,
677    pub fingerprint: String,
678    #[serde(default, skip_serializing_if = "is_false")]
679    pub truncated: bool,
680}
681
682/// Singleton side discriminator for [`GitHubReviewComment::side`].
683#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
684#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
685pub enum GitHubReviewSide {
686    #[serde(rename = "RIGHT")]
687    Right,
688}
689
690/// GitLab merge-request discussion comment.
691#[derive(Debug, Clone, Serialize)]
692#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
693pub struct GitLabReviewComment {
694    pub body: String,
695    pub position: GitLabReviewPosition,
696    pub fingerprint: String,
697    #[serde(default, skip_serializing_if = "is_false")]
698    pub truncated: bool,
699}
700
701/// Helper for `skip_serializing_if = "is_false"` on `truncated` fields above.
702/// Serde calls `skip_serializing_if` with `&T`, so the reference signature
703/// is dictated by the trait and cannot be changed to pass-by-value. Uses
704/// `#[allow]` rather than `#[expect]` per `.claude/rules/code-quality.md`:
705/// `trivially_copy_pass_by_ref` is a pedantic lint that fires inconsistently
706/// across build configurations (lib vs bin), which would trigger
707/// `unfulfilled_lint_expectations` under `#[expect]`.
708#[must_use]
709#[allow(
710    clippy::trivially_copy_pass_by_ref,
711    reason = "serde's skip_serializing_if requires fn(&T) -> bool"
712)]
713pub fn is_false(value: &bool) -> bool {
714    !*value
715}
716
717/// `position` block inside [`GitLabReviewComment`]. Mirrors the GitLab
718/// merge-request discussion-position API.
719#[derive(Debug, Clone, Serialize)]
720#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
721pub struct GitLabReviewPosition {
722    #[serde(default, skip_serializing_if = "Option::is_none")]
723    pub base_sha: Option<String>,
724    #[serde(default, skip_serializing_if = "Option::is_none")]
725    pub start_sha: Option<String>,
726    #[serde(default, skip_serializing_if = "Option::is_none")]
727    pub head_sha: Option<String>,
728    pub position_type: GitLabReviewPositionType,
729    pub old_path: String,
730    pub new_path: String,
731    pub new_line: u32,
732}
733
734/// Singleton position-type discriminator for [`GitLabReviewPosition`].
735#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
736#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
737#[serde(rename_all = "lowercase")]
738pub enum GitLabReviewPositionType {
739    Text,
740}
741
742/// `meta` block inside [`ReviewEnvelopeOutput`].
743#[derive(Debug, Clone, Serialize)]
744#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
745pub struct ReviewEnvelopeMeta {
746    pub schema: ReviewEnvelopeSchema,
747    pub provider: ReviewProvider,
748    #[serde(default, skip_serializing_if = "Option::is_none")]
749    pub check_conclusion: Option<ReviewCheckConclusion>,
750}
751
752/// Schema-version discriminator for the review envelope.
753#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
754#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
755pub enum ReviewEnvelopeSchema {
756    /// First release of the review envelope format. Historical only; no v1
757    /// emit path remains on the current code. Retained on the enum so a
758    /// future Deserialize derive can still parse v1 captures (e.g. from
759    /// committed snapshots predating the issue #528 migration) without
760    /// erroring on an unknown variant.
761    #[serde(rename = "fallow-review-envelope/v1")]
762    #[allow(
763        dead_code,
764        reason = "kept for forward-compat with v1 historical inputs once Deserialize is derived"
765    )]
766    V1,
767    /// Issue #528 evolution. Adds (1) the [`ReviewEnvelopeOutput::summary`]
768    /// block, (2) [`ReviewEnvelopeOutput::marker_regex`], (3) same-line
769    /// `(path, line)` merging in `comments[]` with a
770    /// `merged:<16-char hash>` primary fingerprint over sorted constituent
771    /// fingerprints (identity shifts whenever the set of constituents
772    /// changes, so the bundled skip-if-fingerprint-exists wrappers
773    /// correctly re-post on content change), (4) UTF-8-safe body
774    /// truncation at the GitLab/GitHub note-size floor (65,536 bytes)
775    /// with paired `truncated: bool` + `<!-- fallow-truncated -->`
776    /// signals, (5) `:v2:`-namespaced marker shape
777    /// (`<!-- fallow-fingerprint:v2: <fingerprint> -->`) preventing v1
778    /// marker collision and user-paste spoofing, and (6) diff-aware
779    /// `position.old_path` for renamed files on GitLab.
780    #[serde(rename = "fallow-review-envelope/v2")]
781    V2,
782}
783
784/// Review-envelope provider tag.
785#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
786#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
787#[serde(rename_all = "lowercase")]
788pub enum ReviewProvider {
789    /// GitHub pull-request review envelope.
790    Github,
791    /// GitLab merge-request discussion envelope.
792    Gitlab,
793}
794
795/// `meta.check_conclusion` for the GitHub review envelope. Maps to the
796/// GitHub Checks API conclusion field.
797#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
798#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
799#[serde(rename_all = "lowercase")]
800pub enum ReviewCheckConclusion {
801    /// No findings.
802    Success,
803    /// Findings but none gated as failure.
804    Neutral,
805    /// At least one finding gated as failure.
806    Failure,
807}
808
809/// Envelope emitted by `fallow ci reconcile-review --format json`. Used by
810/// CI integrations to drive comment carry-over and stale-comment cleanup
811/// across PR / MR revisions.
812#[derive(Debug, Clone, Serialize)]
813#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
814#[cfg_attr(
815    feature = "schema",
816    schemars(title = "fallow ci reconcile-review --format json")
817)]
818pub struct ReviewReconcileOutput {
819    pub schema: ReviewReconcileSchema,
820    pub provider: ReviewProvider,
821    pub target: Option<String>,
822    pub dry_run: bool,
823    pub comments: u32,
824    pub current_fingerprints: u32,
825    pub existing_fingerprints: u32,
826    pub new_fingerprints: u32,
827    pub stale_fingerprints: u32,
828    pub new: Vec<String>,
829    pub stale: Vec<String>,
830    pub provider_warning: Option<String>,
831    pub resolution_comments_posted: u32,
832    pub threads_resolved: u32,
833    #[serde(default, skip_serializing_if = "Option::is_none")]
834    pub apply_hint: Option<String>,
835    pub apply_errors: Vec<String>,
836    #[serde(default, skip_serializing_if = "Vec::is_empty")]
837    pub failed_fingerprints: Vec<String>,
838    #[serde(default, skip_serializing_if = "Vec::is_empty")]
839    pub unapplied_fingerprints: Vec<String>,
840}
841
842/// Schema-version discriminator for the review reconcile envelope.
843#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
844#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
845pub enum ReviewReconcileSchema {
846    /// First release of the review reconcile format.
847    #[serde(rename = "fallow-review-reconcile/v1")]
848    V1,
849}
850
851/// Resolver mode label for grouped envelopes (dead-code, dupes, health).
852///
853/// `owner` groups by CODEOWNERS team, `directory` groups by top-level
854/// directory prefix, `package` groups by workspace package name, `section`
855/// groups by GitLab CODEOWNERS `[Section]` header name.
856#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
857#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
858#[serde(rename_all = "lowercase")]
859pub enum GroupByMode {
860    Owner,
861    Directory,
862    Package,
863    Section,
864}
865/// Envelope emitted by `fallow list --boundaries --format json`. Surfaces
866/// the architecture boundary zones, rules, and (issue #373) the user's
867/// pre-expansion `autoDiscover` logical groups so consumers can render
868/// grouping intent that `expand_auto_discover` would otherwise flatten out
869/// of `zones[]`.
870#[derive(Debug, Clone, Serialize)]
871#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
872#[cfg_attr(
873    feature = "schema",
874    schemars(title = "fallow list --boundaries --format json")
875)]
876#[allow(
877    dead_code,
878    reason = "schema-source-of-truth: list.rs still builds the wire via serde_json::json!; this struct and its sub-types lock the schema shape via the drift gate. Migration is a follow-up to issue #384 items 3a/3b/3c."
879)]
880pub struct ListBoundariesOutput {
881    pub boundaries: BoundariesListing,
882}
883
884/// `fallow workspaces --format json` envelope.
885#[derive(Debug, Clone, Serialize)]
886#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
887#[cfg_attr(
888    feature = "schema",
889    schemars(title = "fallow workspaces --format json")
890)]
891pub struct WorkspacesOutput {
892    /// Number of workspace package entries in `workspaces`.
893    pub workspace_count: usize,
894    /// Workspace packages discovered from package manager and tsconfig workspace
895    /// declarations. Paths are project-root-relative and use forward slashes.
896    pub workspaces: Vec<WorkspaceInfo>,
897    /// Workspace discovery diagnostics produced while reading workspace
898    /// declarations. Present for compatibility with the current wire contract,
899    /// even when empty.
900    pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
901}
902
903/// One workspace package emitted by `fallow workspaces --format json`.
904#[derive(Debug, Clone, Serialize)]
905#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
906pub struct WorkspaceInfo {
907    /// Package name from the workspace package.json. This is the value accepted
908    /// by `--workspace <name>`.
909    pub name: String,
910    /// Project-root-relative path to the workspace directory, normalized to
911    /// forward slashes for cross-platform JSON consumers.
912    pub path: String,
913    /// Whether the package is a generated or platform-specific dependency
914    /// package rather than a hand-authored workspace.
915    pub is_internal_dependency: bool,
916}
917
918/// `boundaries` block carried by [`ListBoundariesOutput`].
919#[derive(Debug, Clone, Serialize)]
920#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
921#[allow(
922    dead_code,
923    reason = "schema-source-of-truth: see `ListBoundariesOutput`."
924)]
925pub struct BoundariesListing {
926    pub configured: bool,
927    pub zone_count: usize,
928    pub zones: Vec<BoundariesListZone>,
929    pub rule_count: usize,
930    pub rules: Vec<BoundariesListRule>,
931    pub logical_group_count: usize,
932    pub logical_groups: Vec<BoundariesListLogicalGroup>,
933}
934
935/// A boundary zone after preset and `autoDiscover` expansion. Each entry
936/// classifies files into a single zone via glob patterns.
937#[derive(Debug, Clone, Serialize)]
938#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
939#[allow(
940    dead_code,
941    reason = "schema-source-of-truth: see `ListBoundariesOutput`."
942)]
943pub struct BoundariesListZone {
944    pub name: String,
945    pub patterns: Vec<String>,
946    pub file_count: usize,
947}
948
949/// A boundary import rule, expanded to operate on concrete child zone
950/// names after `autoDiscover` flattening. The user's pre-expansion rule
951/// (keyed on the logical parent name, if any) is preserved on the
952/// corresponding [`BoundariesListLogicalGroup::authored_rule`].
953#[derive(Debug, Clone, Serialize)]
954#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
955#[allow(
956    dead_code,
957    reason = "schema-source-of-truth: see `ListBoundariesOutput`."
958)]
959pub struct BoundariesListRule {
960    pub from: String,
961    pub allow: Vec<String>,
962}
963
964/// A pre-expansion `autoDiscover` logical group surfaced for observability
965/// (issue #373). Captured during `expand_auto_discover` so consumers can
966/// see the user-authored parent name and grouping intent after expansion
967/// would otherwise flatten it out of [`BoundariesListing::zones`].
968#[derive(Debug, Clone, Serialize)]
969#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
970#[allow(
971    dead_code,
972    reason = "schema-source-of-truth: see `ListBoundariesOutput`."
973)]
974pub struct BoundariesListLogicalGroup {
975    pub name: String,
976    pub children: Vec<String>,
977    pub auto_discover: Vec<String>,
978    pub status: fallow_config::LogicalGroupStatus,
979    pub source_zone_index: usize,
980    pub file_count: usize,
981    #[serde(default, skip_serializing_if = "Option::is_none")]
982    pub authored_rule: Option<fallow_config::AuthoredRule>,
983    #[serde(default, skip_serializing_if = "Option::is_none")]
984    pub fallback_zone: Option<String>,
985    #[serde(default, skip_serializing_if = "Option::is_none")]
986    pub merged_from: Option<Vec<usize>>,
987    #[serde(default, skip_serializing_if = "Option::is_none")]
988    pub original_zone_root: Option<String>,
989    #[serde(default, skip_serializing_if = "Vec::is_empty")]
990    pub child_source_indices: Vec<usize>,
991}
992
993/// Typed root of every fallow JSON envelope shape that serializes as a JSON
994/// object and participates in the documented `FallowOutput` contract. The
995/// schema derived from this enum drives the document-root `oneOf` in
996/// `docs/output-schema.json`.
997///
998/// The default wire shape now carries a top-level `kind` discriminator so
999/// agents and schema-validating clients can select the variant in O(1) instead
1000/// of probing for unique field presence. `--legacy-envelope` is a one-cycle
1001/// compatibility flag that removes only this document-root `kind` field from
1002/// CLI JSON output; nested report objects are not rewritten.
1003///
1004/// One envelope is intentionally NOT in this enum:
1005/// - `CodeClimateOutput` serializes as a bare JSON array
1006///   (`#[serde(transparent)]`) per the Code Climate / GitLab Code Quality
1007///   spec; `#[serde(tag = ...)]` cannot internally tag a non-object
1008///   variant and wrapping the array would break the spec. The root schema
1009///   carries it as a sibling `oneOf` branch alongside `FallowOutput`.
1010#[derive(Debug, Clone, Serialize)]
1011#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1012#[cfg_attr(
1013    feature = "schema",
1014    schemars(title = "fallow --format json (typed root)")
1015)]
1016#[serde(tag = "kind")]
1017#[allow(
1018    dead_code,
1019    reason = "some variants are schema-emit only, but runtime roots serialize through this enum where practical"
1020)]
1021pub enum FallowOutput {
1022    /// `fallow audit --format json`. Required `command: "audit"` singleton
1023    /// plus `verdict` and `summary`.
1024    #[serde(rename = "audit")]
1025    Audit(AuditOutput),
1026    /// `fallow explain <issue-type> --format json`. Required `id`, `name`,
1027    /// `rationale`, `example`, `how_to_fix`, `docs`; no `schema_version`.
1028    #[serde(rename = "explain")]
1029    Explain(ExplainOutput),
1030    /// `fallow --format review-github` / `--format review-gitlab`. Required
1031    /// `body`, `comments`, `meta`; no `schema_version`.
1032    #[serde(rename = "review-envelope")]
1033    ReviewEnvelope(ReviewEnvelopeOutput),
1034    /// `fallow ci reconcile-review --format json`. Required `schema`
1035    /// singleton plus `provider`, `comments`, and the various
1036    /// `*_fingerprints` arrays.
1037    #[serde(rename = "review-reconcile")]
1038    ReviewReconcile(ReviewReconcileOutput),
1039    /// `fallow coverage setup --json`. Required `schema_version` singleton
1040    /// plus `framework_detected`, `members`, `commands`, `snippets`.
1041    #[serde(rename = "coverage-setup")]
1042    CoverageSetup(CoverageSetupOutput),
1043    /// `fallow coverage analyze --format json`. Required
1044    /// `schema_version: "1"` singleton plus `version`, `elapsed_ms`,
1045    /// `runtime_coverage`.
1046    #[serde(rename = "coverage-analyze")]
1047    CoverageAnalyze(CoverageAnalyzeOutput),
1048    /// `fallow list --boundaries --format json`. Required `boundaries`
1049    /// sub-object; no `schema_version`.
1050    #[serde(rename = "list-boundaries")]
1051    ListBoundaries(ListBoundariesOutput),
1052    /// `fallow workspaces --format json`. Required `workspace_count`,
1053    /// `workspaces`, and `workspace_diagnostics`.
1054    #[serde(rename = "list-workspaces")]
1055    Workspaces(WorkspacesOutput),
1056    /// `fallow health --format json`. Required `report: HealthReport`.
1057    #[serde(rename = "health")]
1058    Health(HealthOutput),
1059    /// `fallow dupes --format json`. Required `report: DupesReportPayload`
1060    /// (typed wrapper payload carrying `clone_groups[]: CloneGroupFinding`
1061    /// and `clone_families[]: CloneFamilyFinding`).
1062    #[serde(rename = "dupes")]
1063    Dupes(DupesOutput),
1064    /// `fallow dead-code --format json --group-by <mode>`. Required `grouped_by`
1065    /// plus a `groups` array.
1066    #[serde(rename = "dead-code-grouped")]
1067    CheckGrouped(CheckGroupedOutput),
1068    /// `fallow impact --format json`. Required `enabled`, `record_count`,
1069    /// `containment_count`, `recent_containment`; no global `schema_version`,
1070    /// `command`, `total_issues`, or `report`.
1071    #[serde(rename = "impact")]
1072    Impact(crate::impact::ImpactReport),
1073    /// `fallow security --summary --format json`. Required `summary`; no
1074    /// per-finding arrays.
1075    #[serde(rename = "security")]
1076    SecuritySummary(crate::security::SecuritySummaryOutput),
1077    /// `fallow security --format json`. Required `security_findings`,
1078    /// `unresolved_edge_files`, and `unresolved_callee_sites`; ordered before the
1079    /// broader variants because the `security_findings` discriminator is uniquely
1080    /// present here.
1081    #[serde(rename = "security")]
1082    Security(crate::security::SecurityOutput),
1083    /// `fallow dead-code --format json`.
1084    /// Required `total_issues` plus `summary: CheckSummary`.
1085    #[serde(rename = "dead-code")]
1086    Check(CheckOutput),
1087    /// Bare `fallow --format json` (combined dead-code + dupes + health).
1088    /// Required `schema_version`, `version`, and `elapsed_ms`, with optional
1089    /// `check`, `dupes`, and `health` subreports.
1090    #[serde(rename = "combined")]
1091    Combined(CombinedOutput),
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096    use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
1097
1098    use super::*;
1099
1100    static TEST_TELEMETRY_RUN_ID_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1101
1102    struct TelemetryRunIdGuard {
1103        _lock: std::sync::MutexGuard<'static, ()>,
1104    }
1105
1106    impl TelemetryRunIdGuard {
1107        fn set(run_id: Option<&str>) -> Self {
1108            let lock = TEST_TELEMETRY_RUN_ID_LOCK
1109                .lock()
1110                .unwrap_or_else(|poisoned| poisoned.into_inner());
1111            set_telemetry_analysis_run_id(run_id.map(str::to_owned));
1112            Self { _lock: lock }
1113        }
1114    }
1115
1116    impl Drop for TelemetryRunIdGuard {
1117        fn drop(&mut self) {
1118            set_telemetry_analysis_run_id(None);
1119        }
1120    }
1121
1122    fn combined_output() -> CombinedOutput {
1123        CombinedOutput {
1124            schema_version: SchemaVersion(crate::report::SCHEMA_VERSION),
1125            version: ToolVersion("test".to_string()),
1126            elapsed_ms: ElapsedMs(0),
1127            meta: None,
1128            check: None,
1129            dupes: None,
1130            health: None,
1131            next_steps: Vec::new(),
1132        }
1133    }
1134
1135    #[test]
1136    fn root_output_serializes_kind_by_default() {
1137        let _guard = TelemetryRunIdGuard::set(None);
1138        let value = serialize_root_output_with_mode(
1139            FallowOutput::Combined(combined_output()),
1140            EnvelopeMode::Tagged,
1141        )
1142        .expect("combined root should serialize");
1143
1144        assert_eq!(value["kind"], serde_json::Value::String("combined".into()));
1145        assert_eq!(value["schema_version"], crate::report::SCHEMA_VERSION);
1146    }
1147
1148    #[test]
1149    fn legacy_mode_removes_only_root_kind() {
1150        let _guard = TelemetryRunIdGuard::set(None);
1151        let value = serialize_root_output_with_mode(
1152            FallowOutput::Combined(combined_output()),
1153            EnvelopeMode::Legacy,
1154        )
1155        .expect("combined root should serialize");
1156
1157        assert!(value.get("kind").is_none());
1158
1159        let mut nested = serde_json::json!({
1160            "kind": "root",
1161            "action": {
1162                "kind": "suppress"
1163            }
1164        });
1165        remove_root_kind(&mut nested);
1166        assert!(nested.get("kind").is_none());
1167        assert_eq!(nested["action"]["kind"], "suppress");
1168    }
1169
1170    #[test]
1171    fn root_output_attaches_telemetry_meta() {
1172        let _guard = TelemetryRunIdGuard::set(Some("run_test123"));
1173        let value = serialize_root_output_with_mode(
1174            FallowOutput::Combined(combined_output()),
1175            EnvelopeMode::Tagged,
1176        )
1177        .expect("combined root should serialize");
1178
1179        assert_eq!(
1180            value["_meta"]["telemetry"]["analysis_run_id"].as_str(),
1181            Some("run_test123")
1182        );
1183    }
1184
1185    #[test]
1186    fn telemetry_meta_preserves_existing_meta_sections() {
1187        let mut output = combined_output();
1188        output.meta = Some(CombinedMeta {
1189            check: Some(Meta {
1190                docs: Some("https://example.com/check".to_string()),
1191                ..Meta::default()
1192            }),
1193            dupes: None,
1194            health: None,
1195            telemetry: None,
1196        });
1197
1198        let _guard = TelemetryRunIdGuard::set(Some("run_test123"));
1199        let value =
1200            serialize_root_output_with_mode(FallowOutput::Combined(output), EnvelopeMode::Tagged)
1201                .expect("combined root should serialize");
1202
1203        assert_eq!(
1204            value["_meta"]["check"]["docs"].as_str(),
1205            Some("https://example.com/check")
1206        );
1207        assert_eq!(
1208            value["_meta"]["telemetry"]["analysis_run_id"].as_str(),
1209            Some("run_test123")
1210        );
1211    }
1212}