1use std::path::Path;
4use std::time::Duration;
5
6use fallow_output::{
7 COMBINED_SCHEMA_VERSION, CombinedMeta, CombinedOutput, HealthReport, RootEnvelopeMode,
8 check_meta, dupes_meta, harmonize_dead_code_health_suppress_line_actions, health_meta,
9 serialize_combined_json_output, strip_root_prefix,
10};
11use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
12use fallow_types::output::NextStep;
13use fallow_types::results::AnalysisResults;
14
15use crate::{
16 CheckJsonExtraOutputs, CheckJsonPayloadInput, DupesReportPayload, serialize_check_json_payload,
17};
18
19pub struct CombinedCheckJsonSection<'a> {
21 pub results: &'a AnalysisResults,
23 pub root: &'a Path,
25 pub elapsed: Duration,
27 pub config_fixable: bool,
30 pub extras: CheckJsonExtraOutputs,
32}
33
34pub struct CombinedJsonOutputInput<'a> {
36 pub check: Option<CombinedCheckJsonSection<'a>>,
38 pub dupes: Option<&'a DupesReportPayload>,
40 pub health: Option<&'a HealthReport>,
42 pub root: &'a Path,
44 pub elapsed: Duration,
46 pub explain: bool,
48 pub type_aware: Option<fallow_types::envelope::TypeAwareMeta>,
51 pub next_steps: Vec<NextStep>,
53 pub envelope_mode: RootEnvelopeMode,
55 pub telemetry_analysis_run_id: Option<&'a str>,
57}
58
59pub fn serialize_combined_json(
65 input: CombinedJsonOutputInput<'_>,
66) -> Result<serde_json::Value, serde_json::Error> {
67 let mut check_results = input.check.as_ref().map(|section| section.results.clone());
68 let mut health_report = input.health.cloned();
69 harmonize_dead_code_health_suppress_line_actions(
70 check_results.as_mut(),
71 health_report.as_mut(),
72 );
73
74 let check = if let Some(section) = input.check {
75 if let Some(results) = check_results.as_ref() {
76 Some(serialize_combined_check_json(section, results)?)
77 } else {
78 None
79 }
80 } else {
81 None
82 };
83 let dupes = serialize_combined_dupes_json(input.dupes, input.root)?;
84 let health = serialize_combined_health_json(health_report.as_ref(), input.root)?;
85
86 let mut meta = input
87 .explain
88 .then(|| combined_meta_for_output(check.is_some(), dupes.is_some(), health.is_some()));
89 if let Some(type_aware) = input.type_aware {
90 let combined_meta = meta.get_or_insert(CombinedMeta {
91 check: None,
92 dupes: None,
93 health: None,
94 telemetry: None,
95 });
96 let check_meta = combined_meta
97 .check
98 .get_or_insert_with(fallow_types::envelope::Meta::default);
99 check_meta.type_aware = Some(type_aware);
100 }
101
102 let output = CombinedOutput {
103 schema_version: SchemaVersion(COMBINED_SCHEMA_VERSION),
104 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
105 elapsed_ms: ElapsedMs(elapsed_ms_for_output(input.elapsed)),
106 meta,
107 check,
108 dupes,
109 health,
110 next_steps: input.next_steps,
111 };
112
113 serialize_combined_json_output(output, input.envelope_mode, input.telemetry_analysis_run_id)
114}
115
116fn serialize_combined_check_json(
117 section: CombinedCheckJsonSection<'_>,
118 results: &AnalysisResults,
119) -> Result<serde_json::Value, serde_json::Error> {
120 serialize_check_json_payload(CheckJsonPayloadInput {
121 results,
122 root: section.root,
123 elapsed: section.elapsed,
124 config_fixable: section.config_fixable,
125 extras: section.extras,
126 workspace_diagnostics: Vec::new(),
127 })
128}
129
130pub fn serialize_combined_dupes_json(
137 dupes: Option<&DupesReportPayload>,
138 root: &Path,
139) -> Result<Option<serde_json::Value>, serde_json::Error> {
140 let Some(payload) = dupes else {
141 return Ok(None);
142 };
143 let mut json = serde_json::to_value(payload)?;
144 let root_prefix = format!("{}/", root.display());
145 strip_root_prefix(&mut json, &root_prefix);
146 Ok(Some(json))
147}
148
149pub fn serialize_combined_health_json(
155 health: Option<&HealthReport>,
156 root: &Path,
157) -> Result<Option<serde_json::Value>, serde_json::Error> {
158 let Some(report) = health else {
159 return Ok(None);
160 };
161 let mut json = serde_json::to_value(report)?;
162 let root_prefix = format!("{}/", root.display());
163 strip_root_prefix(&mut json, &root_prefix);
164 Ok(Some(json))
165}
166
167fn elapsed_ms_for_output(elapsed: Duration) -> u64 {
168 u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)
169}
170
171fn combined_meta_for_output(
172 include_check: bool,
173 include_dupes: bool,
174 include_health: bool,
175) -> CombinedMeta {
176 CombinedMeta {
177 check: include_check.then(check_meta),
178 dupes: include_dupes.then(dupes_meta),
179 health: include_health.then(health_meta),
180 telemetry: None,
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use std::time::Duration;
187
188 use fallow_output::{
189 ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding, HealthReport,
190 RootEnvelopeMode,
191 };
192 use fallow_types::output_dead_code::UnusedExportFinding;
193 use fallow_types::output_health::{HealthFindingAction, HealthFindingActionType};
194 use fallow_types::results::{AnalysisResults, UnusedExport};
195
196 use super::{CombinedCheckJsonSection, CombinedJsonOutputInput, serialize_combined_json};
197
198 #[test]
199 fn combined_json_root_contains_stable_envelope_fields() {
200 let root = serialize_combined_json(CombinedJsonOutputInput {
201 check: None,
202 dupes: None,
203 health: None,
204 root: std::path::Path::new("."),
205 elapsed: Duration::from_millis(42),
206 explain: false,
207 type_aware: None,
208 next_steps: Vec::new(),
209 envelope_mode: RootEnvelopeMode::Tagged,
210 telemetry_analysis_run_id: None,
211 })
212 .expect("combined JSON root");
213
214 assert_eq!(
215 root.get("kind").and_then(serde_json::Value::as_str),
216 Some("combined")
217 );
218 assert_eq!(
219 root.get("elapsed_ms").and_then(serde_json::Value::as_u64),
220 Some(42)
221 );
222 assert!(root.get("schema_version").is_some());
223 assert!(root.get("version").is_some());
224 }
225
226 #[test]
227 fn combined_json_harmonizes_dead_code_and_health_suppress_actions_before_serialization() {
228 let root = std::path::Path::new("/project");
229 let path = root.join("src/shared.ts");
230 let mut results = AnalysisResults::default();
231 results
232 .unused_exports
233 .push(UnusedExportFinding::with_actions(UnusedExport {
234 path: path.clone(),
235 export_name: "value".to_string(),
236 is_type_only: false,
237 line: 7,
238 col: 0,
239 span_start: 0,
240 is_re_export: false,
241 }));
242 let health = HealthReport {
243 findings: vec![HealthFinding::new(
244 ComplexityViolation {
245 path,
246 name: "expensive".to_string(),
247 line: 7,
248 col: 0,
249 cyclomatic: 22,
250 cognitive: 18,
251 line_count: 40,
252 param_count: 1,
253 react_hook_count: 0,
254 react_jsx_max_depth: 0,
255 react_prop_count: 0,
256 react_hook_profile: None,
257 exceeded: ExceededThreshold::Both,
258 severity: FindingSeverity::High,
259 crap: None,
260 coverage_pct: None,
261 coverage_tier: None,
262 coverage_source: None,
263 inherited_from: None,
264 component_rollup: None,
265 contributions: Vec::new(),
266 effective_thresholds: None,
267 threshold_source: None,
268 },
269 vec![HealthFindingAction {
270 kind: HealthFindingActionType::SuppressLine,
271 auto_fixable: false,
272 description: "Suppress with an inline comment above the function declaration"
273 .to_string(),
274 note: None,
275 comment: Some("// fallow-ignore-next-line complexity".to_string()),
276 placement: Some("above-function-declaration".to_string()),
277 target_path: None,
278 }],
279 None,
280 )],
281 ..HealthReport::default()
282 };
283
284 let output = serialize_combined_json(CombinedJsonOutputInput {
285 check: Some(CombinedCheckJsonSection {
286 results: &results,
287 root,
288 elapsed: Duration::ZERO,
289 config_fixable: false,
290 extras: crate::CheckJsonExtraOutputs::default(),
291 }),
292 dupes: None,
293 health: Some(&health),
294 root,
295 elapsed: Duration::ZERO,
296 explain: false,
297 type_aware: None,
298 next_steps: Vec::new(),
299 envelope_mode: RootEnvelopeMode::Tagged,
300 telemetry_analysis_run_id: None,
301 })
302 .expect("combined JSON");
303
304 assert_eq!(
305 output["check"]["unused_exports"][0]["actions"][1]["comment"],
306 "// fallow-ignore-next-line unused-export, complexity"
307 );
308 assert_eq!(
309 output["health"]["findings"][0]["actions"][0]["comment"],
310 "// fallow-ignore-next-line unused-export, complexity"
311 );
312 }
313}