use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SuppressionType {
Twins,
DeadParrot,
DeadExport,
Circular,
}
impl std::fmt::Display for SuppressionType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SuppressionType::Twins => write!(f, "twins"),
SuppressionType::DeadParrot => write!(f, "dead_parrot"),
SuppressionType::DeadExport => write!(f, "dead_export"),
SuppressionType::Circular => write!(f, "circular"),
}
}
}
impl std::str::FromStr for SuppressionType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"twins" | "twin" => Ok(SuppressionType::Twins),
"dead_parrot" | "dead-parrot" | "parrot" => Ok(SuppressionType::DeadParrot),
"dead_export" | "dead-export" | "dead" => Ok(SuppressionType::DeadExport),
"circular" | "cycle" => Ok(SuppressionType::Circular),
_ => Err(format!("Unknown suppression type: {}", s)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Suppression {
#[serde(rename = "type")]
pub suppression_type: SuppressionType,
pub symbol: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub added: String,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Suppressions {
#[serde(default, rename = "suppress")]
pub items: Vec<Suppression>,
}
impl Suppressions {
pub fn load(root: &Path) -> Self {
let path = crate::snapshot::project_config_dir(root).join("suppressions.toml");
Self::load_from_path(&path)
}
pub fn load_from_path(path: &Path) -> Self {
if !path.exists() {
return Self::default();
}
match std::fs::read_to_string(path) {
Ok(content) => match toml::from_str(&content) {
Ok(suppressions) => suppressions,
Err(e) => {
eprintln!("[loctree][warn] Failed to parse {}: {}", path.display(), e);
Self::default()
}
},
Err(e) => {
eprintln!("[loctree][warn] Failed to read {}: {}", path.display(), e);
Self::default()
}
}
}
pub fn save(&self, root: &Path) -> std::io::Result<()> {
let dir = crate::snapshot::project_config_dir(root);
std::fs::create_dir_all(&dir)?;
let path = dir.join("suppressions.toml");
let content = toml::to_string_pretty(self).map_err(std::io::Error::other)?;
let with_header = format!(
"# Loctree suppressions - findings marked as reviewed/OK\n\
# Edit manually or use: loct suppress <type> <symbol>\n\
# Clear all: loct suppress --clear\n\n{}",
content
);
std::fs::write(&path, with_header)?;
eprintln!("[loctree] Saved suppressions to {}", path.display());
Ok(())
}
pub fn add(
&mut self,
suppression_type: SuppressionType,
symbol: String,
file: Option<String>,
reason: Option<String>,
) {
if self.is_suppressed(&suppression_type, &symbol, file.as_deref()) {
return;
}
self.items.push(Suppression {
suppression_type,
symbol,
file,
reason,
added: Utc::now().format("%Y-%m-%d").to_string(),
});
}
pub fn remove(&mut self, suppression_type: &SuppressionType, symbol: &str) -> bool {
let before = self.items.len();
self.items
.retain(|s| !(&s.suppression_type == suppression_type && s.symbol == symbol));
self.items.len() < before
}
pub fn clear(&mut self) {
self.items.clear();
}
pub fn is_suppressed(
&self,
suppression_type: &SuppressionType,
symbol: &str,
file: Option<&str>,
) -> bool {
self.items.iter().any(|s| {
if &s.suppression_type != suppression_type || s.symbol != symbol {
return false;
}
match (&s.file, file) {
(None, _) => true, (Some(sf), Some(f)) => sf == f, (Some(_), None) => false, }
})
}
pub fn suppressed_symbols(&self, suppression_type: &SuppressionType) -> HashSet<String> {
self.items
.iter()
.filter(|s| &s.suppression_type == suppression_type)
.map(|s| s.symbol.clone())
.collect()
}
pub fn count_by_type(&self, suppression_type: &SuppressionType) -> usize {
self.items
.iter()
.filter(|s| &s.suppression_type == suppression_type)
.count()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn len(&self) -> usize {
self.items.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_suppression_type_parsing() {
assert_eq!(
"twins".parse::<SuppressionType>().unwrap(),
SuppressionType::Twins
);
assert_eq!(
"dead_parrot".parse::<SuppressionType>().unwrap(),
SuppressionType::DeadParrot
);
assert_eq!(
"dead-export".parse::<SuppressionType>().unwrap(),
SuppressionType::DeadExport
);
}
#[test]
fn test_add_and_check_suppression() {
let mut suppressions = Suppressions::default();
suppressions.add(
SuppressionType::Twins,
"Message".to_string(),
None,
Some("FE/BE mirror".to_string()),
);
assert!(suppressions.is_suppressed(&SuppressionType::Twins, "Message", None));
assert!(suppressions.is_suppressed(
&SuppressionType::Twins,
"Message",
Some("src/types.ts")
));
assert!(!suppressions.is_suppressed(&SuppressionType::Twins, "Other", None));
assert!(!suppressions.is_suppressed(&SuppressionType::DeadParrot, "Message", None));
}
#[test]
fn test_file_specific_suppression() {
let mut suppressions = Suppressions::default();
suppressions.add(
SuppressionType::DeadParrot,
"unusedFunc".to_string(),
Some("src/utils.ts".to_string()),
None,
);
assert!(suppressions.is_suppressed(
&SuppressionType::DeadParrot,
"unusedFunc",
Some("src/utils.ts")
));
assert!(!suppressions.is_suppressed(
&SuppressionType::DeadParrot,
"unusedFunc",
Some("src/other.ts")
));
}
#[test]
fn test_save_and_load() {
let tmp = TempDir::new().unwrap();
let mut suppressions = Suppressions::default();
suppressions.add(
SuppressionType::Twins,
"Message".to_string(),
None,
Some("OK".to_string()),
);
suppressions.save(tmp.path()).unwrap();
let loaded = Suppressions::load(tmp.path());
assert_eq!(loaded.items.len(), 1);
assert_eq!(loaded.items[0].symbol, "Message");
}
#[test]
fn test_remove_suppression() {
let mut suppressions = Suppressions::default();
suppressions.add(SuppressionType::Twins, "A".to_string(), None, None);
suppressions.add(SuppressionType::Twins, "B".to_string(), None, None);
assert!(suppressions.remove(&SuppressionType::Twins, "A"));
assert_eq!(suppressions.items.len(), 1);
assert_eq!(suppressions.items[0].symbol, "B");
}
}