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 = 10;
10
11pub const COMBINED_SCHEMA_VERSION: u32 = 11;
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
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum RootEnvelopeMode {
34 Tagged,
36}
37
38pub fn serialize_json_root_output<T: Serialize>(
46 output: T,
47 mode: RootEnvelopeMode,
48) -> Result<serde_json::Value, serde_json::Error> {
49 let _ = mode;
50 serde_json::to_value(output)
51}
52
53pub fn serialize_named_json_output<T: Serialize>(
64 output: T,
65 kind: &'static str,
66 mode: RootEnvelopeMode,
67) -> Result<serde_json::Value, serde_json::Error> {
68 let mut value = serde_json::to_value(output)?;
69 apply_root_kind(&mut value, kind, mode);
70 Ok(value)
71}
72
73pub fn serialize_audit_json_output<
81 Verdict,
82 Summary,
83 Attribution,
84 DeadCode,
85 Duplication,
86 Complexity,
87>(
88 output: AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity>,
89 mode: RootEnvelopeMode,
90 analysis_run_id: Option<&str>,
91) -> Result<serde_json::Value, serde_json::Error>
92where
93 Verdict: Serialize,
94 Summary: Serialize,
95 Attribution: Serialize,
96 DeadCode: Serialize,
97 Duplication: Serialize,
98 Complexity: Serialize,
99{
100 let mut value = serde_json::to_value(output)?;
101 apply_root_kind(&mut value, "audit", mode);
102 attach_telemetry_meta(&mut value, analysis_run_id);
103 Ok(value)
104}
105
106pub fn serialize_combined_json_output<Check, Dupes, Health>(
114 output: CombinedOutput<Check, Dupes, Health>,
115 mode: RootEnvelopeMode,
116 analysis_run_id: Option<&str>,
117) -> Result<serde_json::Value, serde_json::Error>
118where
119 Check: Serialize,
120 Dupes: Serialize,
121 Health: Serialize,
122{
123 let mut value = serde_json::to_value(output)?;
124 apply_root_kind(&mut value, "combined", mode);
125 attach_telemetry_meta(&mut value, analysis_run_id);
126 Ok(value)
127}
128
129pub fn apply_root_kind(value: &mut serde_json::Value, kind: &'static str, mode: RootEnvelopeMode) {
131 let _ = mode;
132 if let serde_json::Value::Object(map) = value {
133 let previous = map.shift_insert(
134 0,
135 "kind".to_string(),
136 serde_json::Value::String(kind.to_string()),
137 );
138 if let Some(previous) = previous
139 && let Some(current) = map.get_mut("kind")
140 {
141 *current = previous;
142 }
143 }
144}
145
146pub fn attach_telemetry_meta(value: &mut serde_json::Value, analysis_run_id: Option<&str>) {
148 let Some(analysis_run_id) = analysis_run_id else {
149 return;
150 };
151 let serde_json::Value::Object(map) = value else {
152 return;
153 };
154 let meta = map
155 .entry("_meta".to_string())
156 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
157 if !meta.is_object() {
158 *meta = serde_json::Value::Object(serde_json::Map::new());
159 }
160 if let serde_json::Value::Object(meta_map) = meta {
161 meta_map.insert(
162 "telemetry".to_string(),
163 serde_json::json!({ "analysis_run_id": analysis_run_id }),
164 );
165 }
166}
167
168#[derive(Debug, Clone, Serialize)]
170#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
171#[cfg_attr(feature = "schema", schemars(title = "fallow audit --format json"))]
172pub struct AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity> {
173 #[cfg_attr(feature = "schema", schemars(with = "AuditSchemaVersion"))]
175 pub schema_version: SchemaVersion,
176 pub version: ToolVersion,
178 pub command: AuditCommand,
180 pub verdict: Verdict,
182 pub changed_files_count: u32,
184 pub base_ref: String,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub base_description: Option<String>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub head_sha: Option<String>,
196 pub elapsed_ms: ElapsedMs,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub base_snapshot_skipped: Option<bool>,
202 pub summary: Summary,
204 pub attribution: Attribution,
206 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
209 pub meta: Option<Meta>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub dead_code: Option<DeadCode>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub duplication: Option<Duplication>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub complexity: Option<Complexity>,
219 #[serde(default, skip_serializing_if = "Vec::is_empty")]
222 pub next_steps: Vec<NextStep>,
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
227#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
228#[serde(rename_all = "lowercase")]
229pub enum AuditCommand {
230 Audit,
232}
233
234#[derive(Debug, Clone, Serialize)]
236#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
237#[cfg_attr(
238 feature = "schema",
239 schemars(title = "fallow --format json (bare, combined)")
240)]
241pub struct CombinedOutput<Check, Dupes, Health> {
242 #[cfg_attr(feature = "schema", schemars(with = "CombinedSchemaVersion"))]
244 pub schema_version: SchemaVersion,
245 pub version: ToolVersion,
247 pub elapsed_ms: ElapsedMs,
249 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
251 pub meta: Option<CombinedMeta>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub check: Option<Check>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub dupes: Option<Dupes>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub health: Option<Health>,
261 #[serde(default, skip_serializing_if = "Vec::is_empty")]
268 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
269 #[serde(default, skip_serializing_if = "Vec::is_empty")]
272 pub next_steps: Vec<NextStep>,
273}
274
275#[derive(Debug, Clone, Serialize)]
277#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
278pub struct CombinedMeta {
279 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub check: Option<Meta>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub dupes: Option<Meta>,
285 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub health: Option<Meta>,
288 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub telemetry: Option<TelemetryMeta>,
291}
292
293#[derive(Debug, Clone, Serialize)]
309#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
310#[cfg_attr(
311 feature = "schema",
312 schemars(title = "fallow --format json (typed root)")
313)]
314#[serde(tag = "kind")]
315#[allow(
316 dead_code,
317 reason = "some variants are schema-emit only, but runtime roots serialize through this enum where practical"
318)]
319pub enum FallowOutput<
320 Audit,
321 Explain,
322 Inspect,
323 Trace,
324 ReviewEnvelope,
325 ReviewReconcile,
326 CoverageSetup,
327 CoverageAnalyze,
328 ListBoundaries,
329 Workspaces,
330 Health,
331 Dupes,
332 CheckGrouped,
333 Impact,
334 ImpactCrossRepo,
335 SecuritySummary,
336 Security,
337 SecuritySurvivors,
338 SecurityBlindSpots,
339 Check,
340 Combined,
341 FeatureFlags,
342 AuditBrief,
343 DecisionSurface,
344 WalkthroughGuide,
345 WalkthroughValidation,
346 SuppressionInventory,
347 TypeAwareStatus,
348> {
349 #[serde(rename = "audit")]
351 Audit(Audit),
352 #[serde(rename = "explain")]
354 Explain(Explain),
355 #[serde(rename = "inspect_target")]
357 Inspect(Inspect),
358 #[serde(rename = "trace")]
360 Trace(Trace),
361 #[serde(rename = "review-envelope")]
363 ReviewEnvelope(ReviewEnvelope),
364 #[serde(rename = "review-reconcile")]
366 ReviewReconcile(ReviewReconcile),
367 #[serde(rename = "coverage-setup")]
369 CoverageSetup(CoverageSetup),
370 #[serde(rename = "coverage-analyze")]
372 CoverageAnalyze(CoverageAnalyze),
373 #[serde(rename = "list-boundaries")]
375 ListBoundaries(ListBoundaries),
376 #[serde(rename = "list-workspaces")]
378 Workspaces(Workspaces),
379 #[serde(rename = "health")]
381 Health(Health),
382 #[serde(rename = "dupes")]
384 Dupes(Dupes),
385 #[serde(rename = "dead-code-grouped")]
387 CheckGrouped(CheckGrouped),
388 #[serde(rename = "impact")]
390 Impact(Impact),
391 #[serde(rename = "impact-cross-repo")]
393 ImpactCrossRepo(ImpactCrossRepo),
394 #[serde(rename = "security")]
396 SecuritySummary(SecuritySummary),
397 #[serde(rename = "security")]
399 Security(Security),
400 #[serde(rename = "security-survivors")]
402 SecuritySurvivors(SecuritySurvivors),
403 #[serde(rename = "security-blind-spots")]
405 SecurityBlindSpots(SecurityBlindSpots),
406 #[serde(rename = "dead-code")]
408 Check(Check),
409 #[serde(rename = "combined")]
411 Combined(Combined),
412 #[serde(rename = "feature-flags")]
414 FeatureFlags(FeatureFlags),
415 #[serde(rename = "audit-brief")]
417 AuditBrief(AuditBrief),
418 #[serde(rename = "decision-surface")]
420 DecisionSurface(DecisionSurface),
421 #[serde(rename = "review-walkthrough-guide")]
423 WalkthroughGuide(WalkthroughGuide),
424 #[serde(rename = "review-walkthrough-validation")]
426 WalkthroughValidation(WalkthroughValidation),
427 #[serde(rename = "suppression-inventory")]
429 SuppressionInventory(SuppressionInventory),
430 #[serde(rename = "type-aware-status")]
432 TypeAwareStatus(TypeAwareStatus),
433}
434
435#[cfg(test)]
436mod tests {
437 use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
438 use serde_json::json;
439
440 use super::*;
441
442 #[test]
443 fn apply_root_kind_sets_tagged_mode() {
444 let mut value = json!({});
445
446 apply_root_kind(&mut value, "dead_code", RootEnvelopeMode::Tagged);
447
448 assert_eq!(value["kind"], "dead_code");
449 }
450
451 #[test]
452 fn apply_root_kind_prepends_without_reordering_existing_fields() {
453 let mut value = json!({ "schema_version": 1, "summary": { "total": 0 } });
454
455 apply_root_kind(&mut value, "example", RootEnvelopeMode::Tagged);
456
457 assert_eq!(
458 serde_json::to_string(&value).expect("root output should serialize"),
459 r#"{"kind":"example","schema_version":1,"summary":{"total":0}}"#
460 );
461 }
462
463 #[test]
464 fn apply_root_kind_preserves_existing_value_and_moves_it_first() {
465 let mut value = json!({ "before": 1, "kind": "custom", "after": 2 });
466
467 apply_root_kind(&mut value, "replacement", RootEnvelopeMode::Tagged);
468
469 assert_eq!(
470 serde_json::to_string(&value).expect("root output should serialize"),
471 r#"{"kind":"custom","before":1,"after":2}"#
472 );
473 }
474
475 #[test]
476 fn apply_root_kind_preserves_non_object_roots() {
477 let mut value = json!(["not", "an", "object"]);
478
479 apply_root_kind(&mut value, "example", RootEnvelopeMode::Tagged);
480
481 assert_eq!(value, json!(["not", "an", "object"]));
482 }
483
484 #[test]
485 fn attach_telemetry_meta_sets_analysis_run_id() {
486 let mut value = json!({});
487
488 attach_telemetry_meta(&mut value, Some("run-123"));
489
490 assert_eq!(
491 value["_meta"]["telemetry"]["analysis_run_id"],
492 json!("run-123")
493 );
494 }
495
496 #[test]
497 fn attach_telemetry_meta_preserves_non_object_roots() {
498 let mut value = json!(["not", "an", "object"]);
499
500 attach_telemetry_meta(&mut value, Some("run-123"));
501
502 assert_eq!(value, json!(["not", "an", "object"]));
503 }
504
505 #[test]
506 fn serialize_named_json_output_applies_explicit_kind() {
507 let value = serialize_named_json_output(
508 json!({
509 "schema_version": 1,
510 "summary": { "total": 0 }
511 }),
512 "example",
513 RootEnvelopeMode::Tagged,
514 )
515 .expect("named output should serialize");
516
517 assert_eq!(value["kind"], "example");
518 assert_eq!(value["summary"]["total"], 0);
519 }
520
521 #[test]
522 fn serialize_audit_json_output_applies_audit_kind() {
523 let value = serialize_audit_json_output(
524 AuditOutput {
525 schema_version: SchemaVersion(7),
526 version: ToolVersion("1.2.3".to_string()),
527 command: AuditCommand::Audit,
528 verdict: "pass",
529 changed_files_count: 2,
530 base_ref: "origin/main".to_string(),
531 base_description: Some("merge-base with origin/main".to_string()),
532 head_sha: Some("abc123".to_string()),
533 elapsed_ms: ElapsedMs(42),
534 base_snapshot_skipped: Some(false),
535 summary: json!({ "dead_code_issues": 0 }),
536 attribution: json!({ "gate": "new_only" }),
537 meta: None,
538 dead_code: Some(json!({ "summary": { "total_issues": 0 } })),
539 duplication: None::<serde_json::Value>,
540 complexity: None::<serde_json::Value>,
541 next_steps: Vec::new(),
542 },
543 RootEnvelopeMode::Tagged,
544 Some("run-audit"),
545 )
546 .expect("audit output should serialize");
547
548 assert_eq!(value["kind"], "audit");
549 assert_eq!(value["command"], "audit");
550 assert_eq!(value["dead_code"]["summary"]["total_issues"], 0);
551 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-audit");
552 }
553
554 #[test]
555 fn serialize_combined_json_output_applies_combined_kind() {
556 let value = serialize_combined_json_output(
557 CombinedOutput {
558 schema_version: SchemaVersion(7),
559 version: ToolVersion("1.2.3".to_string()),
560 elapsed_ms: ElapsedMs(42),
561 meta: None,
562 check: Some(json!({ "summary": { "total_issues": 0 } })),
563 dupes: None::<serde_json::Value>,
564 health: None::<serde_json::Value>,
565 workspace_diagnostics: Vec::new(),
566 next_steps: Vec::new(),
567 },
568 RootEnvelopeMode::Tagged,
569 Some("run-combined"),
570 )
571 .expect("combined output should serialize");
572
573 assert_eq!(value["kind"], "combined");
574 assert_eq!(value["check"]["summary"]["total_issues"], 0);
575 assert_eq!(
576 value["_meta"]["telemetry"]["analysis_run_id"],
577 "run-combined"
578 );
579 }
580}