diff --git a/ck-cli/tests/integration_tests.rs b/ck-cli/tests/integration_tests.rs index 27f1ca9..46ce31b 100644 --- a/ck-cli/tests/integration_tests.rs +++ b/ck-cli/tests/integration_tests.rs @@ -821,3 +821,66 @@ fn test_add_file_with_relative_path() { let stdout = String::from_utf8(output.stdout).unwrap(); assert!(stdout.contains("Relative path content")); } + +#[test] +#[serial] +fn test_no_ckignore_flag_disables_hierarchical_ignore() { + let temp_dir = TempDir::new().unwrap(); + let parent = temp_dir.path(); + let subdir = parent.join("subdir"); + fs::create_dir(&subdir).unwrap(); + + // Create .ckignore at parent level excluding *.tmp files + fs::write(parent.join(".ckignore"), "*.tmp\n").unwrap(); + + // Create test files with easily searchable pattern + fs::write(parent.join("test.txt"), "FINDME_TEXT").unwrap(); + fs::write(parent.join("ignored.tmp"), "FINDME_TMP").unwrap(); + fs::write(subdir.join("nested.txt"), "FINDME_TEXT").unwrap(); + fs::write(subdir.join("also_ignored.tmp"), "FINDME_TMP").unwrap(); + + // Test WITH --no-ckignore flag - .tmp files should be INCLUDED + // Using -r for recursive grep-style search (no indexing needed) + let output = Command::new(ck_binary()) + .args(["-r", "--no-ckignore", "FINDME", "."]) + .current_dir(parent) + .output() + .expect("Failed to run ck search --no-ckignore"); + + assert!( + output.status.success(), + "Search with --no-ckignore failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + + // With --no-ckignore, should find .tmp files + assert!( + stdout.contains("ignored.tmp") || stdout.contains("also_ignored.tmp"), + "Should find .tmp files when --no-ckignore is used. Output: {}", + stdout + ); + + // Test WITHOUT --no-ckignore flag (default behavior) - .tmp files should be EXCLUDED + let output = Command::new(ck_binary()) + .args(["-r", "FINDME", "."]) + .current_dir(parent) + .output() + .expect("Failed to run ck search"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + + // Without --no-ckignore (default), should NOT find .tmp files + assert!( + !stdout.contains("ignored.tmp") && !stdout.contains("also_ignored.tmp"), + "Should NOT find .tmp files when .ckignore is active (default). Output: {}", + stdout + ); + + // Should still find .txt files + assert!( + stdout.contains("test.txt") || stdout.contains("nested.txt"), + "Should find .txt files even with .ckignore active" + ); +} diff --git a/ck-core/src/lib.rs b/ck-core/src/lib.rs index dce5ccb..bc0fb54 100644 --- a/ck-core/src/lib.rs +++ b/ck-core/src/lib.rs @@ -1411,4 +1411,94 @@ mod tests { // Check for issue reference assert!(content.contains("issue #27")); } + + #[test] + fn test_build_exclude_patterns_with_defaults() { + // Test with defaults enabled + let additional = vec!["*.custom".to_string(), "temp/".to_string()]; + let patterns = build_exclude_patterns(&additional, true); + + // Should include additional patterns + assert!(patterns.contains(&"*.custom".to_string())); + assert!(patterns.contains(&"temp/".to_string())); + + // Should include default patterns (from get_default_exclude_patterns) + assert!(patterns.iter().any(|p| p.contains(".git"))); + assert!(patterns.iter().any(|p| p.contains("node_modules"))); + + // Additional patterns should come before defaults + let custom_idx = patterns.iter().position(|p| p == "*.custom").unwrap(); + let default_idx = patterns.iter().position(|p| p.contains(".git")).unwrap(); + assert!(custom_idx < default_idx); + } + + #[test] + fn test_build_exclude_patterns_without_defaults() { + // Test with defaults disabled + let additional = vec!["*.custom".to_string(), "temp/".to_string()]; + let patterns = build_exclude_patterns(&additional, false); + + // Should include additional patterns + assert!(patterns.contains(&"*.custom".to_string())); + assert!(patterns.contains(&"temp/".to_string())); + + // Should NOT include default patterns + assert!(!patterns.iter().any(|p| p.contains(".git"))); + assert!(!patterns.iter().any(|p| p.contains("node_modules"))); + + // Should only have the 2 additional patterns + assert_eq!(patterns.len(), 2); + } + + #[test] + fn test_build_exclude_patterns_empty_additional() { + // Test with empty additional patterns and defaults enabled + let patterns = build_exclude_patterns(&[], true); + + // Should only have default patterns + assert!(patterns.iter().any(|p| p.contains(".git"))); + assert!(!patterns.is_empty()); + + // Test with empty additional patterns and defaults disabled + let patterns = build_exclude_patterns(&[], false); + + // Should be empty + assert!(patterns.is_empty()); + } + + #[test] + fn test_read_ckignore_edge_cases() { + let temp_dir = TempDir::new().unwrap(); + let test_path = temp_dir.path(); + + // Test 1: Empty .ckignore file + let ckignore_path = test_path.join(".ckignore"); + fs::write(&ckignore_path, "").unwrap(); + let patterns = read_ckignore_patterns(test_path).unwrap(); + assert_eq!(patterns.len(), 0); + + // Test 2: .ckignore with only comments + fs::write(&ckignore_path, "# Comment 1\n# Comment 2\n# Comment 3\n").unwrap(); + let patterns = read_ckignore_patterns(test_path).unwrap(); + assert_eq!(patterns.len(), 0); + + // Test 3: .ckignore with only whitespace + fs::write(&ckignore_path, " \n\t\n \t \n").unwrap(); + let patterns = read_ckignore_patterns(test_path).unwrap(); + assert_eq!(patterns.len(), 0); + + // Test 4: .ckignore with mixed content + fs::write( + &ckignore_path, + "# Comment\n\n \n*.tmp \n *.log\n\n# Another comment\n", + ) + .unwrap(); + let patterns = read_ckignore_patterns(test_path).unwrap(); + assert_eq!(patterns.len(), 2); + assert!(patterns.contains(&"*.tmp".to_string())); + assert!(patterns.contains(&"*.log".to_string())); + // Patterns should be trimmed + assert!(!patterns.iter().any(|p| p.starts_with(' '))); + assert!(!patterns.iter().any(|p| p.ends_with(' '))); + } } diff --git a/ck-engine/src/lib.rs b/ck-engine/src/lib.rs index 9ec3132..38d6057 100644 --- a/ck-engine/src/lib.rs +++ b/ck-engine/src/lib.rs @@ -430,7 +430,7 @@ fn regex_search(options: &SearchOptions) -> Result> { // Use ck_index's collect_files which respects gitignore let file_options = ck_core::FileCollectionOptions { respect_gitignore: options.respect_gitignore, - use_ckignore: true, + use_ckignore: options.use_ckignore, exclude_patterns: options.exclude_patterns.clone(), }; let collected = ck_index::collect_files(&options.path, &file_options)?; @@ -1785,4 +1785,91 @@ mod tests { "Should not create .ck directory in subdirectory" ); } + + #[tokio::test] + async fn test_multiple_ckignore_files_merge_correctly() { + // Test that multiple .ckignore files in the hierarchy are all applied + use std::fs; + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let parent = temp_dir.path(); + let subdir = parent.join("subdir"); + let deeper = subdir.join("deeper"); + fs::create_dir(&subdir).unwrap(); + fs::create_dir(&deeper).unwrap(); + + // Create hierarchical .ckignore files + fs::write(parent.join(".ckignore"), "*.log\n").unwrap(); + fs::write(subdir.join(".ckignore"), "*.tmp\n").unwrap(); + fs::write(deeper.join(".ckignore"), "*.cache\n").unwrap(); + + // Create test files at each level + fs::write(parent.join("root.txt"), "searchable").unwrap(); + fs::write(parent.join("root.log"), "should be ignored").unwrap(); + + fs::write(subdir.join("mid.txt"), "searchable").unwrap(); + fs::write(subdir.join("mid.log"), "should be ignored by parent").unwrap(); + fs::write(subdir.join("mid.tmp"), "should be ignored by local").unwrap(); + + fs::write(deeper.join("deep.txt"), "searchable").unwrap(); + fs::write(deeper.join("deep.log"), "should be ignored by grandparent").unwrap(); + fs::write(deeper.join("deep.tmp"), "should be ignored by parent").unwrap(); + fs::write(deeper.join("deep.cache"), "should be ignored by local").unwrap(); + + // Index from parent + let parent_options = SearchOptions { + mode: SearchMode::Semantic, + query: "searchable".to_string(), + path: parent.to_path_buf(), + top_k: Some(20), + threshold: Some(0.1), + ..Default::default() + }; + + let _ = search(&parent_options).await; + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + // Search from deeper directory - should respect ALL three .ckignore files + let deeper_options = SearchOptions { + mode: SearchMode::Semantic, + query: "ignored".to_string(), + path: deeper.clone(), + top_k: Some(20), + threshold: Some(0.1), + ..Default::default() + }; + + let results = search(&deeper_options).await.unwrap(); + + // All ignored files should be excluded + let has_log = results + .iter() + .any(|r| r.file.to_string_lossy().ends_with(".log")); + let has_tmp = results + .iter() + .any(|r| r.file.to_string_lossy().ends_with(".tmp")); + let has_cache = results + .iter() + .any(|r| r.file.to_string_lossy().ends_with(".cache")); + + assert!( + !has_log, + "*.log files should be excluded by parent .ckignore" + ); + assert!( + !has_tmp, + "*.tmp files should be excluded by subdir .ckignore" + ); + assert!( + !has_cache, + "*.cache files should be excluded by deeper .ckignore" + ); + + // Should still find .txt files + let has_txt = results + .iter() + .any(|r| r.file.to_string_lossy().ends_with(".txt")); + assert!(has_txt, "Should find .txt files (not ignored)"); + } } diff --git a/ck-index/src/lib.rs b/ck-index/src/lib.rs index b8b4373..513fa85 100644 --- a/ck-index/src/lib.rs +++ b/ck-index/src/lib.rs @@ -2014,6 +2014,78 @@ mod tests { assert!(!test_path.join("level1").join("level2").exists()); assert!(!test_path.join("level1").exists()); } + + #[test] + fn test_ckignore_works_without_gitignore() { + // Test that .ckignore is respected even when respect_gitignore is false + let temp_dir = TempDir::new().unwrap(); + let test_path = temp_dir.path(); + + // Create .gitignore and .ckignore with different patterns + fs::write(test_path.join(".gitignore"), "*.git\n").unwrap(); + fs::write(test_path.join(".ckignore"), "*.ck\n").unwrap(); + + // Create test files + fs::write(test_path.join("normal.txt"), "normal content").unwrap(); + fs::write(test_path.join("ignored_by_git.git"), "git ignored").unwrap(); + fs::write(test_path.join("ignored_by_ck.ck"), "ck ignored").unwrap(); + + // Test with respect_gitignore=false, use_ckignore=true + let options = ck_core::FileCollectionOptions { + respect_gitignore: false, + use_ckignore: true, + exclude_patterns: vec![], + }; + + let files = collect_files(test_path, &options).unwrap(); + let file_names: Vec = files + .iter() + .filter_map(|p| p.file_name()) + .map(|n| n.to_string_lossy().to_string()) + .collect(); + + // Should find normal.txt + assert!( + file_names.contains(&"normal.txt".to_string()), + "Should find normal.txt" + ); + + // Should find .git file (gitignore not respected) + assert!( + file_names.contains(&"ignored_by_git.git".to_string()), + "Should find .git file when respect_gitignore=false" + ); + + // Should NOT find .ck file (ckignore is respected) + assert!( + !file_names.contains(&"ignored_by_ck.ck".to_string()), + "Should NOT find .ck file when use_ckignore=true" + ); + + // Test with both disabled + let options_both_disabled = ck_core::FileCollectionOptions { + respect_gitignore: false, + use_ckignore: false, + exclude_patterns: vec![], + }; + + let files_all = collect_files(test_path, &options_both_disabled).unwrap(); + let file_names_all: Vec = files_all + .iter() + .filter_map(|p| p.file_name()) + .map(|n| n.to_string_lossy().to_string()) + .collect(); + + // Should find ALL files when both are disabled + assert!( + file_names_all.contains(&"ignored_by_git.git".to_string()), + "Should find .git file" + ); + assert!( + file_names_all.contains(&"ignored_by_ck.ck".to_string()), + "Should find .ck file when use_ckignore=false" + ); + } } // ============================================================================