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