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 = 12;
10
11pub const COMBINED_SCHEMA_VERSION: u32 = 13;
16
17#[cfg(feature = "schema")]
19#[allow(dead_code, reason = "schema-only type used by the field projection")]
20#[derive(schemars::JsonSchema)]
21#[schemars(extend("const" = AUDIT_SCHEMA_VERSION))]
22struct AuditSchemaVersion(u32);
23
24#[cfg(feature = "schema")]
26#[allow(dead_code, reason = "schema-only type used by the field projection")]
27#[derive(schemars::JsonSchema)]
28#[schemars(extend("const" = COMBINED_SCHEMA_VERSION))]
29struct CombinedSchemaVersion(u32);
30
31pub fn serialize_json_root_output<T: Serialize>(
38 output: T,
39) -> Result<serde_json::Value, serde_json::Error> {
40 serde_json::to_value(output)
41}
42
43pub fn serialize_named_json_output<T: Serialize>(
54 output: T,
55 kind: &'static str,
56) -> Result<serde_json::Value, serde_json::Error> {
57 let mut value = serde_json::to_value(output)?;
58 apply_root_kind(&mut value, kind);
59 Ok(value)
60}
61
62pub fn serialize_audit_json_output<
70 Verdict,
71 Summary,
72 Attribution,
73 DeadCode,
74 Duplication,
75 Complexity,
76>(
77 output: AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity>,
78 analysis_run_id: Option<&str>,
79) -> Result<serde_json::Value, serde_json::Error>
80where
81 Verdict: Serialize,
82 Summary: Serialize,
83 Attribution: Serialize,
84 DeadCode: Serialize,
85 Duplication: Serialize,
86 Complexity: Serialize,
87{
88 let mut value = serde_json::to_value(output)?;
89 apply_root_kind(&mut value, "audit");
90 attach_telemetry_meta(&mut value, analysis_run_id);
91 Ok(value)
92}
93
94pub fn serialize_combined_json_output<Check, Dupes, Health>(
102 output: CombinedOutput<Check, Dupes, Health>,
103 analysis_run_id: Option<&str>,
104) -> Result<serde_json::Value, serde_json::Error>
105where
106 Check: Serialize,
107 Dupes: Serialize,
108 Health: Serialize,
109{
110 let mut value = serde_json::to_value(output)?;
111 apply_root_kind(&mut value, "combined");
112 attach_telemetry_meta(&mut value, analysis_run_id);
113 Ok(value)
114}
115
116pub fn apply_root_kind(value: &mut serde_json::Value, kind: &'static str) {
118 if let serde_json::Value::Object(map) = value {
119 let previous = map.shift_insert(
120 0,
121 "kind".to_string(),
122 serde_json::Value::String(kind.to_string()),
123 );
124 if let Some(previous) = previous
125 && let Some(current) = map.get_mut("kind")
126 {
127 *current = previous;
128 }
129 }
130}
131
132pub fn attach_telemetry_meta(value: &mut serde_json::Value, analysis_run_id: Option<&str>) {
134 let Some(analysis_run_id) = analysis_run_id else {
135 return;
136 };
137 let serde_json::Value::Object(map) = value else {
138 return;
139 };
140 let meta = map
141 .entry("_meta".to_string())
142 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
143 if !meta.is_object() {
144 *meta = serde_json::Value::Object(serde_json::Map::new());
145 }
146 if let serde_json::Value::Object(meta_map) = meta {
147 meta_map.insert(
148 "telemetry".to_string(),
149 serde_json::json!({ "analysis_run_id": analysis_run_id }),
150 );
151 }
152}
153
154#[derive(Debug, Clone, Serialize)]
156#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
157#[cfg_attr(feature = "schema", schemars(title = "fallow audit --format json"))]
158pub struct AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity> {
159 #[cfg_attr(feature = "schema", schemars(with = "AuditSchemaVersion"))]
161 pub schema_version: SchemaVersion,
162 pub version: ToolVersion,
164 pub command: AuditCommand,
166 pub verdict: Verdict,
168 pub changed_files_count: u32,
170 pub base_ref: String,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub base_description: Option<String>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub head_sha: Option<String>,
182 pub elapsed_ms: ElapsedMs,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub base_snapshot_skipped: Option<bool>,
188 pub summary: Summary,
190 pub attribution: Attribution,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub gate_outcomes: Option<crate::GateOutcomes>,
201 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
204 pub meta: Option<Meta>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub dead_code: Option<DeadCode>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub duplication: Option<Duplication>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub complexity: Option<Complexity>,
214 #[serde(default, skip_serializing_if = "Vec::is_empty")]
217 pub next_steps: Vec<NextStep>,
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
222#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
223#[serde(rename_all = "lowercase")]
224pub enum AuditCommand {
225 Audit,
227}
228
229#[derive(Debug, Clone, Serialize)]
231#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
232#[cfg_attr(
233 feature = "schema",
234 schemars(title = "fallow --format json (bare, combined)")
235)]
236pub struct CombinedOutput<Check, Dupes, Health> {
237 #[cfg_attr(feature = "schema", schemars(with = "CombinedSchemaVersion"))]
239 pub schema_version: SchemaVersion,
240 pub version: ToolVersion,
242 pub elapsed_ms: ElapsedMs,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub gate_outcomes: Option<crate::GateOutcomes>,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub request_outcomes: Option<crate::RequestOutcomes>,
270 #[serde(default, skip_serializing_if = "Vec::is_empty")]
273 pub package_baselines: Vec<crate::PackageBaselineStatus>,
274 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
276 pub meta: Option<CombinedMeta>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub check: Option<Check>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub dupes: Option<Dupes>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub health: Option<Health>,
286 #[serde(default, skip_serializing_if = "Vec::is_empty")]
293 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
294 #[serde(default, skip_serializing_if = "Vec::is_empty")]
297 pub next_steps: Vec<NextStep>,
298}
299
300#[derive(Debug, Clone, Serialize)]
302#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
303pub struct CombinedMeta {
304 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub check: Option<Meta>,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub dupes: Option<Meta>,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub health: Option<Meta>,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub telemetry: Option<TelemetryMeta>,
316}
317
318#[derive(Debug, Clone, Serialize)]
334#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
335#[cfg_attr(
336 feature = "schema",
337 schemars(title = "fallow --format json (typed root)")
338)]
339#[serde(tag = "kind")]
340#[allow(
341 dead_code,
342 reason = "some variants are schema-emit only, but runtime roots serialize through this enum where practical"
343)]
344pub enum FallowOutput<
345 Audit,
346 Explain,
347 Inspect,
348 Trace,
349 TraceError,
350 ReviewEnvelope,
351 ReviewReconcile,
352 CoverageSetup,
353 CoverageAnalyze,
354 ListBoundaries,
355 Workspaces,
356 Health,
357 Dupes,
358 CheckGrouped,
359 ArchitectureGrouped,
360 Impact,
361 ImpactCrossRepo,
362 SecuritySummary,
363 Security,
364 SecuritySurvivors,
365 SecurityBlindSpots,
366 Check,
367 Architecture,
368 Combined,
369 FeatureFlags,
370 AuditBrief,
371 DecisionSurface,
372 WalkthroughGuide,
373 WalkthroughValidation,
374 SuppressionInventory,
375 Doctor,
376 TypeAwareStatus,
377 SimilarCode,
378 SimilarCodeInspect,
379 SimilarCodeReview,
380 SimilarCodeStatus,
381 SimilarCodeCacheClear,
382> {
383 #[serde(rename = "audit")]
385 Audit(Audit),
386 #[serde(rename = "explain")]
388 Explain(Explain),
389 #[serde(rename = "inspect_target")]
391 Inspect(Inspect),
392 #[serde(rename = "trace")]
394 Trace(Trace),
395 #[serde(rename = "trace-error")]
397 TraceError(TraceError),
398 #[serde(rename = "review-envelope")]
400 ReviewEnvelope(ReviewEnvelope),
401 #[serde(rename = "review-reconcile")]
403 ReviewReconcile(ReviewReconcile),
404 #[serde(rename = "coverage-setup")]
406 CoverageSetup(CoverageSetup),
407 #[serde(rename = "coverage-analyze")]
409 CoverageAnalyze(CoverageAnalyze),
410 #[serde(rename = "list-boundaries")]
412 ListBoundaries(ListBoundaries),
413 #[serde(rename = "list-workspaces")]
415 Workspaces(Workspaces),
416 #[serde(rename = "health")]
418 Health(Health),
419 #[serde(rename = "dupes")]
421 Dupes(Dupes),
422 #[serde(rename = "dead-code-grouped")]
424 CheckGrouped(CheckGrouped),
425 #[serde(rename = "architecture-grouped")]
427 ArchitectureGrouped(ArchitectureGrouped),
428 #[serde(rename = "impact")]
430 Impact(Impact),
431 #[serde(rename = "impact-cross-repo")]
433 ImpactCrossRepo(ImpactCrossRepo),
434 #[serde(rename = "security")]
436 SecuritySummary(SecuritySummary),
437 #[serde(rename = "security")]
439 Security(Security),
440 #[serde(rename = "security-survivors")]
442 SecuritySurvivors(SecuritySurvivors),
443 #[serde(rename = "security-blind-spots")]
445 SecurityBlindSpots(SecurityBlindSpots),
446 #[serde(rename = "dead-code")]
448 Check(Check),
449 #[serde(rename = "architecture")]
451 Architecture(Architecture),
452 #[serde(rename = "combined")]
454 Combined(Combined),
455 #[serde(rename = "feature-flags")]
457 FeatureFlags(FeatureFlags),
458 #[serde(rename = "audit-brief")]
460 AuditBrief(AuditBrief),
461 #[serde(rename = "decision-surface")]
463 DecisionSurface(DecisionSurface),
464 #[serde(rename = "review-walkthrough-guide")]
466 WalkthroughGuide(WalkthroughGuide),
467 #[serde(rename = "review-walkthrough-validation")]
469 WalkthroughValidation(WalkthroughValidation),
470 #[serde(rename = "suppression-inventory")]
472 SuppressionInventory(SuppressionInventory),
473 #[serde(rename = "doctor")]
475 Doctor(Doctor),
476 #[serde(rename = "type-aware-status")]
478 TypeAwareStatus(TypeAwareStatus),
479 #[serde(rename = "similar-code")]
481 SimilarCode(SimilarCode),
482 #[serde(rename = "similar-code-inspect")]
484 SimilarCodeInspect(SimilarCodeInspect),
485 #[serde(rename = "similar-code-review")]
487 SimilarCodeReview(SimilarCodeReview),
488 #[serde(rename = "similar-code-status")]
490 SimilarCodeStatus(SimilarCodeStatus),
491 #[serde(rename = "similar-code-cache-clear")]
493 SimilarCodeCacheClear(SimilarCodeCacheClear),
494}
495
496#[cfg(test)]
497mod tests {
498 use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
499 use serde_json::json;
500
501 use super::*;
502
503 #[test]
504 fn apply_root_kind_sets_kind() {
505 let mut value = json!({});
506
507 apply_root_kind(&mut value, "dead_code");
508
509 assert_eq!(value["kind"], "dead_code");
510 }
511
512 #[test]
513 fn apply_root_kind_prepends_without_reordering_existing_fields() {
514 let mut value = json!({ "schema_version": 1, "summary": { "total": 0 } });
515
516 apply_root_kind(&mut value, "example");
517
518 assert_eq!(
519 serde_json::to_string(&value).expect("root output should serialize"),
520 r#"{"kind":"example","schema_version":1,"summary":{"total":0}}"#
521 );
522 }
523
524 #[test]
525 fn apply_root_kind_preserves_existing_value_and_moves_it_first() {
526 let mut value = json!({ "before": 1, "kind": "custom", "after": 2 });
527
528 apply_root_kind(&mut value, "replacement");
529
530 assert_eq!(
531 serde_json::to_string(&value).expect("root output should serialize"),
532 r#"{"kind":"custom","before":1,"after":2}"#
533 );
534 }
535
536 #[test]
537 fn apply_root_kind_preserves_non_object_roots() {
538 let mut value = json!(["not", "an", "object"]);
539
540 apply_root_kind(&mut value, "example");
541
542 assert_eq!(value, json!(["not", "an", "object"]));
543 }
544
545 #[test]
546 fn attach_telemetry_meta_sets_analysis_run_id() {
547 let mut value = json!({});
548
549 attach_telemetry_meta(&mut value, Some("run-123"));
550
551 assert_eq!(
552 value["_meta"]["telemetry"]["analysis_run_id"],
553 json!("run-123")
554 );
555 }
556
557 #[test]
558 fn attach_telemetry_meta_preserves_non_object_roots() {
559 let mut value = json!(["not", "an", "object"]);
560
561 attach_telemetry_meta(&mut value, Some("run-123"));
562
563 assert_eq!(value, json!(["not", "an", "object"]));
564 }
565
566 #[test]
567 fn serialize_named_json_output_applies_explicit_kind() {
568 let value = serialize_named_json_output(
569 json!({
570 "schema_version": 1,
571 "summary": { "total": 0 }
572 }),
573 "example",
574 )
575 .expect("named output should serialize");
576
577 assert_eq!(value["kind"], "example");
578 assert_eq!(value["summary"]["total"], 0);
579 }
580
581 #[test]
582 fn serialize_audit_json_output_applies_audit_kind() {
583 let value = serialize_audit_json_output(
584 AuditOutput {
585 gate_outcomes: None,
586 schema_version: SchemaVersion(7),
587 version: ToolVersion("1.2.3".to_string()),
588 command: AuditCommand::Audit,
589 verdict: "pass",
590 changed_files_count: 2,
591 base_ref: "origin/main".to_string(),
592 base_description: Some("merge-base with origin/main".to_string()),
593 head_sha: Some("abc123".to_string()),
594 elapsed_ms: ElapsedMs(42),
595 base_snapshot_skipped: Some(false),
596 summary: json!({ "dead_code_issues": 0 }),
597 attribution: json!({ "gate": "new_only" }),
598 meta: None,
599 dead_code: Some(json!({ "summary": { "total_issues": 0 } })),
600 duplication: None::<serde_json::Value>,
601 complexity: None::<serde_json::Value>,
602 next_steps: Vec::new(),
603 },
604 Some("run-audit"),
605 )
606 .expect("audit output should serialize");
607
608 assert_eq!(value["kind"], "audit");
609 assert_eq!(value["command"], "audit");
610 assert_eq!(value["dead_code"]["summary"]["total_issues"], 0);
611 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-audit");
612 }
613
614 #[test]
615 fn serialize_combined_json_output_applies_combined_kind() {
616 let value = serialize_combined_json_output(
617 CombinedOutput {
618 package_baselines: Vec::new(),
619 gate_outcomes: None,
620 request_outcomes: None,
621 schema_version: SchemaVersion(7),
622 version: ToolVersion("1.2.3".to_string()),
623 elapsed_ms: ElapsedMs(42),
624 meta: None,
625 check: Some(json!({ "summary": { "total_issues": 0 } })),
626 dupes: None::<serde_json::Value>,
627 health: None::<serde_json::Value>,
628 workspace_diagnostics: Vec::new(),
629 next_steps: Vec::new(),
630 },
631 Some("run-combined"),
632 )
633 .expect("combined output should serialize");
634
635 assert_eq!(value["kind"], "combined");
636 assert_eq!(value["check"]["summary"]["total_issues"], 0);
637 assert_eq!(
638 value["_meta"]["telemetry"]["analysis_run_id"],
639 "run-combined"
640 );
641 }
642}