From 055bc15ab95a68d6b8cef3df90d32bb052ff2f8e Mon Sep 17 00:00:00 2001 From: Eli Uriegas Date: Wed, 2 Jul 2025 16:00:22 -0700 Subject: [PATCH 1/2] feat: Add recursive config file search Allow lintrunner to find .lintrunner.toml by searching up directory tree. Stops at git repo root or max depth of 10. Fixes issue where lintrunner failed when run from subdirectories. --- src/lint_config.rs | 177 ++++++++++++++++++ src/main.rs | 42 +++-- ...ntegration_test__unknown_config_fails.snap | 5 +- 3 files changed, 207 insertions(+), 17 deletions(-) diff --git a/src/lint_config.rs b/src/lint_config.rs index 4e4df5e..eca77d8 100644 --- a/src/lint_config.rs +++ b/src/lint_config.rs @@ -10,6 +10,63 @@ 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 +307,123 @@ fn patterns_from_strs(pattern_strs: &[String]) -> Result> { }) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::{File, create_dir_all}; + 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..d8bc04d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -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 +8,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}, @@ -20,6 +20,8 @@ use log::debug; const VERSION: &str = env!("CARGO_PKG_VERSION"); + + #[derive(Debug, Parser)] #[clap(version, name = "lintrunner", infer_subcommands(true))] struct Args { @@ -182,8 +184,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 +198,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)" From 4a25a7565a76e8638176fc43407f2a75b53931f8 Mon Sep 17 00:00:00 2001 From: Eli Uriegas Date: Wed, 2 Jul 2025 20:50:01 -0700 Subject: [PATCH 2/2] fix fmt Signed-off-by: Eli Uriegas --- src/lint_config.rs | 57 +++++++++++++++++++++++++--------------------- src/main.rs | 11 +++++---- 2 files changed, 38 insertions(+), 30 deletions(-) diff --git a/src/lint_config.rs b/src/lint_config.rs index eca77d8..75858c4 100644 --- a/src/lint_config.rs +++ b/src/lint_config.rs @@ -18,13 +18,12 @@ use serde::{Deserialize, Serialize}; /// - 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 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); @@ -32,21 +31,21 @@ pub fn find_config_file(config_filename: &str) -> Result { 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) => { @@ -59,7 +58,7 @@ pub fn find_config_file(config_filename: &str) -> Result { } } } - + // 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)", @@ -311,7 +310,7 @@ fn patterns_from_strs(pattern_strs: &[String]) -> Result> { #[cfg(test)] mod tests { use super::*; - use std::fs::{File, create_dir_all}; + use std::fs::{create_dir_all, File}; use std::io::Write; use std::path::Path; use tempfile::TempDir; @@ -323,9 +322,9 @@ mod tests { { 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 } @@ -334,20 +333,20 @@ mod tests { 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")?; @@ -360,10 +359,10 @@ mod tests { 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")?; @@ -378,11 +377,11 @@ mod tests { 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")?; @@ -395,34 +394,40 @@ mod tests { 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'")); + assert!(result + .unwrap_err() + .to_string() + .contains("Could not find '.lintrunner.toml'")); Ok(()) }) } - #[test] + #[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'")); + assert!(result + .unwrap_err() + .to_string() + .contains("Could not find '.lintrunner.toml'")); Ok(()) }) } diff --git a/src/main.rs b/src/main.rs index d8bc04d..72f6468 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,9 @@ -use std::{collections::HashSet, convert::TryFrom, io::Write, path::{Path, PathBuf}}; +use std::{ + collections::HashSet, + convert::TryFrom, + io::Write, + path::{Path, PathBuf}, +}; use anyhow::{Context, Result}; use chrono::SecondsFormat; @@ -20,8 +25,6 @@ use log::debug; const VERSION: &str = env!("CARGO_PKG_VERSION"); - - #[derive(Debug, Parser)] #[clap(version, name = "lintrunner", infer_subcommands(true))] struct Args { @@ -214,7 +217,7 @@ fn do_main() -> Result { } else { primary_config_dir.join(&path) }; - + if full_path.exists() { Some(full_path.to_string_lossy().to_string()) } else {