use std::path::{Path, PathBuf};
use fallow_types::results::{ActiveSuppression, StaleSuppression, SuppressionOrigin};
use fallow_types::serde_path;
use rustc_hash::{FxHashMap, FxHashSet};
use serde::Serialize;
use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
#[derive(Debug, Clone, Copy, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum SuppressionInventorySchemaVersion {
#[serde(rename = "1")]
V1,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(
feature = "schema",
schemars(title = "fallow suppressions --format json")
)]
pub struct SuppressionInventoryOutput {
pub schema_version: SuppressionInventorySchemaVersion,
pub summary: SuppressionInventorySummary,
pub files: Vec<SuppressionInventoryFile>,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SuppressionInventorySummary {
pub total: usize,
pub files: usize,
pub without_reason: usize,
pub stale: usize,
pub by_kind: Vec<SuppressionKindCount>,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SuppressionKindCount {
pub kind: Option<String>,
pub count: usize,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SuppressionInventoryFile {
#[serde(serialize_with = "serde_path::serialize")]
pub path: PathBuf,
pub suppressions: Vec<SuppressionInventoryEntry>,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SuppressionInventoryEntry {
pub line: u32,
pub kind: Option<String>,
pub level: SuppressionInventoryLevel,
pub origin: SuppressionInventoryOrigin,
pub reason: Option<String>,
pub reason_present: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum SuppressionInventoryLevel {
File,
Line,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum SuppressionInventoryOrigin {
Comment,
}
#[derive(Clone, Copy)]
pub struct SuppressionInventoryOutputInput<'a> {
pub active: &'a [ActiveSuppression],
pub stale: &'a [StaleSuppression],
pub root: &'a Path,
}
#[must_use]
pub fn build_suppression_inventory_output(
input: SuppressionInventoryOutputInput<'_>,
) -> SuppressionInventoryOutput {
let mut sorted: Vec<&ActiveSuppression> = input.active.iter().collect();
sorted.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.comment_line.cmp(&b.comment_line))
.then(a.kind.cmp(&b.kind))
});
let files = group_by_file(&sorted, input.root);
let summary = build_summary(&sorted, &files, input.stale);
SuppressionInventoryOutput {
schema_version: SuppressionInventorySchemaVersion::V1,
summary,
files,
}
}
fn group_by_file(sorted: &[&ActiveSuppression], root: &Path) -> Vec<SuppressionInventoryFile> {
let mut files: Vec<SuppressionInventoryFile> = Vec::new();
let mut current: Option<&Path> = None;
for supp in sorted {
if current != Some(supp.path.as_path()) {
files.push(SuppressionInventoryFile {
path: supp
.path
.strip_prefix(root)
.unwrap_or(&supp.path)
.to_path_buf(),
suppressions: Vec::new(),
});
current = Some(supp.path.as_path());
}
if let Some(file) = files.last_mut() {
file.suppressions.push(inventory_entry(supp));
}
}
files
}
fn inventory_entry(supp: &ActiveSuppression) -> SuppressionInventoryEntry {
SuppressionInventoryEntry {
line: supp.comment_line,
kind: supp.kind.clone(),
level: if supp.is_file_level {
SuppressionInventoryLevel::File
} else {
SuppressionInventoryLevel::Line
},
origin: SuppressionInventoryOrigin::Comment,
reason: supp.reason.clone(),
reason_present: supp.reason.is_some(),
}
}
fn build_summary(
sorted: &[&ActiveSuppression],
files: &[SuppressionInventoryFile],
stale: &[StaleSuppression],
) -> SuppressionInventorySummary {
let without_reason = sorted.iter().filter(|s| s.reason.is_none()).count();
let mut kind_counts: FxHashMap<Option<&str>, usize> = FxHashMap::default();
for supp in sorted {
*kind_counts.entry(supp.kind.as_deref()).or_default() += 1;
}
let mut by_kind: Vec<SuppressionKindCount> = kind_counts
.into_iter()
.map(|(kind, count)| SuppressionKindCount {
kind: kind.map(str::to_owned),
count,
})
.collect();
by_kind.sort_by(|a, b| b.count.cmp(&a.count).then(a.kind.cmp(&b.kind)));
SuppressionInventorySummary {
total: sorted.len(),
files: files.len(),
without_reason,
stale: stale_join_count(sorted, stale),
by_kind,
}
}
fn stale_join_count(sorted: &[&ActiveSuppression], stale: &[StaleSuppression]) -> usize {
let active_keys: FxHashSet<(&Path, Option<&str>)> = sorted
.iter()
.map(|s| (s.path.as_path(), s.kind.as_deref()))
.collect();
stale
.iter()
.filter(|entry| !entry.missing_reason)
.filter(|entry| match &entry.origin {
SuppressionOrigin::Comment { issue_kind, .. } => {
active_keys.contains(&(entry.path.as_path(), issue_kind.as_deref()))
}
SuppressionOrigin::JsdocTag { .. } => false,
})
.count()
}
pub fn serialize_suppression_inventory_json_output(
output: SuppressionInventoryOutput,
mode: RootEnvelopeMode,
analysis_run_id: Option<&str>,
) -> Result<serde_json::Value, serde_json::Error> {
let mut value = serialize_named_json_output(output, "suppression-inventory", mode)?;
attach_telemetry_meta(&mut value, analysis_run_id);
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
use fallow_types::output::IssueAction;
fn active(
path: &str,
line: u32,
kind: Option<&str>,
reason: Option<&str>,
file_level: bool,
) -> ActiveSuppression {
ActiveSuppression {
path: PathBuf::from(path),
kind: kind.map(str::to_owned),
is_file_level: file_level,
reason: reason.map(str::to_owned),
comment_line: line,
}
}
fn stale(path: &str, line: u32, kind: Option<&str>, missing_reason: bool) -> StaleSuppression {
StaleSuppression {
path: PathBuf::from(path),
line,
col: 0,
origin: SuppressionOrigin::Comment {
issue_kind: kind.map(str::to_owned),
reason: None,
is_file_level: false,
kind_known: true,
},
missing_reason,
actions: Vec::<IssueAction>::new(),
}
}
#[test]
fn inventory_groups_sorts_and_relativizes() {
let actives = vec![
active("/repo/src/b.ts", 9, Some("unused-export"), None, false),
active(
"/repo/src/a.ts",
4,
Some("unused-export"),
Some("public compatibility export"),
false,
),
active("/repo/src/a.ts", 2, None, None, true),
];
let output = build_suppression_inventory_output(SuppressionInventoryOutputInput {
active: &actives,
stale: &[],
root: Path::new("/repo"),
});
assert_eq!(output.summary.total, 3);
assert_eq!(output.summary.files, 2);
assert_eq!(output.summary.without_reason, 2);
assert_eq!(output.files.len(), 2);
let first = &output.files[0];
assert_eq!(first.path.to_string_lossy().replace('\\', "/"), "src/a.ts");
assert_eq!(first.suppressions[0].line, 2);
assert_eq!(first.suppressions[0].kind, None);
assert_eq!(first.suppressions[0].level, SuppressionInventoryLevel::File);
assert_eq!(first.suppressions[1].line, 4);
assert!(first.suppressions[1].reason_present);
}
#[test]
fn by_kind_sorts_by_count_desc_then_kind() {
let actives = vec![
active("/repo/a.ts", 1, Some("unused-export"), None, false),
active("/repo/a.ts", 3, Some("unused-export"), None, false),
active("/repo/a.ts", 5, Some("complexity"), None, false),
active("/repo/a.ts", 7, None, None, false),
];
let output = build_suppression_inventory_output(SuppressionInventoryOutputInput {
active: &actives,
stale: &[],
root: Path::new("/repo"),
});
let by_kind = &output.summary.by_kind;
assert_eq!(by_kind[0].kind.as_deref(), Some("unused-export"));
assert_eq!(by_kind[0].count, 2);
assert_eq!(by_kind[1].kind, None);
assert_eq!(by_kind[2].kind.as_deref(), Some("complexity"));
}
#[test]
fn stale_join_counts_matching_path_and_kind_only() {
let actives = vec![
active("/repo/a.ts", 1, Some("unused-export"), None, false),
active("/repo/b.ts", 1, Some("complexity"), None, false),
];
let stales = vec![
stale("/repo/a.ts", 1, Some("unused-export"), false),
stale("/repo/a.ts", 1, Some("unused-export"), true),
stale("/repo/c.ts", 1, Some("unused-export"), false),
];
let output = build_suppression_inventory_output(SuppressionInventoryOutputInput {
active: &actives,
stale: &stales,
root: Path::new("/repo"),
});
assert_eq!(output.summary.stale, 1);
}
#[test]
fn json_output_uses_output_owned_root_contract() {
let actives = vec![active(
"/repo/src/api/client.ts",
4,
Some("unused-export"),
Some("public compatibility export"),
false,
)];
let output = build_suppression_inventory_output(SuppressionInventoryOutputInput {
active: &actives,
stale: &[],
root: Path::new("/repo"),
});
let value = serialize_suppression_inventory_json_output(
output,
RootEnvelopeMode::Tagged,
Some("run-suppressions"),
)
.expect("suppression inventory output should serialize");
assert_eq!(value["kind"], "suppression-inventory");
assert_eq!(value["schema_version"], "1");
assert_eq!(value["files"][0]["path"], "src/api/client.ts");
let entry = &value["files"][0]["suppressions"][0];
assert_eq!(entry["line"], 4);
assert_eq!(entry["kind"], "unused-export");
assert_eq!(entry["level"], "line");
assert_eq!(entry["origin"], "comment");
assert_eq!(entry["reason"], "public compatibility export");
assert_eq!(entry["reason_present"], true);
assert_eq!(
value["_meta"]["telemetry"]["analysis_run_id"],
"run-suppressions"
);
}
#[test]
fn blanket_marker_serializes_null_kind() {
let actives = vec![active("/repo/a.ts", 2, None, None, true)];
let output = build_suppression_inventory_output(SuppressionInventoryOutputInput {
active: &actives,
stale: &[],
root: Path::new("/repo"),
});
let value =
serialize_suppression_inventory_json_output(output, RootEnvelopeMode::Tagged, None)
.expect("suppression inventory output should serialize");
let entry = &value["files"][0]["suppressions"][0];
assert!(entry["kind"].is_null(), "blanket kind must stay JSON null");
assert_eq!(entry["level"], "file");
assert_eq!(entry["reason_present"], false);
assert!(entry["reason"].is_null());
assert!(value["summary"]["by_kind"][0]["kind"].is_null());
}
}