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(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
271 pub meta: Option<CombinedMeta>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub check: Option<Check>,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub dupes: Option<Dupes>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub health: Option<Health>,
281 #[serde(default, skip_serializing_if = "Vec::is_empty")]
288 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
289 #[serde(default, skip_serializing_if = "Vec::is_empty")]
292 pub next_steps: Vec<NextStep>,
293}
294
295#[derive(Debug, Clone, Serialize)]
297#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
298pub struct CombinedMeta {
299 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub check: Option<Meta>,
302 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub dupes: Option<Meta>,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub health: Option<Meta>,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub telemetry: Option<TelemetryMeta>,
311}
312
313#[derive(Debug, Clone, Serialize)]
329#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
330#[cfg_attr(
331 feature = "schema",
332 schemars(title = "fallow --format json (typed root)")
333)]
334#[serde(tag = "kind")]
335#[allow(
336 dead_code,
337 reason = "some variants are schema-emit only, but runtime roots serialize through this enum where practical"
338)]
339pub enum FallowOutput<
340 Audit,
341 Explain,
342 Inspect,
343 Trace,
344 ReviewEnvelope,
345 ReviewReconcile,
346 CoverageSetup,
347 CoverageAnalyze,
348 ListBoundaries,
349 Workspaces,
350 Health,
351 Dupes,
352 CheckGrouped,
353 Impact,
354 ImpactCrossRepo,
355 SecuritySummary,
356 Security,
357 SecuritySurvivors,
358 SecurityBlindSpots,
359 Check,
360 Combined,
361 FeatureFlags,
362 AuditBrief,
363 DecisionSurface,
364 WalkthroughGuide,
365 WalkthroughValidation,
366 SuppressionInventory,
367 Doctor,
368 TypeAwareStatus,
369 SimilarCode,
370 SimilarCodeInspect,
371 SimilarCodeReview,
372 SimilarCodeStatus,
373 SimilarCodeCacheClear,
374> {
375 #[serde(rename = "audit")]
377 Audit(Audit),
378 #[serde(rename = "explain")]
380 Explain(Explain),
381 #[serde(rename = "inspect_target")]
383 Inspect(Inspect),
384 #[serde(rename = "trace")]
386 Trace(Trace),
387 #[serde(rename = "review-envelope")]
389 ReviewEnvelope(ReviewEnvelope),
390 #[serde(rename = "review-reconcile")]
392 ReviewReconcile(ReviewReconcile),
393 #[serde(rename = "coverage-setup")]
395 CoverageSetup(CoverageSetup),
396 #[serde(rename = "coverage-analyze")]
398 CoverageAnalyze(CoverageAnalyze),
399 #[serde(rename = "list-boundaries")]
401 ListBoundaries(ListBoundaries),
402 #[serde(rename = "list-workspaces")]
404 Workspaces(Workspaces),
405 #[serde(rename = "health")]
407 Health(Health),
408 #[serde(rename = "dupes")]
410 Dupes(Dupes),
411 #[serde(rename = "dead-code-grouped")]
413 CheckGrouped(CheckGrouped),
414 #[serde(rename = "impact")]
416 Impact(Impact),
417 #[serde(rename = "impact-cross-repo")]
419 ImpactCrossRepo(ImpactCrossRepo),
420 #[serde(rename = "security")]
422 SecuritySummary(SecuritySummary),
423 #[serde(rename = "security")]
425 Security(Security),
426 #[serde(rename = "security-survivors")]
428 SecuritySurvivors(SecuritySurvivors),
429 #[serde(rename = "security-blind-spots")]
431 SecurityBlindSpots(SecurityBlindSpots),
432 #[serde(rename = "dead-code")]
434 Check(Check),
435 #[serde(rename = "combined")]
437 Combined(Combined),
438 #[serde(rename = "feature-flags")]
440 FeatureFlags(FeatureFlags),
441 #[serde(rename = "audit-brief")]
443 AuditBrief(AuditBrief),
444 #[serde(rename = "decision-surface")]
446 DecisionSurface(DecisionSurface),
447 #[serde(rename = "review-walkthrough-guide")]
449 WalkthroughGuide(WalkthroughGuide),
450 #[serde(rename = "review-walkthrough-validation")]
452 WalkthroughValidation(WalkthroughValidation),
453 #[serde(rename = "suppression-inventory")]
455 SuppressionInventory(SuppressionInventory),
456 #[serde(rename = "doctor")]
458 Doctor(Doctor),
459 #[serde(rename = "type-aware-status")]
461 TypeAwareStatus(TypeAwareStatus),
462 #[serde(rename = "similar-code")]
464 SimilarCode(SimilarCode),
465 #[serde(rename = "similar-code-inspect")]
467 SimilarCodeInspect(SimilarCodeInspect),
468 #[serde(rename = "similar-code-review")]
470 SimilarCodeReview(SimilarCodeReview),
471 #[serde(rename = "similar-code-status")]
473 SimilarCodeStatus(SimilarCodeStatus),
474 #[serde(rename = "similar-code-cache-clear")]
476 SimilarCodeCacheClear(SimilarCodeCacheClear),
477}
478
479#[cfg(test)]
480mod tests {
481 use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
482 use serde_json::json;
483
484 use super::*;
485
486 #[test]
487 fn apply_root_kind_sets_tagged_mode() {
488 let mut value = json!({});
489
490 apply_root_kind(&mut value, "dead_code", RootEnvelopeMode::Tagged);
491
492 assert_eq!(value["kind"], "dead_code");
493 }
494
495 #[test]
496 fn apply_root_kind_prepends_without_reordering_existing_fields() {
497 let mut value = json!({ "schema_version": 1, "summary": { "total": 0 } });
498
499 apply_root_kind(&mut value, "example", RootEnvelopeMode::Tagged);
500
501 assert_eq!(
502 serde_json::to_string(&value).expect("root output should serialize"),
503 r#"{"kind":"example","schema_version":1,"summary":{"total":0}}"#
504 );
505 }
506
507 #[test]
508 fn apply_root_kind_preserves_existing_value_and_moves_it_first() {
509 let mut value = json!({ "before": 1, "kind": "custom", "after": 2 });
510
511 apply_root_kind(&mut value, "replacement", RootEnvelopeMode::Tagged);
512
513 assert_eq!(
514 serde_json::to_string(&value).expect("root output should serialize"),
515 r#"{"kind":"custom","before":1,"after":2}"#
516 );
517 }
518
519 #[test]
520 fn apply_root_kind_preserves_non_object_roots() {
521 let mut value = json!(["not", "an", "object"]);
522
523 apply_root_kind(&mut value, "example", RootEnvelopeMode::Tagged);
524
525 assert_eq!(value, json!(["not", "an", "object"]));
526 }
527
528 #[test]
529 fn attach_telemetry_meta_sets_analysis_run_id() {
530 let mut value = json!({});
531
532 attach_telemetry_meta(&mut value, Some("run-123"));
533
534 assert_eq!(
535 value["_meta"]["telemetry"]["analysis_run_id"],
536 json!("run-123")
537 );
538 }
539
540 #[test]
541 fn attach_telemetry_meta_preserves_non_object_roots() {
542 let mut value = json!(["not", "an", "object"]);
543
544 attach_telemetry_meta(&mut value, Some("run-123"));
545
546 assert_eq!(value, json!(["not", "an", "object"]));
547 }
548
549 #[test]
550 fn serialize_named_json_output_applies_explicit_kind() {
551 let value = serialize_named_json_output(
552 json!({
553 "schema_version": 1,
554 "summary": { "total": 0 }
555 }),
556 "example",
557 RootEnvelopeMode::Tagged,
558 )
559 .expect("named output should serialize");
560
561 assert_eq!(value["kind"], "example");
562 assert_eq!(value["summary"]["total"], 0);
563 }
564
565 #[test]
566 fn serialize_audit_json_output_applies_audit_kind() {
567 let value = serialize_audit_json_output(
568 AuditOutput {
569 gate_outcomes: None,
570 schema_version: SchemaVersion(7),
571 version: ToolVersion("1.2.3".to_string()),
572 command: AuditCommand::Audit,
573 verdict: "pass",
574 changed_files_count: 2,
575 base_ref: "origin/main".to_string(),
576 base_description: Some("merge-base with origin/main".to_string()),
577 head_sha: Some("abc123".to_string()),
578 elapsed_ms: ElapsedMs(42),
579 base_snapshot_skipped: Some(false),
580 summary: json!({ "dead_code_issues": 0 }),
581 attribution: json!({ "gate": "new_only" }),
582 meta: None,
583 dead_code: Some(json!({ "summary": { "total_issues": 0 } })),
584 duplication: None::<serde_json::Value>,
585 complexity: None::<serde_json::Value>,
586 next_steps: Vec::new(),
587 },
588 RootEnvelopeMode::Tagged,
589 Some("run-audit"),
590 )
591 .expect("audit output should serialize");
592
593 assert_eq!(value["kind"], "audit");
594 assert_eq!(value["command"], "audit");
595 assert_eq!(value["dead_code"]["summary"]["total_issues"], 0);
596 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-audit");
597 }
598
599 #[test]
600 fn serialize_combined_json_output_applies_combined_kind() {
601 let value = serialize_combined_json_output(
602 CombinedOutput {
603 gate_outcomes: None,
604 schema_version: SchemaVersion(7),
605 version: ToolVersion("1.2.3".to_string()),
606 elapsed_ms: ElapsedMs(42),
607 meta: None,
608 check: Some(json!({ "summary": { "total_issues": 0 } })),
609 dupes: None::<serde_json::Value>,
610 health: None::<serde_json::Value>,
611 workspace_diagnostics: Vec::new(),
612 next_steps: Vec::new(),
613 },
614 RootEnvelopeMode::Tagged,
615 Some("run-combined"),
616 )
617 .expect("combined output should serialize");
618
619 assert_eq!(value["kind"], "combined");
620 assert_eq!(value["check"]["summary"]["total_issues"], 0);
621 assert_eq!(
622 value["_meta"]["telemetry"]["analysis_run_id"],
623 "run-combined"
624 );
625 }
626}