diff --git a/src/lint_config.rs b/src/lint_config.rs index 4e4df5e..75858c4 100644 --- a/src/lint_config.rs +++ b/src/lint_config.rs @@ -10,6 +10,62 @@ use glob::Pattern; use log::debug; use serde::{Deserialize, Serialize}; +/// Recursively search for a config file starting from the current directory +/// and moving up through parent directories. Stop when we hit: +/// - A directory containing the config file (success) +/// - A git repository root (identified by .git directory) +/// - Maximum depth of 10 +/// - Root directory +pub fn find_config_file(config_filename: &str) -> Result { + use std::env; + + let mut current_dir = env::current_dir().context("Failed to get current working directory")?; + + let max_depth = 10; + let mut depth = 0; + + loop { + // Check if config file exists in current directory + let config_path = current_dir.join(config_filename); + if config_path.exists() { + debug!("Found config file at: {}", config_path.display()); + return AbsPath::try_from(config_path); + } + + // Check if we've hit a git repository root + let git_dir = current_dir.join(".git"); + if git_dir.exists() { + debug!("Hit git repository root at: {}", current_dir.display()); + break; + } + + // Check if we've hit maximum depth + depth += 1; + if depth >= max_depth { + debug!("Hit maximum search depth of {}", max_depth); + break; + } + + // Move to parent directory + match current_dir.parent() { + Some(parent) => { + current_dir = parent.to_path_buf(); + debug!("Searching in parent directory: {}", current_dir.display()); + } + None => { + debug!("Hit root directory"); + break; + } + } + } + + // If we get here, we didn't find the config file + Err(anyhow::Error::msg(format!( + "Could not find '{}' in current directory or any parent directory (searched up to {} levels or until git repository root)", + config_filename, max_depth + ))) +} + #[derive(Serialize, Deserialize)] pub struct LintRunnerConfig { #[serde(rename = "linter")] @@ -250,3 +306,129 @@ fn patterns_from_strs(pattern_strs: &[String]) -> Result> { }) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::{create_dir_all, File}; + use std::io::Write; + use std::path::Path; + use tempfile::TempDir; + + /// Helper function to run a test in a specific directory and restore the original working directory afterward + fn with_current_dir(dir: &Path, test_fn: F) -> Result + where + F: FnOnce() -> Result, + { + let original_dir = std::env::current_dir()?; + std::env::set_current_dir(dir)?; + + let result = test_fn(); + + std::env::set_current_dir(original_dir)?; + result + } + + /// Helper function to create a temporary directory with a standard .lintrunner.toml config file + fn create_temp_dir_with_config() -> Result { + let temp_dir = TempDir::new()?; + let config_path = temp_dir.path().join(".lintrunner.toml"); + + let mut file = File::create(&config_path)?; + writeln!(file, "[[linter]]")?; + writeln!(file, "code = 'TEST'")?; + writeln!(file, "include_patterns = ['**']")?; + writeln!(file, "command = ['echo', 'test']")?; + + Ok(temp_dir) + } + + #[test] + fn test_find_config_file_in_current_directory() -> Result<()> { + let temp_dir = create_temp_dir_with_config()?; + + // Test that we find the config file + with_current_dir(temp_dir.path(), || { + let result = find_config_file(".lintrunner.toml")?; + assert_eq!(result.file_name().unwrap(), ".lintrunner.toml"); + Ok(()) + }) + } + + #[test] + fn test_find_config_file_in_parent_directory() -> Result<()> { + let temp_dir = create_temp_dir_with_config()?; + let subdir = temp_dir.path().join("subdir"); + + // Create subdirectory + create_dir_all(&subdir)?; + + // Test that we find the config file in the parent directory + with_current_dir(&subdir, || { + let result = find_config_file(".lintrunner.toml")?; + assert_eq!(result.file_name().unwrap(), ".lintrunner.toml"); + Ok(()) + }) + } + + #[test] + fn test_find_config_file_stops_at_git_root() -> Result<()> { + let temp_dir = create_temp_dir_with_config()?; + let git_dir = temp_dir.path().join(".git"); + let subdir = temp_dir.path().join("subdir"); + let nested_subdir = subdir.join("nested"); + + // Create directory structure + create_dir_all(&git_dir)?; + create_dir_all(&nested_subdir)?; + + // Test that we find the config file (should stop at git root and find it) + with_current_dir(&nested_subdir, || { + let result = find_config_file(".lintrunner.toml")?; + assert_eq!(result.file_name().unwrap(), ".lintrunner.toml"); + Ok(()) + }) + } + + #[test] + fn test_find_config_file_not_found() -> Result<()> { + let temp_dir = TempDir::new()?; + let subdir = temp_dir.path().join("subdir"); + + // Create subdirectory but no config file + create_dir_all(&subdir)?; + + // Test that we don't find the config file + with_current_dir(&subdir, || { + let result = find_config_file(".lintrunner.toml"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Could not find '.lintrunner.toml'")); + Ok(()) + }) + } + + #[test] + fn test_find_config_file_stops_at_git_root_without_config() -> Result<()> { + let temp_dir = TempDir::new()?; + let git_dir = temp_dir.path().join(".git"); + let subdir = temp_dir.path().join("subdir"); + + // Create git directory and subdirectory, but no config file + create_dir_all(&git_dir)?; + create_dir_all(&subdir)?; + + // Test that we don't find the config file and stop at git root + with_current_dir(&subdir, || { + let result = find_config_file(".lintrunner.toml"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Could not find '.lintrunner.toml'")); + Ok(()) + }) + } +} diff --git a/src/main.rs b/src/main.rs index 3295611..72f6468 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,9 @@ -use std::{collections::HashSet, convert::TryFrom, io::Write, path::Path}; +use std::{ + collections::HashSet, + convert::TryFrom, + io::Write, + path::{Path, PathBuf}, +}; use anyhow::{Context, Result}; use chrono::SecondsFormat; @@ -8,7 +13,7 @@ use itertools::Itertools; use lintrunner::{ do_init, do_lint, init::check_init_changed, - lint_config::{get_linters_from_configs, LintRunnerConfig}, + lint_config::{find_config_file, get_linters_from_configs, LintRunnerConfig}, log_utils::setup_logger, path::AbsPath, persistent_data::{ExitInfo, PersistentDataStore, RunInfo}, @@ -182,8 +187,7 @@ fn do_main() -> Result { .map(|path| path.trim().to_string()) .collect_vec(); // check if first config path exists - let primary_config_path = AbsPath::try_from(config_paths[0].clone()) - .with_context(|| format!("Could not read lintrunner config at: '{}'", config_paths[0]))?; + let primary_config_path = find_config_file(&config_paths[0])?; let persistent_data_store = PersistentDataStore::new(&primary_config_path, run_info)?; @@ -197,18 +201,33 @@ fn do_main() -> Result { debug!("Passed args: {:?}", std::env::args()); debug!("Computed args: {:?}", args); - // report config paths which do not exist - for path in &config_paths { - match AbsPath::try_from(path) { - Ok(_) => {}, // do nothing on success - Err(_) => eprintln!("Warning: Could not find a lintrunner config at: '{}'. Continuing without using configuration file.", path), - } - } - + // For additional config files, resolve them relative to the primary config directory + let primary_config_dir = primary_config_path.parent().unwrap(); let config_paths: Vec = config_paths .into_iter() - .filter(|path| Path::new(&path).exists()) + .enumerate() + .filter_map(|(i, path)| { + if i == 0 { + // First config is the primary one we already found + Some(primary_config_path.to_string_lossy().to_string()) + } else { + // Additional configs are relative to the primary config directory + let full_path = if Path::new(&path).is_absolute() { + PathBuf::from(&path) + } else { + primary_config_dir.join(&path) + }; + + if full_path.exists() { + Some(full_path.to_string_lossy().to_string()) + } else { + eprintln!("Warning: Could not find a lintrunner config at: '{}'. Continuing without using configuration file.", path); + None + } + } + }) .collect(); + let cmd = args.cmd.unwrap_or(SubCommand::Lint); let lint_runner_config = LintRunnerConfig::new(&config_paths)?; let skipped_linters = args.skip.map(|linters| { diff --git a/tests/snapshots/integration_test__unknown_config_fails.snap b/tests/snapshots/integration_test__unknown_config_fails.snap index 89b22ac..03654cd 100644 --- a/tests/snapshots/integration_test__unknown_config_fails.snap +++ b/tests/snapshots/integration_test__unknown_config_fails.snap @@ -1,12 +1,9 @@ --- source: tests/integration_test.rs expression: output_lines - --- - "STDOUT:" - "" - "" - "STDERR:" -- "error: Could not read lintrunner config at: 'asdfasdfasdf'" -- "caused_by: No such file or directory (os error 2)" - +- "error: Could not find 'asdfasdfasdf' in current directory or any parent directory (searched up to 10 levels or until git repository root)"