1use fallow_types::envelope::{ElapsedMs, Meta, SchemaVersion, TelemetryMeta, ToolVersion};
4use fallow_types::output::NextStep;
5use fallow_types::workspace::WorkspaceDiagnostic;
6use serde::Serialize;
7
8pub const AUDIT_SCHEMA_VERSION: u32 = 11;
10
11pub const COMBINED_SCHEMA_VERSION: u32 = 12;
18
19#[cfg(feature = "schema")]
21#[allow(dead_code, reason = "schema-only type used by the field projection")]
22#[derive(schemars::JsonSchema)]
23#[schemars(extend("const" = AUDIT_SCHEMA_VERSION))]
24struct AuditSchemaVersion(u32);
25
26#[cfg(feature = "schema")]
28#[allow(dead_code, reason = "schema-only type used by the field projection")]
29#[derive(schemars::JsonSchema)]
30#[schemars(extend("const" = COMBINED_SCHEMA_VERSION))]
31struct CombinedSchemaVersion(u32);
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum RootEnvelopeMode {
36 Tagged,
38}
39
40pub fn serialize_json_root_output<T: Serialize>(
48 output: T,
49 mode: RootEnvelopeMode,
50) -> Result<serde_json::Value, serde_json::Error> {
51 let _ = mode;
52 serde_json::to_value(output)
53}
54
55pub fn serialize_named_json_output<T: Serialize>(
66 output: T,
67 kind: &'static str,
68 mode: RootEnvelopeMode,
69) -> Result<serde_json::Value, serde_json::Error> {
70 let mut value = serde_json::to_value(output)?;
71 apply_root_kind(&mut value, kind, mode);
72 Ok(value)
73}
74
75pub fn serialize_audit_json_output<
83 Verdict,
84 Summary,
85 Attribution,
86 DeadCode,
87 Duplication,
88 Complexity,
89>(
90 output: AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity>,
91 mode: RootEnvelopeMode,
92 analysis_run_id: Option<&str>,
93) -> Result<serde_json::Value, serde_json::Error>
94where
95 Verdict: Serialize,
96 Summary: Serialize,
97 Attribution: Serialize,
98 DeadCode: Serialize,
99 Duplication: Serialize,
100 Complexity: Serialize,
101{
102 let mut value = serde_json::to_value(output)?;
103 apply_root_kind(&mut value, "audit", mode);
104 attach_telemetry_meta(&mut value, analysis_run_id);
105 Ok(value)
106}
107
108pub fn serialize_combined_json_output<Check, Dupes, Health>(
116 output: CombinedOutput<Check, Dupes, Health>,
117 mode: RootEnvelopeMode,
118 analysis_run_id: Option<&str>,
119) -> Result<serde_json::Value, serde_json::Error>
120where
121 Check: Serialize,
122 Dupes: Serialize,
123 Health: Serialize,
124{
125 let mut value = serde_json::to_value(output)?;
126 apply_root_kind(&mut value, "combined", mode);
127 attach_telemetry_meta(&mut value, analysis_run_id);
128 Ok(value)
129}
130
131pub fn apply_root_kind(value: &mut serde_json::Value, kind: &'static str, mode: RootEnvelopeMode) {
133 let _ = mode;
134 if let serde_json::Value::Object(map) = value {
135 let previous = map.shift_insert(
136 0,
137 "kind".to_string(),
138 serde_json::Value::String(kind.to_string()),
139 );
140 if let Some(previous) = previous
141 && let Some(current) = map.get_mut("kind")
142 {
143 *current = previous;
144 }
145 }
146}
147
148pub fn attach_telemetry_meta(value: &mut serde_json::Value, analysis_run_id: Option<&str>) {
150 let Some(analysis_run_id) = analysis_run_id else {
151 return;
152 };
153 let serde_json::Value::Object(map) = value else {
154 return;
155 };
156 let meta = map
157 .entry("_meta".to_string())
158 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
159 if !meta.is_object() {
160 *meta = serde_json::Value::Object(serde_json::Map::new());
161 }
162 if let serde_json::Value::Object(meta_map) = meta {
163 meta_map.insert(
164 "telemetry".to_string(),
165 serde_json::json!({ "analysis_run_id": analysis_run_id }),
166 );
167 }
168}
169
170#[derive(Debug, Clone, Serialize)]
172#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
173#[cfg_attr(feature = "schema", schemars(title = "fallow audit --format json"))]
174pub struct AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity> {
175 #[cfg_attr(feature = "schema", schemars(with = "AuditSchemaVersion"))]
177 pub schema_version: SchemaVersion,
178 pub version: ToolVersion,
180 pub command: AuditCommand,
182 pub verdict: Verdict,
184 pub changed_files_count: u32,
186 pub base_ref: String,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub base_description: Option<String>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub head_sha: Option<String>,
198 pub elapsed_ms: ElapsedMs,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub base_snapshot_skipped: Option<bool>,
204 pub summary: Summary,
206 pub attribution: Attribution,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub gate_outcomes: Option<crate::GateOutcomes>,
217 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
220 pub meta: Option<Meta>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub dead_code: Option<DeadCode>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub duplication: Option<Duplication>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub complexity: Option<Complexity>,
230 #[serde(default, skip_serializing_if = "Vec::is_empty")]
233 pub next_steps: Vec<NextStep>,
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
238#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
239#[serde(rename_all = "lowercase")]
240pub enum AuditCommand {
241 Audit,
243}
244
245#[derive(Debug, Clone, Serialize)]
247#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
248#[cfg_attr(
249 feature = "schema",
250 schemars(title = "fallow --format json (bare, combined)")
251)]
252pub struct CombinedOutput<Check, Dupes, Health> {
253 #[cfg_attr(feature = "schema", schemars(with = "CombinedSchemaVersion"))]
255 pub schema_version: SchemaVersion,
256 pub version: ToolVersion,
258 pub elapsed_ms: ElapsedMs,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub gate_outcomes: Option<crate::GateOutcomes>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub request_outcomes: Option<crate::RequestOutcomes>,
278 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
280 pub meta: Option<CombinedMeta>,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub check: Option<Check>,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub dupes: Option<Dupes>,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub health: Option<Health>,
290 #[serde(default, skip_serializing_if = "Vec::is_empty")]
297 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
298 #[serde(default, skip_serializing_if = "Vec::is_empty")]
301 pub next_steps: Vec<NextStep>,
302}
303
304#[derive(Debug, Clone, Serialize)]
306#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
307pub struct CombinedMeta {
308 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub check: Option<Meta>,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub dupes: Option<Meta>,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub health: Option<Meta>,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub telemetry: Option<TelemetryMeta>,
320}
321
322#[derive(Debug, Clone, Serialize)]
338#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
339#[cfg_attr(
340 feature = "schema",
341 schemars(title = "fallow --format json (typed root)")
342)]
343#[serde(tag = "kind")]
344#[allow(
345 dead_code,
346 reason = "some variants are schema-emit only, but runtime roots serialize through this enum where practical"
347)]
348pub enum FallowOutput<
349 Audit,
350 Explain,
351 Inspect,
352 Trace,
353 ReviewEnvelope,
354 ReviewReconcile,
355 CoverageSetup,
356 CoverageAnalyze,
357 ListBoundaries,
358 Workspaces,
359 Health,
360 Dupes,
361 CheckGrouped,
362 Impact,
363 ImpactCrossRepo,
364 SecuritySummary,
365 Security,
366 SecuritySurvivors,
367 SecurityBlindSpots,
368 Check,
369 Combined,
370 FeatureFlags,
371 AuditBrief,
372 DecisionSurface,
373 WalkthroughGuide,
374 WalkthroughValidation,
375 SuppressionInventory,
376 Doctor,
377 TypeAwareStatus,
378 SimilarCode,
379 SimilarCodeInspect,
380 SimilarCodeReview,
381 SimilarCodeStatus,
382 SimilarCodeCacheClear,
383> {
384 #[serde(rename = "audit")]
386 Audit(Audit),
387 #[serde(rename = "explain")]
389 Explain(Explain),
390 #[serde(rename = "inspect_target")]
392 Inspect(Inspect),
393 #[serde(rename = "trace")]
395 Trace(Trace),
396 #[serde(rename = "review-envelope")]
398 ReviewEnvelope(ReviewEnvelope),
399 #[serde(rename = "review-reconcile")]
401 ReviewReconcile(ReviewReconcile),
402 #[serde(rename = "coverage-setup")]
404 CoverageSetup(CoverageSetup),
405 #[serde(rename = "coverage-analyze")]
407 CoverageAnalyze(CoverageAnalyze),
408 #[serde(rename = "list-boundaries")]
410 ListBoundaries(ListBoundaries),
411 #[serde(rename = "list-workspaces")]
413 Workspaces(Workspaces),
414 #[serde(rename = "health")]
416 Health(Health),
417 #[serde(rename = "dupes")]
419 Dupes(Dupes),
420 #[serde(rename = "dead-code-grouped")]
422 CheckGrouped(CheckGrouped),
423 #[serde(rename = "impact")]
425 Impact(Impact),
426 #[serde(rename = "impact-cross-repo")]
428 ImpactCrossRepo(ImpactCrossRepo),
429 #[serde(rename = "security")]
431 SecuritySummary(SecuritySummary),
432 #[serde(rename = "security")]
434 Security(Security),
435 #[serde(rename = "security-survivors")]
437 SecuritySurvivors(SecuritySurvivors),
438 #[serde(rename = "security-blind-spots")]
440 SecurityBlindSpots(SecurityBlindSpots),
441 #[serde(rename = "dead-code")]
443 Check(Check),
444 #[serde(rename = "combined")]
446 Combined(Combined),
447 #[serde(rename = "feature-flags")]
449 FeatureFlags(FeatureFlags),
450 #[serde(rename = "audit-brief")]
452 AuditBrief(AuditBrief),
453 #[serde(rename = "decision-surface")]
455 DecisionSurface(DecisionSurface),
456 #[serde(rename = "review-walkthrough-guide")]
458 WalkthroughGuide(WalkthroughGuide),
459 #[serde(rename = "review-walkthrough-validation")]
461 WalkthroughValidation(WalkthroughValidation),
462 #[serde(rename = "suppression-inventory")]
464 SuppressionInventory(SuppressionInventory),
465 #[serde(rename = "doctor")]
467 Doctor(Doctor),
468 #[serde(rename = "type-aware-status")]
470 TypeAwareStatus(TypeAwareStatus),
471 #[serde(rename = "similar-code")]
473 SimilarCode(SimilarCode),
474 #[serde(rename = "similar-code-inspect")]
476 SimilarCodeInspect(SimilarCodeInspect),
477 #[serde(rename = "similar-code-review")]
479 SimilarCodeReview(SimilarCodeReview),
480 #[serde(rename = "similar-code-status")]
482 SimilarCodeStatus(SimilarCodeStatus),
483 #[serde(rename = "similar-code-cache-clear")]
485 SimilarCodeCacheClear(SimilarCodeCacheClear),
486}
487
488#[cfg(test)]
489mod tests {
490 use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
491 use serde_json::json;
492
493 use super::*;
494
495 #[test]
496 fn apply_root_kind_sets_tagged_mode() {
497 let mut value = json!({});
498
499 apply_root_kind(&mut value, "dead_code", RootEnvelopeMode::Tagged);
500
501 assert_eq!(value["kind"], "dead_code");
502 }
503
504 #[test]
505 fn apply_root_kind_prepends_without_reordering_existing_fields() {
506 let mut value = json!({ "schema_version": 1, "summary": { "total": 0 } });
507
508 apply_root_kind(&mut value, "example", RootEnvelopeMode::Tagged);
509
510 assert_eq!(
511 serde_json::to_string(&value).expect("root output should serialize"),
512 r#"{"kind":"example","schema_version":1,"summary":{"total":0}}"#
513 );
514 }
515
516 #[test]
517 fn apply_root_kind_preserves_existing_value_and_moves_it_first() {
518 let mut value = json!({ "before": 1, "kind": "custom", "after": 2 });
519
520 apply_root_kind(&mut value, "replacement", RootEnvelopeMode::Tagged);
521
522 assert_eq!(
523 serde_json::to_string(&value).expect("root output should serialize"),
524 r#"{"kind":"custom","before":1,"after":2}"#
525 );
526 }
527
528 #[test]
529 fn apply_root_kind_preserves_non_object_roots() {
530 let mut value = json!(["not", "an", "object"]);
531
532 apply_root_kind(&mut value, "example", RootEnvelopeMode::Tagged);
533
534 assert_eq!(value, json!(["not", "an", "object"]));
535 }
536
537 #[test]
538 fn attach_telemetry_meta_sets_analysis_run_id() {
539 let mut value = json!({});
540
541 attach_telemetry_meta(&mut value, Some("run-123"));
542
543 assert_eq!(
544 value["_meta"]["telemetry"]["analysis_run_id"],
545 json!("run-123")
546 );
547 }
548
549 #[test]
550 fn attach_telemetry_meta_preserves_non_object_roots() {
551 let mut value = json!(["not", "an", "object"]);
552
553 attach_telemetry_meta(&mut value, Some("run-123"));
554
555 assert_eq!(value, json!(["not", "an", "object"]));
556 }
557
558 #[test]
559 fn serialize_named_json_output_applies_explicit_kind() {
560 let value = serialize_named_json_output(
561 json!({
562 "schema_version": 1,
563 "summary": { "total": 0 }
564 }),
565 "example",
566 RootEnvelopeMode::Tagged,
567 )
568 .expect("named output should serialize");
569
570 assert_eq!(value["kind"], "example");
571 assert_eq!(value["summary"]["total"], 0);
572 }
573
574 #[test]
575 fn serialize_audit_json_output_applies_audit_kind() {
576 let value = serialize_audit_json_output(
577 AuditOutput {
578 gate_outcomes: None,
579 schema_version: SchemaVersion(7),
580 version: ToolVersion("1.2.3".to_string()),
581 command: AuditCommand::Audit,
582 verdict: "pass",
583 changed_files_count: 2,
584 base_ref: "origin/main".to_string(),
585 base_description: Some("merge-base with origin/main".to_string()),
586 head_sha: Some("abc123".to_string()),
587 elapsed_ms: ElapsedMs(42),
588 base_snapshot_skipped: Some(false),
589 summary: json!({ "dead_code_issues": 0 }),
590 attribution: json!({ "gate": "new_only" }),
591 meta: None,
592 dead_code: Some(json!({ "summary": { "total_issues": 0 } })),
593 duplication: None::<serde_json::Value>,
594 complexity: None::<serde_json::Value>,
595 next_steps: Vec::new(),
596 },
597 RootEnvelopeMode::Tagged,
598 Some("run-audit"),
599 )
600 .expect("audit output should serialize");
601
602 assert_eq!(value["kind"], "audit");
603 assert_eq!(value["command"], "audit");
604 assert_eq!(value["dead_code"]["summary"]["total_issues"], 0);
605 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-audit");
606 }
607
608 #[test]
609 fn serialize_combined_json_output_applies_combined_kind() {
610 let value = serialize_combined_json_output(
611 CombinedOutput {
612 gate_outcomes: None,
613 request_outcomes: None,
614 schema_version: SchemaVersion(7),
615 version: ToolVersion("1.2.3".to_string()),
616 elapsed_ms: ElapsedMs(42),
617 meta: None,
618 check: Some(json!({ "summary": { "total_issues": 0 } })),
619 dupes: None::<serde_json::Value>,
620 health: None::<serde_json::Value>,
621 workspace_diagnostics: Vec::new(),
622 next_steps: Vec::new(),
623 },
624 RootEnvelopeMode::Tagged,
625 Some("run-combined"),
626 )
627 .expect("combined output should serialize");
628
629 assert_eq!(value["kind"], "combined");
630 assert_eq!(value["check"]["summary"]["total_issues"], 0);
631 assert_eq!(
632 value["_meta"]["telemetry"]["analysis_run_id"],
633 "run-combined"
634 );
635 }
636}