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 serde::Serialize;
15
16use crate::audit::{AuditAttribution, AuditSummary, AuditVerdict};
17use crate::health_types::{HealthGroup, HealthReport, RuntimeCoverageReport};
18use crate::output_dupes::DupesReportPayload;
19use crate::report::dupes_grouping::DuplicationGroup;
20
21static LEGACY_ENVELOPE: AtomicBool = AtomicBool::new(false);
22static TELEMETRY_ANALYSIS_RUN_ID: Mutex<Option<String>> = Mutex::new(None);
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum EnvelopeMode {
26 Tagged,
27 Legacy,
28}
29
30impl EnvelopeMode {
31 #[must_use]
32 pub fn current() -> Self {
33 if LEGACY_ENVELOPE.load(Ordering::Relaxed) {
34 Self::Legacy
35 } else {
36 Self::Tagged
37 }
38 }
39}
40
41pub fn set_legacy_envelope(enabled: bool) {
42 LEGACY_ENVELOPE.store(enabled, Ordering::Relaxed);
43}
44
45pub fn set_telemetry_analysis_run_id(run_id: Option<String>) {
46 if let Ok(mut current) = TELEMETRY_ANALYSIS_RUN_ID.lock() {
47 *current = run_id;
48 }
49}
50
51fn telemetry_analysis_run_id() -> Option<String> {
52 TELEMETRY_ANALYSIS_RUN_ID
53 .lock()
54 .ok()
55 .and_then(|id| id.clone())
56}
57
58pub fn serialize_root_output(output: FallowOutput) -> Result<serde_json::Value, serde_json::Error> {
59 serialize_root_output_with_mode(output, EnvelopeMode::current())
60}
61
62pub fn serialize_root_output_without_telemetry(
63 output: FallowOutput,
64) -> Result<serde_json::Value, serde_json::Error> {
65 let mut value = serde_json::to_value(output)?;
66 if EnvelopeMode::current() == EnvelopeMode::Legacy {
67 remove_root_kind(&mut value);
68 }
69 Ok(value)
70}
71
72pub fn serialize_root_output_with_mode(
73 output: FallowOutput,
74 mode: EnvelopeMode,
75) -> Result<serde_json::Value, serde_json::Error> {
76 let mut value = serde_json::to_value(output)?;
77 if mode == EnvelopeMode::Legacy {
78 remove_root_kind(&mut value);
79 }
80 attach_telemetry_meta(&mut value);
81 Ok(value)
82}
83
84pub fn attach_telemetry_meta(value: &mut serde_json::Value) {
85 let Some(run_id) = telemetry_analysis_run_id() else {
86 return;
87 };
88 let serde_json::Value::Object(map) = value else {
89 return;
90 };
91 let meta = map
92 .entry("_meta".to_string())
93 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
94 if !meta.is_object() {
95 *meta = serde_json::Value::Object(serde_json::Map::new());
96 }
97 if let serde_json::Value::Object(meta_map) = meta {
98 meta_map.insert(
99 "telemetry".to_string(),
100 serde_json::json!({ "analysis_run_id": run_id }),
101 );
102 }
103}
104
105pub fn remove_root_kind(value: &mut serde_json::Value) {
109 if let serde_json::Value::Object(map) = value {
110 map.remove("kind");
111 }
112}
113
114pub fn apply_root_kind(value: &mut serde_json::Value, kind: &'static str) {
115 if EnvelopeMode::current() == EnvelopeMode::Tagged
116 && let serde_json::Value::Object(map) = value
117 {
118 map.insert(
119 "kind".to_string(),
120 serde_json::Value::String(kind.to_string()),
121 );
122 }
123}
124#[derive(Debug, Clone, Serialize)]
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127#[cfg_attr(feature = "schema", schemars(title = "fallow coverage setup --json"))]
128pub struct CoverageSetupOutput {
129 pub schema_version: CoverageSetupSchemaVersion,
130 pub framework_detected: CoverageSetupFramework,
131 pub package_manager: Option<CoverageSetupPackageManager>,
132 pub runtime_targets: Vec<CoverageSetupRuntimeTarget>,
133 pub members: Vec<CoverageSetupMember>,
134 pub config_written: Option<serde_json::Value>,
135 pub commands: Vec<String>,
136 pub files_to_edit: Vec<CoverageSetupFileToEdit>,
137 pub snippets: Vec<CoverageSetupSnippet>,
138 pub dockerfile_snippet: Option<String>,
139 pub next_steps: Vec<String>,
140 pub warnings: Vec<String>,
141 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
142 pub meta: Option<serde_json::Value>,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
146#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
147pub enum CoverageSetupSchemaVersion {
148 #[serde(rename = "1")]
149 V1,
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
153#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
154#[serde(rename_all = "snake_case")]
155pub enum CoverageSetupFramework {
156 #[serde(rename = "nextjs")]
157 NextJs,
158 #[serde(rename = "nestjs")]
159 NestJs,
160 Nuxt,
161 #[serde(rename = "sveltekit")]
162 SvelteKit,
163 Astro,
164 Remix,
165 Vite,
166 PlainNode,
167 Unknown,
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
171#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
172#[serde(rename_all = "lowercase")]
173pub enum CoverageSetupPackageManager {
174 Npm,
175 Pnpm,
176 Yarn,
177 Bun,
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
181#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
182#[serde(rename_all = "lowercase")]
183pub enum CoverageSetupRuntimeTarget {
184 Node,
185 Browser,
186}
187
188#[derive(Debug, Clone, Serialize)]
189#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
190pub struct CoverageSetupMember {
191 pub name: String,
192 pub path: String,
193 pub framework_detected: CoverageSetupFramework,
194 pub package_manager: Option<CoverageSetupPackageManager>,
195 pub runtime_targets: Vec<CoverageSetupRuntimeTarget>,
196 pub files_to_edit: Vec<CoverageSetupFileToEdit>,
197 pub snippets: Vec<CoverageSetupSnippet>,
198 pub dockerfile_snippet: Option<String>,
199 pub warnings: Vec<String>,
200}
201
202#[derive(Debug, Clone, Serialize)]
203#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
204pub struct CoverageSetupFileToEdit {
205 pub path: String,
206 pub reason: String,
207}
208
209#[derive(Debug, Clone, Serialize)]
210#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
211pub struct CoverageSetupSnippet {
212 pub label: String,
213 pub path: String,
214 pub content: String,
215}
216
217#[derive(Debug, Clone, Serialize)]
219#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
220#[cfg_attr(feature = "schema", schemars(title = "fallow audit --format json"))]
221#[allow(
222 dead_code,
223 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."
224)]
225pub struct AuditOutput {
226 pub schema_version: SchemaVersion,
227 pub version: ToolVersion,
228 pub command: AuditCommand,
229 pub verdict: AuditVerdict,
230 pub changed_files_count: u32,
231 pub base_ref: String,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub base_description: Option<String>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub head_sha: Option<String>,
241 pub elapsed_ms: ElapsedMs,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub base_snapshot_skipped: Option<bool>,
244 pub summary: AuditSummary,
245 pub attribution: AuditAttribution,
246 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
247 pub meta: Option<Meta>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub dead_code: Option<CheckOutput>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub duplication: Option<DupesReportPayload>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub complexity: Option<HealthReport>,
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
257#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
258#[serde(rename_all = "lowercase")]
259#[allow(dead_code, reason = "schema-source-of-truth: see `AuditOutput`.")]
260pub enum AuditCommand {
261 Audit,
262}
263
264#[derive(Debug, Clone, Serialize)]
266#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
267#[cfg_attr(
268 feature = "schema",
269 schemars(title = "fallow --format json (bare, combined)")
270)]
271pub struct CombinedOutput {
272 pub schema_version: SchemaVersion,
273 pub version: ToolVersion,
274 pub elapsed_ms: ElapsedMs,
275 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
276 pub meta: Option<CombinedMeta>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub check: Option<CheckOutput>,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub dupes: Option<DupesReportPayload>,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub health: Option<HealthReport>,
283}
284
285#[derive(Debug, Clone, Serialize)]
286#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
287pub struct CombinedMeta {
288 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub check: Option<Meta>,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub dupes: Option<Meta>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub health: Option<Meta>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub telemetry: Option<TelemetryMeta>,
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
299#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
300pub enum CoverageAnalyzeSchemaVersion {
301 #[serde(rename = "1")]
302 V1,
303}
304
305#[derive(Debug, Clone, Serialize)]
306#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
307#[cfg_attr(
308 feature = "schema",
309 schemars(title = "fallow coverage analyze --format json")
310)]
311pub struct CoverageAnalyzeOutput {
312 pub schema_version: CoverageAnalyzeSchemaVersion,
313 pub version: ToolVersion,
314 pub elapsed_ms: ElapsedMs,
315 pub runtime_coverage: RuntimeCoverageReport,
316 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
317 pub meta: Option<Meta>,
318}
319
320#[derive(Debug, Clone, Serialize)]
321#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
322#[cfg_attr(feature = "schema", schemars(title = "fallow dupes --format json"))]
323pub struct DupesOutput {
324 pub schema_version: SchemaVersion,
325 pub version: ToolVersion,
326 pub elapsed_ms: ElapsedMs,
327 #[serde(flatten)]
328 pub report: DupesReportPayload,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub grouped_by: Option<GroupByMode>,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub total_issues: Option<usize>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub groups: Option<Vec<DuplicationGroup>>,
335 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
338 pub meta: Option<Meta>,
339 #[serde(default, skip_serializing_if = "Vec::is_empty")]
345 pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
346}
347
348#[derive(Debug, Clone, Serialize)]
358#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
359#[cfg_attr(feature = "schema", schemars(title = "fallow dead-code --format json"))]
360pub struct CheckOutput {
361 pub schema_version: SchemaVersion,
362 pub version: ToolVersion,
363 pub elapsed_ms: ElapsedMs,
364 pub total_issues: usize,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub entry_points: Option<EntryPoints>,
367 pub summary: CheckSummary,
368 #[serde(flatten)]
369 pub results: AnalysisResults,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub baseline_deltas: Option<BaselineDeltas>,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub baseline: Option<BaselineMatch>,
374 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub regression: Option<RegressionResult>,
376 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
377 pub meta: Option<Meta>,
378 #[serde(default, skip_serializing_if = "Vec::is_empty")]
379 pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
380}
381
382#[derive(Debug, Clone, Serialize)]
389#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
390#[cfg_attr(
391 feature = "schema",
392 schemars(
393 title = "fallow dead-code --group-by <owner|directory|package|section> --format json"
394 )
395)]
396pub struct CheckGroupedOutput {
397 pub schema_version: SchemaVersion,
398 pub version: ToolVersion,
399 pub elapsed_ms: ElapsedMs,
400 pub grouped_by: GroupByMode,
401 pub total_issues: usize,
402 pub groups: Vec<CheckGroupedEntry>,
403 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
404 pub meta: Option<Meta>,
405}
406
407#[derive(Debug, Clone, Serialize)]
411#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
412pub struct CheckGroupedEntry {
413 pub key: String,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub owners: Option<Vec<String>>,
416 pub total_issues: usize,
417 #[serde(flatten)]
418 pub results: AnalysisResults,
419}
420
421#[derive(Debug, Clone, Serialize)]
433#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
434#[cfg_attr(feature = "schema", schemars(title = "fallow health --format json"))]
435pub struct HealthOutput {
436 pub schema_version: SchemaVersion,
437 pub version: ToolVersion,
438 pub elapsed_ms: ElapsedMs,
439 #[serde(flatten)]
440 pub report: HealthReport,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub grouped_by: Option<GroupByMode>,
443 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub groups: Option<Vec<HealthGroup>>,
445 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
446 pub meta: Option<Meta>,
447 #[serde(default, skip_serializing_if = "Vec::is_empty")]
448 pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
449}
450
451#[derive(Debug, Clone, Serialize)]
458#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
459#[cfg_attr(
460 feature = "schema",
461 schemars(title = "fallow explain <issue-type> --format json")
462)]
463pub struct ExplainOutput {
464 pub id: String,
465 pub name: String,
466 pub summary: String,
467 pub rationale: String,
468 pub example: String,
469 pub how_to_fix: String,
470 pub docs: String,
471}
472
473#[derive(Debug, Clone, Serialize)]
477#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
478#[cfg_attr(
479 feature = "schema",
480 schemars(title = "fallow --format codeclimate / gitlab-codequality")
481)]
482#[serde(transparent)]
483#[allow(
484 dead_code,
485 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."
486)]
487pub struct CodeClimateOutput(pub Vec<CodeClimateIssue>);
488
489#[derive(Debug, Clone, Serialize)]
491#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
492pub struct CodeClimateIssue {
493 #[serde(rename = "type")]
494 pub kind: CodeClimateIssueKind,
495 pub check_name: String,
496 pub description: String,
497 pub categories: Vec<String>,
498 pub severity: CodeClimateSeverity,
499 pub fingerprint: String,
500 pub location: CodeClimateLocation,
501}
502
503#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
505#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
506#[serde(rename_all = "lowercase")]
507pub enum CodeClimateIssueKind {
508 Issue,
510}
511
512#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
514#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
515#[serde(rename_all = "lowercase")]
516pub enum CodeClimateSeverity {
517 #[allow(
522 dead_code,
523 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."
524 )]
525 Info,
526 Minor,
528 Major,
530 Critical,
532 #[allow(
535 dead_code,
536 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."
537 )]
538 Blocker,
539}
540
541#[derive(Debug, Clone, Serialize)]
543#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
544pub struct CodeClimateLocation {
545 pub path: String,
547 pub lines: CodeClimateLines,
550}
551
552#[derive(Debug, Clone, Copy, Serialize)]
554#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
555pub struct CodeClimateLines {
556 pub begin: u32,
558}
559
560#[derive(Debug, Clone, Serialize)]
562#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
563#[cfg_attr(
564 feature = "schema",
565 schemars(title = "fallow --format review-github / review-gitlab")
566)]
567pub struct ReviewEnvelopeOutput {
568 #[serde(default, skip_serializing_if = "Option::is_none")]
569 pub event: Option<ReviewEnvelopeEvent>,
570 pub body: String,
571 #[serde(default = "ReviewEnvelopeSummary::empty_default")]
572 pub summary: ReviewEnvelopeSummary,
573 pub comments: Vec<ReviewComment>,
574 #[serde(default = "default_marker_regex")]
575 pub marker_regex: String,
576 #[serde(default = "default_marker_regex_flags")]
577 pub marker_regex_flags: String,
578 pub meta: ReviewEnvelopeMeta,
579}
580
581#[must_use]
583pub fn default_marker_regex() -> String {
584 MARKER_REGEX_V2.to_owned()
585}
586
587#[must_use]
589pub fn default_marker_regex_flags() -> String {
590 MARKER_REGEX_FLAGS_V2.to_owned()
591}
592
593pub const MARKER_REGEX_V2: &str =
595 r"^<!-- fallow-fingerprint:v2: ((?:[a-z]+:)?[0-9a-f]{16}) -->\s*$";
596
597pub const MARKER_REGEX_FLAGS_V2: &str = "m";
599
600#[derive(Debug, Clone, Serialize, Default)]
602#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
603pub struct ReviewEnvelopeSummary {
604 pub body: String,
605 pub fingerprint: String,
606}
607
608impl ReviewEnvelopeSummary {
609 #[must_use]
611 #[allow(
612 dead_code,
613 reason = "referenced via serde default = \"...\" attr; no direct callsite until Deserialize is derived"
614 )]
615 pub fn empty_default() -> Self {
616 Self::default()
617 }
618}
619
620#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
622#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
623pub enum ReviewEnvelopeEvent {
624 #[serde(rename = "COMMENT")]
625 Comment,
626}
627
628#[derive(Debug, Clone, Serialize)]
633#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
634#[serde(untagged)]
635pub enum ReviewComment {
636 GitHub(GitHubReviewComment),
637 GitLab(GitLabReviewComment),
638}
639
640#[derive(Debug, Clone, Serialize)]
642#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
643pub struct GitHubReviewComment {
644 pub path: String,
645 pub line: u32,
646 pub side: GitHubReviewSide,
647 pub body: String,
648 pub fingerprint: String,
649 #[serde(default, skip_serializing_if = "is_false")]
650 pub truncated: bool,
651}
652
653#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
655#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
656pub enum GitHubReviewSide {
657 #[serde(rename = "RIGHT")]
658 Right,
659}
660
661#[derive(Debug, Clone, Serialize)]
663#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
664pub struct GitLabReviewComment {
665 pub body: String,
666 pub position: GitLabReviewPosition,
667 pub fingerprint: String,
668 #[serde(default, skip_serializing_if = "is_false")]
669 pub truncated: bool,
670}
671
672#[must_use]
680#[allow(
681 clippy::trivially_copy_pass_by_ref,
682 reason = "serde's skip_serializing_if requires fn(&T) -> bool"
683)]
684pub fn is_false(value: &bool) -> bool {
685 !*value
686}
687
688#[derive(Debug, Clone, Serialize)]
691#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
692pub struct GitLabReviewPosition {
693 #[serde(default, skip_serializing_if = "Option::is_none")]
694 pub base_sha: Option<String>,
695 #[serde(default, skip_serializing_if = "Option::is_none")]
696 pub start_sha: Option<String>,
697 #[serde(default, skip_serializing_if = "Option::is_none")]
698 pub head_sha: Option<String>,
699 pub position_type: GitLabReviewPositionType,
700 pub old_path: String,
701 pub new_path: String,
702 pub new_line: u32,
703}
704
705#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
707#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
708#[serde(rename_all = "lowercase")]
709pub enum GitLabReviewPositionType {
710 Text,
711}
712
713#[derive(Debug, Clone, Serialize)]
715#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
716pub struct ReviewEnvelopeMeta {
717 pub schema: ReviewEnvelopeSchema,
718 pub provider: ReviewProvider,
719 #[serde(default, skip_serializing_if = "Option::is_none")]
720 pub check_conclusion: Option<ReviewCheckConclusion>,
721}
722
723#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
725#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
726pub enum ReviewEnvelopeSchema {
727 #[serde(rename = "fallow-review-envelope/v1")]
733 #[allow(
734 dead_code,
735 reason = "kept for forward-compat with v1 historical inputs once Deserialize is derived"
736 )]
737 V1,
738 #[serde(rename = "fallow-review-envelope/v2")]
752 V2,
753}
754
755#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
757#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
758#[serde(rename_all = "lowercase")]
759pub enum ReviewProvider {
760 Github,
762 Gitlab,
764}
765
766#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
769#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
770#[serde(rename_all = "lowercase")]
771pub enum ReviewCheckConclusion {
772 Success,
774 Neutral,
776 Failure,
778}
779
780#[derive(Debug, Clone, Serialize)]
784#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
785#[cfg_attr(
786 feature = "schema",
787 schemars(title = "fallow ci reconcile-review --format json")
788)]
789pub struct ReviewReconcileOutput {
790 pub schema: ReviewReconcileSchema,
791 pub provider: ReviewProvider,
792 pub target: Option<String>,
793 pub dry_run: bool,
794 pub comments: u32,
795 pub current_fingerprints: u32,
796 pub existing_fingerprints: u32,
797 pub new_fingerprints: u32,
798 pub stale_fingerprints: u32,
799 pub new: Vec<String>,
800 pub stale: Vec<String>,
801 pub provider_warning: Option<String>,
802 pub resolution_comments_posted: u32,
803 pub threads_resolved: u32,
804 #[serde(default, skip_serializing_if = "Option::is_none")]
805 pub apply_hint: Option<String>,
806 pub apply_errors: Vec<String>,
807 #[serde(default, skip_serializing_if = "Vec::is_empty")]
808 pub failed_fingerprints: Vec<String>,
809 #[serde(default, skip_serializing_if = "Vec::is_empty")]
810 pub unapplied_fingerprints: Vec<String>,
811}
812
813#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
815#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
816pub enum ReviewReconcileSchema {
817 #[serde(rename = "fallow-review-reconcile/v1")]
819 V1,
820}
821
822#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
828#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
829#[serde(rename_all = "lowercase")]
830pub enum GroupByMode {
831 Owner,
832 Directory,
833 Package,
834 Section,
835}
836#[derive(Debug, Clone, Serialize)]
842#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
843#[cfg_attr(
844 feature = "schema",
845 schemars(title = "fallow list --boundaries --format json")
846)]
847#[allow(
848 dead_code,
849 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."
850)]
851pub struct ListBoundariesOutput {
852 pub boundaries: BoundariesListing,
853}
854
855#[derive(Debug, Clone, Serialize)]
857#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
858#[cfg_attr(
859 feature = "schema",
860 schemars(title = "fallow workspaces --format json")
861)]
862pub struct WorkspacesOutput {
863 pub workspace_count: usize,
865 pub workspaces: Vec<WorkspaceInfo>,
868 pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
872}
873
874#[derive(Debug, Clone, Serialize)]
876#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
877pub struct WorkspaceInfo {
878 pub name: String,
881 pub path: String,
884 pub is_internal_dependency: bool,
887}
888
889#[derive(Debug, Clone, Serialize)]
891#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
892#[allow(
893 dead_code,
894 reason = "schema-source-of-truth: see `ListBoundariesOutput`."
895)]
896pub struct BoundariesListing {
897 pub configured: bool,
898 pub zone_count: usize,
899 pub zones: Vec<BoundariesListZone>,
900 pub rule_count: usize,
901 pub rules: Vec<BoundariesListRule>,
902 pub logical_group_count: usize,
903 pub logical_groups: Vec<BoundariesListLogicalGroup>,
904}
905
906#[derive(Debug, Clone, Serialize)]
909#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
910#[allow(
911 dead_code,
912 reason = "schema-source-of-truth: see `ListBoundariesOutput`."
913)]
914pub struct BoundariesListZone {
915 pub name: String,
916 pub patterns: Vec<String>,
917 pub file_count: usize,
918}
919
920#[derive(Debug, Clone, Serialize)]
925#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
926#[allow(
927 dead_code,
928 reason = "schema-source-of-truth: see `ListBoundariesOutput`."
929)]
930pub struct BoundariesListRule {
931 pub from: String,
932 pub allow: Vec<String>,
933}
934
935#[derive(Debug, Clone, Serialize)]
940#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
941#[allow(
942 dead_code,
943 reason = "schema-source-of-truth: see `ListBoundariesOutput`."
944)]
945pub struct BoundariesListLogicalGroup {
946 pub name: String,
947 pub children: Vec<String>,
948 pub auto_discover: Vec<String>,
949 pub status: fallow_config::LogicalGroupStatus,
950 pub source_zone_index: usize,
951 pub file_count: usize,
952 #[serde(default, skip_serializing_if = "Option::is_none")]
953 pub authored_rule: Option<fallow_config::AuthoredRule>,
954 #[serde(default, skip_serializing_if = "Option::is_none")]
955 pub fallback_zone: Option<String>,
956 #[serde(default, skip_serializing_if = "Option::is_none")]
957 pub merged_from: Option<Vec<usize>>,
958 #[serde(default, skip_serializing_if = "Option::is_none")]
959 pub original_zone_root: Option<String>,
960 #[serde(default, skip_serializing_if = "Vec::is_empty")]
961 pub child_source_indices: Vec<usize>,
962}
963
964#[derive(Debug, Clone, Serialize)]
982#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
983#[cfg_attr(
984 feature = "schema",
985 schemars(title = "fallow --format json (typed root)")
986)]
987#[serde(tag = "kind")]
988#[allow(
989 dead_code,
990 reason = "some variants are schema-emit only, but runtime roots serialize through this enum where practical"
991)]
992pub enum FallowOutput {
993 #[serde(rename = "audit")]
996 Audit(AuditOutput),
997 #[serde(rename = "explain")]
1000 Explain(ExplainOutput),
1001 #[serde(rename = "review-envelope")]
1004 ReviewEnvelope(ReviewEnvelopeOutput),
1005 #[serde(rename = "review-reconcile")]
1009 ReviewReconcile(ReviewReconcileOutput),
1010 #[serde(rename = "coverage-setup")]
1013 CoverageSetup(CoverageSetupOutput),
1014 #[serde(rename = "coverage-analyze")]
1018 CoverageAnalyze(CoverageAnalyzeOutput),
1019 #[serde(rename = "list-boundaries")]
1022 ListBoundaries(ListBoundariesOutput),
1023 #[serde(rename = "list-workspaces")]
1026 Workspaces(WorkspacesOutput),
1027 #[serde(rename = "health")]
1029 Health(HealthOutput),
1030 #[serde(rename = "dupes")]
1034 Dupes(DupesOutput),
1035 #[serde(rename = "dead-code-grouped")]
1038 CheckGrouped(CheckGroupedOutput),
1039 #[serde(rename = "impact")]
1043 Impact(crate::impact::ImpactReport),
1044 #[serde(rename = "security")]
1047 SecuritySummary(crate::security::SecuritySummaryOutput),
1048 #[serde(rename = "security")]
1053 Security(crate::security::SecurityOutput),
1054 #[serde(rename = "dead-code")]
1057 Check(CheckOutput),
1058 #[serde(rename = "combined")]
1062 Combined(CombinedOutput),
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067 use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
1068
1069 use super::*;
1070
1071 static TEST_TELEMETRY_RUN_ID_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1072
1073 struct TelemetryRunIdGuard {
1074 _lock: std::sync::MutexGuard<'static, ()>,
1075 }
1076
1077 impl TelemetryRunIdGuard {
1078 fn set(run_id: Option<&str>) -> Self {
1079 let lock = TEST_TELEMETRY_RUN_ID_LOCK
1080 .lock()
1081 .unwrap_or_else(|poisoned| poisoned.into_inner());
1082 set_telemetry_analysis_run_id(run_id.map(str::to_owned));
1083 Self { _lock: lock }
1084 }
1085 }
1086
1087 impl Drop for TelemetryRunIdGuard {
1088 fn drop(&mut self) {
1089 set_telemetry_analysis_run_id(None);
1090 }
1091 }
1092
1093 fn combined_output() -> CombinedOutput {
1094 CombinedOutput {
1095 schema_version: SchemaVersion(crate::report::SCHEMA_VERSION),
1096 version: ToolVersion("test".to_string()),
1097 elapsed_ms: ElapsedMs(0),
1098 meta: None,
1099 check: None,
1100 dupes: None,
1101 health: None,
1102 }
1103 }
1104
1105 #[test]
1106 fn root_output_serializes_kind_by_default() {
1107 let _guard = TelemetryRunIdGuard::set(None);
1108 let value = serialize_root_output_with_mode(
1109 FallowOutput::Combined(combined_output()),
1110 EnvelopeMode::Tagged,
1111 )
1112 .expect("combined root should serialize");
1113
1114 assert_eq!(value["kind"], serde_json::Value::String("combined".into()));
1115 assert_eq!(value["schema_version"], crate::report::SCHEMA_VERSION);
1116 }
1117
1118 #[test]
1119 fn legacy_mode_removes_only_root_kind() {
1120 let _guard = TelemetryRunIdGuard::set(None);
1121 let value = serialize_root_output_with_mode(
1122 FallowOutput::Combined(combined_output()),
1123 EnvelopeMode::Legacy,
1124 )
1125 .expect("combined root should serialize");
1126
1127 assert!(value.get("kind").is_none());
1128
1129 let mut nested = serde_json::json!({
1130 "kind": "root",
1131 "action": {
1132 "kind": "suppress"
1133 }
1134 });
1135 remove_root_kind(&mut nested);
1136 assert!(nested.get("kind").is_none());
1137 assert_eq!(nested["action"]["kind"], "suppress");
1138 }
1139
1140 #[test]
1141 fn root_output_attaches_telemetry_meta() {
1142 let _guard = TelemetryRunIdGuard::set(Some("run_test123"));
1143 let value = serialize_root_output_with_mode(
1144 FallowOutput::Combined(combined_output()),
1145 EnvelopeMode::Tagged,
1146 )
1147 .expect("combined root should serialize");
1148
1149 assert_eq!(
1150 value["_meta"]["telemetry"]["analysis_run_id"].as_str(),
1151 Some("run_test123")
1152 );
1153 }
1154
1155 #[test]
1156 fn telemetry_meta_preserves_existing_meta_sections() {
1157 let mut output = combined_output();
1158 output.meta = Some(CombinedMeta {
1159 check: Some(Meta {
1160 docs: Some("https://example.com/check".to_string()),
1161 ..Meta::default()
1162 }),
1163 dupes: None,
1164 health: None,
1165 telemetry: None,
1166 });
1167
1168 let _guard = TelemetryRunIdGuard::set(Some("run_test123"));
1169 let value =
1170 serialize_root_output_with_mode(FallowOutput::Combined(output), EnvelopeMode::Tagged)
1171 .expect("combined root should serialize");
1172
1173 assert_eq!(
1174 value["_meta"]["check"]["docs"].as_str(),
1175 Some("https://example.com/check")
1176 );
1177 assert_eq!(
1178 value["_meta"]["telemetry"]["analysis_run_id"].as_str(),
1179 Some("run_test123")
1180 );
1181 }
1182}