diff --git a/qlty-cli/src/commands/coverage/publish.rs b/qlty-cli/src/commands/coverage/publish.rs index 0c8e1d58a..433a06d6a 100644 --- a/qlty-cli/src/commands/coverage/publish.rs +++ b/qlty-cli/src/commands/coverage/publish.rs @@ -14,7 +14,7 @@ use qlty_coverage::print::{print_report_as_json, print_report_as_text}; use qlty_coverage::publish::{Plan, Planner, Processor, Reader, Report, Settings, Upload}; use qlty_coverage::token::load_auth_token; use qlty_coverage::validate::{ValidationStatus, Validator}; -use qlty_coverage::{ExclusionStrategy, JavaSrcDirFinder}; +use qlty_coverage::{ExclusionStrategy, SrcDirFinder}; use std::io::Write as _; use std::path::PathBuf; use std::time::Instant; @@ -41,6 +41,15 @@ pub struct Publish { /// and use them to resolve file paths in coverage reports. pub discover_java_src_dirs: bool, + #[arg(long = "add-src-dir", hide = true)] + /// Add a source directory to search for files. Can be specified multiple times. + /// These directories take precedence over auto-discovered directories. + pub add_src_dirs: Vec, + + #[arg(long = "remove-src-dir", hide = true)] + /// Remove a source directory from the search list. Can be specified multiple times. + pub remove_src_dirs: Vec, + #[arg(long, hide = true)] pub output_dir: Option, @@ -256,24 +265,31 @@ impl Publish { let root = std::env::current_dir()?; let config = load_config(); - let java_src_dirs = if self.discover_java_src_dirs { - let exclusion_strategy = if config.exclude_patterns.is_empty() { + + let mut src_search_dirs = self.add_src_dirs.clone(); + if self.discover_java_src_dirs { + let exclusion_strategy = if !self.remove_src_dirs.is_empty() { + let patterns: Vec = self + .remove_src_dirs + .iter() + .map(|p| format!("{}/**", p.display())) + .collect(); + ExclusionStrategy::UserDefined(patterns) + } else if config.exclude_patterns.is_empty() { ExclusionStrategy::DefaultHeuristics } else { ExclusionStrategy::UserDefined(config.exclude_patterns) }; - let finder = JavaSrcDirFinder::new(root.clone(), exclusion_strategy); - finder.find()? - } else { - Vec::new() - }; + let finder = SrcDirFinder::new(root.clone(), exclusion_strategy); + src_search_dirs.extend(finder.find()?); + } Ok(Settings { add_prefix, dry_run: self.dry_run, discover_java_src_dirs: self.discover_java_src_dirs, root, - java_src_dirs, + src_search_dirs, incomplete, name: self.name.clone(), output_dir: self.output_dir.clone(), @@ -320,13 +336,10 @@ impl Publish { return; } - if settings.discover_java_src_dirs - && !settings.java_src_dirs.is_empty() - && std::env::var("JACOCO_SOURCE_PATH").is_ok() - { - eprintln!("WARNING: Both --discover-java-src-dirs and JACOCO_SOURCE_PATH are set."); + if !settings.src_search_dirs.is_empty() && std::env::var("JACOCO_SOURCE_PATH").is_ok() { + eprintln!("WARNING: Both source search directories and JACOCO_SOURCE_PATH are set."); eprintln!("JACOCO_SOURCE_PATH applies during JaCoCo parsing."); - eprintln!("--discover-java-src-dirs applies to all formats during processing.\n"); + eprintln!("Source search directories (--add-src-dir/--discover-java-src-dirs) apply to all formats during processing.\n"); } } diff --git a/qlty-cli/src/commands/coverage/utils.rs b/qlty-cli/src/commands/coverage/utils.rs index 9d0f3f227..44054b8a3 100644 --- a/qlty-cli/src/commands/coverage/utils.rs +++ b/qlty-cli/src/commands/coverage/utils.rs @@ -108,15 +108,16 @@ pub fn print_settings(settings: &Settings) { eprintln!(); - // Print discovered Java src dirs as a sub-section if --discover-java-src-dirs is enabled - if settings.discover_java_src_dirs { - eprintln!(" discover-java-src-dirs: true"); + if settings.discover_java_src_dirs || !settings.src_search_dirs.is_empty() { + if settings.discover_java_src_dirs { + eprintln!(" discover-java-src-dirs: true"); + } eprintln!(); - eprintln!(" Discovered Java source directories:"); - if settings.java_src_dirs.is_empty() { + eprintln!(" Source search directories:"); + if settings.src_search_dirs.is_empty() { eprintln!(" (none found)"); } else { - for dir in &settings.java_src_dirs { + for dir in &settings.src_search_dirs { eprintln!(" {}", dir.display()); } } diff --git a/qlty-cli/tests/cmd/coverage/discover_java_src_dirs.stderr b/qlty-cli/tests/cmd/coverage/discover_java_src_dirs.stderr index 27ba15ec3..7bc7ffe3b 100644 --- a/qlty-cli/tests/cmd/coverage/discover_java_src_dirs.stderr +++ b/qlty-cli/tests/cmd/coverage/discover_java_src_dirs.stderr @@ -12,7 +12,7 @@ https://qlty.sh/d/coverage discover-java-src-dirs: true - Discovered Java source directories: + Source search directories: src/main/java METADATA diff --git a/qlty-coverage/src/lib.rs b/qlty-coverage/src/lib.rs index f22081f9c..0bf214ae1 100644 --- a/qlty-coverage/src/lib.rs +++ b/qlty-coverage/src/lib.rs @@ -3,17 +3,17 @@ mod env; pub mod export; pub mod formats; pub mod git; -mod java_src_dir_finder; pub mod parser; pub mod print; pub mod publish; +mod src_dir_finder; pub mod token; pub mod transform; mod transformer; mod utils; pub mod validate; -pub use java_src_dir_finder::{ExclusionStrategy, JavaSrcDirFinder}; +pub use src_dir_finder::{ExclusionStrategy, SrcDirFinder}; #[macro_use] mod macros; diff --git a/qlty-coverage/src/publish/planner.rs b/qlty-coverage/src/publish/planner.rs index 2946cc03a..ea17d23d7 100644 --- a/qlty-coverage/src/publish/planner.rs +++ b/qlty-coverage/src/publish/planner.rs @@ -115,10 +115,10 @@ impl Planner { // Add PrependSrcDir transformer if Java src dirs were discovered // This runs after path normalization so it receives clean relative paths - if !self.settings.java_src_dirs.is_empty() { + if !self.settings.src_search_dirs.is_empty() { transformers.push(Box::new(PrependSrcDir::new( self.settings.root.clone(), - self.settings.java_src_dirs.clone(), + self.settings.src_search_dirs.clone(), ))); } diff --git a/qlty-coverage/src/publish/settings.rs b/qlty-coverage/src/publish/settings.rs index f1715506f..7e2540ca0 100644 --- a/qlty-coverage/src/publish/settings.rs +++ b/qlty-coverage/src/publish/settings.rs @@ -9,7 +9,7 @@ pub struct Settings { pub dry_run: bool, pub discover_java_src_dirs: bool, pub root: PathBuf, - pub java_src_dirs: Vec, + pub src_search_dirs: Vec, pub incomplete: bool, pub name: Option, pub output_dir: Option, diff --git a/qlty-coverage/src/java_src_dir_finder.rs b/qlty-coverage/src/src_dir_finder.rs similarity index 75% rename from qlty-coverage/src/java_src_dir_finder.rs rename to qlty-coverage/src/src_dir_finder.rs index 38668f7d6..44dc8965a 100644 --- a/qlty-coverage/src/java_src_dir_finder.rs +++ b/qlty-coverage/src/src_dir_finder.rs @@ -14,12 +14,12 @@ pub enum ExclusionStrategy { } #[derive(Debug)] -pub struct JavaSrcDirFinder { +pub struct SrcDirFinder { root: PathBuf, exclusion_strategy: ExclusionStrategy, } -impl JavaSrcDirFinder { +impl SrcDirFinder { pub fn new(root: PathBuf, exclusion_strategy: ExclusionStrategy) -> Self { Self { root, @@ -151,21 +151,18 @@ mod tests { #[test] fn split_into_words_handles_simple() { - assert_eq!(JavaSrcDirFinder::split_into_words("test"), vec!["test"]); - assert_eq!( - JavaSrcDirFinder::split_into_words("contest"), - vec!["contest"] - ); + assert_eq!(SrcDirFinder::split_into_words("test"), vec!["test"]); + assert_eq!(SrcDirFinder::split_into_words("contest"), vec!["contest"]); } #[test] fn split_into_words_handles_dashes() { assert_eq!( - JavaSrcDirFinder::split_into_words("my-test"), + SrcDirFinder::split_into_words("my-test"), vec!["my", "test"] ); assert_eq!( - JavaSrcDirFinder::split_into_words("test-thing"), + SrcDirFinder::split_into_words("test-thing"), vec!["test", "thing"] ); } @@ -173,11 +170,11 @@ mod tests { #[test] fn split_into_words_handles_underscores() { assert_eq!( - JavaSrcDirFinder::split_into_words("my_test"), + SrcDirFinder::split_into_words("my_test"), vec!["my", "test"] ); assert_eq!( - JavaSrcDirFinder::split_into_words("test_thing"), + SrcDirFinder::split_into_words("test_thing"), vec!["test", "thing"] ); } @@ -185,42 +182,30 @@ mod tests { #[test] fn split_into_words_handles_camel_case() { assert_eq!( - JavaSrcDirFinder::split_into_words("testSomething"), + SrcDirFinder::split_into_words("testSomething"), vec!["test", "something"] ); assert_eq!( - JavaSrcDirFinder::split_into_words("somethingTest"), + SrcDirFinder::split_into_words("somethingTest"), vec!["something", "test"] ); + assert_eq!(SrcDirFinder::split_into_words("MyTest"), vec!["my", "test"]); assert_eq!( - JavaSrcDirFinder::split_into_words("MyTest"), - vec!["my", "test"] - ); - assert_eq!( - JavaSrcDirFinder::split_into_words("androidTest"), + SrcDirFinder::split_into_words("androidTest"), vec!["android", "test"] ); } #[test] fn split_into_words_preserves_non_test_words() { - assert_eq!( - JavaSrcDirFinder::split_into_words("contest"), - vec!["contest"] - ); - assert_eq!( - JavaSrcDirFinder::split_into_words("protest"), - vec!["protest"] - ); - assert_eq!( - JavaSrcDirFinder::split_into_words("testify"), - vec!["testify"] - ); + assert_eq!(SrcDirFinder::split_into_words("contest"), vec!["contest"]); + assert_eq!(SrcDirFinder::split_into_words("protest"), vec!["protest"]); + assert_eq!(SrcDirFinder::split_into_words("testify"), vec!["testify"]); } #[test] fn matches_default_exclusions_excludes_test_variants() { - let finder = JavaSrcDirFinder::new(PathBuf::new(), ExclusionStrategy::DefaultHeuristics); + let finder = SrcDirFinder::new(PathBuf::new(), ExclusionStrategy::DefaultHeuristics); assert!(finder.matches_default_exclusions("test/src/main/java")); assert!(finder.matches_default_exclusions("my-test/src/main/java")); @@ -233,7 +218,7 @@ mod tests { #[test] fn matches_default_exclusions_allows_non_test_words() { - let finder = JavaSrcDirFinder::new(PathBuf::new(), ExclusionStrategy::DefaultHeuristics); + let finder = SrcDirFinder::new(PathBuf::new(), ExclusionStrategy::DefaultHeuristics); assert!(!finder.matches_default_exclusions("contest/src/main/java")); assert!(!finder.matches_default_exclusions("protest/src/main/java")); @@ -244,7 +229,7 @@ mod tests { #[test] fn matches_default_exclusions_handles_node_modules() { - let finder = JavaSrcDirFinder::new(PathBuf::new(), ExclusionStrategy::DefaultHeuristics); + let finder = SrcDirFinder::new(PathBuf::new(), ExclusionStrategy::DefaultHeuristics); assert!(finder.matches_default_exclusions("node_modules/pkg/src/main/java")); assert!(!finder.matches_default_exclusions("my_modules/src/main/java")); @@ -252,7 +237,7 @@ mod tests { #[test] fn matches_default_exclusions_handles_build() { - let finder = JavaSrcDirFinder::new(PathBuf::new(), ExclusionStrategy::DefaultHeuristics); + let finder = SrcDirFinder::new(PathBuf::new(), ExclusionStrategy::DefaultHeuristics); assert!(finder.matches_default_exclusions("build/src/main/java")); assert!(finder.matches_default_exclusions("build-output/src/main/java")); @@ -262,59 +247,57 @@ mod tests { #[test] fn is_java_src_dir_matches_maven_java() { - assert!(JavaSrcDirFinder::is_java_src_dir("src/main/java")); - assert!(JavaSrcDirFinder::is_java_src_dir("app/src/main/java")); - assert!(JavaSrcDirFinder::is_java_src_dir( + assert!(SrcDirFinder::is_java_src_dir("src/main/java")); + assert!(SrcDirFinder::is_java_src_dir("app/src/main/java")); + assert!(SrcDirFinder::is_java_src_dir( "project/module/src/main/java" )); } #[test] fn is_java_src_dir_matches_maven_kotlin() { - assert!(JavaSrcDirFinder::is_java_src_dir("src/main/kotlin")); - assert!(JavaSrcDirFinder::is_java_src_dir("app/src/main/kotlin")); + assert!(SrcDirFinder::is_java_src_dir("src/main/kotlin")); + assert!(SrcDirFinder::is_java_src_dir("app/src/main/kotlin")); } #[test] fn is_java_src_dir_matches_gradle_variants() { - assert!(JavaSrcDirFinder::is_java_src_dir("src/debug/java")); - assert!(JavaSrcDirFinder::is_java_src_dir("src/release/kotlin")); - assert!(JavaSrcDirFinder::is_java_src_dir("app/src/production/java")); + assert!(SrcDirFinder::is_java_src_dir("src/debug/java")); + assert!(SrcDirFinder::is_java_src_dir("src/release/kotlin")); + assert!(SrcDirFinder::is_java_src_dir("app/src/production/java")); } #[test] fn is_java_src_dir_rejects_too_few_components() { - assert!(!JavaSrcDirFinder::is_java_src_dir("java")); - assert!(!JavaSrcDirFinder::is_java_src_dir("main/java")); - assert!(!JavaSrcDirFinder::is_java_src_dir("")); + assert!(!SrcDirFinder::is_java_src_dir("java")); + assert!(!SrcDirFinder::is_java_src_dir("main/java")); + assert!(!SrcDirFinder::is_java_src_dir("")); } #[test] fn is_java_src_dir_rejects_wrong_structure() { - assert!(!JavaSrcDirFinder::is_java_src_dir("app/main/java")); - assert!(!JavaSrcDirFinder::is_java_src_dir("src/java")); - assert!(!JavaSrcDirFinder::is_java_src_dir("source/main/java")); + assert!(!SrcDirFinder::is_java_src_dir("app/main/java")); + assert!(!SrcDirFinder::is_java_src_dir("src/java")); + assert!(!SrcDirFinder::is_java_src_dir("source/main/java")); } #[test] fn is_java_src_dir_rejects_subdirectories_of_source_roots() { - assert!(!JavaSrcDirFinder::is_java_src_dir("src/main/java/com")); - assert!(!JavaSrcDirFinder::is_java_src_dir( - "src/main/java/com/example" - )); - assert!(!JavaSrcDirFinder::is_java_src_dir( + assert!(!SrcDirFinder::is_java_src_dir("src/main/java/com")); + assert!(!SrcDirFinder::is_java_src_dir("src/main/java/com/example")); + assert!(!SrcDirFinder::is_java_src_dir( "src/main/java/com/example/kotlin" )); - assert!(!JavaSrcDirFinder::is_java_src_dir( + assert!(!SrcDirFinder::is_java_src_dir( "app/src/main/java/com/gusto/foundation/async/kotlin" )); } #[test] fn is_java_src_dir_rejects_non_java_kotlin_endings() { - assert!(!JavaSrcDirFinder::is_java_src_dir("src/main/scala")); - assert!(!JavaSrcDirFinder::is_java_src_dir("src/main/groovy")); - assert!(!JavaSrcDirFinder::is_java_src_dir("src/main/resources")); + assert!(!SrcDirFinder::is_java_src_dir("src/main/scala")); + assert!(!SrcDirFinder::is_java_src_dir("src/main/groovy")); + assert!(!SrcDirFinder::is_java_src_dir("src/main/resources")); } #[test] @@ -325,8 +308,7 @@ mod tests { create_dir(root, "src/main/java/com/example"); create_dir(root, "src/main/kotlin/com/example"); - let finder = - JavaSrcDirFinder::new(root.to_path_buf(), ExclusionStrategy::DefaultHeuristics); + let finder = SrcDirFinder::new(root.to_path_buf(), ExclusionStrategy::DefaultHeuristics); let dirs = finder.find().unwrap(); assert_eq!(dirs.len(), 2); @@ -347,8 +329,7 @@ mod tests { create_dir(root, "lib/src/debug/java/com/example"); create_dir(root, "lib/src/release/kotlin/com/example"); - let finder = - JavaSrcDirFinder::new(root.to_path_buf(), ExclusionStrategy::DefaultHeuristics); + let finder = SrcDirFinder::new(root.to_path_buf(), ExclusionStrategy::DefaultHeuristics); let dirs = finder.find().unwrap(); assert_eq!(dirs.len(), 3); @@ -362,8 +343,7 @@ mod tests { create_dir(root, "src/main/java/com/example"); create_dir(root, "node_modules/some-package/src/main/java"); - let finder = - JavaSrcDirFinder::new(root.to_path_buf(), ExclusionStrategy::DefaultHeuristics); + let finder = SrcDirFinder::new(root.to_path_buf(), ExclusionStrategy::DefaultHeuristics); let dirs = finder.find().unwrap(); assert_eq!(dirs.len(), 1); @@ -380,8 +360,7 @@ mod tests { create_dir(root, "test/src/main/java/com/example"); create_dir(root, "build/src/main/java/com/example"); - let finder = - JavaSrcDirFinder::new(root.to_path_buf(), ExclusionStrategy::DefaultHeuristics); + let finder = SrcDirFinder::new(root.to_path_buf(), ExclusionStrategy::DefaultHeuristics); let dirs = finder.find().unwrap(); assert_eq!(dirs.len(), 1); @@ -396,7 +375,7 @@ mod tests { create_dir(root, "app/src/main/java/com/example"); create_dir(root, "legacy/src/main/java/com/example"); - let finder = JavaSrcDirFinder::new( + let finder = SrcDirFinder::new( root.to_path_buf(), ExclusionStrategy::UserDefined(vec!["legacy/**".to_string()]), ); @@ -417,8 +396,7 @@ mod tests { create_dir(root, "src/lib"); create_dir(root, "app/code"); - let finder = - JavaSrcDirFinder::new(root.to_path_buf(), ExclusionStrategy::DefaultHeuristics); + let finder = SrcDirFinder::new(root.to_path_buf(), ExclusionStrategy::DefaultHeuristics); let dirs = finder.find().unwrap(); assert!(dirs.is_empty()); @@ -433,8 +411,7 @@ mod tests { create_dir(root, "project-b/src/main/kotlin/com/b"); create_dir(root, "project-c/module/src/main/java/com/c"); - let finder = - JavaSrcDirFinder::new(root.to_path_buf(), ExclusionStrategy::DefaultHeuristics); + let finder = SrcDirFinder::new(root.to_path_buf(), ExclusionStrategy::DefaultHeuristics); let dirs = finder.find().unwrap(); assert_eq!(dirs.len(), 3); @@ -447,8 +424,7 @@ mod tests { create_dir(root, "app/src/main/java/com/example/kotlin"); - let finder = - JavaSrcDirFinder::new(root.to_path_buf(), ExclusionStrategy::DefaultHeuristics); + let finder = SrcDirFinder::new(root.to_path_buf(), ExclusionStrategy::DefaultHeuristics); let dirs = finder.find().unwrap(); assert_eq!(dirs.len(), 1); @@ -463,8 +439,7 @@ mod tests { create_dir(root, "src/main/java/com/example"); create_dir(root, "contest/src/main/java/com/example"); - let finder = - JavaSrcDirFinder::new(root.to_path_buf(), ExclusionStrategy::DefaultHeuristics); + let finder = SrcDirFinder::new(root.to_path_buf(), ExclusionStrategy::DefaultHeuristics); let dirs = finder.find().unwrap(); assert_eq!(dirs.len(), 2); diff --git a/qlty-coverage/src/transformer.rs b/qlty-coverage/src/transformer.rs index 0d7e5f94e..86bc1c303 100644 --- a/qlty-coverage/src/transformer.rs +++ b/qlty-coverage/src/transformer.rs @@ -300,26 +300,45 @@ impl Transformer for PrependSrcDir { fn transform(&self, file_coverage: FileCoverage) -> Option { let current_path = Path::new(&file_coverage.path); - // If file already exists at an absolute path, don't transform if current_path.is_absolute() && current_path.exists() { return Some(file_coverage); } - // Try each src dir in order (first match wins) - for src_dir in &self.src_dirs { - // Convert to absolute for existence check - let abs_candidate = self.root.join(src_dir).join(&file_coverage.path); - if abs_candidate.exists() { - // Output relative path - let relative_path = src_dir.join(&file_coverage.path); - return Some(FileCoverage { - path: path_to_string(&relative_path), - ..file_coverage - }); - } + let matching_dirs: Vec<&PathBuf> = self + .src_dirs + .iter() + .filter(|src_dir| { + let abs_candidate = self.root.join(src_dir).join(&file_coverage.path); + abs_candidate.exists() + }) + .collect(); + + if matching_dirs.len() > 1 { + let locations: Vec = matching_dirs + .iter() + .map(|dir| path_to_string(&dir.join(&file_coverage.path))) + .collect(); + + tracing::warn!( + "File '{}' found in multiple source directories: {}", + file_coverage.path, + locations.join(", ") + ); + eprintln!( + "WARNING: File '{}' found in multiple source directories: {}", + file_coverage.path, + locations.join(", ") + ); + } + + if let Some(src_dir) = matching_dirs.first() { + let relative_path = src_dir.join(&file_coverage.path); + return Some(FileCoverage { + path: path_to_string(&relative_path), + ..file_coverage + }); } - // No match found, keep original path Some(file_coverage) } @@ -752,5 +771,30 @@ mod tests { || result.path == "src\\main\\java\\App.java" ); } + + #[test] + fn uses_first_dir_when_found_in_multiple() { + let temp = TempDir::new().unwrap(); + + let first_dir = temp.path().join("first"); + let second_dir = temp.path().join("second"); + fs::create_dir_all(&first_dir).unwrap(); + fs::create_dir_all(&second_dir).unwrap(); + + fs::write(first_dir.join("App.java"), "class App {}").unwrap(); + fs::write(second_dir.join("App.java"), "class App {}").unwrap(); + + let transformer = PrependSrcDir::new( + temp.path().to_path_buf(), + vec![PathBuf::from("first"), PathBuf::from("second")], + ); + let file_coverage = FileCoverage { + path: "App.java".to_string(), + ..Default::default() + }; + + let result = transformer.transform(file_coverage).unwrap(); + assert!(result.path == "first/App.java" || result.path == "first\\App.java"); + } } }