1use 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
106pub 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#[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#[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 #[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 #[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#[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 #[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 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
347 pub meta: Option<Meta>,
348 #[serde(default, skip_serializing_if = "Vec::is_empty")]
354 pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
355 #[serde(default, skip_serializing_if = "Vec::is_empty")]
358 pub next_steps: Vec<NextStep>,
359}
360
361#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
400 pub next_steps: Vec<NextStep>,
401}
402
403#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
429 pub next_steps: Vec<NextStep>,
430}
431
432#[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#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
477 pub next_steps: Vec<NextStep>,
478}
479
480#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
534#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
535#[serde(rename_all = "lowercase")]
536pub enum CodeClimateIssueKind {
537 Issue,
539}
540
541#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
543#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
544#[serde(rename_all = "lowercase")]
545pub enum CodeClimateSeverity {
546 #[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,
557 Major,
559 Critical,
561 #[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#[derive(Debug, Clone, Serialize)]
572#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
573pub struct CodeClimateLocation {
574 pub path: String,
576 pub lines: CodeClimateLines,
579}
580
581#[derive(Debug, Clone, Copy, Serialize)]
583#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
584pub struct CodeClimateLines {
585 pub begin: u32,
587}
588
589#[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#[must_use]
612pub fn default_marker_regex() -> String {
613 MARKER_REGEX_V2.to_owned()
614}
615
616#[must_use]
618pub fn default_marker_regex_flags() -> String {
619 MARKER_REGEX_FLAGS_V2.to_owned()
620}
621
622pub const MARKER_REGEX_V2: &str =
624 r"^<!-- fallow-fingerprint:v2: ((?:[a-z]+:)?[0-9a-f]{16}) -->\s*$";
625
626pub const MARKER_REGEX_FLAGS_V2: &str = "m";
628
629#[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 #[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
754#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
755pub enum ReviewEnvelopeSchema {
756 #[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 #[serde(rename = "fallow-review-envelope/v2")]
781 V2,
782}
783
784#[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,
791 Gitlab,
793}
794
795#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
798#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
799#[serde(rename_all = "lowercase")]
800pub enum ReviewCheckConclusion {
801 Success,
803 Neutral,
805 Failure,
807}
808
809#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
844#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
845pub enum ReviewReconcileSchema {
846 #[serde(rename = "fallow-review-reconcile/v1")]
848 V1,
849}
850
851#[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#[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#[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 pub workspace_count: usize,
894 pub workspaces: Vec<WorkspaceInfo>,
897 pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
901}
902
903#[derive(Debug, Clone, Serialize)]
905#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
906pub struct WorkspaceInfo {
907 pub name: String,
910 pub path: String,
913 pub is_internal_dependency: bool,
916}
917
918#[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#[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#[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#[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#[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 #[serde(rename = "audit")]
1025 Audit(AuditOutput),
1026 #[serde(rename = "explain")]
1029 Explain(ExplainOutput),
1030 #[serde(rename = "review-envelope")]
1033 ReviewEnvelope(ReviewEnvelopeOutput),
1034 #[serde(rename = "review-reconcile")]
1038 ReviewReconcile(ReviewReconcileOutput),
1039 #[serde(rename = "coverage-setup")]
1042 CoverageSetup(CoverageSetupOutput),
1043 #[serde(rename = "coverage-analyze")]
1047 CoverageAnalyze(CoverageAnalyzeOutput),
1048 #[serde(rename = "list-boundaries")]
1051 ListBoundaries(ListBoundariesOutput),
1052 #[serde(rename = "list-workspaces")]
1055 Workspaces(WorkspacesOutput),
1056 #[serde(rename = "health")]
1058 Health(HealthOutput),
1059 #[serde(rename = "dupes")]
1063 Dupes(DupesOutput),
1064 #[serde(rename = "dead-code-grouped")]
1067 CheckGrouped(CheckGroupedOutput),
1068 #[serde(rename = "impact")]
1072 Impact(crate::impact::ImpactReport),
1073 #[serde(rename = "security")]
1076 SecuritySummary(crate::security::SecuritySummaryOutput),
1077 #[serde(rename = "security")]
1082 Security(crate::security::SecurityOutput),
1083 #[serde(rename = "dead-code")]
1086 Check(CheckOutput),
1087 #[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}