1use fallow_types::envelope::{ElapsedMs, Meta, SchemaVersion, TelemetryMeta, ToolVersion};
4use fallow_types::output::NextStep;
5use serde::Serialize;
6
7pub const AUDIT_SCHEMA_VERSION: u32 = 9;
9
10pub const COMBINED_SCHEMA_VERSION: u32 = 10;
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 existing = std::mem::take(map);
134 map.insert(
135 "kind".to_string(),
136 serde_json::Value::String(kind.to_string()),
137 );
138 map.extend(existing);
139 }
140}
141
142pub fn attach_telemetry_meta(value: &mut serde_json::Value, analysis_run_id: Option<&str>) {
144 let Some(analysis_run_id) = analysis_run_id else {
145 return;
146 };
147 let serde_json::Value::Object(map) = value else {
148 return;
149 };
150 let meta = map
151 .entry("_meta".to_string())
152 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
153 if !meta.is_object() {
154 *meta = serde_json::Value::Object(serde_json::Map::new());
155 }
156 if let serde_json::Value::Object(meta_map) = meta {
157 meta_map.insert(
158 "telemetry".to_string(),
159 serde_json::json!({ "analysis_run_id": analysis_run_id }),
160 );
161 }
162}
163
164#[derive(Debug, Clone, Serialize)]
166#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
167#[cfg_attr(feature = "schema", schemars(title = "fallow audit --format json"))]
168pub struct AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity> {
169 #[cfg_attr(feature = "schema", schemars(with = "AuditSchemaVersion"))]
171 pub schema_version: SchemaVersion,
172 pub version: ToolVersion,
174 pub command: AuditCommand,
176 pub verdict: Verdict,
178 pub changed_files_count: u32,
180 pub base_ref: String,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub base_description: Option<String>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub head_sha: Option<String>,
192 pub elapsed_ms: ElapsedMs,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub base_snapshot_skipped: Option<bool>,
198 pub summary: Summary,
200 pub attribution: Attribution,
202 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
205 pub meta: Option<Meta>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub dead_code: Option<DeadCode>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub duplication: Option<Duplication>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub complexity: Option<Complexity>,
215 #[serde(default, skip_serializing_if = "Vec::is_empty")]
218 pub next_steps: Vec<NextStep>,
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
223#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
224#[serde(rename_all = "lowercase")]
225pub enum AuditCommand {
226 Audit,
228}
229
230#[derive(Debug, Clone, Serialize)]
232#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
233#[cfg_attr(
234 feature = "schema",
235 schemars(title = "fallow --format json (bare, combined)")
236)]
237pub struct CombinedOutput<Check, Dupes, Health> {
238 #[cfg_attr(feature = "schema", schemars(with = "CombinedSchemaVersion"))]
240 pub schema_version: SchemaVersion,
241 pub version: ToolVersion,
243 pub elapsed_ms: ElapsedMs,
245 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
247 pub meta: Option<CombinedMeta>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub check: Option<Check>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub dupes: Option<Dupes>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub health: Option<Health>,
257 #[serde(default, skip_serializing_if = "Vec::is_empty")]
260 pub next_steps: Vec<NextStep>,
261}
262
263#[derive(Debug, Clone, Serialize)]
265#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
266pub struct CombinedMeta {
267 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub check: Option<Meta>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub dupes: Option<Meta>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub health: Option<Meta>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub telemetry: Option<TelemetryMeta>,
279}
280
281#[derive(Debug, Clone, Serialize)]
297#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
298#[cfg_attr(
299 feature = "schema",
300 schemars(title = "fallow --format json (typed root)")
301)]
302#[serde(tag = "kind")]
303#[allow(
304 dead_code,
305 reason = "some variants are schema-emit only, but runtime roots serialize through this enum where practical"
306)]
307pub enum FallowOutput<
308 Audit,
309 Explain,
310 Inspect,
311 Trace,
312 ReviewEnvelope,
313 ReviewReconcile,
314 CoverageSetup,
315 CoverageAnalyze,
316 ListBoundaries,
317 Workspaces,
318 Health,
319 Dupes,
320 CheckGrouped,
321 Impact,
322 ImpactCrossRepo,
323 SecuritySummary,
324 Security,
325 SecuritySurvivors,
326 SecurityBlindSpots,
327 Check,
328 Combined,
329 FeatureFlags,
330 AuditBrief,
331 DecisionSurface,
332 WalkthroughGuide,
333 WalkthroughValidation,
334 SuppressionInventory,
335 TypeAwareStatus,
336> {
337 #[serde(rename = "audit")]
339 Audit(Audit),
340 #[serde(rename = "explain")]
342 Explain(Explain),
343 #[serde(rename = "inspect_target")]
345 Inspect(Inspect),
346 #[serde(rename = "trace")]
348 Trace(Trace),
349 #[serde(rename = "review-envelope")]
351 ReviewEnvelope(ReviewEnvelope),
352 #[serde(rename = "review-reconcile")]
354 ReviewReconcile(ReviewReconcile),
355 #[serde(rename = "coverage-setup")]
357 CoverageSetup(CoverageSetup),
358 #[serde(rename = "coverage-analyze")]
360 CoverageAnalyze(CoverageAnalyze),
361 #[serde(rename = "list-boundaries")]
363 ListBoundaries(ListBoundaries),
364 #[serde(rename = "list-workspaces")]
366 Workspaces(Workspaces),
367 #[serde(rename = "health")]
369 Health(Health),
370 #[serde(rename = "dupes")]
372 Dupes(Dupes),
373 #[serde(rename = "dead-code-grouped")]
375 CheckGrouped(CheckGrouped),
376 #[serde(rename = "impact")]
378 Impact(Impact),
379 #[serde(rename = "impact-cross-repo")]
381 ImpactCrossRepo(ImpactCrossRepo),
382 #[serde(rename = "security")]
384 SecuritySummary(SecuritySummary),
385 #[serde(rename = "security")]
387 Security(Security),
388 #[serde(rename = "security-survivors")]
390 SecuritySurvivors(SecuritySurvivors),
391 #[serde(rename = "security-blind-spots")]
393 SecurityBlindSpots(SecurityBlindSpots),
394 #[serde(rename = "dead-code")]
396 Check(Check),
397 #[serde(rename = "combined")]
399 Combined(Combined),
400 #[serde(rename = "feature-flags")]
402 FeatureFlags(FeatureFlags),
403 #[serde(rename = "audit-brief")]
405 AuditBrief(AuditBrief),
406 #[serde(rename = "decision-surface")]
408 DecisionSurface(DecisionSurface),
409 #[serde(rename = "review-walkthrough-guide")]
411 WalkthroughGuide(WalkthroughGuide),
412 #[serde(rename = "review-walkthrough-validation")]
414 WalkthroughValidation(WalkthroughValidation),
415 #[serde(rename = "suppression-inventory")]
417 SuppressionInventory(SuppressionInventory),
418 #[serde(rename = "type-aware-status")]
420 TypeAwareStatus(TypeAwareStatus),
421}
422
423#[cfg(test)]
424mod tests {
425 use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
426 use serde_json::json;
427
428 use super::*;
429
430 #[test]
431 fn apply_root_kind_sets_tagged_mode() {
432 let mut value = json!({});
433
434 apply_root_kind(&mut value, "dead_code", RootEnvelopeMode::Tagged);
435
436 assert_eq!(value["kind"], "dead_code");
437 }
438
439 #[test]
440 fn attach_telemetry_meta_sets_analysis_run_id() {
441 let mut value = json!({});
442
443 attach_telemetry_meta(&mut value, Some("run-123"));
444
445 assert_eq!(
446 value["_meta"]["telemetry"]["analysis_run_id"],
447 json!("run-123")
448 );
449 }
450
451 #[test]
452 fn attach_telemetry_meta_preserves_non_object_roots() {
453 let mut value = json!(["not", "an", "object"]);
454
455 attach_telemetry_meta(&mut value, Some("run-123"));
456
457 assert_eq!(value, json!(["not", "an", "object"]));
458 }
459
460 #[test]
461 fn serialize_named_json_output_applies_explicit_kind() {
462 let value = serialize_named_json_output(
463 json!({
464 "schema_version": 1,
465 "summary": { "total": 0 }
466 }),
467 "example",
468 RootEnvelopeMode::Tagged,
469 )
470 .expect("named output should serialize");
471
472 assert_eq!(value["kind"], "example");
473 assert_eq!(value["summary"]["total"], 0);
474 }
475
476 #[test]
477 fn serialize_audit_json_output_applies_audit_kind() {
478 let value = serialize_audit_json_output(
479 AuditOutput {
480 schema_version: SchemaVersion(7),
481 version: ToolVersion("1.2.3".to_string()),
482 command: AuditCommand::Audit,
483 verdict: "pass",
484 changed_files_count: 2,
485 base_ref: "origin/main".to_string(),
486 base_description: Some("merge-base with origin/main".to_string()),
487 head_sha: Some("abc123".to_string()),
488 elapsed_ms: ElapsedMs(42),
489 base_snapshot_skipped: Some(false),
490 summary: json!({ "dead_code_issues": 0 }),
491 attribution: json!({ "gate": "new_only" }),
492 meta: None,
493 dead_code: Some(json!({ "summary": { "total_issues": 0 } })),
494 duplication: None::<serde_json::Value>,
495 complexity: None::<serde_json::Value>,
496 next_steps: Vec::new(),
497 },
498 RootEnvelopeMode::Tagged,
499 Some("run-audit"),
500 )
501 .expect("audit output should serialize");
502
503 assert_eq!(value["kind"], "audit");
504 assert_eq!(value["command"], "audit");
505 assert_eq!(value["dead_code"]["summary"]["total_issues"], 0);
506 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-audit");
507 }
508
509 #[test]
510 fn serialize_combined_json_output_applies_combined_kind() {
511 let value = serialize_combined_json_output(
512 CombinedOutput {
513 schema_version: SchemaVersion(7),
514 version: ToolVersion("1.2.3".to_string()),
515 elapsed_ms: ElapsedMs(42),
516 meta: None,
517 check: Some(json!({ "summary": { "total_issues": 0 } })),
518 dupes: None::<serde_json::Value>,
519 health: None::<serde_json::Value>,
520 next_steps: Vec::new(),
521 },
522 RootEnvelopeMode::Tagged,
523 Some("run-combined"),
524 )
525 .expect("combined output should serialize");
526
527 assert_eq!(value["kind"], "combined");
528 assert_eq!(value["check"]["summary"]["total_issues"], 0);
529 assert_eq!(
530 value["_meta"]["telemetry"]["analysis_run_id"],
531 "run-combined"
532 );
533 }
534}