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