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