loregrep 0.6.0

Repository indexing library for AI coding assistants. Tree-sitter parsing, fast in-memory indexing, and tool APIs for LLM integration.
Documentation
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![
                // `**/…/**` so the glob matches at any depth: `node_modules/*`
                // does not cross `/`, which let vendored JS into the index once
                // JavaScript gained an analyzer.
                "**/target/**".to_string(),
                "**/node_modules/**".to_string(),
                "**/dist/**".to_string(),
                "**/build/**".to_string(),
                "**/.git/**".to_string(),
                // Generated type stubs: no executable code, pure index noise.
                "*.d.ts".to_string(),
                // NOTE: test files are deliberately NOT excluded. They were once
                // (*.test.ts/*.spec.ts/*.test.js/*.spec.js) which (a) disagreed
                // with the library defaults in LoreGrepConfig, so the same binary
                // indexed different trees depending on entry point, (b) was
                // asymmetric — *.test.tsx and *.spec.tsx were still indexed — and
                // (c) silently hid test call sites from "who calls X", which is
                // often exactly where the usage examples live. Users who want
                // them gone can say so in loregrep.toml.
            ],
            max_file_size: 1024 * 1024, // 1MB
            follow_symlinks: false,
            max_depth: Some(20),
            respect_gitignore: true,
        }
    }
}

impl Default for AnalysisConfig {
    fn default() -> Self {
        Self {
            languages: vec!["rust".to_string()], // Start with Rust only
            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 {
        // Everything the CLI persists per repository lives under this
        // directory, NOT inside the analysed tree (K11). The fallback used when
        // ProjectDirs is unavailable must therefore be absolute too: a relative
        // ".loregrep_cache" would follow the process working directory around
        // and drop cache files into whatever tree the user happened to be
        // standing in — the same bug in a different costume.
        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,
        }
    }
}

/// Resolve `path` against the current working directory if it is relative.
/// Purely lexical: the directory need not exist yet.
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 {
    /// Load configuration from file, environment variables, and defaults
    pub fn load(config_path: Option<&Path>) -> Result<Self> {
        let mut config = Self::default();

        // Try to load from config file
        if let Some(path) = config_path {
            config = Self::load_from_file(path)?;
        } else {
            // Try default config locations
            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;
                }
            }
        }

        // Override with environment variables
        config.apply_env_vars();

        // A cache path is only meaningful as an absolute location; see
        // `CacheConfig::default`. A config file or `LOREGREP_CACHE_PATH` may
        // still name a relative one, so pin it to the working directory *once*,
        // here, rather than letting every later write re-resolve it.
        config.cache.path = absolutize(&config.cache.path);

        // Validate configuration
        config.validate()?;

        Ok(config)
    }

    /// Load configuration from TOML file
    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)
    }

    /// Apply environment variable overrides
    fn apply_env_vars(&mut self) {
        // Cache configuration
        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);
        }

        // Output configuration
        if let Ok(verbose) = std::env::var("LOREGREP_VERBOSE") {
            self.output.verbose = verbose.parse().unwrap_or(self.output.verbose);
        }

        // File scanning
        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;
            }
        }
    }

    /// Validate configuration values
    fn validate(&self) -> Result<()> {
        // Validate cache configuration
        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");
        }

        // Validate file scanning
        if self.file_scanning.max_file_size == 0 {
            anyhow::bail!("max_file_size must be greater than 0");
        }

        // Validate analysis configuration
        if self.analysis.max_parallel_files == 0 {
            anyhow::bail!("max_parallel_files must be greater than 0");
        }

        Ok(())
    }

    /// Get default configuration file paths
    fn default_config_paths() -> Vec<PathBuf> {
        let mut paths = Vec::new();

        // Current directory
        paths.push(PathBuf::from("loregrep.toml"));
        paths.push(PathBuf::from(".loregrep.toml"));

        // User config directory
        if let Some(dirs) = ProjectDirs::from("com", "loregrep", "loregrep") {
            paths.push(dirs.config_dir().join("config.toml"));
        }

        // Home directory
        if let Some(home) = directories::UserDirs::new().map(|d| d.home_dir().to_path_buf()) {
            paths.push(home.join(".loregrep.toml"));
        }

        paths
    }

    /// Save configuration to file
    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(())
    }

    /// Create a sample configuration file
    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() {
        // A stale/partial config: [file_scanning] omits respect_gitignore,
        // and there are removed sections ([ai]) and fields (output.colors).
        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");
        // Present field kept:
        assert_eq!(cfg.file_scanning.max_file_size, 2048);
        // Missing field filled from Default (true), not a parse error:
        assert!(cfg.file_scanning.respect_gitignore);
        // Missing section filled from Default:
        assert!(cfg.cache.enabled);
        // Present output field kept; removed `colors` ignored (no error):
        assert!(cfg.output.verbose);
    }

    #[test]
    fn the_default_cache_path_is_absolute() {
        // K11: a relative cache root follows the process working directory and
        // writes into whatever tree the user happens to be standing in.
        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);
    }
}