From 98b207484159cf332e8ae8fb72eddb049ac59022 Mon Sep 17 00:00:00 2001 From: David Christle Date: Fri, 25 Jul 2025 13:02:31 -0700 Subject: [PATCH 1/2] fix: make include patterns additive to source detection - Fix include patterns not working for non-source file types (e.g., *.peb) - Refactor filtering logic into centralized should_process_file() function - Implement union behavior: files match if source OR include patterns match - Add comprehensive test coverage for pattern interactions and edge cases --- src/analyzer.rs | 644 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 433 insertions(+), 211 deletions(-) diff --git a/src/analyzer.rs b/src/analyzer.rs index 04704e5..b81de3c 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -88,6 +88,119 @@ fn determine_project_name(paths: &[String]) -> String { } } +fn should_process_file(entry: &ignore::DirEntry, args: &Cli, base_path: &Path) -> bool { + // Basic file checks + if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) { + return false; + } + + let path = entry.path(); + let max_size = args.max_size.expect("max_size should be set from config"); + + // Size check + if !entry + .metadata() + .map(|m| m.len() <= max_size) + .unwrap_or(false) + { + return false; + } + + // Check if it's a source file + let is_source = source_detection::is_source_file(path); + + // Check if it matches additional include patterns + let matches_include = if let Some(ref includes) = args.include { + matches_include_patterns(path, includes, base_path) + } else { + false + }; + + // Include if EITHER source file OR matches include patterns + let should_include = is_source || matches_include; + + if !should_include { + return false; + } + + // Apply excludes to the union + if let Some(ref excludes) = args.exclude { + return !matches_exclude_patterns(path, excludes, base_path); + } + + true +} + +fn matches_include_patterns(path: &Path, includes: &[String], base_path: &Path) -> bool { + let mut override_builder = OverrideBuilder::new(base_path); + + // Add include patterns (positive) + for pattern in includes { + if let Err(e) = override_builder.add(pattern) { + eprintln!("Warning: Invalid include pattern '{pattern}': {e}"); + } + } + + let overrides = override_builder.build().unwrap_or_else(|_| { + // Return a default override that matches nothing if build fails + OverrideBuilder::new(base_path).build().unwrap() + }); + let match_result = overrides.matched(path, false); + + // Must be whitelisted and not ignored + match_result.is_whitelist() && !match_result.is_ignore() +} + +fn matches_exclude_patterns(path: &Path, excludes: &[Exclude], base_path: &Path) -> bool { + let mut override_builder = OverrideBuilder::new(base_path); + + // Add exclude patterns (negative) + for exclude in excludes { + match exclude { + Exclude::Pattern(pattern) => { + let exclude_pattern = if !pattern.starts_with('!') { + format!("!{pattern}") + } else { + pattern.clone() + }; + if let Err(e) = override_builder.add(&exclude_pattern) { + eprintln!("Warning: Invalid exclude pattern '{pattern}': {e}"); + } + } + Exclude::File(file_path) => { + // Handle file exclusions + if file_path.is_absolute() { + if file_path.exists() { + if let Ok(relative_path) = file_path.strip_prefix(base_path) { + let pattern = format!("!{}", relative_path.display()); + if let Err(e) = override_builder.add(&pattern) { + eprintln!( + "Warning: Could not add file exclude pattern for '{}': {}", + file_path.display(), + e + ); + } + } + } + } else { + let pattern = format!("!{}", file_path.display()); + if let Err(e) = override_builder.add(&pattern) { + eprintln!("Warning: Could not add file exclude pattern '{pattern}': {e}"); + } + } + } + } + } + + let overrides = override_builder.build().unwrap_or_else(|_| { + // Return a default override that matches nothing if build fails + OverrideBuilder::new(base_path).build().unwrap() + }); + let match_result = overrides.matched(path, false); + + match_result.is_ignore() +} + pub fn process_entries(args: &Cli) -> Result> { let max_size = args.max_size.expect("max_size should be set from config"); let max_depth = args.max_depth.expect("max_depth should be set from config"); @@ -125,69 +238,8 @@ pub fn process_entries(args: &Cli) -> Result> { .ignore(!args.no_ignore); let mut override_builder = OverrideBuilder::new(path); - override_builder.add("!**/GLIMPSE.md")?; override_builder.add("!**/.glimpse")?; - - // Handle include patterns first (positive patterns) - if let Some(ref includes) = args.include { - for pattern in includes { - // Include patterns are positive patterns (no ! prefix) - if let Err(e) = override_builder.add(pattern) { - eprintln!("Warning: Invalid include pattern '{pattern}': {e}"); - } - } - } - - // Handle exclude patterns (negative patterns) - if let Some(ref excludes) = args.exclude { - for exclude in excludes { - match exclude { - Exclude::Pattern(pattern) => { - // Add a '!' prefix if it doesn't already have one - // This makes it a negative pattern (exclude) - let exclude_pattern = if !pattern.starts_with('!') { - format!("!{pattern}") - } else { - pattern.clone() - }; - - if let Err(e) = override_builder.add(&exclude_pattern) { - eprintln!("Warning: Invalid exclude pattern '{pattern}': {e}"); - } - } - Exclude::File(file_path) => { - // For file excludes, handle differently if: - if file_path.is_absolute() { - // For absolute paths, check if they exist - if file_path.exists() { - // If base_path is part of file_path, make it relative - if let Ok(relative_path) = file_path.strip_prefix(path) { - let pattern = format!("!{}", relative_path.display()); - if let Err(e) = override_builder.add(&pattern) { - eprintln!("Warning: Could not add file exclude pattern for '{}': {}", file_path.display(), e); - } - } else { - // This doesn't affect current directory - eprintln!( - "Note: File exclude not under current path: {}", - file_path.display() - ); - } - } - } else { - // For relative paths like "src", use as-is with a ! prefix - let pattern = format!("!{}", file_path.display()); - if let Err(e) = override_builder.add(&pattern) { - eprintln!( - "Warning: Could not add file exclude pattern '{pattern}': {e}" - ); - } - } - } - } - } - } let overrides = override_builder.build()?; builder.overrides(overrides); @@ -195,94 +247,21 @@ pub fn process_entries(args: &Cli) -> Result> { .build() .par_bridge() .filter_map(|entry| entry.ok()) - // No longer need the is_excluded filter here, WalkBuilder handles it - .filter(|entry| { - entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) - && source_detection::is_source_file(entry.path()) - && entry - .metadata() - .map(|m| m.len() <= max_size) - .unwrap_or(false) - }) + .filter(|entry| should_process_file(entry, args, path)) .filter_map(|entry| process_file(&entry, path).ok()) .collect(); all_entries.extend(dir_entries); } else if path.is_file() { // Process single file - if source_detection::is_source_file(path) - && path - .metadata() - .map(|m| m.len() <= max_size) - .unwrap_or(false) - { - // Need to check includes and excludes even for single files explicitly passed - let mut excluded = false; - let mut override_builder = OverrideBuilder::new(path.parent().unwrap_or(path)); // Base relative to parent - - // Handle include patterns first (positive patterns) - if let Some(ref includes) = args.include { - for pattern in includes { - // Include patterns are positive patterns (no ! prefix) - if let Err(e) = override_builder.add(pattern) { - eprintln!("Warning: Invalid include pattern '{pattern}': {e}"); - } - } - } - - // Handle exclude patterns (negative patterns) - if let Some(ref excludes) = args.exclude { - for exclude in excludes { - match exclude { - Exclude::Pattern(pattern) => { - // Add a '!' prefix if it doesn't already have one - // This makes it a negative pattern (exclude) - let exclude_pattern = if !pattern.starts_with('!') { - format!("!{pattern}") - } else { - pattern.clone() - }; - if let Err(e) = override_builder.add(&exclude_pattern) { - eprintln!( - "Warning: Invalid exclude pattern '{pattern}': {e}" - ); - } - } - Exclude::File(file_path) => { - if path == file_path { - excluded = true; - break; - } - } - } - } - if excluded { - continue; - } - } - - let overrides = override_builder.build()?; - let match_result = overrides.matched(path, false); - - // If there are include patterns, the file must match at least one include pattern - // and not be excluded by any exclude pattern - if args.include.is_some() { - // With include patterns: file must be whitelisted (matched by include) and not ignored (excluded) - excluded = !match_result.is_whitelist() || match_result.is_ignore(); - } else { - // Without include patterns: file is excluded only if it matches an exclude pattern - excluded = match_result.is_ignore(); - } - - if !excluded { - let entry = ignore::WalkBuilder::new(path) - .build() - .next() - .and_then(|r| r.ok()); - if let Some(entry) = entry { - if let Ok(file_entry) = process_file(&entry, path) { - all_entries.push(file_entry); - } + let entry = ignore::WalkBuilder::new(path) + .build() + .next() + .and_then(|r| r.ok()); + if let Some(entry) = entry { + if should_process_file(&entry, args, path.parent().unwrap_or(path)) { + if let Ok(file_entry) = process_file(&entry, path) { + all_entries.push(file_entry); } } } @@ -467,8 +446,9 @@ mod tests { // Verify no .rs files were processed for entry in &entries { - assert!( - entry.path.extension().and_then(|ext| ext.to_str()) != Some("rs"), + assert_ne!( + entry.path.extension().and_then(|ext| ext.to_str()), + Some("rs"), "Found .rs file that should have been excluded: {:?}", entry.path ); @@ -502,43 +482,39 @@ mod tests { let (dir, _files) = setup_test_directory()?; let mut cli = create_test_cli(dir.path()); - // Test including only Rust files + // Test including additional Rust files (should get all source files) cli.include = Some(vec!["**/*.rs".to_string()]); let entries = process_entries(&cli)?; - // Verify only .rs files were processed - assert!(!entries.is_empty(), "Should have found some .rs files"); - for entry in &entries { - assert!( - entry.path.extension().and_then(|ext| ext.to_str()) == Some("rs"), - "Found non-.rs file: {:?}", - entry.path - ); - } + // Should include all source files (since .rs is already a source extension) + assert!(!entries.is_empty(), "Should have found files"); - // Should find 4 .rs files: main.rs, lib.rs, test.rs, code.rs - assert_eq!(entries.len(), 4, "Should find exactly 4 .rs files"); + // Should include source files: .rs, .py, .md + let extensions: Vec<_> = entries + .iter() + .filter_map(|e| e.path.extension().and_then(|ext| ext.to_str())) + .collect(); + assert!(extensions.contains(&"rs")); + assert!(extensions.contains(&"py")); + assert!(extensions.contains(&"md")); - // Test including multiple patterns - cli.include = Some(vec!["**/*.rs".to_string(), "**/*.py".to_string()]); - let entries = process_entries(&cli)?; + // Test including a non-source extension as additional + cli.include = Some(vec!["**/*.xyz".to_string()]); - // Verify only .rs and .py files were processed - assert!( - !entries.is_empty(), - "Should have found some .rs and .py files" - ); - for entry in &entries { - let ext = entry.path.extension().and_then(|ext| ext.to_str()); - assert!( - ext == Some("rs") || ext == Some("py"), - "Found file with unexpected extension: {:?}", - entry.path - ); - } + // Create a .xyz file + fs::write(dir.path().join("test.xyz"), "data")?; + + let entries = process_entries(&cli)?; - // Should find 4 .rs files + 1 .py file = 5 total - assert_eq!(entries.len(), 5, "Should find exactly 5 .rs and .py files"); + // Should include BOTH .xyz files AND normal source files + let extensions: Vec<_> = entries + .iter() + .filter_map(|e| e.path.extension().and_then(|ext| ext.to_str())) + .collect(); + assert!(extensions.contains(&"xyz")); // Additional pattern + assert!(extensions.contains(&"rs")); // Normal source file + assert!(extensions.contains(&"py")); // Normal source file + assert!(extensions.contains(&"md")); // Normal source file Ok(()) } @@ -548,19 +524,31 @@ mod tests { let (dir, _files) = setup_test_directory()?; let mut cli = create_test_cli(dir.path()); - // Test including only Rust files but excluding specific ones - cli.include = Some(vec!["**/*.rs".to_string()]); + // Test additional includes with excludes - should get all source files plus additional, minus excludes + cli.include = Some(vec!["**/*.xyz".to_string()]); cli.exclude = Some(vec![Exclude::Pattern("**/test.rs".to_string())]); + + // Create a .xyz file + fs::write(dir.path().join("test.xyz"), "data")?; + let entries = process_entries(&cli)?; - // Verify only .rs files were processed, but test.rs was excluded - assert!(!entries.is_empty(), "Should have found some .rs files"); + // Should include all source files + .xyz files, but exclude test.rs + assert!(!entries.is_empty(), "Should have found files"); + + let extensions: Vec<_> = entries + .iter() + .filter_map(|e| e.path.extension().and_then(|ext| ext.to_str())) + .collect(); + + // Should have .xyz (additional) plus source files (.rs, .py, .md) + assert!(extensions.contains(&"xyz")); // Additional pattern + assert!(extensions.contains(&"rs")); // Source files (but not test.rs) + assert!(extensions.contains(&"py")); // Source files + assert!(extensions.contains(&"md")); // Source files + + // Verify test.rs was excluded for entry in &entries { - assert!( - entry.path.extension().and_then(|ext| ext.to_str()) == Some("rs"), - "Found non-.rs file: {:?}", - entry.path - ); assert!( !entry.path.to_string_lossy().contains("test.rs"), "Found excluded test.rs file: {:?}", @@ -568,30 +556,13 @@ mod tests { ); } - // Should find 3 .rs files (main.rs, lib.rs, code.rs) but not test.rs - assert_eq!( - entries.len(), - 3, - "Should find exactly 3 .rs files (excluding test.rs)" - ); - - // Test including multiple file types but excluding a directory - cli.include = Some(vec!["**/*.rs".to_string(), "**/*.py".to_string()]); + // Test excluding a directory + cli.include = Some(vec!["**/*.xyz".to_string()]); cli.exclude = Some(vec![Exclude::Pattern("**/nested/**".to_string())]); let entries = process_entries(&cli)?; - // Verify only .rs and .py files were processed, but nested directory was excluded - assert!( - !entries.is_empty(), - "Should have found some .rs and .py files" - ); + // Should include source files + .xyz, but exclude nested directory for entry in &entries { - let ext = entry.path.extension().and_then(|ext| ext.to_str()); - assert!( - ext == Some("rs") || ext == Some("py"), - "Found file with unexpected extension: {:?}", - entry.path - ); assert!( !entry.path.to_string_lossy().contains("nested"), "Found file from excluded nested directory: {:?}", @@ -599,13 +570,6 @@ mod tests { ); } - // Should find 3 .rs files (main.rs, lib.rs, test.rs) but not code.rs or script.py from nested - assert_eq!( - entries.len(), - 3, - "Should find exactly 3 files (excluding nested directory)" - ); - Ok(()) } @@ -656,4 +620,262 @@ mod tests { Ok(()) } + + #[test] + fn test_include_patterns_extend_source_detection() -> Result<()> { + let (dir, _files) = setup_test_directory()?; + + // Create a .peb file (not recognized by source detection) + let peb_path = dir.path().join("template.peb"); + let mut peb_file = File::create(&peb_path)?; + writeln!(peb_file, "template content")?; + + // Create a .xyz file (also not recognized) + let xyz_path = dir.path().join("data.xyz"); + let mut xyz_file = File::create(&xyz_path)?; + writeln!(xyz_file, "data content")?; + + let mut cli = create_test_cli(dir.path()); + + // Test 1: Without include patterns, non-source files should be excluded + cli.include = None; + let entries = process_entries(&cli)?; + assert!(!entries + .iter() + .any(|e| e.path.extension().and_then(|ext| ext.to_str()) == Some("peb"))); + assert!(!entries + .iter() + .any(|e| e.path.extension().and_then(|ext| ext.to_str()) == Some("xyz"))); + + // Test 2: With include patterns, should ADD to source detection + cli.include = Some(vec!["*.peb".to_string()]); + let entries = process_entries(&cli)?; + + // Should include .peb files PLUS all normal source files + let extensions: Vec<_> = entries + .iter() + .filter_map(|e| e.path.extension().and_then(|ext| ext.to_str())) + .collect(); + assert!(extensions.contains(&"peb")); // Additional pattern + assert!(extensions.contains(&"rs")); // Normal source file + assert!(extensions.contains(&"py")); // Normal source file + assert!(extensions.contains(&"md")); // Normal source file + + // Test 3: Multiple include patterns (additive) + cli.include = Some(vec!["*.peb".to_string(), "*.xyz".to_string()]); + let entries = process_entries(&cli)?; + + let extensions: Vec<_> = entries + .iter() + .filter_map(|e| e.path.extension().and_then(|ext| ext.to_str())) + .collect(); + assert!(extensions.contains(&"peb")); // Additional pattern + assert!(extensions.contains(&"xyz")); // Additional pattern + assert!(extensions.contains(&"rs")); // Normal source file + assert!(extensions.contains(&"py")); // Normal source file + assert!(extensions.contains(&"md")); // Normal source file + + // Test 4: Include + exclude patterns (union then subtract) + cli.include = Some(vec!["*.peb".to_string(), "*.xyz".to_string()]); + cli.exclude = Some(vec![Exclude::Pattern("*.xyz".to_string())]); + let entries = process_entries(&cli)?; + + let extensions: Vec<_> = entries + .iter() + .filter_map(|e| e.path.extension().and_then(|ext| ext.to_str())) + .collect(); + assert!(extensions.contains(&"peb")); // Additional pattern, not excluded + assert!(!extensions.contains(&"xyz")); // Additional pattern, but excluded + assert!(extensions.contains(&"rs")); // Normal source file, not excluded + assert!(extensions.contains(&"py")); // Normal source file, not excluded + assert!(extensions.contains(&"md")); // Normal source file, not excluded + + Ok(()) + } + + #[test] + fn test_backward_compatibility_no_patterns() -> Result<()> { + let (dir, _files) = setup_test_directory()?; + + // Add some non-source files that should be ignored by default + let binary_path = dir.path().join("binary.bin"); + fs::write(&binary_path, b"\x00\x01\x02\x03")?; + + let config_path = dir.path().join("config.conf"); + fs::write(&config_path, "key=value")?; + + let mut cli = create_test_cli(dir.path()); + + // Test 1: No patterns specified - should only get source files + cli.include = None; + cli.exclude = None; + let entries = process_entries(&cli)?; + + // Should find source files (.rs, .py, .md) but not .bin or .conf + let extensions: Vec<_> = entries + .iter() + .filter_map(|e| e.path.extension().and_then(|ext| ext.to_str())) + .collect(); + + assert!(extensions.contains(&"rs")); + assert!(extensions.contains(&"py")); + assert!(extensions.contains(&"md")); + assert!(!extensions.contains(&"bin")); + assert!(!extensions.contains(&"conf")); + + // Test 2: Only exclude patterns - should work as before + cli.exclude = Some(vec![Exclude::Pattern("**/*.rs".to_string())]); + let entries = process_entries(&cli)?; + + // Should still apply source detection, but exclude .rs files + let extensions: Vec<_> = entries + .iter() + .filter_map(|e| e.path.extension().and_then(|ext| ext.to_str())) + .collect(); + + assert!(!extensions.contains(&"rs")); // Excluded + assert!(extensions.contains(&"py")); // Source file, not excluded + assert!(!extensions.contains(&"bin")); // Not source file + + Ok(()) + } + + #[test] + fn test_single_file_processing_with_patterns() -> Result<()> { + let (dir, _files) = setup_test_directory()?; + + // Create a .peb file + let peb_path = dir.path().join("template.peb"); + fs::write(&peb_path, "template content")?; + + // Test 1: Single .peb file without include patterns - should be rejected + let mut cli = create_test_cli(&peb_path); + cli.paths = vec![peb_path.to_string_lossy().to_string()]; + cli.include = None; + let entries = process_entries(&cli)?; + assert_eq!(entries.len(), 0); + + // Test 2: Single .peb file WITH include patterns - should be accepted + cli.include = Some(vec!["*.peb".to_string()]); + let entries = process_entries(&cli)?; + assert_eq!(entries.len(), 1); + + // Test 3: Single .rs file with exclude pattern - should be rejected + let rs_path = dir.path().join("src/main.rs"); + cli.paths = vec![rs_path.to_string_lossy().to_string()]; + cli.include = None; + cli.exclude = Some(vec![Exclude::Pattern("**/*.rs".to_string())]); + let entries = process_entries(&cli)?; + assert_eq!(entries.len(), 0); + + Ok(()) + } + + #[test] + fn test_pattern_edge_cases() -> Result<()> { + let (dir, _files) = setup_test_directory()?; + + // Create various test files + fs::write(dir.path().join("test.peb"), "content")?; + fs::write(dir.path().join("test.xyz"), "content")?; + fs::write(dir.path().join("script.py"), "print('test')")?; + + let mut cli = create_test_cli(dir.path()); + + // Test 1: Empty include patterns (edge case) - should still get source files + cli.include = Some(vec![]); + let entries = process_entries(&cli)?; + // With empty include patterns, should still get source files + assert!(!entries.is_empty()); + + let extensions: Vec<_> = entries + .iter() + .filter_map(|e| e.path.extension().and_then(|ext| ext.to_str())) + .collect(); + assert!(extensions.contains(&"rs")); // Source files should still be included + assert!(extensions.contains(&"py")); + assert!(extensions.contains(&"md")); + + // Test 2: Include pattern that matches source files (additive) + cli.include = Some(vec!["**/*.py".to_string()]); + let entries = process_entries(&cli)?; + // Should include source files + additional .py matches + let extensions: Vec<_> = entries + .iter() + .filter_map(|e| e.path.extension().and_then(|ext| ext.to_str())) + .collect(); + assert!(extensions.contains(&"py")); // Both existing and additional + assert!(extensions.contains(&"rs")); // Source files + assert!(extensions.contains(&"md")); // Source files + + // Test 3: Include everything, then exclude + cli.include = Some(vec!["**/*".to_string()]); + cli.exclude = Some(vec![Exclude::Pattern("**/*.rs".to_string())]); + let entries = process_entries(&cli)?; + + // Should include everything (.peb, .xyz, .py, .md from both source detection and include pattern) but not .rs files + let extensions: Vec<_> = entries + .iter() + .filter_map(|e| e.path.extension().and_then(|ext| ext.to_str())) + .collect(); + + assert!(extensions.contains(&"peb")); + assert!(extensions.contains(&"xyz")); + assert!(extensions.contains(&"py")); + assert!(extensions.contains(&"md")); + assert!(!extensions.contains(&"rs")); + + Ok(()) + } + + #[test] + fn test_invalid_patterns_handling() -> Result<()> { + let (dir, _files) = setup_test_directory()?; + let mut cli = create_test_cli(dir.path()); + + // Test 1: Invalid glob pattern (this should not panic) + cli.include = Some(vec!["[invalid".to_string()]); + let _entries = process_entries(&cli)?; + // Should handle gracefully, possibly matching nothing + + // Test 2: Mix of valid and invalid patterns + cli.include = Some(vec![ + "**/*.rs".to_string(), + "[invalid".to_string(), + "**/*.py".to_string(), + ]); + let _entries = process_entries(&cli)?; + // Should process valid patterns, ignore invalid ones + + Ok(()) + } + + #[test] + fn test_include_patterns_are_additional() -> Result<()> { + let (dir, _files) = setup_test_directory()?; + + // Create a .peb file + fs::write(dir.path().join("template.peb"), "template content")?; + + let mut cli = create_test_cli(dir.path()); + cli.include = Some(vec!["*.peb".to_string()]); + + let entries = process_entries(&cli)?; + + // Should include BOTH .peb files AND normal source files + let extensions: Vec<_> = entries + .iter() + .filter_map(|e| e.path.extension().and_then(|ext| ext.to_str())) + .collect(); + + assert!(extensions.contains(&"peb")); // Additional pattern + assert!(extensions.contains(&"rs")); // Normal source file + assert!(extensions.contains(&"py")); // Normal source file + assert!(extensions.contains(&"md")); // Normal source file + + // Should be more than just the .peb file + assert!(entries.len() > 1); + + Ok(()) + } } From 620801ba80ab86ab231dc73aafbd0b7ccff9b7d4 Mon Sep 17 00:00:00 2001 From: David Christle Date: Sat, 26 Jul 2025 16:22:25 -0700 Subject: [PATCH 2/2] feat: add --only-include flag for replacement include behavior - Add --only-include CLI flag that replaces source detection entirely - Keep existing --include flag as additive behavior (source files + patterns) - Add validation to prevent both flags being used together + tests --- src/analyzer.rs | 181 ++++++++++++++++++++++++++++++++++++++++++++++++ src/cli.rs | 13 +++- src/output.rs | 1 + 3 files changed, 194 insertions(+), 1 deletion(-) diff --git a/src/analyzer.rs b/src/analyzer.rs index b81de3c..2d350e9 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -106,6 +106,23 @@ fn should_process_file(entry: &ignore::DirEntry, args: &Cli, base_path: &Path) - return false; } + // Handle replacement mode with --only-include + if let Some(ref only_includes) = args.only_include { + let matches_only_include = matches_include_patterns(path, only_includes, base_path); + + if !matches_only_include { + return false; + } + + // Apply excludes if any + if let Some(ref excludes) = args.exclude { + return !matches_exclude_patterns(path, excludes, base_path); + } + + return true; + } + + // Handle additive mode // Check if it's a source file let is_source = source_detection::is_source_file(path); @@ -357,6 +374,7 @@ mod tests { paths: vec![dir_path.to_string_lossy().to_string()], config_path: false, include: None, + only_include: None, exclude: None, max_size: Some(10 * 1024 * 1024), // 10MB max_depth: Some(10), @@ -878,4 +896,167 @@ mod tests { Ok(()) } + + #[test] + fn test_only_include_replacement_behavior() -> Result<()> { + let (dir, _files) = setup_test_directory()?; + + // Create various test files including non-source files + fs::write(dir.path().join("config.conf"), "key=value")?; + fs::write(dir.path().join("data.toml"), "[section]\nkey = 'value'")?; + fs::write(dir.path().join("template.peb"), "template content")?; + + let mut cli = create_test_cli(dir.path()); + + // Test 1: --only-include should ONLY include specified patterns, no other files + cli.only_include = Some(vec!["*.conf".to_string()]); + let entries = process_entries(&cli)?; + + assert_eq!(entries.len(), 1); + assert!(entries[0].path.extension().and_then(|ext| ext.to_str()) == Some("conf")); + + // Verify no other files are included + let extensions: Vec<_> = entries + .iter() + .filter_map(|e| e.path.extension().and_then(|ext| ext.to_str())) + .collect(); + assert!(!extensions.contains(&"rs")); + assert!(!extensions.contains(&"py")); + assert!(!extensions.contains(&"md")); + assert!(!extensions.contains(&"toml")); + + // Test 2: Multiple patterns in --only-include + cli.only_include = Some(vec!["*.conf".to_string(), "*.toml".to_string()]); + let entries = process_entries(&cli)?; + + assert_eq!(entries.len(), 2); + let extensions: Vec<_> = entries + .iter() + .filter_map(|e| e.path.extension().and_then(|ext| ext.to_str())) + .collect(); + assert!(extensions.contains(&"conf")); + assert!(extensions.contains(&"toml")); + assert!(!extensions.contains(&"rs")); // No other files + assert!(!extensions.contains(&"py")); + + // Test 3: --only-include with exclude patterns + cli.only_include = Some(vec![ + "*.conf".to_string(), + "*.toml".to_string(), + "*.peb".to_string(), + ]); + cli.exclude = Some(vec![Exclude::Pattern("*.toml".to_string())]); + let entries = process_entries(&cli)?; + + assert_eq!(entries.len(), 2); // conf and peb, but not toml (excluded) + let extensions: Vec<_> = entries + .iter() + .filter_map(|e| e.path.extension().and_then(|ext| ext.to_str())) + .collect(); + assert!(extensions.contains(&"conf")); + assert!(extensions.contains(&"peb")); + assert!(!extensions.contains(&"toml")); // Excluded + assert!(!extensions.contains(&"rs")); // No other files + + // Test 4: --only-include with pattern that matches nothing + cli.only_include = Some(vec!["*.nonexistent".to_string()]); + cli.exclude = None; + let entries = process_entries(&cli)?; + + assert_eq!(entries.len(), 0); // Should match nothing + + Ok(()) + } + + #[test] + fn test_only_include_vs_include_behavior_difference() -> Result<()> { + let (dir, _files) = setup_test_directory()?; + + // Create a non-source file + fs::write(dir.path().join("config.conf"), "key=value")?; + + let mut cli = create_test_cli(dir.path()); + + // Test additive behavior with --include + cli.include = Some(vec!["*.conf".to_string()]); + cli.only_include = None; + let additive_entries = process_entries(&cli)?; + + // Should include conf + source files + let additive_extensions: Vec<_> = additive_entries + .iter() + .filter_map(|e| e.path.extension().and_then(|ext| ext.to_str())) + .collect(); + assert!(additive_extensions.contains(&"conf")); // Additional pattern + assert!(additive_extensions.contains(&"rs")); // Source files + assert!(additive_extensions.contains(&"py")); // Source files + assert!(additive_extensions.contains(&"md")); // Source files + + // Test replacement behavior with --only-include + cli.include = None; + cli.only_include = Some(vec!["*.conf".to_string()]); + let replacement_entries = process_entries(&cli)?; + + // Should include ONLY conf files + assert_eq!(replacement_entries.len(), 1); + assert!( + replacement_entries[0] + .path + .extension() + .and_then(|ext| ext.to_str()) + == Some("conf") + ); + + let replacement_extensions: Vec<_> = replacement_entries + .iter() + .filter_map(|e| e.path.extension().and_then(|ext| ext.to_str())) + .collect(); + assert!(replacement_extensions.contains(&"conf")); // Only pattern + assert!(!replacement_extensions.contains(&"rs")); // No source files + assert!(!replacement_extensions.contains(&"py")); // No source files + assert!(!replacement_extensions.contains(&"md")); // No source files + + // Verify the counts are different + assert!(additive_entries.len() > replacement_entries.len()); + + Ok(()) + } + + #[test] + fn test_only_include_single_file_processing() -> Result<()> { + let (dir, _files) = setup_test_directory()?; + + // Create and test single file processing with a truly non-source file + let config_path = dir.path().join("config.conf"); + fs::write(&config_path, "key=value")?; + + let mut cli = create_test_cli(&config_path); + cli.paths = vec![config_path.to_string_lossy().to_string()]; + + // Test 1: Single non-source file without --only-include should be rejected + cli.only_include = None; + let entries = process_entries(&cli)?; + assert_eq!(entries.len(), 0); + + // Test 2: Single non-source file WITH --only-include should be accepted + cli.only_include = Some(vec!["*.conf".to_string()]); + let entries = process_entries(&cli)?; + assert_eq!(entries.len(), 1); + assert!(entries[0].path.extension().and_then(|ext| ext.to_str()) == Some("conf")); + + // Test 3: Single source file WITH --only-include that doesn't match should be rejected + let rs_path = dir.path().join("src/main.rs"); + cli.paths = vec![rs_path.to_string_lossy().to_string()]; + cli.only_include = Some(vec!["*.conf".to_string()]); + let entries = process_entries(&cli)?; + assert_eq!(entries.len(), 0); + + // Test 4: Single source file WITH --only-include that matches should be accepted + cli.only_include = Some(vec!["*.rs".to_string()]); + let entries = process_entries(&cli)?; + assert_eq!(entries.len(), 1); + assert!(entries[0].path.extension().and_then(|ext| ext.to_str()) == Some("rs")); + + Ok(()) + } } diff --git a/src/cli.rs b/src/cli.rs index 5d846fe..d8147e4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -39,10 +39,14 @@ pub struct Cli { #[arg(long)] pub config_path: bool, - /// Additional patterns to include (e.g. "*.rs,*.go") + /// Additional patterns to include (e.g. "*.rs,*.go") - adds to source file detection #[arg(short, long, value_delimiter = ',')] pub include: Option>, + /// Only include files matching these patterns (e.g. "*.yml,*.toml") - replaces source file detection + #[arg(long, value_delimiter = ',')] + pub only_include: Option>, + /// Additional patterns to exclude #[arg(short, long, value_parser = parse_exclude, value_delimiter = ',')] pub exclude: Option>, @@ -168,6 +172,13 @@ impl Cli { } pub fn validate_args(&self, is_url: bool) -> anyhow::Result<()> { + // Validate that both include and only_include are not used together + if self.include.is_some() && self.only_include.is_some() { + return Err(anyhow::anyhow!( + "Cannot use both --include and --only-include flags together. Use --include for additive behavior (add to source files) or --only-include for replacement behavior (only specified patterns)." + )); + } + if is_url { return Ok(()); } diff --git a/src/output.rs b/src/output.rs index 3a12deb..ee305e4 100644 --- a/src/output.rs +++ b/src/output.rs @@ -449,6 +449,7 @@ mod tests { config: false, paths: vec![".".to_string()], include: None, + only_include: None, exclude: None, max_size: Some(1000), max_depth: Some(10),