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 ReviewEnvelope,
350 ReviewReconcile,
351 CoverageSetup,
352 CoverageAnalyze,
353 ListBoundaries,
354 Workspaces,
355 Health,
356 Dupes,
357 CheckGrouped,
358 Impact,
359 ImpactCrossRepo,
360 SecuritySummary,
361 Security,
362 SecuritySurvivors,
363 SecurityBlindSpots,
364 Check,
365 Combined,
366 FeatureFlags,
367 AuditBrief,
368 DecisionSurface,
369 WalkthroughGuide,
370 WalkthroughValidation,
371 SuppressionInventory,
372 Doctor,
373 TypeAwareStatus,
374 SimilarCode,
375 SimilarCodeInspect,
376 SimilarCodeReview,
377 SimilarCodeStatus,
378 SimilarCodeCacheClear,
379> {
380 #[serde(rename = "audit")]
382 Audit(Audit),
383 #[serde(rename = "explain")]
385 Explain(Explain),
386 #[serde(rename = "inspect_target")]
388 Inspect(Inspect),
389 #[serde(rename = "trace")]
391 Trace(Trace),
392 #[serde(rename = "review-envelope")]
394 ReviewEnvelope(ReviewEnvelope),
395 #[serde(rename = "review-reconcile")]
397 ReviewReconcile(ReviewReconcile),
398 #[serde(rename = "coverage-setup")]
400 CoverageSetup(CoverageSetup),
401 #[serde(rename = "coverage-analyze")]
403 CoverageAnalyze(CoverageAnalyze),
404 #[serde(rename = "list-boundaries")]
406 ListBoundaries(ListBoundaries),
407 #[serde(rename = "list-workspaces")]
409 Workspaces(Workspaces),
410 #[serde(rename = "health")]
412 Health(Health),
413 #[serde(rename = "dupes")]
415 Dupes(Dupes),
416 #[serde(rename = "dead-code-grouped")]
418 CheckGrouped(CheckGrouped),
419 #[serde(rename = "impact")]
421 Impact(Impact),
422 #[serde(rename = "impact-cross-repo")]
424 ImpactCrossRepo(ImpactCrossRepo),
425 #[serde(rename = "security")]
427 SecuritySummary(SecuritySummary),
428 #[serde(rename = "security")]
430 Security(Security),
431 #[serde(rename = "security-survivors")]
433 SecuritySurvivors(SecuritySurvivors),
434 #[serde(rename = "security-blind-spots")]
436 SecurityBlindSpots(SecurityBlindSpots),
437 #[serde(rename = "dead-code")]
439 Check(Check),
440 #[serde(rename = "combined")]
442 Combined(Combined),
443 #[serde(rename = "feature-flags")]
445 FeatureFlags(FeatureFlags),
446 #[serde(rename = "audit-brief")]
448 AuditBrief(AuditBrief),
449 #[serde(rename = "decision-surface")]
451 DecisionSurface(DecisionSurface),
452 #[serde(rename = "review-walkthrough-guide")]
454 WalkthroughGuide(WalkthroughGuide),
455 #[serde(rename = "review-walkthrough-validation")]
457 WalkthroughValidation(WalkthroughValidation),
458 #[serde(rename = "suppression-inventory")]
460 SuppressionInventory(SuppressionInventory),
461 #[serde(rename = "doctor")]
463 Doctor(Doctor),
464 #[serde(rename = "type-aware-status")]
466 TypeAwareStatus(TypeAwareStatus),
467 #[serde(rename = "similar-code")]
469 SimilarCode(SimilarCode),
470 #[serde(rename = "similar-code-inspect")]
472 SimilarCodeInspect(SimilarCodeInspect),
473 #[serde(rename = "similar-code-review")]
475 SimilarCodeReview(SimilarCodeReview),
476 #[serde(rename = "similar-code-status")]
478 SimilarCodeStatus(SimilarCodeStatus),
479 #[serde(rename = "similar-code-cache-clear")]
481 SimilarCodeCacheClear(SimilarCodeCacheClear),
482}
483
484#[cfg(test)]
485mod tests {
486 use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
487 use serde_json::json;
488
489 use super::*;
490
491 #[test]
492 fn apply_root_kind_sets_kind() {
493 let mut value = json!({});
494
495 apply_root_kind(&mut value, "dead_code");
496
497 assert_eq!(value["kind"], "dead_code");
498 }
499
500 #[test]
501 fn apply_root_kind_prepends_without_reordering_existing_fields() {
502 let mut value = json!({ "schema_version": 1, "summary": { "total": 0 } });
503
504 apply_root_kind(&mut value, "example");
505
506 assert_eq!(
507 serde_json::to_string(&value).expect("root output should serialize"),
508 r#"{"kind":"example","schema_version":1,"summary":{"total":0}}"#
509 );
510 }
511
512 #[test]
513 fn apply_root_kind_preserves_existing_value_and_moves_it_first() {
514 let mut value = json!({ "before": 1, "kind": "custom", "after": 2 });
515
516 apply_root_kind(&mut value, "replacement");
517
518 assert_eq!(
519 serde_json::to_string(&value).expect("root output should serialize"),
520 r#"{"kind":"custom","before":1,"after":2}"#
521 );
522 }
523
524 #[test]
525 fn apply_root_kind_preserves_non_object_roots() {
526 let mut value = json!(["not", "an", "object"]);
527
528 apply_root_kind(&mut value, "example");
529
530 assert_eq!(value, json!(["not", "an", "object"]));
531 }
532
533 #[test]
534 fn attach_telemetry_meta_sets_analysis_run_id() {
535 let mut value = json!({});
536
537 attach_telemetry_meta(&mut value, Some("run-123"));
538
539 assert_eq!(
540 value["_meta"]["telemetry"]["analysis_run_id"],
541 json!("run-123")
542 );
543 }
544
545 #[test]
546 fn attach_telemetry_meta_preserves_non_object_roots() {
547 let mut value = json!(["not", "an", "object"]);
548
549 attach_telemetry_meta(&mut value, Some("run-123"));
550
551 assert_eq!(value, json!(["not", "an", "object"]));
552 }
553
554 #[test]
555 fn serialize_named_json_output_applies_explicit_kind() {
556 let value = serialize_named_json_output(
557 json!({
558 "schema_version": 1,
559 "summary": { "total": 0 }
560 }),
561 "example",
562 )
563 .expect("named output should serialize");
564
565 assert_eq!(value["kind"], "example");
566 assert_eq!(value["summary"]["total"], 0);
567 }
568
569 #[test]
570 fn serialize_audit_json_output_applies_audit_kind() {
571 let value = serialize_audit_json_output(
572 AuditOutput {
573 gate_outcomes: None,
574 schema_version: SchemaVersion(7),
575 version: ToolVersion("1.2.3".to_string()),
576 command: AuditCommand::Audit,
577 verdict: "pass",
578 changed_files_count: 2,
579 base_ref: "origin/main".to_string(),
580 base_description: Some("merge-base with origin/main".to_string()),
581 head_sha: Some("abc123".to_string()),
582 elapsed_ms: ElapsedMs(42),
583 base_snapshot_skipped: Some(false),
584 summary: json!({ "dead_code_issues": 0 }),
585 attribution: json!({ "gate": "new_only" }),
586 meta: None,
587 dead_code: Some(json!({ "summary": { "total_issues": 0 } })),
588 duplication: None::<serde_json::Value>,
589 complexity: None::<serde_json::Value>,
590 next_steps: Vec::new(),
591 },
592 Some("run-audit"),
593 )
594 .expect("audit output should serialize");
595
596 assert_eq!(value["kind"], "audit");
597 assert_eq!(value["command"], "audit");
598 assert_eq!(value["dead_code"]["summary"]["total_issues"], 0);
599 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-audit");
600 }
601
602 #[test]
603 fn serialize_combined_json_output_applies_combined_kind() {
604 let value = serialize_combined_json_output(
605 CombinedOutput {
606 package_baselines: Vec::new(),
607 gate_outcomes: None,
608 request_outcomes: None,
609 schema_version: SchemaVersion(7),
610 version: ToolVersion("1.2.3".to_string()),
611 elapsed_ms: ElapsedMs(42),
612 meta: None,
613 check: Some(json!({ "summary": { "total_issues": 0 } })),
614 dupes: None::<serde_json::Value>,
615 health: None::<serde_json::Value>,
616 workspace_diagnostics: Vec::new(),
617 next_steps: Vec::new(),
618 },
619 Some("run-combined"),
620 )
621 .expect("combined output should serialize");
622
623 assert_eq!(value["kind"], "combined");
624 assert_eq!(value["check"]["summary"]["total_issues"], 0);
625 assert_eq!(
626 value["_meta"]["telemetry"]["analysis_run_id"],
627 "run-combined"
628 );
629 }
630}