use anyhow::{Context, Result};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct CliConfig {
pub file_scanning: FileScanningConfig,
pub analysis: AnalysisConfig,
pub output: OutputConfig,
pub cache: CacheConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct FileScanningConfig {
pub include_patterns: Vec<String>,
pub exclude_patterns: Vec<String>,
pub max_file_size: u64,
pub follow_symlinks: bool,
pub max_depth: Option<u32>,
pub respect_gitignore: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AnalysisConfig {
pub languages: Vec<String>,
pub max_parallel_files: usize,
pub timeout_seconds: u64,
pub extract_function_calls: bool,
pub extract_dependencies: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct OutputConfig {
pub verbose: bool,
pub max_results: usize,
pub truncate_lines: bool,
pub line_numbers: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CacheConfig {
pub enabled: bool,
pub path: PathBuf,
pub max_size_mb: u64,
}
impl Default for FileScanningConfig {
fn default() -> Self {
Self {
include_patterns: vec![
"*.rs".to_string(),
"*.py".to_string(),
"*.ts".to_string(),
"*.tsx".to_string(),
"*.js".to_string(),
"*.jsx".to_string(),
"*.mts".to_string(),
"*.cts".to_string(),
"*.mjs".to_string(),
"*.cjs".to_string(),
"*.go".to_string(),
],
exclude_patterns: vec![
"**/target/**".to_string(),
"**/node_modules/**".to_string(),
"**/dist/**".to_string(),
"**/build/**".to_string(),
"**/.git/**".to_string(),
"*.d.ts".to_string(),
],
max_file_size: 1024 * 1024, follow_symlinks: false,
max_depth: Some(20),
respect_gitignore: true,
}
}
}
impl Default for AnalysisConfig {
fn default() -> Self {
Self {
languages: vec!["rust".to_string()], max_parallel_files: num_cpus::get(),
timeout_seconds: 30,
extract_function_calls: true,
extract_dependencies: true,
}
}
}
impl Default for OutputConfig {
fn default() -> Self {
Self {
verbose: false,
max_results: 50,
truncate_lines: true,
line_numbers: true,
}
}
}
impl Default for CacheConfig {
fn default() -> Self {
let cache_dir = ProjectDirs::from("com", "loregrep", "loregrep")
.map(|dirs| dirs.cache_dir().to_path_buf())
.or_else(|| {
directories::UserDirs::new().map(|d| d.home_dir().join(".cache").join("loregrep"))
})
.unwrap_or_else(|| std::env::temp_dir().join("loregrep-cache"));
Self {
enabled: true,
path: cache_dir,
max_size_mb: 100,
}
}
}
fn absolutize(path: &Path) -> PathBuf {
if path.is_absolute() {
return path.to_path_buf();
}
std::env::current_dir()
.map(|cwd| cwd.join(path))
.unwrap_or_else(|_| std::env::temp_dir().join(path))
}
impl CliConfig {
pub fn load(config_path: Option<&Path>) -> Result<Self> {
let mut config = Self::default();
if let Some(path) = config_path {
config = Self::load_from_file(path)?;
} else {
let default_paths = Self::default_config_paths();
for path in default_paths {
if path.exists() {
config = Self::load_from_file(&path)
.with_context(|| format!("Failed to load config from {:?}", path))?;
break;
}
}
}
config.apply_env_vars();
config.cache.path = absolutize(&config.cache.path);
config.validate()?;
Ok(config)
}
fn load_from_file(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {:?}", path))?;
let config: Self = toml::from_str(&content)
.with_context(|| format!("Failed to parse config file: {:?}", path))?;
Ok(config)
}
fn apply_env_vars(&mut self) {
if let Ok(cache_enabled) = std::env::var("LOREGREP_CACHE_ENABLED") {
self.cache.enabled = cache_enabled.parse().unwrap_or(self.cache.enabled);
}
if let Ok(cache_path) = std::env::var("LOREGREP_CACHE_PATH") {
self.cache.path = PathBuf::from(cache_path);
}
if let Ok(verbose) = std::env::var("LOREGREP_VERBOSE") {
self.output.verbose = verbose.parse().unwrap_or(self.output.verbose);
}
if let Ok(max_file_size) = std::env::var("LOREGREP_MAX_FILE_SIZE") {
if let Ok(size) = max_file_size.parse() {
self.file_scanning.max_file_size = size;
}
}
}
fn validate(&self) -> Result<()> {
if self.cache.enabled && self.cache.max_size_mb == 0 {
anyhow::bail!("Cache max_size_mb must be greater than 0 when cache is enabled");
}
if self.file_scanning.max_file_size == 0 {
anyhow::bail!("max_file_size must be greater than 0");
}
if self.analysis.max_parallel_files == 0 {
anyhow::bail!("max_parallel_files must be greater than 0");
}
Ok(())
}
fn default_config_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
paths.push(PathBuf::from("loregrep.toml"));
paths.push(PathBuf::from(".loregrep.toml"));
if let Some(dirs) = ProjectDirs::from("com", "loregrep", "loregrep") {
paths.push(dirs.config_dir().join("config.toml"));
}
if let Some(home) = directories::UserDirs::new().map(|d| d.home_dir().to_path_buf()) {
paths.push(home.join(".loregrep.toml"));
}
paths
}
pub fn save_to_file(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {:?}", parent))?;
}
let content = toml::to_string_pretty(self).context("Failed to serialize config to TOML")?;
std::fs::write(path, content)
.with_context(|| format!("Failed to write config file: {:?}", path))?;
Ok(())
}
pub fn create_sample_config() -> String {
let config = Self::default();
toml::to_string_pretty(&config)
.unwrap_or_else(|_| "# Error generating sample config".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn partial_config_fills_missing_fields_and_ignores_unknown() {
let toml = r#"
[file_scanning]
include_patterns = ["*.rs"]
exclude_patterns = []
max_file_size = 2048
follow_symlinks = false
max_depth = 10
[output]
colors = true
verbose = true
[ai]
api_key = "removed"
"#;
let cfg: CliConfig = toml::from_str(toml).expect("partial config should parse");
assert_eq!(cfg.file_scanning.max_file_size, 2048);
assert!(cfg.file_scanning.respect_gitignore);
assert!(cfg.cache.enabled);
assert!(cfg.output.verbose);
}
#[test]
fn the_default_cache_path_is_absolute() {
let cfg = CacheConfig::default();
assert!(
cfg.path.is_absolute(),
"default cache path must be absolute, got {:?}",
cfg.path
);
}
#[test]
fn a_relative_configured_cache_path_is_pinned_to_the_cwd() {
assert!(absolutize(Path::new("relative/cache")).is_absolute());
assert_eq!(
absolutize(Path::new("/already/absolute")),
PathBuf::from("/already/absolute")
);
}
#[test]
fn empty_config_is_all_defaults() {
let cfg: CliConfig = toml::from_str("").expect("empty config should parse");
assert_eq!(cfg.file_scanning.max_file_size, 1024 * 1024);
assert!(cfg.file_scanning.respect_gitignore);
}
}