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")]
269 pub gate_outcomes: Option<crate::GateOutcomes>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub request_outcomes: Option<crate::RequestOutcomes>,
279 #[serde(default, skip_serializing_if = "Vec::is_empty")]
282 pub package_baselines: Vec<crate::PackageBaselineStatus>,
283 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
285 pub meta: Option<CombinedMeta>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub check: Option<Check>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub dupes: Option<Dupes>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub health: Option<Health>,
295 #[serde(default, skip_serializing_if = "Vec::is_empty")]
302 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
303 #[serde(default, skip_serializing_if = "Vec::is_empty")]
306 pub next_steps: Vec<NextStep>,
307}
308
309#[derive(Debug, Clone, Serialize)]
311#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
312pub struct CombinedMeta {
313 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub check: Option<Meta>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub dupes: Option<Meta>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub health: Option<Meta>,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub telemetry: Option<TelemetryMeta>,
325}
326
327#[derive(Debug, Clone, Serialize)]
343#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
344#[cfg_attr(
345 feature = "schema",
346 schemars(title = "fallow --format json (typed root)")
347)]
348#[serde(tag = "kind")]
349#[allow(
350 dead_code,
351 reason = "some variants are schema-emit only, but runtime roots serialize through this enum where practical"
352)]
353pub enum FallowOutput<
354 Audit,
355 Explain,
356 Inspect,
357 Trace,
358 TraceError,
359 ReviewEnvelope,
360 ReviewReconcile,
361 CoverageSetup,
362 CoverageAnalyze,
363 ListBoundaries,
364 Workspaces,
365 Health,
366 Dupes,
367 CheckGrouped,
368 ArchitectureGrouped,
369 Impact,
370 ImpactCrossRepo,
371 SecuritySummary,
372 Security,
373 SecuritySurvivors,
374 SecurityBlindSpots,
375 Check,
376 Architecture,
377 Combined,
378 FeatureFlags,
379 AuditBrief,
380 DecisionSurface,
381 WalkthroughGuide,
382 WalkthroughValidation,
383 SuppressionInventory,
384 Doctor,
385 TypeAwareStatus,
386 SimilarCode,
387 SimilarCodeInspect,
388 SimilarCodeReview,
389 SimilarCodeStatus,
390 SimilarCodeCacheClear,
391 Mutation,
392 MutationPlan,
393 MutationInspect,
394> {
395 #[serde(rename = "audit")]
397 Audit(Audit),
398 #[serde(rename = "explain")]
400 Explain(Explain),
401 #[serde(rename = "inspect_target")]
403 Inspect(Inspect),
404 #[serde(rename = "trace")]
406 Trace(Trace),
407 #[serde(rename = "trace-error")]
409 TraceError(TraceError),
410 #[serde(rename = "review-envelope")]
412 ReviewEnvelope(ReviewEnvelope),
413 #[serde(rename = "review-reconcile")]
415 ReviewReconcile(ReviewReconcile),
416 #[serde(rename = "coverage-setup")]
418 CoverageSetup(CoverageSetup),
419 #[serde(rename = "coverage-analyze")]
421 CoverageAnalyze(CoverageAnalyze),
422 #[serde(rename = "list-boundaries")]
424 ListBoundaries(ListBoundaries),
425 #[serde(rename = "list-workspaces")]
427 Workspaces(Workspaces),
428 #[serde(rename = "health")]
430 Health(Health),
431 #[serde(rename = "dupes")]
433 Dupes(Dupes),
434 #[serde(rename = "dead-code-grouped")]
436 CheckGrouped(CheckGrouped),
437 #[serde(rename = "architecture-grouped")]
439 ArchitectureGrouped(ArchitectureGrouped),
440 #[serde(rename = "impact")]
442 Impact(Impact),
443 #[serde(rename = "impact-cross-repo")]
445 ImpactCrossRepo(ImpactCrossRepo),
446 #[serde(rename = "security")]
448 SecuritySummary(SecuritySummary),
449 #[serde(rename = "security")]
451 Security(Security),
452 #[serde(rename = "security-survivors")]
454 SecuritySurvivors(SecuritySurvivors),
455 #[serde(rename = "security-blind-spots")]
457 SecurityBlindSpots(SecurityBlindSpots),
458 #[serde(rename = "dead-code")]
460 Check(Check),
461 #[serde(rename = "architecture")]
463 Architecture(Architecture),
464 #[serde(rename = "combined")]
466 Combined(Combined),
467 #[serde(rename = "feature-flags")]
469 FeatureFlags(FeatureFlags),
470 #[serde(rename = "audit-brief")]
472 AuditBrief(AuditBrief),
473 #[serde(rename = "decision-surface")]
475 DecisionSurface(DecisionSurface),
476 #[serde(rename = "review-walkthrough-guide")]
478 WalkthroughGuide(WalkthroughGuide),
479 #[serde(rename = "review-walkthrough-validation")]
481 WalkthroughValidation(WalkthroughValidation),
482 #[serde(rename = "suppression-inventory")]
484 SuppressionInventory(SuppressionInventory),
485 #[serde(rename = "doctor")]
487 Doctor(Doctor),
488 #[serde(rename = "type-aware-status")]
490 TypeAwareStatus(TypeAwareStatus),
491 #[serde(rename = "similar-code")]
493 SimilarCode(SimilarCode),
494 #[serde(rename = "similar-code-inspect")]
496 SimilarCodeInspect(SimilarCodeInspect),
497 #[serde(rename = "similar-code-review")]
499 SimilarCodeReview(SimilarCodeReview),
500 #[serde(rename = "similar-code-status")]
502 SimilarCodeStatus(SimilarCodeStatus),
503 #[serde(rename = "similar-code-cache-clear")]
505 SimilarCodeCacheClear(SimilarCodeCacheClear),
506 #[serde(rename = "mutation")]
508 Mutation(Mutation),
509 #[serde(rename = "mutation-plan")]
511 MutationPlan(MutationPlan),
512 #[serde(rename = "mutation-inspect")]
514 MutationInspect(MutationInspect),
515}
516
517#[cfg(test)]
518mod tests {
519 use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
520 use serde_json::json;
521
522 use super::*;
523
524 #[test]
525 fn apply_root_kind_sets_kind() {
526 let mut value = json!({});
527
528 apply_root_kind(&mut value, "dead_code");
529
530 assert_eq!(value["kind"], "dead_code");
531 }
532
533 #[test]
534 fn apply_root_kind_prepends_without_reordering_existing_fields() {
535 let mut value = json!({ "schema_version": 1, "summary": { "total": 0 } });
536
537 apply_root_kind(&mut value, "example");
538
539 assert_eq!(
540 serde_json::to_string(&value).expect("root output should serialize"),
541 r#"{"kind":"example","schema_version":1,"summary":{"total":0}}"#
542 );
543 }
544
545 #[test]
546 fn apply_root_kind_preserves_existing_value_and_moves_it_first() {
547 let mut value = json!({ "before": 1, "kind": "custom", "after": 2 });
548
549 apply_root_kind(&mut value, "replacement");
550
551 assert_eq!(
552 serde_json::to_string(&value).expect("root output should serialize"),
553 r#"{"kind":"custom","before":1,"after":2}"#
554 );
555 }
556
557 #[test]
558 fn apply_root_kind_preserves_non_object_roots() {
559 let mut value = json!(["not", "an", "object"]);
560
561 apply_root_kind(&mut value, "example");
562
563 assert_eq!(value, json!(["not", "an", "object"]));
564 }
565
566 #[test]
567 fn attach_telemetry_meta_sets_analysis_run_id() {
568 let mut value = json!({});
569
570 attach_telemetry_meta(&mut value, Some("run-123"));
571
572 assert_eq!(
573 value["_meta"]["telemetry"]["analysis_run_id"],
574 json!("run-123")
575 );
576 }
577
578 #[test]
579 fn attach_telemetry_meta_preserves_non_object_roots() {
580 let mut value = json!(["not", "an", "object"]);
581
582 attach_telemetry_meta(&mut value, Some("run-123"));
583
584 assert_eq!(value, json!(["not", "an", "object"]));
585 }
586
587 #[test]
588 fn serialize_named_json_output_applies_explicit_kind() {
589 let value = serialize_named_json_output(
590 json!({
591 "schema_version": 1,
592 "summary": { "total": 0 }
593 }),
594 "example",
595 )
596 .expect("named output should serialize");
597
598 assert_eq!(value["kind"], "example");
599 assert_eq!(value["summary"]["total"], 0);
600 }
601
602 #[test]
603 fn serialize_audit_json_output_applies_audit_kind() {
604 let value = serialize_audit_json_output(
605 AuditOutput {
606 gate_outcomes: None,
607 schema_version: SchemaVersion(7),
608 version: ToolVersion("1.2.3".to_string()),
609 command: AuditCommand::Audit,
610 verdict: "pass",
611 changed_files_count: 2,
612 base_ref: "origin/main".to_string(),
613 base_description: Some("merge-base with origin/main".to_string()),
614 head_sha: Some("abc123".to_string()),
615 elapsed_ms: ElapsedMs(42),
616 base_snapshot_skipped: Some(false),
617 summary: json!({ "dead_code_issues": 0 }),
618 attribution: json!({ "gate": "new_only" }),
619 meta: None,
620 dead_code: Some(json!({ "summary": { "total_issues": 0 } })),
621 duplication: None::<serde_json::Value>,
622 complexity: None::<serde_json::Value>,
623 diff_risk: None,
624 next_steps: Vec::new(),
625 },
626 Some("run-audit"),
627 )
628 .expect("audit output should serialize");
629
630 assert_eq!(value["kind"], "audit");
631 assert_eq!(value["command"], "audit");
632 assert_eq!(value["dead_code"]["summary"]["total_issues"], 0);
633 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-audit");
634 }
635
636 #[test]
637 fn serialize_combined_json_output_applies_combined_kind() {
638 let value = serialize_combined_json_output(
639 CombinedOutput {
640 package_baselines: Vec::new(),
641 gate_outcomes: None,
642 request_outcomes: None,
643 schema_version: SchemaVersion(7),
644 version: ToolVersion("1.2.3".to_string()),
645 elapsed_ms: ElapsedMs(42),
646 meta: None,
647 check: Some(json!({ "summary": { "total_issues": 0 } })),
648 dupes: None::<serde_json::Value>,
649 health: None::<serde_json::Value>,
650 workspace_diagnostics: Vec::new(),
651 next_steps: Vec::new(),
652 },
653 Some("run-combined"),
654 )
655 .expect("combined output should serialize");
656
657 assert_eq!(value["kind"], "combined");
658 assert_eq!(value["check"]["summary"]["total_issues"], 0);
659 assert_eq!(
660 value["_meta"]["telemetry"]["analysis_run_id"],
661 "run-combined"
662 );
663 }
664}