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