Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions ck-cli/tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
90 changes: 90 additions & 0 deletions ck-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(' ')));
}
}
89 changes: 88 additions & 1 deletion ck-engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ fn regex_search(options: &SearchOptions) -> Result<Vec<SearchResult>> {
// 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)?;
Expand Down Expand Up @@ -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)");
}
}
72 changes: 72 additions & 0 deletions ck-index/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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<String> = 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"
);
}
}

// ============================================================================
Expand Down