1use fallow_types::envelope::{ElapsedMs, Meta, SchemaVersion, TelemetryMeta, ToolVersion};
4use fallow_types::output::NextStep;
5use serde::Serialize;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum RootEnvelopeMode {
10 Tagged,
11 Legacy,
12}
13
14impl RootEnvelopeMode {
15 #[must_use]
17 pub const fn from_legacy(legacy_envelope: bool) -> Self {
18 if legacy_envelope {
19 Self::Legacy
20 } else {
21 Self::Tagged
22 }
23 }
24}
25
26pub fn serialize_json_root_output<T: Serialize>(
34 output: T,
35 mode: RootEnvelopeMode,
36) -> Result<serde_json::Value, serde_json::Error> {
37 let mut value = serde_json::to_value(output)?;
38 if mode == RootEnvelopeMode::Legacy {
39 remove_root_kind(&mut value);
40 }
41 Ok(value)
42}
43
44pub fn serialize_named_json_output<T: Serialize>(
55 output: T,
56 kind: &'static str,
57 mode: RootEnvelopeMode,
58) -> Result<serde_json::Value, serde_json::Error> {
59 let mut value = serde_json::to_value(output)?;
60 apply_root_kind(&mut value, kind, mode);
61 Ok(value)
62}
63
64pub fn serialize_audit_json_output<
72 Verdict,
73 Summary,
74 Attribution,
75 DeadCode,
76 Duplication,
77 Complexity,
78>(
79 output: AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity>,
80 mode: RootEnvelopeMode,
81 analysis_run_id: Option<&str>,
82) -> Result<serde_json::Value, serde_json::Error>
83where
84 Verdict: Serialize,
85 Summary: Serialize,
86 Attribution: Serialize,
87 DeadCode: Serialize,
88 Duplication: Serialize,
89 Complexity: Serialize,
90{
91 let mut value = serde_json::to_value(output)?;
92 apply_root_kind(&mut value, "audit", mode);
93 attach_telemetry_meta(&mut value, analysis_run_id);
94 Ok(value)
95}
96
97pub fn serialize_combined_json_output<Check, Dupes, Health>(
105 output: CombinedOutput<Check, Dupes, Health>,
106 mode: RootEnvelopeMode,
107 analysis_run_id: Option<&str>,
108) -> Result<serde_json::Value, serde_json::Error>
109where
110 Check: Serialize,
111 Dupes: Serialize,
112 Health: Serialize,
113{
114 let mut value = serde_json::to_value(output)?;
115 apply_root_kind(&mut value, "combined", mode);
116 attach_telemetry_meta(&mut value, analysis_run_id);
117 Ok(value)
118}
119
120pub fn remove_root_kind(value: &mut serde_json::Value) {
123 if let serde_json::Value::Object(map) = value {
124 map.remove("kind");
125 }
126}
127
128pub fn apply_root_kind(value: &mut serde_json::Value, kind: &'static str, mode: RootEnvelopeMode) {
131 if mode == RootEnvelopeMode::Tagged
132 && let serde_json::Value::Object(map) = value
133 {
134 let existing = std::mem::take(map);
135 map.insert(
136 "kind".to_string(),
137 serde_json::Value::String(kind.to_string()),
138 );
139 map.extend(existing);
140 }
141}
142
143pub fn attach_telemetry_meta(value: &mut serde_json::Value, analysis_run_id: Option<&str>) {
145 let Some(analysis_run_id) = analysis_run_id else {
146 return;
147 };
148 let serde_json::Value::Object(map) = value else {
149 return;
150 };
151 let meta = map
152 .entry("_meta".to_string())
153 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
154 if !meta.is_object() {
155 *meta = serde_json::Value::Object(serde_json::Map::new());
156 }
157 if let serde_json::Value::Object(meta_map) = meta {
158 meta_map.insert(
159 "telemetry".to_string(),
160 serde_json::json!({ "analysis_run_id": analysis_run_id }),
161 );
162 }
163}
164
165#[derive(Debug, Clone, Serialize)]
167#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
168#[cfg_attr(feature = "schema", schemars(title = "fallow audit --format json"))]
169pub struct AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity> {
170 pub schema_version: SchemaVersion,
171 pub version: ToolVersion,
172 pub command: AuditCommand,
173 pub verdict: Verdict,
174 pub changed_files_count: u32,
175 pub base_ref: String,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub base_description: Option<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub head_sha: Option<String>,
185 pub elapsed_ms: ElapsedMs,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub base_snapshot_skipped: Option<bool>,
188 pub summary: Summary,
189 pub attribution: Attribution,
190 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
191 pub meta: Option<Meta>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub dead_code: Option<DeadCode>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub duplication: Option<Duplication>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub complexity: Option<Complexity>,
198 #[serde(default, skip_serializing_if = "Vec::is_empty")]
201 pub next_steps: Vec<NextStep>,
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
206#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
207#[serde(rename_all = "lowercase")]
208pub enum AuditCommand {
209 Audit,
210}
211
212#[derive(Debug, Clone, Serialize)]
214#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
215#[cfg_attr(
216 feature = "schema",
217 schemars(title = "fallow --format json (bare, combined)")
218)]
219pub struct CombinedOutput<Check, Dupes, Health> {
220 pub schema_version: SchemaVersion,
221 pub version: ToolVersion,
222 pub elapsed_ms: ElapsedMs,
223 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
224 pub meta: Option<CombinedMeta>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub check: Option<Check>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub dupes: Option<Dupes>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub health: Option<Health>,
231 #[serde(default, skip_serializing_if = "Vec::is_empty")]
234 pub next_steps: Vec<NextStep>,
235}
236
237#[derive(Debug, Clone, Serialize)]
239#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
240pub struct CombinedMeta {
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub check: Option<Meta>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub dupes: Option<Meta>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub health: Option<Meta>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub telemetry: Option<TelemetryMeta>,
249}
250
251#[derive(Debug, Clone, Serialize)]
269#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
270#[cfg_attr(
271 feature = "schema",
272 schemars(title = "fallow --format json (typed root)")
273)]
274#[serde(tag = "kind")]
275#[allow(
276 dead_code,
277 reason = "some variants are schema-emit only, but runtime roots serialize through this enum where practical"
278)]
279pub enum FallowOutput<
280 Audit,
281 Explain,
282 Inspect,
283 Trace,
284 ReviewEnvelope,
285 ReviewReconcile,
286 CoverageSetup,
287 CoverageAnalyze,
288 ListBoundaries,
289 Workspaces,
290 Health,
291 Dupes,
292 CheckGrouped,
293 Impact,
294 ImpactCrossRepo,
295 SecuritySummary,
296 Security,
297 SecuritySurvivors,
298 SecurityBlindSpots,
299 Check,
300 Combined,
301 AuditBrief,
302 DecisionSurface,
303 WalkthroughGuide,
304 WalkthroughValidation,
305> {
306 #[serde(rename = "audit")]
308 Audit(Audit),
309 #[serde(rename = "explain")]
311 Explain(Explain),
312 #[serde(rename = "inspect_target")]
314 Inspect(Inspect),
315 #[serde(rename = "trace")]
317 Trace(Trace),
318 #[serde(rename = "review-envelope")]
320 ReviewEnvelope(ReviewEnvelope),
321 #[serde(rename = "review-reconcile")]
323 ReviewReconcile(ReviewReconcile),
324 #[serde(rename = "coverage-setup")]
326 CoverageSetup(CoverageSetup),
327 #[serde(rename = "coverage-analyze")]
329 CoverageAnalyze(CoverageAnalyze),
330 #[serde(rename = "list-boundaries")]
332 ListBoundaries(ListBoundaries),
333 #[serde(rename = "list-workspaces")]
335 Workspaces(Workspaces),
336 #[serde(rename = "health")]
338 Health(Health),
339 #[serde(rename = "dupes")]
341 Dupes(Dupes),
342 #[serde(rename = "dead-code-grouped")]
344 CheckGrouped(CheckGrouped),
345 #[serde(rename = "impact")]
347 Impact(Impact),
348 #[serde(rename = "impact-cross-repo")]
350 ImpactCrossRepo(ImpactCrossRepo),
351 #[serde(rename = "security")]
353 SecuritySummary(SecuritySummary),
354 #[serde(rename = "security")]
356 Security(Security),
357 #[serde(rename = "security-survivors")]
359 SecuritySurvivors(SecuritySurvivors),
360 #[serde(rename = "security-blind-spots")]
362 SecurityBlindSpots(SecurityBlindSpots),
363 #[serde(rename = "dead-code")]
365 Check(Check),
366 #[serde(rename = "combined")]
368 Combined(Combined),
369 #[serde(rename = "audit-brief")]
371 AuditBrief(AuditBrief),
372 #[serde(rename = "decision-surface")]
374 DecisionSurface(DecisionSurface),
375 #[serde(rename = "review-walkthrough-guide")]
377 WalkthroughGuide(WalkthroughGuide),
378 #[serde(rename = "review-walkthrough-validation")]
380 WalkthroughValidation(WalkthroughValidation),
381}
382
383#[cfg(test)]
384mod tests {
385 use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
386 use serde_json::json;
387
388 use super::*;
389
390 #[test]
391 fn root_envelope_mode_maps_legacy_flag() {
392 assert_eq!(
393 RootEnvelopeMode::from_legacy(false),
394 RootEnvelopeMode::Tagged
395 );
396 assert_eq!(
397 RootEnvelopeMode::from_legacy(true),
398 RootEnvelopeMode::Legacy
399 );
400 }
401
402 #[test]
403 fn legacy_mode_removes_only_root_kind() {
404 let mut value = json!({
405 "kind": "root",
406 "action": {
407 "kind": "suppress"
408 }
409 });
410
411 remove_root_kind(&mut value);
412
413 assert!(value.get("kind").is_none());
414 assert_eq!(value["action"]["kind"], "suppress");
415 }
416
417 #[test]
418 fn apply_root_kind_respects_legacy_mode() {
419 let mut value = json!({});
420
421 apply_root_kind(&mut value, "dead_code", RootEnvelopeMode::Legacy);
422
423 assert!(value.get("kind").is_none());
424 }
425
426 #[test]
427 fn apply_root_kind_sets_tagged_mode() {
428 let mut value = json!({});
429
430 apply_root_kind(&mut value, "dead_code", RootEnvelopeMode::Tagged);
431
432 assert_eq!(value["kind"], "dead_code");
433 }
434
435 #[test]
436 fn attach_telemetry_meta_sets_analysis_run_id() {
437 let mut value = json!({});
438
439 attach_telemetry_meta(&mut value, Some("run-123"));
440
441 assert_eq!(
442 value["_meta"]["telemetry"]["analysis_run_id"],
443 json!("run-123")
444 );
445 }
446
447 #[test]
448 fn attach_telemetry_meta_preserves_non_object_roots() {
449 let mut value = json!(["not", "an", "object"]);
450
451 attach_telemetry_meta(&mut value, Some("run-123"));
452
453 assert_eq!(value, json!(["not", "an", "object"]));
454 }
455
456 #[test]
457 fn serialize_json_root_output_removes_root_kind_in_legacy_mode() {
458 let value = serialize_json_root_output(
459 json!({
460 "kind": "combined",
461 "schema_version": 1
462 }),
463 RootEnvelopeMode::Legacy,
464 )
465 .expect("root should serialize");
466
467 assert!(value.get("kind").is_none());
468 assert_eq!(value["schema_version"], 1);
469 }
470
471 #[test]
472 fn serialize_named_json_output_applies_explicit_kind() {
473 let value = serialize_named_json_output(
474 json!({
475 "schema_version": 1,
476 "summary": { "total": 0 }
477 }),
478 "example",
479 RootEnvelopeMode::Tagged,
480 )
481 .expect("named output should serialize");
482
483 assert_eq!(value["kind"], "example");
484 assert_eq!(value["summary"]["total"], 0);
485 }
486
487 #[test]
488 fn serialize_audit_json_output_applies_audit_kind() {
489 let value = serialize_audit_json_output(
490 AuditOutput {
491 schema_version: SchemaVersion(7),
492 version: ToolVersion("1.2.3".to_string()),
493 command: AuditCommand::Audit,
494 verdict: "pass",
495 changed_files_count: 2,
496 base_ref: "origin/main".to_string(),
497 base_description: Some("merge-base with origin/main".to_string()),
498 head_sha: Some("abc123".to_string()),
499 elapsed_ms: ElapsedMs(42),
500 base_snapshot_skipped: Some(false),
501 summary: json!({ "dead_code_issues": 0 }),
502 attribution: json!({ "gate": "new_only" }),
503 meta: None,
504 dead_code: Some(json!({ "summary": { "total_issues": 0 } })),
505 duplication: None::<serde_json::Value>,
506 complexity: None::<serde_json::Value>,
507 next_steps: Vec::new(),
508 },
509 RootEnvelopeMode::Tagged,
510 Some("run-audit"),
511 )
512 .expect("audit output should serialize");
513
514 assert_eq!(value["kind"], "audit");
515 assert_eq!(value["command"], "audit");
516 assert_eq!(value["dead_code"]["summary"]["total_issues"], 0);
517 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-audit");
518 }
519
520 #[test]
521 fn serialize_combined_json_output_applies_combined_kind() {
522 let value = serialize_combined_json_output(
523 CombinedOutput {
524 schema_version: SchemaVersion(7),
525 version: ToolVersion("1.2.3".to_string()),
526 elapsed_ms: ElapsedMs(42),
527 meta: None,
528 check: Some(json!({ "summary": { "total_issues": 0 } })),
529 dupes: None::<serde_json::Value>,
530 health: None::<serde_json::Value>,
531 next_steps: Vec::new(),
532 },
533 RootEnvelopeMode::Tagged,
534 Some("run-combined"),
535 )
536 .expect("combined output should serialize");
537
538 assert_eq!(value["kind"], "combined");
539 assert_eq!(value["check"]["summary"]["total_issues"], 0);
540 assert_eq!(
541 value["_meta"]["telemetry"]["analysis_run_id"],
542 "run-combined"
543 );
544 }
545}