pub mod cache;
pub mod presets;
use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct FossilConfig {
pub dead_code: DeadCodeConfig,
pub clones: ClonesConfig,
pub security: SecurityConfig,
pub output: OutputConfig,
pub entry_points: EntryPointConfig,
pub ci: CiConfig,
pub cache: cache::CacheConfig,
}
impl FossilConfig {
pub fn load(path: &Path) -> Result<Self, crate::core::Error> {
let content = std::fs::read_to_string(path)
.map_err(|e| crate::core::Error::config(format!("Cannot read config: {e}")))?;
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("toml");
match ext {
"toml" => toml::from_str(&content)
.map_err(|e| crate::core::Error::config(format!("TOML error: {e}"))),
"yml" | "yaml" => serde_yaml_ng::from_str(&content)
.map_err(|e| crate::core::Error::config(format!("YAML error: {e}"))),
"json" => serde_json::from_str(&content)
.map_err(|e| crate::core::Error::config(format!("JSON error: {e}"))),
_ => Err(crate::core::Error::config(format!(
"Unsupported config format: {ext}"
))),
}
}
pub fn discover(root: &Path) -> Self {
let candidates = [
"fossil.toml",
".fossil.toml",
"fossil.yml",
"fossil.yaml",
"fossil.json",
];
for name in &candidates {
let path = root.join(name);
if path.exists() {
if let Ok(config) = Self::load(&path) {
return config;
}
}
}
Self::default()
}
pub fn apply_env_overrides(&mut self) {
if let Ok(val) = std::env::var("FOSSIL_MIN_CONFIDENCE") {
self.dead_code.min_confidence = val;
}
if let Ok(val) = std::env::var("FOSSIL_MIN_LINES") {
if let Ok(n) = val.parse() {
self.clones.min_lines = n;
}
}
if let Ok(val) = std::env::var("FOSSIL_SIMILARITY") {
if let Ok(n) = val.parse() {
self.clones.similarity_threshold = n;
}
}
if let Ok(val) = std::env::var("FOSSIL_MIN_SEVERITY") {
self.security.min_severity = val;
}
if let Ok(val) = std::env::var("FOSSIL_OUTPUT_FORMAT") {
self.output.format = val;
}
if let Ok(val) = std::env::var("FOSSIL_CI_MAX_DEAD_CODE") {
if let Ok(n) = val.parse() {
self.ci.max_dead_code = Some(n);
}
}
if let Ok(val) = std::env::var("FOSSIL_CI_MAX_CLONES") {
if let Ok(n) = val.parse() {
self.ci.max_clones = Some(n);
}
}
if let Ok(val) = std::env::var("FOSSIL_CI_MIN_CONFIDENCE") {
self.ci.min_confidence = Some(val);
}
if let Ok(val) = std::env::var("FOSSIL_CI_FAIL_ON_SCAFFOLDING") {
self.ci.fail_on_scaffolding = Some(val.to_lowercase().parse().unwrap_or(false));
}
if let Ok(val) = std::env::var("FOSSIL_CI_MAX_SCAFFOLDING") {
if let Ok(n) = val.parse() {
self.ci.max_scaffolding = Some(n);
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DeadCodeConfig {
pub enabled: bool,
pub min_confidence: String,
pub include_tests: bool,
pub exclude: Vec<String>,
}
impl Default for DeadCodeConfig {
fn default() -> Self {
Self {
enabled: true,
min_confidence: "low".to_string(),
include_tests: true,
exclude: vec!["tests/**".to_string(), "vendor/**".to_string()],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ClonesConfig {
pub enabled: bool,
pub min_lines: usize,
pub similarity_threshold: f64,
pub types: Vec<String>,
}
impl Default for ClonesConfig {
fn default() -> Self {
Self {
enabled: true,
min_lines: 6,
similarity_threshold: 0.8,
types: vec![
"type1".to_string(),
"type2".to_string(),
"type3".to_string(),
],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SecurityConfig {
pub enabled: bool,
pub rules_dir: Option<String>,
pub min_severity: String,
pub enable_taint: bool,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
enabled: true,
rules_dir: None,
min_severity: "info".to_string(),
enable_taint: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct OutputConfig {
pub format: String,
pub output_file: Option<String>,
pub verbose: bool,
pub quiet: bool,
}
impl Default for OutputConfig {
fn default() -> Self {
Self {
format: "text".to_string(),
output_file: None,
verbose: false,
quiet: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct EntryPointConfig {
pub files: Vec<String>,
pub functions: Vec<String>,
pub attributes: Vec<String>,
pub config_files: Vec<String>,
pub presets: Vec<String>,
pub auto_detect_presets: bool,
}
impl Default for EntryPointConfig {
fn default() -> Self {
Self {
files: Vec::new(),
functions: Vec::new(),
attributes: Vec::new(),
config_files: vec![
"Dockerfile".into(),
"docker-compose.yml".into(),
"package.json".into(),
],
presets: Vec::new(),
auto_detect_presets: true,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CiConfig {
pub max_dead_code: Option<usize>,
pub max_clones: Option<usize>,
pub min_confidence: Option<String>,
pub max_scaffolding: Option<usize>,
pub fail_on_scaffolding: Option<bool>,
}
#[derive(Debug, Clone, Default)]
pub struct ResolvedEntryPointRules {
pub exact_attributes: std::collections::HashSet<String>,
pub prefix_attributes: Vec<String>,
pub exact_functions: std::collections::HashSet<String>,
}
impl ResolvedEntryPointRules {
pub fn from_config(config: &EntryPointConfig, project_root: Option<&Path>) -> Self {
let mut rules = Self::with_defaults();
let active_presets = if config.auto_detect_presets && config.presets.is_empty() {
if let Some(root) = project_root {
presets::auto_detect_presets(root)
} else {
Vec::new()
}
} else {
config.presets.clone()
};
for preset_name in &active_presets {
if let Some(preset) = presets::get_preset(preset_name) {
for attr in preset.entry_attributes {
if attr.ends_with(':') || attr.ends_with('*') {
rules
.prefix_attributes
.push(attr.trim_end_matches('*').to_string());
} else {
rules.exact_attributes.insert(attr.to_string());
}
}
for func in preset.entry_functions {
rules.exact_functions.insert(func.to_string());
}
for method in preset.lifecycle_methods {
rules.exact_functions.insert(method.to_string());
}
}
}
for attr in &config.attributes {
if attr.ends_with('*') {
rules
.prefix_attributes
.push(attr.trim_end_matches('*').to_string());
} else {
rules.exact_attributes.insert(attr.clone());
}
}
for func in &config.functions {
rules.exact_functions.insert(func.clone());
}
rules
}
pub fn with_defaults() -> Self {
let mut rules = Self::default();
for prefix in &[
"impl_trait:",
"derive:",
"serde_default:",
"serde_serialize_with:",
"serde_deserialize_with:",
"extends:",
"implements:",
"trait_default:", ] {
rules.prefix_attributes.push(prefix.to_string());
}
for attr in &[
"route",
"handler",
"api",
"endpoint",
"Bean",
"Controller",
"RestController",
"Service",
"Component",
"Scheduled",
"PostConstruct",
"RequestMapping",
"HttpGet",
"HttpPost",
"HttpPut",
"HttpDelete",
"ApiController",
"dataclass",
"attrs",
"Data",
"Getter",
"Setter",
"Builder",
"NoArgsConstructor",
"AllArgsConstructor",
"RequiredArgsConstructor",
"Value",
"EqualsAndHashCode",
"ToString",
"Entity",
"Table",
"MappedSuperclass",
"Embeddable",
"Serializable",
"DataContract",
"DataMember",
"JsonConverter",
"ProtoContract",
"Parcelize",
"component",
"pymethods",
"pyfunction",
"pyclass",
"bench",
"cfg_feature",
] {
rules.exact_attributes.insert(attr.to_string());
}
for func in &[
"componentDidMount",
"componentDidUpdate",
"componentWillUnmount",
"componentDidCatch",
"getDerivedStateFromError",
"shouldComponentUpdate",
"getSnapshotBeforeUpdate",
"render",
"mounted",
"created",
"beforeDestroy",
"destroyed",
"beforeMount",
"ngOnInit",
"ngOnDestroy",
"ngOnChanges",
"ngAfterViewInit",
] {
rules.exact_functions.insert(func.to_string());
}
rules
}
pub fn matches_attribute(&self, attr: &str) -> bool {
if self.exact_attributes.contains(attr) {
return true;
}
self.prefix_attributes
.iter()
.any(|prefix| attr.starts_with(prefix))
}
pub fn matches_function(&self, name: &str) -> bool {
self.exact_functions.contains(name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = FossilConfig::default();
assert!(config.dead_code.enabled);
assert!(config.clones.enabled);
assert!(config.security.enabled);
assert_eq!(config.clones.min_lines, 6);
}
#[test]
fn test_toml_roundtrip() {
let config = FossilConfig::default();
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: FossilConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.clones.min_lines, 6);
}
#[test]
fn test_json_roundtrip() {
let config = FossilConfig::default();
let json = serde_json::to_string_pretty(&config).unwrap();
let parsed: FossilConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.security.min_severity, "info");
}
#[test]
fn test_discover_defaults() {
let dir = std::env::temp_dir();
let config = FossilConfig::discover(&dir);
assert!(config.dead_code.enabled);
}
}