use figment::{
providers::{Env, Format, Json, Serialized, Toml, Yaml},
Figment,
};
use serde::{Deserialize, Serialize};
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub version: u32,
pub scan: ScanConfig,
pub output: OutputConfig,
pub symbols: SymbolConfig,
pub security: SecurityConfig,
pub performance: PerformanceConfig,
pub patterns: PatternConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
version: 1,
scan: ScanConfig::default(),
output: OutputConfig::default(),
symbols: SymbolConfig::default(),
security: SecurityConfig::default(),
performance: PerformanceConfig::default(),
patterns: PatternConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ScanConfig {
pub include: Vec<String>,
pub exclude: Vec<String>,
pub max_file_size: String,
pub follow_symlinks: bool,
pub include_hidden: bool,
pub respect_gitignore: bool,
pub read_contents: bool,
}
impl Default for ScanConfig {
fn default() -> Self {
Self {
include: vec!["**/*".to_owned()],
exclude: vec![
"**/node_modules/**".to_owned(),
"**/.git/**".to_owned(),
"**/target/**".to_owned(),
"**/__pycache__/**".to_owned(),
"**/dist/**".to_owned(),
"**/build/**".to_owned(),
"**/.venv/**".to_owned(),
"**/venv/**".to_owned(),
"**/*.min.js".to_owned(),
"**/*.min.css".to_owned(),
],
max_file_size: "10MB".to_owned(),
follow_symlinks: false,
include_hidden: false,
respect_gitignore: true,
read_contents: true,
}
}
}
impl ScanConfig {
pub fn max_file_size_bytes(&self) -> u64 {
parse_size(&self.max_file_size).unwrap_or(10 * 1024 * 1024)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct OutputConfig {
pub format: String,
pub model: String,
pub compression: String,
pub token_budget: u32,
pub line_numbers: bool,
pub cache_optimized: bool,
pub output_file: String,
pub header_text: Option<String>,
pub instruction_file: Option<String>,
pub copy_to_clipboard: bool,
pub show_token_tree: bool,
pub show_directory_structure: bool,
pub show_file_summary: bool,
pub remove_empty_lines: bool,
pub remove_comments: bool,
pub top_files_length: usize,
pub include_empty_directories: bool,
}
impl Default for OutputConfig {
fn default() -> Self {
Self {
format: "xml".to_owned(),
model: "claude".to_owned(),
compression: "none".to_owned(),
token_budget: 0,
line_numbers: true,
cache_optimized: true,
output_file: "-".to_owned(),
header_text: None,
instruction_file: None,
copy_to_clipboard: false,
show_token_tree: false,
show_directory_structure: true,
show_file_summary: true,
remove_empty_lines: false,
remove_comments: false,
top_files_length: 0,
include_empty_directories: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SymbolConfig {
pub enabled: bool,
pub languages: Vec<String>,
pub extract_docstrings: bool,
pub extract_signatures: bool,
pub max_symbols: usize,
pub include_imports: bool,
pub build_dependency_graph: bool,
}
impl Default for SymbolConfig {
fn default() -> Self {
Self {
enabled: true,
languages: vec![],
extract_docstrings: true,
extract_signatures: true,
max_symbols: 100,
include_imports: true,
build_dependency_graph: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SecurityConfig {
pub scan_secrets: bool,
pub fail_on_secrets: bool,
pub allowlist: Vec<String>,
pub custom_patterns: Vec<String>,
pub redact_secrets: bool,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
scan_secrets: true,
fail_on_secrets: false,
allowlist: vec![],
custom_patterns: vec![],
redact_secrets: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PerformanceConfig {
pub threads: usize,
pub incremental: bool,
pub cache_dir: String,
pub memory_mapped: bool,
pub skip_symbols: bool,
pub batch_size: usize,
}
impl Default for PerformanceConfig {
fn default() -> Self {
Self {
threads: 0, incremental: false,
cache_dir: ".infiniloom/cache".to_owned(),
memory_mapped: true,
skip_symbols: false,
batch_size: 5000, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PatternConfig {
pub extensions: Vec<String>,
pub priority_paths: Vec<String>,
pub ignore_paths: Vec<String>,
pub modified_since: Option<String>,
pub by_author: Option<String>,
}
impl Default for PatternConfig {
fn default() -> Self {
Self {
extensions: vec![],
priority_paths: vec![
"README.md".to_owned(),
"package.json".to_owned(),
"Cargo.toml".to_owned(),
"pyproject.toml".to_owned(),
],
ignore_paths: vec!["*.lock".to_owned(), "*.sum".to_owned()],
modified_since: None,
by_author: None,
}
}
}
impl Config {
#[allow(clippy::result_large_err)]
pub fn load(repo_path: &Path) -> Result<Self, ConfigError> {
Self::load_with_profile(repo_path, None)
}
#[allow(clippy::result_large_err)]
pub fn load_with_profile(repo_path: &Path, profile: Option<&str>) -> Result<Self, ConfigError> {
let mut figment = Figment::new().merge(Serialized::defaults(Config::default()));
let config_files = [
repo_path.join(".infiniloomrc"),
repo_path.join(".infiniloom.yaml"),
repo_path.join(".infiniloom.yml"),
repo_path.join(".infiniloom.toml"),
repo_path.join(".infiniloom.json"),
repo_path.join("infiniloom.yaml"),
repo_path.join("infiniloom.toml"),
repo_path.join("infiniloom.json"),
];
for config_file in &config_files {
if config_file.exists() {
figment = match config_file.extension().and_then(|e| e.to_str()) {
Some("yaml") | Some("yml") => figment.merge(Yaml::file(config_file)),
Some("toml") => figment.merge(Toml::file(config_file)),
Some("json") => figment.merge(Json::file(config_file)),
None => {
if let Ok(content) = std::fs::read_to_string(config_file) {
if content.trim_start().starts_with('{') {
figment.merge(Json::file(config_file))
} else if content.contains(':') {
figment.merge(Yaml::file(config_file))
} else {
figment.merge(Toml::file(config_file))
}
} else {
figment
}
},
_ => figment,
};
break; }
}
if let Some(home) = dirs::home_dir() {
let global_config = home.join(".config/infiniloom/config.yaml");
if global_config.exists() {
figment = figment.merge(Yaml::file(global_config));
}
}
figment = figment.merge(Env::prefixed("INFINILOOM_").split("__"));
if let Some(profile_name) = profile {
figment = figment.select(profile_name);
}
figment.extract().map_err(ConfigError::ParseError)
}
#[allow(clippy::result_large_err)]
pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
let content = match path.extension().and_then(|e| e.to_str()) {
Some("json") => serde_json::to_string_pretty(self)
.map_err(|e| ConfigError::SerializeError(e.to_string()))?,
Some("toml") => toml::to_string_pretty(self)
.map_err(|e| ConfigError::SerializeError(e.to_string()))?,
_ => serde_yaml::to_string(self)
.map_err(|e| ConfigError::SerializeError(e.to_string()))?,
};
std::fs::write(path, content).map_err(ConfigError::IoError)
}
pub fn generate_default(format: &str) -> String {
let minimal = MinimalConfig::default();
match format {
"json" => serde_json::to_string_pretty(&minimal).unwrap_or_default(),
"toml" => toml::to_string_pretty(&minimal).unwrap_or_default(),
_ => serde_yaml::to_string(&minimal).unwrap_or_default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MinimalConfig {
output: MinimalOutputConfig,
scan: MinimalScanConfig,
security: MinimalSecurityConfig,
#[serde(skip_serializing_if = "is_false")]
include_tests: bool,
#[serde(skip_serializing_if = "is_false")]
include_docs: bool,
}
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MinimalOutputConfig {
format: String,
model: String,
compression: String,
token_budget: u32,
line_numbers: bool,
show_directory_structure: bool,
show_file_summary: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MinimalScanConfig {
include: Vec<String>,
exclude: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MinimalSecurityConfig {
scan_secrets: bool,
fail_on_secrets: bool,
redact_secrets: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
allowlist: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
custom_patterns: Vec<String>,
}
impl Default for MinimalConfig {
fn default() -> Self {
Self {
output: MinimalOutputConfig {
format: "xml".to_owned(),
model: "claude".to_owned(),
compression: "balanced".to_owned(),
token_budget: 0,
line_numbers: true,
show_directory_structure: true,
show_file_summary: true,
},
scan: MinimalScanConfig {
include: vec![],
exclude: vec![
"**/node_modules/**".to_owned(),
"**/.git/**".to_owned(),
"**/target/**".to_owned(),
"**/__pycache__/**".to_owned(),
"**/dist/**".to_owned(),
"**/build/**".to_owned(),
],
},
security: MinimalSecurityConfig {
scan_secrets: true,
fail_on_secrets: false,
redact_secrets: true,
allowlist: vec![],
custom_patterns: vec![],
},
include_tests: false,
include_docs: false,
}
}
}
impl Config {
pub fn effective_threads(&self) -> usize {
if self.performance.threads == 0 {
std::thread::available_parallelism()
.map(|p| p.get())
.unwrap_or(4)
} else {
self.performance.threads
}
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Configuration parse error: {0}")]
ParseError(#[from] figment::Error),
#[error("Configuration I/O error: {0}")]
IoError(#[from] std::io::Error),
#[error("Configuration serialize error: {0}")]
SerializeError(String),
}
fn parse_size(s: &str) -> Option<u64> {
let s = s.trim().to_uppercase();
if let Ok(n) = s.parse::<u64>() {
return Some(n);
}
let (num_str, multiplier) = if s.ends_with("KB") || s.ends_with('K') {
(s.trim_end_matches("KB").trim_end_matches('K'), 1024u64)
} else if s.ends_with("MB") || s.ends_with('M') {
(s.trim_end_matches("MB").trim_end_matches('M'), 1024 * 1024)
} else if s.ends_with("GB") || s.ends_with('G') {
(s.trim_end_matches("GB").trim_end_matches('G'), 1024 * 1024 * 1024)
} else if s.ends_with('B') {
(s.trim_end_matches('B'), 1)
} else {
return None;
};
num_str.trim().parse::<u64>().ok().map(|n| n * multiplier)
}
mod dirs {
use std::path::PathBuf;
pub(super) fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.version, 1);
assert!(config.scan.respect_gitignore);
assert_eq!(config.output.format, "xml");
}
#[test]
fn test_parse_size() {
assert_eq!(parse_size("100"), Some(100));
assert_eq!(parse_size("100B"), Some(100));
assert_eq!(parse_size("1KB"), Some(1024));
assert_eq!(parse_size("1K"), Some(1024));
assert_eq!(parse_size("10MB"), Some(10 * 1024 * 1024));
assert_eq!(parse_size("1GB"), Some(1024 * 1024 * 1024));
assert_eq!(parse_size("invalid"), None);
}
#[test]
fn test_generate_default_yaml() {
let yaml = Config::generate_default("yaml");
assert!(yaml.contains("output:"));
assert!(yaml.contains("scan:"));
assert!(yaml.contains("format:"));
}
#[test]
fn test_generate_default_toml() {
let toml = Config::generate_default("toml");
assert!(toml.contains("[output]"));
assert!(toml.contains("[scan]"));
}
#[test]
fn test_generate_default_json() {
let json = Config::generate_default("json");
assert!(json.contains("\"output\""));
assert!(json.contains("\"scan\""));
}
#[test]
fn test_effective_threads() {
let mut config = Config::default();
config.performance.threads = 0;
assert!(config.effective_threads() > 0);
config.performance.threads = 8;
assert_eq!(config.effective_threads(), 8);
}
}