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 SimilarCode,
349 SimilarCodeInspect,
350 SimilarCodeReview,
351 SimilarCodeStatus,
352 SimilarCodeCacheClear,
353> {
354 #[serde(rename = "audit")]
356 Audit(Audit),
357 #[serde(rename = "explain")]
359 Explain(Explain),
360 #[serde(rename = "inspect_target")]
362 Inspect(Inspect),
363 #[serde(rename = "trace")]
365 Trace(Trace),
366 #[serde(rename = "review-envelope")]
368 ReviewEnvelope(ReviewEnvelope),
369 #[serde(rename = "review-reconcile")]
371 ReviewReconcile(ReviewReconcile),
372 #[serde(rename = "coverage-setup")]
374 CoverageSetup(CoverageSetup),
375 #[serde(rename = "coverage-analyze")]
377 CoverageAnalyze(CoverageAnalyze),
378 #[serde(rename = "list-boundaries")]
380 ListBoundaries(ListBoundaries),
381 #[serde(rename = "list-workspaces")]
383 Workspaces(Workspaces),
384 #[serde(rename = "health")]
386 Health(Health),
387 #[serde(rename = "dupes")]
389 Dupes(Dupes),
390 #[serde(rename = "dead-code-grouped")]
392 CheckGrouped(CheckGrouped),
393 #[serde(rename = "impact")]
395 Impact(Impact),
396 #[serde(rename = "impact-cross-repo")]
398 ImpactCrossRepo(ImpactCrossRepo),
399 #[serde(rename = "security")]
401 SecuritySummary(SecuritySummary),
402 #[serde(rename = "security")]
404 Security(Security),
405 #[serde(rename = "security-survivors")]
407 SecuritySurvivors(SecuritySurvivors),
408 #[serde(rename = "security-blind-spots")]
410 SecurityBlindSpots(SecurityBlindSpots),
411 #[serde(rename = "dead-code")]
413 Check(Check),
414 #[serde(rename = "combined")]
416 Combined(Combined),
417 #[serde(rename = "feature-flags")]
419 FeatureFlags(FeatureFlags),
420 #[serde(rename = "audit-brief")]
422 AuditBrief(AuditBrief),
423 #[serde(rename = "decision-surface")]
425 DecisionSurface(DecisionSurface),
426 #[serde(rename = "review-walkthrough-guide")]
428 WalkthroughGuide(WalkthroughGuide),
429 #[serde(rename = "review-walkthrough-validation")]
431 WalkthroughValidation(WalkthroughValidation),
432 #[serde(rename = "suppression-inventory")]
434 SuppressionInventory(SuppressionInventory),
435 #[serde(rename = "type-aware-status")]
437 TypeAwareStatus(TypeAwareStatus),
438 #[serde(rename = "similar-code")]
440 SimilarCode(SimilarCode),
441 #[serde(rename = "similar-code-inspect")]
443 SimilarCodeInspect(SimilarCodeInspect),
444 #[serde(rename = "similar-code-review")]
446 SimilarCodeReview(SimilarCodeReview),
447 #[serde(rename = "similar-code-status")]
449 SimilarCodeStatus(SimilarCodeStatus),
450 #[serde(rename = "similar-code-cache-clear")]
452 SimilarCodeCacheClear(SimilarCodeCacheClear),
453}
454
455#[cfg(test)]
456mod tests {
457 use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
458 use serde_json::json;
459
460 use super::*;
461
462 #[test]
463 fn apply_root_kind_sets_tagged_mode() {
464 let mut value = json!({});
465
466 apply_root_kind(&mut value, "dead_code", RootEnvelopeMode::Tagged);
467
468 assert_eq!(value["kind"], "dead_code");
469 }
470
471 #[test]
472 fn apply_root_kind_prepends_without_reordering_existing_fields() {
473 let mut value = json!({ "schema_version": 1, "summary": { "total": 0 } });
474
475 apply_root_kind(&mut value, "example", RootEnvelopeMode::Tagged);
476
477 assert_eq!(
478 serde_json::to_string(&value).expect("root output should serialize"),
479 r#"{"kind":"example","schema_version":1,"summary":{"total":0}}"#
480 );
481 }
482
483 #[test]
484 fn apply_root_kind_preserves_existing_value_and_moves_it_first() {
485 let mut value = json!({ "before": 1, "kind": "custom", "after": 2 });
486
487 apply_root_kind(&mut value, "replacement", RootEnvelopeMode::Tagged);
488
489 assert_eq!(
490 serde_json::to_string(&value).expect("root output should serialize"),
491 r#"{"kind":"custom","before":1,"after":2}"#
492 );
493 }
494
495 #[test]
496 fn apply_root_kind_preserves_non_object_roots() {
497 let mut value = json!(["not", "an", "object"]);
498
499 apply_root_kind(&mut value, "example", RootEnvelopeMode::Tagged);
500
501 assert_eq!(value, json!(["not", "an", "object"]));
502 }
503
504 #[test]
505 fn attach_telemetry_meta_sets_analysis_run_id() {
506 let mut value = json!({});
507
508 attach_telemetry_meta(&mut value, Some("run-123"));
509
510 assert_eq!(
511 value["_meta"]["telemetry"]["analysis_run_id"],
512 json!("run-123")
513 );
514 }
515
516 #[test]
517 fn attach_telemetry_meta_preserves_non_object_roots() {
518 let mut value = json!(["not", "an", "object"]);
519
520 attach_telemetry_meta(&mut value, Some("run-123"));
521
522 assert_eq!(value, json!(["not", "an", "object"]));
523 }
524
525 #[test]
526 fn serialize_named_json_output_applies_explicit_kind() {
527 let value = serialize_named_json_output(
528 json!({
529 "schema_version": 1,
530 "summary": { "total": 0 }
531 }),
532 "example",
533 RootEnvelopeMode::Tagged,
534 )
535 .expect("named output should serialize");
536
537 assert_eq!(value["kind"], "example");
538 assert_eq!(value["summary"]["total"], 0);
539 }
540
541 #[test]
542 fn serialize_audit_json_output_applies_audit_kind() {
543 let value = serialize_audit_json_output(
544 AuditOutput {
545 schema_version: SchemaVersion(7),
546 version: ToolVersion("1.2.3".to_string()),
547 command: AuditCommand::Audit,
548 verdict: "pass",
549 changed_files_count: 2,
550 base_ref: "origin/main".to_string(),
551 base_description: Some("merge-base with origin/main".to_string()),
552 head_sha: Some("abc123".to_string()),
553 elapsed_ms: ElapsedMs(42),
554 base_snapshot_skipped: Some(false),
555 summary: json!({ "dead_code_issues": 0 }),
556 attribution: json!({ "gate": "new_only" }),
557 meta: None,
558 dead_code: Some(json!({ "summary": { "total_issues": 0 } })),
559 duplication: None::<serde_json::Value>,
560 complexity: None::<serde_json::Value>,
561 next_steps: Vec::new(),
562 },
563 RootEnvelopeMode::Tagged,
564 Some("run-audit"),
565 )
566 .expect("audit output should serialize");
567
568 assert_eq!(value["kind"], "audit");
569 assert_eq!(value["command"], "audit");
570 assert_eq!(value["dead_code"]["summary"]["total_issues"], 0);
571 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-audit");
572 }
573
574 #[test]
575 fn serialize_combined_json_output_applies_combined_kind() {
576 let value = serialize_combined_json_output(
577 CombinedOutput {
578 schema_version: SchemaVersion(7),
579 version: ToolVersion("1.2.3".to_string()),
580 elapsed_ms: ElapsedMs(42),
581 meta: None,
582 check: Some(json!({ "summary": { "total_issues": 0 } })),
583 dupes: None::<serde_json::Value>,
584 health: None::<serde_json::Value>,
585 workspace_diagnostics: Vec::new(),
586 next_steps: Vec::new(),
587 },
588 RootEnvelopeMode::Tagged,
589 Some("run-combined"),
590 )
591 .expect("combined output should serialize");
592
593 assert_eq!(value["kind"], "combined");
594 assert_eq!(value["check"]["summary"]["total_issues"], 0);
595 assert_eq!(
596 value["_meta"]["telemetry"]["analysis_run_id"],
597 "run-combined"
598 );
599 }
600}