1use std::path::{Path, PathBuf};
4
5use fallow_types::results::{ActiveSuppression, StaleSuppression, SuppressionOrigin};
6use fallow_types::serde_path;
7use rustc_hash::{FxHashMap, FxHashSet};
8use serde::Serialize;
9
10use crate::root_envelopes::{attach_telemetry_meta, serialize_named_json_output};
11
12#[derive(Debug, Clone, Copy, Serialize)]
15#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
16pub enum SuppressionInventorySchemaVersion {
17 #[serde(rename = "1")]
19 V1,
20}
21
22#[derive(Debug, Clone, Serialize)]
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
30#[cfg_attr(
31 feature = "schema",
32 schemars(title = "fallow suppressions --format json")
33)]
34pub struct SuppressionInventoryOutput {
35 pub schema_version: SuppressionInventorySchemaVersion,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub request_outcomes: Option<crate::RequestOutcomes>,
52 pub summary: SuppressionInventorySummary,
54 pub files: Vec<SuppressionInventoryFile>,
56}
57
58#[derive(Debug, Clone, Serialize)]
60#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
61pub struct SuppressionInventorySummary {
62 pub total: usize,
64 pub files: usize,
66 pub without_reason: usize,
68 pub stale: usize,
72 pub by_kind: Vec<SuppressionKindCount>,
76}
77
78#[derive(Debug, Clone, Serialize)]
80#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
81pub struct SuppressionKindCount {
82 pub kind: Option<String>,
86 pub count: usize,
88}
89
90#[derive(Debug, Clone, Serialize)]
92#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
93pub struct SuppressionInventoryFile {
94 #[serde(serialize_with = "serde_path::serialize")]
96 pub path: PathBuf,
97 pub suppressions: Vec<SuppressionInventoryEntry>,
99}
100
101#[derive(Debug, Clone, Serialize)]
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104pub struct SuppressionInventoryEntry {
105 pub line: u32,
107 pub kind: Option<String>,
113 pub level: SuppressionInventoryLevel,
116 pub origin: SuppressionInventoryOrigin,
120 pub reason: Option<String>,
122 pub reason_present: bool,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
128#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
129#[serde(rename_all = "lowercase")]
130pub enum SuppressionInventoryLevel {
131 File,
133 Line,
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
139#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
140#[serde(rename_all = "snake_case")]
141pub enum SuppressionInventoryOrigin {
142 Comment,
144}
145
146#[derive(Clone)]
148pub struct SuppressionInventoryOutputInput<'a> {
149 pub active: &'a [ActiveSuppression],
151 pub stale: &'a [StaleSuppression],
154 pub root: &'a Path,
156 pub request_outcomes: Option<crate::RequestOutcomes>,
159}
160
161#[must_use]
164pub fn build_suppression_inventory_output(
165 input: SuppressionInventoryOutputInput<'_>,
166) -> SuppressionInventoryOutput {
167 let mut sorted: Vec<&ActiveSuppression> = input.active.iter().collect();
168 sorted.sort_by(|a, b| {
169 a.path
170 .cmp(&b.path)
171 .then(a.comment_line.cmp(&b.comment_line))
172 .then(a.kind.cmp(&b.kind))
173 });
174
175 let files = group_by_file(&sorted, input.root);
176 let summary = build_summary(&sorted, &files, input.stale);
177
178 SuppressionInventoryOutput {
179 schema_version: SuppressionInventorySchemaVersion::V1,
180 request_outcomes: input.request_outcomes,
181 summary,
182 files,
183 }
184}
185
186fn group_by_file(sorted: &[&ActiveSuppression], root: &Path) -> Vec<SuppressionInventoryFile> {
188 let mut files: Vec<SuppressionInventoryFile> = Vec::new();
189 let mut current: Option<&Path> = None;
190 for supp in sorted {
191 if current != Some(supp.path.as_path()) {
192 files.push(SuppressionInventoryFile {
193 path: supp
194 .path
195 .strip_prefix(root)
196 .unwrap_or(&supp.path)
197 .to_path_buf(),
198 suppressions: Vec::new(),
199 });
200 current = Some(supp.path.as_path());
201 }
202 if let Some(file) = files.last_mut() {
203 file.suppressions.push(inventory_entry(supp));
204 }
205 }
206 files
207}
208
209fn inventory_entry(supp: &ActiveSuppression) -> SuppressionInventoryEntry {
210 SuppressionInventoryEntry {
211 line: supp.comment_line,
212 kind: supp.kind.clone(),
213 level: if supp.is_file_level {
214 SuppressionInventoryLevel::File
215 } else {
216 SuppressionInventoryLevel::Line
217 },
218 origin: SuppressionInventoryOrigin::Comment,
219 reason: supp.reason.clone(),
220 reason_present: supp.reason.is_some(),
221 }
222}
223
224fn build_summary(
225 sorted: &[&ActiveSuppression],
226 files: &[SuppressionInventoryFile],
227 stale: &[StaleSuppression],
228) -> SuppressionInventorySummary {
229 let without_reason = sorted.iter().filter(|s| s.reason.is_none()).count();
230
231 let mut kind_counts: FxHashMap<Option<&str>, usize> = FxHashMap::default();
232 for supp in sorted {
233 *kind_counts.entry(supp.kind.as_deref()).or_default() += 1;
234 }
235 let mut by_kind: Vec<SuppressionKindCount> = kind_counts
236 .into_iter()
237 .map(|(kind, count)| SuppressionKindCount {
238 kind: kind.map(str::to_owned),
239 count,
240 })
241 .collect();
242 by_kind.sort_by(|a, b| b.count.cmp(&a.count).then(a.kind.cmp(&b.kind)));
243
244 SuppressionInventorySummary {
245 total: sorted.len(),
246 files: files.len(),
247 without_reason,
248 stale: stale_join_count(sorted, stale),
249 by_kind,
250 }
251}
252
253fn stale_join_count(sorted: &[&ActiveSuppression], stale: &[StaleSuppression]) -> usize {
259 let active_keys: FxHashSet<(&Path, Option<&str>)> = sorted
260 .iter()
261 .map(|s| (s.path.as_path(), s.kind.as_deref()))
262 .collect();
263
264 stale
265 .iter()
266 .filter(|entry| !entry.missing_reason)
267 .filter(|entry| match &entry.origin {
268 SuppressionOrigin::Comment { issue_kind, .. } => {
269 active_keys.contains(&(entry.path.as_path(), issue_kind.as_deref()))
270 }
271 SuppressionOrigin::JsdocTag { .. } => false,
272 })
273 .count()
274}
275
276pub fn serialize_suppression_inventory_json_output(
283 output: SuppressionInventoryOutput,
284 analysis_run_id: Option<&str>,
285) -> Result<serde_json::Value, serde_json::Error> {
286 let mut value = serialize_named_json_output(output, "suppression-inventory")?;
287 attach_telemetry_meta(&mut value, analysis_run_id);
288 Ok(value)
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use fallow_types::output::IssueAction;
295
296 fn active(
297 path: &str,
298 line: u32,
299 kind: Option<&str>,
300 reason: Option<&str>,
301 file_level: bool,
302 ) -> ActiveSuppression {
303 ActiveSuppression {
304 path: PathBuf::from(path),
305 kind: kind.map(str::to_owned),
306 is_file_level: file_level,
307 reason: reason.map(str::to_owned),
308 comment_line: line,
309 }
310 }
311
312 fn stale(path: &str, line: u32, kind: Option<&str>, missing_reason: bool) -> StaleSuppression {
313 StaleSuppression {
314 finding_id: None,
315 path: PathBuf::from(path),
316 line,
317 col: 0,
318 origin: SuppressionOrigin::Comment {
319 issue_kind: kind.map(str::to_owned),
320 reason: None,
321 is_file_level: false,
322 kind_known: true,
323 },
324 missing_reason,
325 actions: Vec::<IssueAction>::new(),
326 effective_severity: None,
327 }
328 }
329
330 #[test]
331 fn inventory_groups_sorts_and_relativizes() {
332 let actives = vec![
333 active("/repo/src/b.ts", 9, Some("unused-export"), None, false),
334 active(
335 "/repo/src/a.ts",
336 4,
337 Some("unused-export"),
338 Some("public compatibility export"),
339 false,
340 ),
341 active("/repo/src/a.ts", 2, None, None, true),
342 ];
343 let output = build_suppression_inventory_output(SuppressionInventoryOutputInput {
344 active: &actives,
345 stale: &[],
346 root: Path::new("/repo"),
347 request_outcomes: None,
348 });
349
350 assert_eq!(output.summary.total, 3);
351 assert_eq!(output.summary.files, 2);
352 assert_eq!(output.summary.without_reason, 2);
353 assert_eq!(output.files.len(), 2);
354 let first = &output.files[0];
355 assert_eq!(first.path.to_string_lossy().replace('\\', "/"), "src/a.ts");
356 assert_eq!(first.suppressions[0].line, 2);
357 assert_eq!(first.suppressions[0].kind, None);
358 assert_eq!(first.suppressions[0].level, SuppressionInventoryLevel::File);
359 assert_eq!(first.suppressions[1].line, 4);
360 assert!(first.suppressions[1].reason_present);
361 }
362
363 #[test]
364 fn by_kind_sorts_by_count_desc_then_kind() {
365 let actives = vec![
366 active("/repo/a.ts", 1, Some("unused-export"), None, false),
367 active("/repo/a.ts", 3, Some("unused-export"), None, false),
368 active("/repo/a.ts", 5, Some("complexity"), None, false),
369 active("/repo/a.ts", 7, None, None, false),
370 ];
371 let output = build_suppression_inventory_output(SuppressionInventoryOutputInput {
372 active: &actives,
373 stale: &[],
374 root: Path::new("/repo"),
375 request_outcomes: None,
376 });
377
378 let by_kind = &output.summary.by_kind;
379 assert_eq!(by_kind[0].kind.as_deref(), Some("unused-export"));
380 assert_eq!(by_kind[0].count, 2);
381 assert_eq!(by_kind[1].kind, None);
383 assert_eq!(by_kind[2].kind.as_deref(), Some("complexity"));
384 }
385
386 #[test]
387 fn stale_join_counts_matching_path_and_kind_only() {
388 let actives = vec![
389 active("/repo/a.ts", 1, Some("unused-export"), None, false),
390 active("/repo/b.ts", 1, Some("complexity"), None, false),
391 ];
392 let stales = vec![
393 stale("/repo/a.ts", 1, Some("unused-export"), false),
395 stale("/repo/a.ts", 1, Some("unused-export"), true),
398 stale("/repo/c.ts", 1, Some("unused-export"), false),
400 ];
401 let output = build_suppression_inventory_output(SuppressionInventoryOutputInput {
402 active: &actives,
403 stale: &stales,
404 root: Path::new("/repo"),
405 request_outcomes: None,
406 });
407
408 assert_eq!(output.summary.stale, 1);
409 }
410
411 #[test]
412 fn json_output_uses_output_owned_root_contract() {
413 let actives = vec![active(
414 "/repo/src/api/client.ts",
415 4,
416 Some("unused-export"),
417 Some("public compatibility export"),
418 false,
419 )];
420 let output = build_suppression_inventory_output(SuppressionInventoryOutputInput {
421 active: &actives,
422 stale: &[],
423 root: Path::new("/repo"),
424 request_outcomes: None,
425 });
426
427 let value = serialize_suppression_inventory_json_output(output, Some("run-suppressions"))
428 .expect("suppression inventory output should serialize");
429
430 assert_eq!(value["kind"], "suppression-inventory");
431 assert_eq!(value["schema_version"], "1");
432 assert_eq!(value["files"][0]["path"], "src/api/client.ts");
433 let entry = &value["files"][0]["suppressions"][0];
434 assert_eq!(entry["line"], 4);
435 assert_eq!(entry["kind"], "unused-export");
436 assert_eq!(entry["level"], "line");
437 assert_eq!(entry["origin"], "comment");
438 assert_eq!(entry["reason"], "public compatibility export");
439 assert_eq!(entry["reason_present"], true);
440 assert_eq!(
441 value["_meta"]["telemetry"]["analysis_run_id"],
442 "run-suppressions"
443 );
444 }
445
446 #[test]
447 fn blanket_marker_serializes_null_kind() {
448 let actives = vec![active("/repo/a.ts", 2, None, None, true)];
449 let output = build_suppression_inventory_output(SuppressionInventoryOutputInput {
450 active: &actives,
451 stale: &[],
452 root: Path::new("/repo"),
453 request_outcomes: None,
454 });
455
456 let value = serialize_suppression_inventory_json_output(output, None)
457 .expect("suppression inventory output should serialize");
458
459 let entry = &value["files"][0]["suppressions"][0];
460 assert!(entry["kind"].is_null(), "blanket kind must stay JSON null");
461 assert_eq!(entry["level"], "file");
462 assert_eq!(entry["reason_present"], false);
463 assert!(entry["reason"].is_null());
464 assert!(value["summary"]["by_kind"][0]["kind"].is_null());
465 }
466}