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}
12
13pub fn serialize_json_root_output<T: Serialize>(
21 output: T,
22 mode: RootEnvelopeMode,
23) -> Result<serde_json::Value, serde_json::Error> {
24 let _ = mode;
25 serde_json::to_value(output)
26}
27
28pub fn serialize_named_json_output<T: Serialize>(
39 output: T,
40 kind: &'static str,
41 mode: RootEnvelopeMode,
42) -> Result<serde_json::Value, serde_json::Error> {
43 let mut value = serde_json::to_value(output)?;
44 apply_root_kind(&mut value, kind, mode);
45 Ok(value)
46}
47
48pub fn serialize_audit_json_output<
56 Verdict,
57 Summary,
58 Attribution,
59 DeadCode,
60 Duplication,
61 Complexity,
62>(
63 output: AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity>,
64 mode: RootEnvelopeMode,
65 analysis_run_id: Option<&str>,
66) -> Result<serde_json::Value, serde_json::Error>
67where
68 Verdict: Serialize,
69 Summary: Serialize,
70 Attribution: Serialize,
71 DeadCode: Serialize,
72 Duplication: Serialize,
73 Complexity: Serialize,
74{
75 let mut value = serde_json::to_value(output)?;
76 apply_root_kind(&mut value, "audit", mode);
77 attach_telemetry_meta(&mut value, analysis_run_id);
78 Ok(value)
79}
80
81pub fn serialize_combined_json_output<Check, Dupes, Health>(
89 output: CombinedOutput<Check, Dupes, Health>,
90 mode: RootEnvelopeMode,
91 analysis_run_id: Option<&str>,
92) -> Result<serde_json::Value, serde_json::Error>
93where
94 Check: Serialize,
95 Dupes: Serialize,
96 Health: Serialize,
97{
98 let mut value = serde_json::to_value(output)?;
99 apply_root_kind(&mut value, "combined", mode);
100 attach_telemetry_meta(&mut value, analysis_run_id);
101 Ok(value)
102}
103
104pub fn apply_root_kind(value: &mut serde_json::Value, kind: &'static str, mode: RootEnvelopeMode) {
106 let _ = mode;
107 if let serde_json::Value::Object(map) = value {
108 let existing = std::mem::take(map);
109 map.insert(
110 "kind".to_string(),
111 serde_json::Value::String(kind.to_string()),
112 );
113 map.extend(existing);
114 }
115}
116
117pub fn attach_telemetry_meta(value: &mut serde_json::Value, analysis_run_id: Option<&str>) {
119 let Some(analysis_run_id) = analysis_run_id else {
120 return;
121 };
122 let serde_json::Value::Object(map) = value else {
123 return;
124 };
125 let meta = map
126 .entry("_meta".to_string())
127 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
128 if !meta.is_object() {
129 *meta = serde_json::Value::Object(serde_json::Map::new());
130 }
131 if let serde_json::Value::Object(meta_map) = meta {
132 meta_map.insert(
133 "telemetry".to_string(),
134 serde_json::json!({ "analysis_run_id": analysis_run_id }),
135 );
136 }
137}
138
139#[derive(Debug, Clone, Serialize)]
141#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
142#[cfg_attr(feature = "schema", schemars(title = "fallow audit --format json"))]
143pub struct AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity> {
144 pub schema_version: SchemaVersion,
145 pub version: ToolVersion,
146 pub command: AuditCommand,
147 pub verdict: Verdict,
148 pub changed_files_count: u32,
149 pub base_ref: String,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub base_description: Option<String>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub head_sha: Option<String>,
159 pub elapsed_ms: ElapsedMs,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub base_snapshot_skipped: Option<bool>,
162 pub summary: Summary,
163 pub attribution: Attribution,
164 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
165 pub meta: Option<Meta>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub dead_code: Option<DeadCode>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub duplication: Option<Duplication>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub complexity: Option<Complexity>,
172 #[serde(default, skip_serializing_if = "Vec::is_empty")]
175 pub next_steps: Vec<NextStep>,
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
180#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
181#[serde(rename_all = "lowercase")]
182pub enum AuditCommand {
183 Audit,
184}
185
186#[derive(Debug, Clone, Serialize)]
188#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
189#[cfg_attr(
190 feature = "schema",
191 schemars(title = "fallow --format json (bare, combined)")
192)]
193pub struct CombinedOutput<Check, Dupes, Health> {
194 pub schema_version: SchemaVersion,
195 pub version: ToolVersion,
196 pub elapsed_ms: ElapsedMs,
197 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
198 pub meta: Option<CombinedMeta>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub check: Option<Check>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub dupes: Option<Dupes>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub health: Option<Health>,
205 #[serde(default, skip_serializing_if = "Vec::is_empty")]
208 pub next_steps: Vec<NextStep>,
209}
210
211#[derive(Debug, Clone, Serialize)]
213#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
214pub struct CombinedMeta {
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub check: Option<Meta>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub dupes: Option<Meta>,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub health: Option<Meta>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub telemetry: Option<TelemetryMeta>,
223}
224
225#[derive(Debug, Clone, Serialize)]
241#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
242#[cfg_attr(
243 feature = "schema",
244 schemars(title = "fallow --format json (typed root)")
245)]
246#[serde(tag = "kind")]
247#[allow(
248 dead_code,
249 reason = "some variants are schema-emit only, but runtime roots serialize through this enum where practical"
250)]
251pub enum FallowOutput<
252 Audit,
253 Explain,
254 Inspect,
255 Trace,
256 ReviewEnvelope,
257 ReviewReconcile,
258 CoverageSetup,
259 CoverageAnalyze,
260 ListBoundaries,
261 Workspaces,
262 Health,
263 Dupes,
264 CheckGrouped,
265 Impact,
266 ImpactCrossRepo,
267 SecuritySummary,
268 Security,
269 SecuritySurvivors,
270 SecurityBlindSpots,
271 Check,
272 Combined,
273 FeatureFlags,
274 AuditBrief,
275 DecisionSurface,
276 WalkthroughGuide,
277 WalkthroughValidation,
278 SuppressionInventory,
279 TypeAwareStatus,
280> {
281 #[serde(rename = "audit")]
283 Audit(Audit),
284 #[serde(rename = "explain")]
286 Explain(Explain),
287 #[serde(rename = "inspect_target")]
289 Inspect(Inspect),
290 #[serde(rename = "trace")]
292 Trace(Trace),
293 #[serde(rename = "review-envelope")]
295 ReviewEnvelope(ReviewEnvelope),
296 #[serde(rename = "review-reconcile")]
298 ReviewReconcile(ReviewReconcile),
299 #[serde(rename = "coverage-setup")]
301 CoverageSetup(CoverageSetup),
302 #[serde(rename = "coverage-analyze")]
304 CoverageAnalyze(CoverageAnalyze),
305 #[serde(rename = "list-boundaries")]
307 ListBoundaries(ListBoundaries),
308 #[serde(rename = "list-workspaces")]
310 Workspaces(Workspaces),
311 #[serde(rename = "health")]
313 Health(Health),
314 #[serde(rename = "dupes")]
316 Dupes(Dupes),
317 #[serde(rename = "dead-code-grouped")]
319 CheckGrouped(CheckGrouped),
320 #[serde(rename = "impact")]
322 Impact(Impact),
323 #[serde(rename = "impact-cross-repo")]
325 ImpactCrossRepo(ImpactCrossRepo),
326 #[serde(rename = "security")]
328 SecuritySummary(SecuritySummary),
329 #[serde(rename = "security")]
331 Security(Security),
332 #[serde(rename = "security-survivors")]
334 SecuritySurvivors(SecuritySurvivors),
335 #[serde(rename = "security-blind-spots")]
337 SecurityBlindSpots(SecurityBlindSpots),
338 #[serde(rename = "dead-code")]
340 Check(Check),
341 #[serde(rename = "combined")]
343 Combined(Combined),
344 #[serde(rename = "feature-flags")]
346 FeatureFlags(FeatureFlags),
347 #[serde(rename = "audit-brief")]
349 AuditBrief(AuditBrief),
350 #[serde(rename = "decision-surface")]
352 DecisionSurface(DecisionSurface),
353 #[serde(rename = "review-walkthrough-guide")]
355 WalkthroughGuide(WalkthroughGuide),
356 #[serde(rename = "review-walkthrough-validation")]
358 WalkthroughValidation(WalkthroughValidation),
359 #[serde(rename = "suppression-inventory")]
361 SuppressionInventory(SuppressionInventory),
362 #[serde(rename = "type-aware-status")]
364 TypeAwareStatus(TypeAwareStatus),
365}
366
367#[cfg(test)]
368mod tests {
369 use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
370 use serde_json::json;
371
372 use super::*;
373
374 #[test]
375 fn apply_root_kind_sets_tagged_mode() {
376 let mut value = json!({});
377
378 apply_root_kind(&mut value, "dead_code", RootEnvelopeMode::Tagged);
379
380 assert_eq!(value["kind"], "dead_code");
381 }
382
383 #[test]
384 fn attach_telemetry_meta_sets_analysis_run_id() {
385 let mut value = json!({});
386
387 attach_telemetry_meta(&mut value, Some("run-123"));
388
389 assert_eq!(
390 value["_meta"]["telemetry"]["analysis_run_id"],
391 json!("run-123")
392 );
393 }
394
395 #[test]
396 fn attach_telemetry_meta_preserves_non_object_roots() {
397 let mut value = json!(["not", "an", "object"]);
398
399 attach_telemetry_meta(&mut value, Some("run-123"));
400
401 assert_eq!(value, json!(["not", "an", "object"]));
402 }
403
404 #[test]
405 fn serialize_named_json_output_applies_explicit_kind() {
406 let value = serialize_named_json_output(
407 json!({
408 "schema_version": 1,
409 "summary": { "total": 0 }
410 }),
411 "example",
412 RootEnvelopeMode::Tagged,
413 )
414 .expect("named output should serialize");
415
416 assert_eq!(value["kind"], "example");
417 assert_eq!(value["summary"]["total"], 0);
418 }
419
420 #[test]
421 fn serialize_audit_json_output_applies_audit_kind() {
422 let value = serialize_audit_json_output(
423 AuditOutput {
424 schema_version: SchemaVersion(7),
425 version: ToolVersion("1.2.3".to_string()),
426 command: AuditCommand::Audit,
427 verdict: "pass",
428 changed_files_count: 2,
429 base_ref: "origin/main".to_string(),
430 base_description: Some("merge-base with origin/main".to_string()),
431 head_sha: Some("abc123".to_string()),
432 elapsed_ms: ElapsedMs(42),
433 base_snapshot_skipped: Some(false),
434 summary: json!({ "dead_code_issues": 0 }),
435 attribution: json!({ "gate": "new_only" }),
436 meta: None,
437 dead_code: Some(json!({ "summary": { "total_issues": 0 } })),
438 duplication: None::<serde_json::Value>,
439 complexity: None::<serde_json::Value>,
440 next_steps: Vec::new(),
441 },
442 RootEnvelopeMode::Tagged,
443 Some("run-audit"),
444 )
445 .expect("audit output should serialize");
446
447 assert_eq!(value["kind"], "audit");
448 assert_eq!(value["command"], "audit");
449 assert_eq!(value["dead_code"]["summary"]["total_issues"], 0);
450 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-audit");
451 }
452
453 #[test]
454 fn serialize_combined_json_output_applies_combined_kind() {
455 let value = serialize_combined_json_output(
456 CombinedOutput {
457 schema_version: SchemaVersion(7),
458 version: ToolVersion("1.2.3".to_string()),
459 elapsed_ms: ElapsedMs(42),
460 meta: None,
461 check: Some(json!({ "summary": { "total_issues": 0 } })),
462 dupes: None::<serde_json::Value>,
463 health: None::<serde_json::Value>,
464 next_steps: Vec::new(),
465 },
466 RootEnvelopeMode::Tagged,
467 Some("run-combined"),
468 )
469 .expect("combined output should serialize");
470
471 assert_eq!(value["kind"], "combined");
472 assert_eq!(value["check"]["summary"]["total_issues"], 0);
473 assert_eq!(
474 value["_meta"]["telemetry"]["analysis_run_id"],
475 "run-combined"
476 );
477 }
478}