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> {
279 #[serde(rename = "audit")]
281 Audit(Audit),
282 #[serde(rename = "explain")]
284 Explain(Explain),
285 #[serde(rename = "inspect_target")]
287 Inspect(Inspect),
288 #[serde(rename = "trace")]
290 Trace(Trace),
291 #[serde(rename = "review-envelope")]
293 ReviewEnvelope(ReviewEnvelope),
294 #[serde(rename = "review-reconcile")]
296 ReviewReconcile(ReviewReconcile),
297 #[serde(rename = "coverage-setup")]
299 CoverageSetup(CoverageSetup),
300 #[serde(rename = "coverage-analyze")]
302 CoverageAnalyze(CoverageAnalyze),
303 #[serde(rename = "list-boundaries")]
305 ListBoundaries(ListBoundaries),
306 #[serde(rename = "list-workspaces")]
308 Workspaces(Workspaces),
309 #[serde(rename = "health")]
311 Health(Health),
312 #[serde(rename = "dupes")]
314 Dupes(Dupes),
315 #[serde(rename = "dead-code-grouped")]
317 CheckGrouped(CheckGrouped),
318 #[serde(rename = "impact")]
320 Impact(Impact),
321 #[serde(rename = "impact-cross-repo")]
323 ImpactCrossRepo(ImpactCrossRepo),
324 #[serde(rename = "security")]
326 SecuritySummary(SecuritySummary),
327 #[serde(rename = "security")]
329 Security(Security),
330 #[serde(rename = "security-survivors")]
332 SecuritySurvivors(SecuritySurvivors),
333 #[serde(rename = "security-blind-spots")]
335 SecurityBlindSpots(SecurityBlindSpots),
336 #[serde(rename = "dead-code")]
338 Check(Check),
339 #[serde(rename = "combined")]
341 Combined(Combined),
342 #[serde(rename = "feature-flags")]
344 FeatureFlags(FeatureFlags),
345 #[serde(rename = "audit-brief")]
347 AuditBrief(AuditBrief),
348 #[serde(rename = "decision-surface")]
350 DecisionSurface(DecisionSurface),
351 #[serde(rename = "review-walkthrough-guide")]
353 WalkthroughGuide(WalkthroughGuide),
354 #[serde(rename = "review-walkthrough-validation")]
356 WalkthroughValidation(WalkthroughValidation),
357}
358
359#[cfg(test)]
360mod tests {
361 use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
362 use serde_json::json;
363
364 use super::*;
365
366 #[test]
367 fn apply_root_kind_sets_tagged_mode() {
368 let mut value = json!({});
369
370 apply_root_kind(&mut value, "dead_code", RootEnvelopeMode::Tagged);
371
372 assert_eq!(value["kind"], "dead_code");
373 }
374
375 #[test]
376 fn attach_telemetry_meta_sets_analysis_run_id() {
377 let mut value = json!({});
378
379 attach_telemetry_meta(&mut value, Some("run-123"));
380
381 assert_eq!(
382 value["_meta"]["telemetry"]["analysis_run_id"],
383 json!("run-123")
384 );
385 }
386
387 #[test]
388 fn attach_telemetry_meta_preserves_non_object_roots() {
389 let mut value = json!(["not", "an", "object"]);
390
391 attach_telemetry_meta(&mut value, Some("run-123"));
392
393 assert_eq!(value, json!(["not", "an", "object"]));
394 }
395
396 #[test]
397 fn serialize_named_json_output_applies_explicit_kind() {
398 let value = serialize_named_json_output(
399 json!({
400 "schema_version": 1,
401 "summary": { "total": 0 }
402 }),
403 "example",
404 RootEnvelopeMode::Tagged,
405 )
406 .expect("named output should serialize");
407
408 assert_eq!(value["kind"], "example");
409 assert_eq!(value["summary"]["total"], 0);
410 }
411
412 #[test]
413 fn serialize_audit_json_output_applies_audit_kind() {
414 let value = serialize_audit_json_output(
415 AuditOutput {
416 schema_version: SchemaVersion(7),
417 version: ToolVersion("1.2.3".to_string()),
418 command: AuditCommand::Audit,
419 verdict: "pass",
420 changed_files_count: 2,
421 base_ref: "origin/main".to_string(),
422 base_description: Some("merge-base with origin/main".to_string()),
423 head_sha: Some("abc123".to_string()),
424 elapsed_ms: ElapsedMs(42),
425 base_snapshot_skipped: Some(false),
426 summary: json!({ "dead_code_issues": 0 }),
427 attribution: json!({ "gate": "new_only" }),
428 meta: None,
429 dead_code: Some(json!({ "summary": { "total_issues": 0 } })),
430 duplication: None::<serde_json::Value>,
431 complexity: None::<serde_json::Value>,
432 next_steps: Vec::new(),
433 },
434 RootEnvelopeMode::Tagged,
435 Some("run-audit"),
436 )
437 .expect("audit output should serialize");
438
439 assert_eq!(value["kind"], "audit");
440 assert_eq!(value["command"], "audit");
441 assert_eq!(value["dead_code"]["summary"]["total_issues"], 0);
442 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-audit");
443 }
444
445 #[test]
446 fn serialize_combined_json_output_applies_combined_kind() {
447 let value = serialize_combined_json_output(
448 CombinedOutput {
449 schema_version: SchemaVersion(7),
450 version: ToolVersion("1.2.3".to_string()),
451 elapsed_ms: ElapsedMs(42),
452 meta: None,
453 check: Some(json!({ "summary": { "total_issues": 0 } })),
454 dupes: None::<serde_json::Value>,
455 health: None::<serde_json::Value>,
456 next_steps: Vec::new(),
457 },
458 RootEnvelopeMode::Tagged,
459 Some("run-combined"),
460 )
461 .expect("combined output should serialize");
462
463 assert_eq!(value["kind"], "combined");
464 assert_eq!(value["check"]["summary"]["total_issues"], 0);
465 assert_eq!(
466 value["_meta"]["telemetry"]["analysis_run_id"],
467 "run-combined"
468 );
469 }
470}