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 = 13;
13
14pub const COMBINED_SCHEMA_VERSION: u32 = 14;
19
20#[cfg(feature = "schema")]
22#[allow(dead_code, reason = "schema-only type used by the field projection")]
23#[derive(schemars::JsonSchema)]
24#[schemars(extend("const" = AUDIT_SCHEMA_VERSION))]
25struct AuditSchemaVersion(u32);
26
27#[cfg(feature = "schema")]
29#[allow(dead_code, reason = "schema-only type used by the field projection")]
30#[derive(schemars::JsonSchema)]
31#[schemars(extend("const" = COMBINED_SCHEMA_VERSION))]
32struct CombinedSchemaVersion(u32);
33
34pub fn serialize_json_root_output<T: Serialize>(
41 output: T,
42) -> Result<serde_json::Value, serde_json::Error> {
43 serde_json::to_value(output)
44}
45
46pub fn serialize_named_json_output<T: Serialize>(
57 output: T,
58 kind: &'static str,
59) -> Result<serde_json::Value, serde_json::Error> {
60 let mut value = serde_json::to_value(output)?;
61 apply_root_kind(&mut value, kind);
62 Ok(value)
63}
64
65pub fn serialize_audit_json_output<
73 Verdict,
74 Summary,
75 Attribution,
76 DeadCode,
77 Duplication,
78 Complexity,
79>(
80 output: AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity>,
81 analysis_run_id: Option<&str>,
82) -> Result<serde_json::Value, serde_json::Error>
83where
84 Verdict: Serialize,
85 Summary: Serialize,
86 Attribution: Serialize,
87 DeadCode: Serialize,
88 Duplication: Serialize,
89 Complexity: Serialize,
90{
91 let mut value = serde_json::to_value(output)?;
92 apply_root_kind(&mut value, "audit");
93 attach_telemetry_meta(&mut value, analysis_run_id);
94 Ok(value)
95}
96
97pub fn serialize_combined_json_output<Check, Dupes, Health>(
105 output: CombinedOutput<Check, Dupes, Health>,
106 analysis_run_id: Option<&str>,
107) -> Result<serde_json::Value, serde_json::Error>
108where
109 Check: Serialize,
110 Dupes: Serialize,
111 Health: Serialize,
112{
113 let mut value = serde_json::to_value(output)?;
114 apply_root_kind(&mut value, "combined");
115 attach_telemetry_meta(&mut value, analysis_run_id);
116 Ok(value)
117}
118
119pub fn apply_root_kind(value: &mut serde_json::Value, kind: &'static str) {
121 if let serde_json::Value::Object(map) = value {
122 let previous = map.shift_insert(
123 0,
124 "kind".to_string(),
125 serde_json::Value::String(kind.to_string()),
126 );
127 if let Some(previous) = previous
128 && let Some(current) = map.get_mut("kind")
129 {
130 *current = previous;
131 }
132 }
133}
134
135pub fn attach_telemetry_meta(value: &mut serde_json::Value, analysis_run_id: Option<&str>) {
137 let Some(analysis_run_id) = analysis_run_id else {
138 return;
139 };
140 let serde_json::Value::Object(map) = value else {
141 return;
142 };
143 let meta = map
144 .entry("_meta".to_string())
145 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
146 if !meta.is_object() {
147 *meta = serde_json::Value::Object(serde_json::Map::new());
148 }
149 if let serde_json::Value::Object(meta_map) = meta {
150 meta_map.insert(
151 "telemetry".to_string(),
152 serde_json::json!({ "analysis_run_id": analysis_run_id }),
153 );
154 }
155}
156
157#[derive(Debug, Clone, Serialize)]
159#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
160#[cfg_attr(feature = "schema", schemars(title = "fallow audit --format json"))]
161pub struct AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity> {
162 #[cfg_attr(feature = "schema", schemars(with = "AuditSchemaVersion"))]
164 pub schema_version: SchemaVersion,
165 pub version: ToolVersion,
167 pub command: AuditCommand,
169 pub verdict: Verdict,
171 pub changed_files_count: u32,
173 pub base_ref: String,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub base_description: Option<String>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub head_sha: Option<String>,
185 pub elapsed_ms: ElapsedMs,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub base_snapshot_skipped: Option<bool>,
191 pub summary: Summary,
193 pub attribution: Attribution,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub gate_outcomes: Option<crate::GateOutcomes>,
204 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
207 pub meta: Option<Meta>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub dead_code: Option<DeadCode>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub duplication: Option<Duplication>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub complexity: Option<Complexity>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub diff_risk: Option<crate::DiffRiskReport>,
223 #[serde(default, skip_serializing_if = "Vec::is_empty")]
226 pub next_steps: Vec<NextStep>,
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
231#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
232#[serde(rename_all = "lowercase")]
233pub enum AuditCommand {
234 Audit,
236}
237
238#[derive(Debug, Clone, Serialize)]
240#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
241#[cfg_attr(
242 feature = "schema",
243 schemars(title = "fallow --format json (bare, combined)")
244)]
245pub struct CombinedOutput<Check, Dupes, Health> {
246 #[cfg_attr(feature = "schema", schemars(with = "CombinedSchemaVersion"))]
248 pub schema_version: SchemaVersion,
249 pub version: ToolVersion,
251 pub elapsed_ms: ElapsedMs,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub gate_outcomes: Option<crate::GateOutcomes>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub request_outcomes: Option<crate::RequestOutcomes>,
281 #[serde(default, skip_serializing_if = "Vec::is_empty")]
284 pub package_baselines: Vec<crate::PackageBaselineStatus>,
285 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
287 pub meta: Option<CombinedMeta>,
288 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub check: Option<Check>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub dupes: Option<Dupes>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub health: Option<Health>,
297 #[serde(default, skip_serializing_if = "Vec::is_empty")]
304 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
305 #[serde(default, skip_serializing_if = "Vec::is_empty")]
308 pub next_steps: Vec<NextStep>,
309}
310
311#[derive(Debug, Clone, Serialize)]
313#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
314pub struct CombinedMeta {
315 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub check: Option<Meta>,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub dupes: Option<Meta>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub health: Option<Meta>,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub telemetry: Option<TelemetryMeta>,
327}
328
329#[derive(Debug, Clone, Serialize)]
345#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
346#[cfg_attr(
347 feature = "schema",
348 schemars(title = "fallow --format json (typed root)")
349)]
350#[serde(tag = "kind")]
351#[allow(
352 dead_code,
353 reason = "some variants are schema-emit only, but runtime roots serialize through this enum where practical"
354)]
355pub enum FallowOutput<
356 Audit,
357 Explain,
358 Inspect,
359 Trace,
360 TraceError,
361 ReviewEnvelope,
362 ReviewReconcile,
363 CoverageSetup,
364 CoverageAnalyze,
365 ListBoundaries,
366 Workspaces,
367 Health,
368 Dupes,
369 CheckGrouped,
370 ArchitectureGrouped,
371 Impact,
372 ImpactCrossRepo,
373 SecuritySummary,
374 Security,
375 SecuritySurvivors,
376 SecurityBlindSpots,
377 Check,
378 Architecture,
379 Combined,
380 FeatureFlags,
381 AuditBrief,
382 DecisionSurface,
383 WalkthroughGuide,
384 WalkthroughValidation,
385 SuppressionInventory,
386 Doctor,
387 TypeAwareStatus,
388 SimilarCode,
389 SimilarCodeInspect,
390 SimilarCodeReview,
391 SimilarCodeStatus,
392 SimilarCodeCacheClear,
393 Mutation,
394 MutationPlan,
395 MutationInspect,
396> {
397 #[serde(rename = "audit")]
399 Audit(Audit),
400 #[serde(rename = "explain")]
402 Explain(Explain),
403 #[serde(rename = "inspect_target")]
405 Inspect(Inspect),
406 #[serde(rename = "trace")]
408 Trace(Trace),
409 #[serde(rename = "trace-error")]
411 TraceError(TraceError),
412 #[serde(rename = "review-envelope")]
414 ReviewEnvelope(ReviewEnvelope),
415 #[serde(rename = "review-reconcile")]
417 ReviewReconcile(ReviewReconcile),
418 #[serde(rename = "coverage-setup")]
420 CoverageSetup(CoverageSetup),
421 #[serde(rename = "coverage-analyze")]
423 CoverageAnalyze(CoverageAnalyze),
424 #[serde(rename = "list-boundaries")]
426 ListBoundaries(ListBoundaries),
427 #[serde(rename = "list-workspaces")]
429 Workspaces(Workspaces),
430 #[serde(rename = "health")]
432 Health(Health),
433 #[serde(rename = "dupes")]
435 Dupes(Dupes),
436 #[serde(rename = "dead-code-grouped")]
438 CheckGrouped(CheckGrouped),
439 #[serde(rename = "architecture-grouped")]
441 ArchitectureGrouped(ArchitectureGrouped),
442 #[serde(rename = "impact")]
444 Impact(Impact),
445 #[serde(rename = "impact-cross-repo")]
447 ImpactCrossRepo(ImpactCrossRepo),
448 #[serde(rename = "security")]
450 SecuritySummary(SecuritySummary),
451 #[serde(rename = "security")]
453 Security(Security),
454 #[serde(rename = "security-survivors")]
456 SecuritySurvivors(SecuritySurvivors),
457 #[serde(rename = "security-blind-spots")]
459 SecurityBlindSpots(SecurityBlindSpots),
460 #[serde(rename = "dead-code")]
462 Check(Check),
463 #[serde(rename = "architecture")]
465 Architecture(Architecture),
466 #[serde(rename = "combined")]
468 Combined(Combined),
469 #[serde(rename = "feature-flags")]
471 FeatureFlags(FeatureFlags),
472 #[serde(rename = "audit-brief")]
474 AuditBrief(AuditBrief),
475 #[serde(rename = "decision-surface")]
477 DecisionSurface(DecisionSurface),
478 #[serde(rename = "review-walkthrough-guide")]
480 WalkthroughGuide(WalkthroughGuide),
481 #[serde(rename = "review-walkthrough-validation")]
483 WalkthroughValidation(WalkthroughValidation),
484 #[serde(rename = "suppression-inventory")]
486 SuppressionInventory(SuppressionInventory),
487 #[serde(rename = "doctor")]
489 Doctor(Doctor),
490 #[serde(rename = "type-aware-status")]
492 TypeAwareStatus(TypeAwareStatus),
493 #[serde(rename = "similar-code")]
495 SimilarCode(SimilarCode),
496 #[serde(rename = "similar-code-inspect")]
498 SimilarCodeInspect(SimilarCodeInspect),
499 #[serde(rename = "similar-code-review")]
501 SimilarCodeReview(SimilarCodeReview),
502 #[serde(rename = "similar-code-status")]
504 SimilarCodeStatus(SimilarCodeStatus),
505 #[serde(rename = "similar-code-cache-clear")]
507 SimilarCodeCacheClear(SimilarCodeCacheClear),
508 #[serde(rename = "mutation")]
510 Mutation(Mutation),
511 #[serde(rename = "mutation-plan")]
513 MutationPlan(MutationPlan),
514 #[serde(rename = "mutation-inspect")]
516 MutationInspect(MutationInspect),
517}
518
519#[cfg(test)]
520mod tests {
521 use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
522 use serde_json::json;
523
524 use super::*;
525
526 #[test]
527 fn apply_root_kind_sets_kind() {
528 let mut value = json!({});
529
530 apply_root_kind(&mut value, "dead_code");
531
532 assert_eq!(value["kind"], "dead_code");
533 }
534
535 #[test]
536 fn apply_root_kind_prepends_without_reordering_existing_fields() {
537 let mut value = json!({ "schema_version": 1, "summary": { "total": 0 } });
538
539 apply_root_kind(&mut value, "example");
540
541 assert_eq!(
542 serde_json::to_string(&value).expect("root output should serialize"),
543 r#"{"kind":"example","schema_version":1,"summary":{"total":0}}"#
544 );
545 }
546
547 #[test]
548 fn apply_root_kind_preserves_existing_value_and_moves_it_first() {
549 let mut value = json!({ "before": 1, "kind": "custom", "after": 2 });
550
551 apply_root_kind(&mut value, "replacement");
552
553 assert_eq!(
554 serde_json::to_string(&value).expect("root output should serialize"),
555 r#"{"kind":"custom","before":1,"after":2}"#
556 );
557 }
558
559 #[test]
560 fn apply_root_kind_preserves_non_object_roots() {
561 let mut value = json!(["not", "an", "object"]);
562
563 apply_root_kind(&mut value, "example");
564
565 assert_eq!(value, json!(["not", "an", "object"]));
566 }
567
568 #[test]
569 fn attach_telemetry_meta_sets_analysis_run_id() {
570 let mut value = json!({});
571
572 attach_telemetry_meta(&mut value, Some("run-123"));
573
574 assert_eq!(
575 value["_meta"]["telemetry"]["analysis_run_id"],
576 json!("run-123")
577 );
578 }
579
580 #[test]
581 fn attach_telemetry_meta_preserves_non_object_roots() {
582 let mut value = json!(["not", "an", "object"]);
583
584 attach_telemetry_meta(&mut value, Some("run-123"));
585
586 assert_eq!(value, json!(["not", "an", "object"]));
587 }
588
589 #[test]
590 fn serialize_named_json_output_applies_explicit_kind() {
591 let value = serialize_named_json_output(
592 json!({
593 "schema_version": 1,
594 "summary": { "total": 0 }
595 }),
596 "example",
597 )
598 .expect("named output should serialize");
599
600 assert_eq!(value["kind"], "example");
601 assert_eq!(value["summary"]["total"], 0);
602 }
603
604 #[test]
605 fn serialize_audit_json_output_applies_audit_kind() {
606 let value = serialize_audit_json_output(
607 AuditOutput {
608 gate_outcomes: None,
609 schema_version: SchemaVersion(7),
610 version: ToolVersion("1.2.3".to_string()),
611 command: AuditCommand::Audit,
612 verdict: "pass",
613 changed_files_count: 2,
614 base_ref: "origin/main".to_string(),
615 base_description: Some("merge-base with origin/main".to_string()),
616 head_sha: Some("abc123".to_string()),
617 elapsed_ms: ElapsedMs(42),
618 base_snapshot_skipped: Some(false),
619 summary: json!({ "dead_code_issues": 0 }),
620 attribution: json!({ "gate": "new_only" }),
621 meta: None,
622 dead_code: Some(json!({ "summary": { "total_issues": 0 } })),
623 duplication: None::<serde_json::Value>,
624 complexity: None::<serde_json::Value>,
625 diff_risk: None,
626 next_steps: Vec::new(),
627 },
628 Some("run-audit"),
629 )
630 .expect("audit output should serialize");
631
632 assert_eq!(value["kind"], "audit");
633 assert_eq!(value["command"], "audit");
634 assert_eq!(value["dead_code"]["summary"]["total_issues"], 0);
635 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-audit");
636 }
637
638 #[test]
639 fn serialize_combined_json_output_applies_combined_kind() {
640 let value = serialize_combined_json_output(
641 CombinedOutput {
642 package_baselines: Vec::new(),
643 gate_outcomes: None,
644 request_outcomes: None,
645 schema_version: SchemaVersion(7),
646 version: ToolVersion("1.2.3".to_string()),
647 elapsed_ms: ElapsedMs(42),
648 meta: None,
649 check: Some(json!({ "summary": { "total_issues": 0 } })),
650 dupes: None::<serde_json::Value>,
651 health: None::<serde_json::Value>,
652 workspace_diagnostics: Vec::new(),
653 next_steps: Vec::new(),
654 },
655 Some("run-combined"),
656 )
657 .expect("combined output should serialize");
658
659 assert_eq!(value["kind"], "combined");
660 assert_eq!(value["check"]["summary"]["total_issues"], 0);
661 assert_eq!(
662 value["_meta"]["telemetry"]["analysis_run_id"],
663 "run-combined"
664 );
665 }
666}