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