1pub mod heatmap;
2
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5use thiserror::Error;
6
7#[derive(Debug, Error)]
8pub enum CkError {
9 #[error("IO error: {0}")]
10 Io(#[from] std::io::Error),
11
12 #[error("Regex error: {0}")]
13 Regex(#[from] regex::Error),
14
15 #[error("Serialization error: {0}")]
16 Serialization(#[from] bincode::Error),
17
18 #[error("JSON error: {0}")]
19 Json(#[from] serde_json::Error),
20
21 #[error("Index error: {0}")]
22 Index(String),
23
24 #[error("Search error: {0}")]
25 Search(String),
26
27 #[error("Embedding error: {0}")]
28 Embedding(String),
29
30 #[error("Span validation error: {0}")]
31 SpanValidation(String),
32
33 #[error("Other error: {0}")]
34 Other(String),
35}
36
37pub type Result<T> = std::result::Result<T, CkError>;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
40pub enum Language {
41 Rust,
42 Python,
43 JavaScript,
44 TypeScript,
45 Haskell,
46 Go,
47 Java,
48 C,
49 Cpp,
50 CSharp,
51 Ruby,
52 Php,
53 Swift,
54 Kotlin,
55 Zig,
56 Dart,
57 Elixir,
58 Markdown,
59 Pdf,
60}
61
62impl Language {
63 pub fn from_extension(ext: &str) -> Option<Self> {
64 match ext.to_lowercase().as_str() {
66 "rs" => Some(Language::Rust),
67 "py" => Some(Language::Python),
68 "js" => Some(Language::JavaScript),
69 "ts" | "tsx" => Some(Language::TypeScript),
70 "hs" | "lhs" => Some(Language::Haskell),
71 "go" => Some(Language::Go),
72 "java" => Some(Language::Java),
73 "c" => Some(Language::C),
74 "cpp" | "cc" | "cxx" | "c++" => Some(Language::Cpp),
75 "h" | "hpp" => Some(Language::Cpp), "cs" => Some(Language::CSharp),
77 "rb" => Some(Language::Ruby),
78 "php" => Some(Language::Php),
79 "swift" => Some(Language::Swift),
80 "kt" | "kts" => Some(Language::Kotlin),
81 "zig" => Some(Language::Zig),
82 "dart" => Some(Language::Dart),
83 "ex" | "exs" => Some(Language::Elixir),
84 "md" | "markdown" | "mdx" => Some(Language::Markdown),
85 "pdf" => Some(Language::Pdf),
86 _ => None,
87 }
88 }
89
90 pub fn from_path(path: &Path) -> Option<Self> {
91 path.extension()
92 .and_then(|ext| ext.to_str())
93 .and_then(Self::from_extension)
94 }
95}
96
97impl std::fmt::Display for Language {
98 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 let name = match self {
100 Language::Rust => "rust",
101 Language::Python => "python",
102 Language::JavaScript => "javascript",
103 Language::TypeScript => "typescript",
104 Language::Haskell => "haskell",
105 Language::Go => "go",
106 Language::Java => "java",
107 Language::C => "c",
108 Language::Cpp => "cpp",
109 Language::CSharp => "csharp",
110 Language::Ruby => "ruby",
111 Language::Php => "php",
112 Language::Swift => "swift",
113 Language::Kotlin => "kotlin",
114 Language::Zig => "zig",
115 Language::Dart => "dart",
116 Language::Elixir => "elixir",
117 Language::Markdown => "markdown",
118 Language::Pdf => "pdf",
119 };
120 write!(f, "{name}")
121 }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct Span {
126 pub byte_start: usize,
127 pub byte_end: usize,
128 pub line_start: usize,
129 pub line_end: usize,
130}
131
132impl Span {
133 pub fn new(
135 byte_start: usize,
136 byte_end: usize,
137 line_start: usize,
138 line_end: usize,
139 ) -> Result<Self> {
140 let span = Self {
141 byte_start,
142 byte_end,
143 line_start,
144 line_end,
145 };
146 span.validate()?;
147 Ok(span)
148 }
149
150 pub fn new_unchecked(
156 byte_start: usize,
157 byte_end: usize,
158 line_start: usize,
159 line_end: usize,
160 ) -> Self {
161 Self {
162 byte_start,
163 byte_end,
164 line_start,
165 line_end,
166 }
167 }
168
169 pub fn validate(&self) -> Result<()> {
171 if self.line_start == 0 {
173 return Err(CkError::SpanValidation(
174 "Line start cannot be zero (lines are 1-indexed)".to_string(),
175 ));
176 }
177
178 if self.line_end == 0 {
179 return Err(CkError::SpanValidation(
180 "Line end cannot be zero (lines are 1-indexed)".to_string(),
181 ));
182 }
183
184 if self.byte_start > self.byte_end {
186 return Err(CkError::SpanValidation(format!(
187 "Invalid byte range: start ({}) > end ({})",
188 self.byte_start, self.byte_end
189 )));
190 }
191
192 if self.line_start > self.line_end {
194 return Err(CkError::SpanValidation(format!(
195 "Invalid line range: start ({}) > end ({})",
196 self.line_start, self.line_end
197 )));
198 }
199
200 Ok(())
201 }
202
203 pub fn is_valid(&self) -> bool {
205 self.validate().is_ok()
206 }
207
208 pub fn byte_len(&self) -> usize {
210 self.byte_end.saturating_sub(self.byte_start)
211 }
212
213 pub fn line_count(&self) -> usize {
215 self.line_end.saturating_sub(self.line_start) + 1
216 }
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct FileMetadata {
221 pub path: PathBuf,
222 pub hash: String,
223 pub last_modified: u64,
224 pub size: u64,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct SearchResult {
229 pub file: PathBuf,
230 pub span: Span,
231 pub score: f32,
232 pub preview: String,
233 #[serde(skip_serializing_if = "Option::is_none")]
234 pub lang: Option<Language>,
235 #[serde(skip_serializing_if = "Option::is_none")]
236 pub symbol: Option<String>,
237 #[serde(skip_serializing_if = "Option::is_none")]
238 pub chunk_hash: Option<String>,
239 #[serde(skip_serializing_if = "Option::is_none")]
240 pub index_epoch: Option<u64>,
241}
242
243#[derive(Debug, Clone)]
245pub struct SearchResults {
246 pub matches: Vec<SearchResult>,
247 pub closest_below_threshold: Option<SearchResult>,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct JsonSearchResult {
253 pub file: String,
254 pub span: Span,
255 pub lang: Option<Language>,
256 pub symbol: Option<String>,
257 pub score: f32,
258 pub signals: SearchSignals,
259 pub preview: String,
260 pub model: String,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct JsonlSearchResult {
265 pub path: String,
266 pub span: Span,
267 pub language: Option<String>,
268 #[serde(skip_serializing_if = "Option::is_none")]
269 pub snippet: Option<String>,
270 #[serde(skip_serializing_if = "Option::is_none")]
271 pub score: Option<f32>,
272 #[serde(skip_serializing_if = "Option::is_none")]
273 pub chunk_hash: Option<String>,
274 #[serde(skip_serializing_if = "Option::is_none")]
275 pub index_epoch: Option<u64>,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct SearchSignals {
280 pub lex_rank: Option<usize>,
281 pub vec_rank: Option<usize>,
282 pub rrf_score: f32,
283}
284
285#[derive(Debug, Clone, PartialEq)]
286pub enum SearchMode {
287 Regex,
288 Lexical,
289 Semantic,
290 Hybrid,
291}
292
293#[derive(Debug, Clone)]
294pub struct IncludePattern {
295 pub path: PathBuf,
296 pub is_dir: bool,
297}
298
299#[derive(Debug, Clone)]
303pub struct FileCollectionOptions {
304 pub respect_gitignore: bool,
306 pub use_ckignore: bool,
308 pub exclude_patterns: Vec<String>,
310 pub show_hidden: bool,
312}
313
314impl Default for FileCollectionOptions {
315 fn default() -> Self {
316 Self {
317 respect_gitignore: true,
318 use_ckignore: true,
319 exclude_patterns: Vec::new(),
320 show_hidden: false,
321 }
322 }
323}
324
325impl From<&SearchOptions> for FileCollectionOptions {
326 fn from(opts: &SearchOptions) -> Self {
327 Self {
328 respect_gitignore: opts.respect_gitignore,
329 use_ckignore: true, exclude_patterns: opts.exclude_patterns.clone(),
331 show_hidden: opts.hidden,
332 }
333 }
334}
335
336#[derive(Debug, Clone)]
337pub struct SearchOptions {
338 pub mode: SearchMode,
339 pub query: String,
340 pub path: PathBuf,
341 pub top_k: Option<usize>,
342 pub threshold: Option<f32>,
343 pub case_insensitive: bool,
344 pub whole_word: bool,
345 pub fixed_string: bool,
346 pub line_numbers: bool,
347 pub context_lines: usize,
348 pub before_context_lines: usize,
349 pub after_context_lines: usize,
350 pub recursive: bool,
351 pub json_output: bool,
352 pub jsonl_output: bool,
353 pub no_snippet: bool,
354 pub reindex: bool,
355 pub show_scores: bool,
356 pub show_filenames: bool,
357 pub files_with_matches: bool,
358 pub files_without_matches: bool,
359 pub exclude_patterns: Vec<String>,
360 pub include_patterns: Vec<IncludePattern>,
361 pub respect_gitignore: bool,
362 pub use_ckignore: bool,
363 pub full_section: bool,
364 pub hidden: bool,
366 pub rerank: bool,
368 pub rerank_model: Option<String>,
369 pub embedding_model: Option<String>,
370}
371
372impl JsonlSearchResult {
373 pub fn from_search_result(result: &SearchResult, include_snippet: bool) -> Self {
374 Self {
375 path: result.file.to_string_lossy().to_string(),
376 span: result.span.clone(),
377 language: result.lang.as_ref().map(std::string::ToString::to_string),
378 snippet: if include_snippet {
379 Some(result.preview.clone())
380 } else {
381 None
382 },
383 score: if result.score >= 0.0 {
384 Some(result.score)
385 } else {
386 None
387 },
388 chunk_hash: result.chunk_hash.clone(),
389 index_epoch: result.index_epoch,
390 }
391 }
392}
393
394impl Default for SearchOptions {
395 fn default() -> Self {
396 Self {
397 mode: SearchMode::Regex,
398 query: String::new(),
399 path: PathBuf::from("."),
400 top_k: None,
401 threshold: None,
402 case_insensitive: false,
403 whole_word: false,
404 fixed_string: false,
405 line_numbers: false,
406 context_lines: 0,
407 before_context_lines: 0,
408 after_context_lines: 0,
409 recursive: true,
410 json_output: false,
411 jsonl_output: false,
412 no_snippet: false,
413 reindex: false,
414 show_scores: false,
415 show_filenames: false,
416 files_with_matches: false,
417 files_without_matches: false,
418 exclude_patterns: get_default_exclude_patterns(),
419 include_patterns: Vec::new(),
420 respect_gitignore: true,
421 use_ckignore: true,
422 full_section: false,
423 hidden: false,
424 rerank: false,
426 rerank_model: None,
427 embedding_model: None,
428 }
429 }
430}
431
432pub fn get_default_exclude_patterns() -> Vec<String> {
435 vec![
436 ".ck".to_string(),
438 ".fastembed_cache".to_string(),
440 ".cache".to_string(),
441 "__pycache__".to_string(),
442 ".git".to_string(),
444 ".svn".to_string(),
445 ".hg".to_string(),
446 "target".to_string(), "build".to_string(), "dist".to_string(), "node_modules".to_string(), ".gradle".to_string(), ".mvn".to_string(), "bin".to_string(), "obj".to_string(), "venv".to_string(),
457 ".venv".to_string(),
458 "env".to_string(),
459 ".env".to_string(),
460 "virtualenv".to_string(),
461 ".vscode".to_string(),
463 ".idea".to_string(),
464 ".eclipse".to_string(),
465 "tmp".to_string(),
467 "temp".to_string(),
468 ".tmp".to_string(),
469 ]
470}
471
472pub fn get_default_ckignore_content() -> &'static str {
474 r"# .ckignore - Default patterns for ck semantic search
475# Created automatically during first index
476# Syntax: same as .gitignore (glob patterns, ! for negation)
477
478# Images
479*.png
480*.jpg
481*.jpeg
482*.gif
483*.bmp
484*.svg
485*.ico
486*.webp
487*.tiff
488
489# Video
490*.mp4
491*.avi
492*.mov
493*.mkv
494*.wmv
495*.flv
496*.webm
497
498# Audio
499*.mp3
500*.wav
501*.flac
502*.aac
503*.ogg
504*.m4a
505
506# Binary/Compiled
507*.exe
508*.dll
509*.so
510*.dylib
511*.a
512*.lib
513*.obj
514*.o
515
516# Archives
517*.zip
518*.tar
519*.tar.gz
520*.tgz
521*.rar
522*.7z
523*.bz2
524*.gz
525
526# Data files
527*.db
528*.sqlite
529*.sqlite3
530*.parquet
531*.arrow
532
533# Config formats (issue #27)
534*.json
535*.yaml
536*.yml
537
538# Add your custom patterns below this line
539"
540}
541
542pub fn read_ckignore_patterns(repo_root: &Path) -> Result<Vec<String>> {
544 let ckignore_path = repo_root.join(".ckignore");
545
546 if !ckignore_path.exists() {
547 return Ok(Vec::new());
548 }
549
550 let content = std::fs::read_to_string(&ckignore_path).map_err(CkError::Io)?;
551
552 let patterns: Vec<String> = content
553 .lines()
554 .map(str::trim)
555 .filter(|line| !line.is_empty() && !line.starts_with('#'))
556 .map(std::string::ToString::to_string)
557 .collect();
558
559 Ok(patterns)
560}
561
562pub fn create_ckignore_if_missing(repo_root: &Path) -> Result<bool> {
564 let ckignore_path = repo_root.join(".ckignore");
565
566 if ckignore_path.exists() {
567 return Ok(false); }
569
570 std::fs::write(&ckignore_path, get_default_ckignore_content()).map_err(CkError::Io)?;
571
572 Ok(true) }
574
575pub fn build_exclude_patterns(additional_excludes: &[String], use_defaults: bool) -> Vec<String> {
597 let mut patterns = Vec::new();
598
599 patterns.extend(additional_excludes.iter().cloned());
601
602 if use_defaults {
605 patterns.extend(get_default_exclude_patterns());
606 }
607
608 patterns
609}
610
611pub const INDEX_DIR_ENV: &str = "CK_INDEX_DIR";
614
615const INDEX_ROOT_MARKER: &str = "root_path";
619
620fn relocation_base() -> Option<PathBuf> {
623 match std::env::var(INDEX_DIR_ENV) {
624 Ok(base) if !base.is_empty() => Some(absolute_for_hash(Path::new(&base))),
625 _ => None,
626 }
627}
628
629fn absolute_for_hash(path: &Path) -> PathBuf {
637 std::fs::canonicalize(path)
638 .or_else(|_| std::path::absolute(path))
639 .unwrap_or_else(|_| path.to_path_buf())
640}
641
642pub fn index_dir(root: &Path) -> PathBuf {
661 match relocation_base() {
662 Some(base) => {
663 let abs = absolute_for_hash(root);
664 let basename = abs
665 .file_name()
666 .map(|n| n.to_string_lossy().into_owned())
667 .unwrap_or_else(|| "root".to_string());
668 let hash = blake3::hash(abs.to_string_lossy().as_bytes());
669 let hash8 = &hash.to_hex()[..8];
670 base.join(format!("{basename}-{hash8}"))
671 }
672 None => root.join(".ck"),
673 }
674}
675
676pub fn index_exists(root: &Path) -> bool {
678 index_dir(root).exists()
679}
680
681pub fn write_index_root_marker(root: &Path) -> std::io::Result<()> {
688 if relocation_base().is_none() {
689 return Ok(());
690 }
691 let dir = index_dir(root);
692 std::fs::create_dir_all(&dir)?;
693 std::fs::write(
694 dir.join(INDEX_ROOT_MARKER),
695 absolute_for_hash(root).to_string_lossy().as_bytes(),
696 )
697}
698
699pub fn check_index_root_marker(root: &Path) -> Result<()> {
707 if relocation_base().is_none() {
708 return Ok(());
709 }
710 let dir = index_dir(root);
711 match std::fs::read_to_string(dir.join(INDEX_ROOT_MARKER)) {
712 Ok(stored) => {
713 let expected = absolute_for_hash(root);
714 if stored.trim() == expected.to_string_lossy() {
715 Ok(())
716 } else {
717 Err(CkError::Index(format!(
718 "index directory {} was built for a different search root ({}), not {}; \
719 this is a CK_INDEX_DIR basename-hash collision — point CK_INDEX_DIR at a \
720 different location for one of the roots",
721 dir.display(),
722 stored.trim(),
723 expected.display(),
724 )))
725 }
726 }
727 Err(_) => Ok(()),
728 }
729}
730
731pub fn get_sidecar_path(repo_root: &Path, file_path: &Path) -> PathBuf {
732 let relative = file_path.strip_prefix(repo_root).unwrap_or(file_path);
733 let mut sidecar = index_dir(repo_root);
734 sidecar.push(relative);
735 let ext = relative
736 .extension()
737 .map(|e| format!("{}.ck", e.to_string_lossy()))
738 .unwrap_or_else(|| "ck".to_string());
739 sidecar.set_extension(ext);
740 sidecar
741}
742
743pub fn compute_file_hash(path: &Path) -> Result<String> {
744 use std::io::Read;
745
746 let mut file = std::fs::File::open(path)?;
747 let mut hasher = blake3::Hasher::new();
748
749 let mut buffer = [0u8; 65536]; loop {
752 let bytes_read = file.read(&mut buffer)?;
753 if bytes_read == 0 {
754 break;
755 }
756 hasher.update(&buffer[..bytes_read]);
757 }
758
759 let hash = hasher.finalize();
760 Ok(hash.to_hex().to_string())
761}
762
763pub fn compute_chunk_hash(
771 text: &str,
772 leading_trivia: &[String],
773 trailing_trivia: &[String],
774) -> String {
775 let mut hasher = blake3::Hasher::new();
776
777 hasher.update(text.as_bytes());
779
780 for trivia in leading_trivia {
782 hasher.update(trivia.as_bytes());
783 }
784
785 for trivia in trailing_trivia {
787 hasher.update(trivia.as_bytes());
788 }
789
790 hasher.finalize().to_hex().to_string()
791}
792
793pub mod pdf {
795 use std::path::{Path, PathBuf};
796
797 pub fn is_pdf_file(path: &Path) -> bool {
799 path.extension()
800 .and_then(|ext| ext.to_str())
801 .map(|ext| ext.eq_ignore_ascii_case("pdf")) .unwrap_or(false)
803 }
804
805 pub fn get_content_cache_path(repo_root: &Path, file_path: &Path) -> PathBuf {
807 let relative = file_path.strip_prefix(repo_root).unwrap_or(file_path);
808 let mut cache_path = crate::index_dir(repo_root).join("content");
809 cache_path.push(relative);
810
811 let ext = relative
813 .extension()
814 .map(|e| format!("{}.txt", e.to_string_lossy()))
815 .unwrap_or_else(|| "txt".to_string());
816 cache_path.set_extension(ext);
817
818 cache_path
819 }
820
821 #[cfg(test)]
822 mod tests {
823 use super::*;
824 use serial_test::serial;
825 use std::path::PathBuf;
826
827 #[test]
828 fn test_is_pdf_file() {
829 assert!(is_pdf_file(&PathBuf::from("test.pdf")));
830 assert!(is_pdf_file(&PathBuf::from("test.PDF"))); assert!(is_pdf_file(&PathBuf::from("test.Pdf")));
832 assert!(!is_pdf_file(&PathBuf::from("test.txt")));
833 assert!(!is_pdf_file(&PathBuf::from("test"))); assert!(!is_pdf_file(&PathBuf::from("pdf"))); }
836
837 #[test]
838 #[serial]
839 fn test_get_content_cache_path() {
840 unsafe { std::env::remove_var(crate::INDEX_DIR_ENV) };
841 let repo_root = PathBuf::from("/project");
842 let file_path = PathBuf::from("/project/docs/manual.pdf");
843
844 let cache_path = get_content_cache_path(&repo_root, &file_path);
845 assert_eq!(
846 cache_path,
847 PathBuf::from("/project/.ck/content/docs/manual.pdf.txt")
848 );
849 }
850
851 #[test]
852 #[serial]
853 fn test_get_content_cache_path_no_extension() {
854 unsafe { std::env::remove_var(crate::INDEX_DIR_ENV) };
855 let repo_root = PathBuf::from("/project");
856 let file_path = PathBuf::from("/project/docs/manual");
857
858 let cache_path = get_content_cache_path(&repo_root, &file_path);
859 assert_eq!(
860 cache_path,
861 PathBuf::from("/project/.ck/content/docs/manual.txt")
862 );
863 }
864
865 #[test]
866 #[serial]
867 fn test_get_content_cache_path_relative() {
868 unsafe { std::env::remove_var(crate::INDEX_DIR_ENV) };
869 let repo_root = PathBuf::from("/project");
870 let file_path = PathBuf::from("docs/manual.pdf"); let cache_path = get_content_cache_path(&repo_root, &file_path);
873 assert_eq!(
874 cache_path,
875 PathBuf::from("/project/.ck/content/docs/manual.pdf.txt")
876 );
877 }
878 }
879}
880
881#[cfg(test)]
882mod tests {
883 use super::*;
884 use serial_test::serial;
885 use std::fs;
886 use tempfile::TempDir;
887
888 #[test]
889 fn test_span_valid_creation() {
890 let span = Span::new(0, 10, 1, 2).unwrap();
892 assert_eq!(span.byte_start, 0);
893 assert_eq!(span.byte_end, 10);
894 assert_eq!(span.line_start, 1);
895 assert_eq!(span.line_end, 2);
896 assert!(span.is_valid());
897 }
898
899 #[test]
900 fn test_span_validation_valid_cases() {
901 let span = Span::new(10, 10, 1, 1).unwrap();
903 assert!(span.is_valid());
904 assert_eq!(span.byte_len(), 0);
905 assert_eq!(span.line_count(), 1);
906
907 let span = Span::new(0, 100, 1, 10).unwrap();
909 assert!(span.is_valid());
910 assert_eq!(span.byte_len(), 100);
911 assert_eq!(span.line_count(), 10);
912
913 let span = Span::new(5, 25, 3, 3).unwrap();
915 assert!(span.is_valid());
916 assert_eq!(span.byte_len(), 20);
917 assert_eq!(span.line_count(), 1);
918 }
919
920 #[test]
921 fn test_span_validation_invalid_byte_range() {
922 let result = Span::new(10, 5, 1, 2);
924 assert!(result.is_err());
925 if let Err(CkError::SpanValidation(msg)) = result {
926 assert!(msg.contains("Invalid byte range"));
927 assert!(msg.contains("start (10) > end (5)"));
928 } else {
929 panic!("Expected SpanValidation error");
930 }
931 }
932
933 #[test]
934 fn test_span_validation_invalid_line_range() {
935 let result = Span::new(0, 10, 5, 2);
937 assert!(result.is_err());
938 if let Err(CkError::SpanValidation(msg)) = result {
939 assert!(msg.contains("Invalid line range"));
940 assert!(msg.contains("start (5) > end (2)"));
941 } else {
942 panic!("Expected SpanValidation error");
943 }
944 }
945
946 #[test]
947 fn test_span_validation_zero_line_numbers() {
948 let result = Span::new(0, 10, 0, 2);
950 assert!(result.is_err());
951 if let Err(CkError::SpanValidation(msg)) = result {
952 assert!(msg.contains("Line start cannot be zero"));
953 } else {
954 panic!("Expected SpanValidation error");
955 }
956
957 let result = Span::new(0, 10, 1, 0);
959 assert!(result.is_err());
960 if let Err(CkError::SpanValidation(msg)) = result {
961 assert!(msg.contains("Line end cannot be zero"));
962 } else {
963 panic!("Expected SpanValidation error");
964 }
965 }
966
967 #[test]
968 fn test_span_unchecked_creation() {
969 let span = Span::new_unchecked(10, 5, 0, 1);
971 assert_eq!(span.byte_start, 10);
972 assert_eq!(span.byte_end, 5);
973 assert_eq!(span.line_start, 0);
974 assert_eq!(span.line_end, 1);
975 assert!(!span.is_valid()); }
977
978 #[test]
979 fn test_span_validation_methods() {
980 let valid_span = Span::new_unchecked(0, 10, 1, 2);
982 assert!(valid_span.validate().is_ok());
983 assert!(valid_span.is_valid());
984
985 let invalid_span = Span::new_unchecked(10, 5, 1, 2);
987 assert!(invalid_span.validate().is_err());
988 assert!(!invalid_span.is_valid());
989
990 let zero_line_span = Span::new_unchecked(0, 10, 0, 1);
992 assert!(zero_line_span.validate().is_err());
993 assert!(!zero_line_span.is_valid());
994 }
995
996 #[test]
997 fn test_span_utility_methods() {
998 let span = Span::new(10, 25, 5, 8).unwrap();
999
1000 assert_eq!(span.byte_len(), 15);
1002
1003 assert_eq!(span.line_count(), 4); let single_line = Span::new(0, 5, 1, 1).unwrap();
1008 assert_eq!(single_line.line_count(), 1);
1009 assert_eq!(single_line.byte_len(), 5);
1010
1011 let empty = Span::new(10, 10, 3, 3).unwrap();
1013 assert_eq!(empty.byte_len(), 0);
1014 assert_eq!(empty.line_count(), 1);
1015 }
1016
1017 #[test]
1018 fn test_span_legacy_struct_literal_still_works() {
1019 let span = Span {
1021 byte_start: 0,
1022 byte_end: 10,
1023 line_start: 1,
1024 line_end: 2,
1025 };
1026
1027 assert_eq!(span.byte_start, 0);
1028 assert_eq!(span.byte_end, 10);
1029 assert_eq!(span.line_start, 1);
1030 assert_eq!(span.line_end, 2);
1031 assert!(span.is_valid());
1032 }
1033
1034 #[test]
1035 fn test_search_options_default() {
1036 let options = SearchOptions::default();
1037 assert!(matches!(options.mode, SearchMode::Regex));
1038 assert_eq!(options.query, "");
1039 assert_eq!(options.path, PathBuf::from("."));
1040 assert_eq!(options.top_k, None);
1041 assert_eq!(options.threshold, None);
1042 assert!(!options.case_insensitive);
1043 assert!(!options.whole_word);
1044 assert!(!options.fixed_string);
1045 assert!(!options.line_numbers);
1046 assert_eq!(options.context_lines, 0);
1047 assert!(options.recursive);
1048 assert!(!options.json_output);
1049 assert!(!options.reindex);
1050 assert!(!options.show_scores);
1051 assert!(!options.show_filenames);
1052 }
1053
1054 #[test]
1055 fn test_file_metadata_serialization() {
1056 let metadata = FileMetadata {
1057 path: PathBuf::from("test.txt"),
1058 hash: "abc123".to_string(),
1059 last_modified: 1234567890,
1060 size: 1024,
1061 };
1062
1063 let json = serde_json::to_string(&metadata).unwrap();
1064 let deserialized: FileMetadata = serde_json::from_str(&json).unwrap();
1065
1066 assert_eq!(metadata.path, deserialized.path);
1067 assert_eq!(metadata.hash, deserialized.hash);
1068 assert_eq!(metadata.last_modified, deserialized.last_modified);
1069 assert_eq!(metadata.size, deserialized.size);
1070 }
1071
1072 #[test]
1073 fn test_search_result_serialization() {
1074 let result = SearchResult {
1075 file: PathBuf::from("test.txt"),
1076 span: Span {
1077 byte_start: 0,
1078 byte_end: 10,
1079 line_start: 1,
1080 line_end: 1,
1081 },
1082 score: 0.95,
1083 preview: "hello world".to_string(),
1084 lang: Some(Language::Rust),
1085 symbol: Some("main".to_string()),
1086 chunk_hash: Some("abc123".to_string()),
1087 index_epoch: Some(1699123456),
1088 };
1089
1090 let json = serde_json::to_string(&result).unwrap();
1091 let deserialized: SearchResult = serde_json::from_str(&json).unwrap();
1092
1093 assert_eq!(result.file, deserialized.file);
1094 assert_eq!(result.score, deserialized.score);
1095 assert_eq!(result.preview, deserialized.preview);
1096 assert_eq!(result.lang, deserialized.lang);
1097 assert_eq!(result.symbol, deserialized.symbol);
1098 assert_eq!(result.chunk_hash, deserialized.chunk_hash);
1099 assert_eq!(result.index_epoch, deserialized.index_epoch);
1100 }
1101
1102 #[test]
1103 fn test_jsonl_search_result_conversion() {
1104 let result = SearchResult {
1105 file: PathBuf::from("src/auth.rs"),
1106 span: Span {
1107 byte_start: 1203,
1108 byte_end: 1456,
1109 line_start: 42,
1110 line_end: 58,
1111 },
1112 score: 0.89,
1113 preview: "function authenticate(user) {...}".to_string(),
1114 lang: Some(Language::Rust),
1115 symbol: Some("authenticate".to_string()),
1116 chunk_hash: Some("abc123def456".to_string()),
1117 index_epoch: Some(1699123456),
1118 };
1119
1120 let jsonl_with_snippet = JsonlSearchResult::from_search_result(&result, true);
1122 assert_eq!(jsonl_with_snippet.path, "src/auth.rs");
1123 assert_eq!(jsonl_with_snippet.span.line_start, 42);
1124 assert_eq!(jsonl_with_snippet.language, Some("rust".to_string()));
1125 assert_eq!(
1126 jsonl_with_snippet.snippet,
1127 Some("function authenticate(user) {...}".to_string())
1128 );
1129 assert_eq!(jsonl_with_snippet.score, Some(0.89));
1130 assert_eq!(
1131 jsonl_with_snippet.chunk_hash,
1132 Some("abc123def456".to_string())
1133 );
1134 assert_eq!(jsonl_with_snippet.index_epoch, Some(1699123456));
1135
1136 let jsonl_no_snippet = JsonlSearchResult::from_search_result(&result, false);
1138 assert_eq!(jsonl_no_snippet.snippet, None);
1139 assert_eq!(jsonl_no_snippet.path, "src/auth.rs");
1140 }
1141
1142 #[test]
1143 #[serial]
1144 fn test_get_sidecar_path() {
1145 unsafe { std::env::remove_var(INDEX_DIR_ENV) };
1146 let repo_root = PathBuf::from("/home/user/project");
1147 let file_path = PathBuf::from("/home/user/project/src/main.rs");
1148
1149 let sidecar = get_sidecar_path(&repo_root, &file_path);
1150 let expected = PathBuf::from("/home/user/project/.ck/src/main.rs.ck");
1151
1152 assert_eq!(sidecar, expected);
1153 }
1154
1155 #[test]
1156 #[serial]
1157 fn test_get_sidecar_path_no_extension() {
1158 unsafe { std::env::remove_var(INDEX_DIR_ENV) };
1159 let repo_root = PathBuf::from("/project");
1160 let file_path = PathBuf::from("/project/README");
1161
1162 let sidecar = get_sidecar_path(&repo_root, &file_path);
1163 let expected = PathBuf::from("/project/.ck/README.ck");
1164
1165 assert_eq!(sidecar, expected);
1166 }
1167
1168 #[test]
1169 fn test_compute_file_hash() {
1170 let temp_dir = TempDir::new().unwrap();
1171 let file_path = temp_dir.path().join("test.txt");
1172
1173 fs::write(&file_path, "hello world").unwrap();
1174
1175 let hash1 = compute_file_hash(&file_path).unwrap();
1176 let hash2 = compute_file_hash(&file_path).unwrap();
1177
1178 assert_eq!(hash1, hash2);
1180 assert!(!hash1.is_empty());
1181
1182 fs::write(&file_path, "hello rust").unwrap();
1184 let hash3 = compute_file_hash(&file_path).unwrap();
1185 assert_ne!(hash1, hash3);
1186 }
1187
1188 #[test]
1189 fn test_compute_file_hash_nonexistent() {
1190 let result = compute_file_hash(&PathBuf::from("nonexistent.txt"));
1191 assert!(result.is_err());
1192 }
1193
1194 #[test]
1195 fn test_compute_file_hash_large_file() {
1196 let temp_dir = TempDir::new().unwrap();
1197 let file_path = temp_dir.path().join("large_test.txt");
1198
1199 let large_content = "a".repeat(100_000); fs::write(&file_path, &large_content).unwrap();
1202
1203 let hash1 = compute_file_hash(&file_path).unwrap();
1204 let hash2 = compute_file_hash(&file_path).unwrap();
1205
1206 assert_eq!(hash1, hash2);
1208 assert!(!hash1.is_empty());
1209
1210 fs::write(&file_path, "small content").unwrap();
1212 let hash3 = compute_file_hash(&file_path).unwrap();
1213 assert_ne!(hash1, hash3);
1214 }
1215
1216 #[test]
1217 fn test_json_search_result_serialization() {
1218 let signals = SearchSignals {
1219 lex_rank: Some(1),
1220 vec_rank: Some(2),
1221 rrf_score: 0.85,
1222 };
1223
1224 let result = JsonSearchResult {
1225 file: "test.txt".to_string(),
1226 span: Span {
1227 byte_start: 0,
1228 byte_end: 5,
1229 line_start: 1,
1230 line_end: 1,
1231 },
1232 lang: None, symbol: None,
1234 score: 0.95,
1235 signals,
1236 preview: "hello".to_string(),
1237 model: "bge-small".to_string(),
1238 };
1239
1240 let json = serde_json::to_string(&result).unwrap();
1241 let deserialized: JsonSearchResult = serde_json::from_str(&json).unwrap();
1242
1243 assert_eq!(result.file, deserialized.file);
1244 assert_eq!(result.score, deserialized.score);
1245 assert_eq!(result.signals.rrf_score, deserialized.signals.rrf_score);
1246 assert_eq!(result.model, deserialized.model);
1247 }
1248
1249 #[test]
1250 fn test_language_from_extension() {
1251 assert_eq!(Language::from_extension("rs"), Some(Language::Rust));
1252 assert_eq!(Language::from_extension("py"), Some(Language::Python));
1253 assert_eq!(Language::from_extension("js"), Some(Language::JavaScript));
1254 assert_eq!(Language::from_extension("ts"), Some(Language::TypeScript));
1255 assert_eq!(Language::from_extension("tsx"), Some(Language::TypeScript));
1256 assert_eq!(Language::from_extension("hs"), Some(Language::Haskell));
1257 assert_eq!(Language::from_extension("lhs"), Some(Language::Haskell));
1258 assert_eq!(Language::from_extension("go"), Some(Language::Go));
1259 assert_eq!(Language::from_extension("java"), Some(Language::Java));
1260 assert_eq!(Language::from_extension("c"), Some(Language::C));
1261 assert_eq!(Language::from_extension("cpp"), Some(Language::Cpp));
1262 assert_eq!(Language::from_extension("cs"), Some(Language::CSharp));
1263 assert_eq!(Language::from_extension("rb"), Some(Language::Ruby));
1264 assert_eq!(Language::from_extension("php"), Some(Language::Php));
1265 assert_eq!(Language::from_extension("swift"), Some(Language::Swift));
1266 assert_eq!(Language::from_extension("kt"), Some(Language::Kotlin));
1267 assert_eq!(Language::from_extension("kts"), Some(Language::Kotlin));
1268 assert_eq!(Language::from_extension("ex"), Some(Language::Elixir));
1269 assert_eq!(Language::from_extension("exs"), Some(Language::Elixir));
1270 assert_eq!(Language::from_extension("unknown"), None);
1271 }
1272
1273 #[test]
1274 fn test_language_from_extension_case_insensitive() {
1275 assert_eq!(Language::from_extension("RS"), Some(Language::Rust));
1277 assert_eq!(Language::from_extension("PY"), Some(Language::Python));
1278 assert_eq!(Language::from_extension("JS"), Some(Language::JavaScript));
1279 assert_eq!(Language::from_extension("TS"), Some(Language::TypeScript));
1280 assert_eq!(Language::from_extension("TSX"), Some(Language::TypeScript));
1281 assert_eq!(Language::from_extension("HS"), Some(Language::Haskell));
1282 assert_eq!(Language::from_extension("LHS"), Some(Language::Haskell));
1283 assert_eq!(Language::from_extension("GO"), Some(Language::Go));
1284 assert_eq!(Language::from_extension("JAVA"), Some(Language::Java));
1285 assert_eq!(Language::from_extension("C"), Some(Language::C));
1286 assert_eq!(Language::from_extension("CPP"), Some(Language::Cpp));
1287 assert_eq!(Language::from_extension("CC"), Some(Language::Cpp));
1288 assert_eq!(Language::from_extension("CXX"), Some(Language::Cpp));
1289 assert_eq!(Language::from_extension("H"), Some(Language::Cpp));
1290 assert_eq!(Language::from_extension("HPP"), Some(Language::Cpp));
1291 assert_eq!(Language::from_extension("CS"), Some(Language::CSharp));
1292 assert_eq!(Language::from_extension("RB"), Some(Language::Ruby));
1293 assert_eq!(Language::from_extension("PHP"), Some(Language::Php));
1294 assert_eq!(Language::from_extension("SWIFT"), Some(Language::Swift));
1295 assert_eq!(Language::from_extension("KT"), Some(Language::Kotlin));
1296 assert_eq!(Language::from_extension("KTS"), Some(Language::Kotlin));
1297 assert_eq!(Language::from_extension("EX"), Some(Language::Elixir));
1298 assert_eq!(Language::from_extension("EXS"), Some(Language::Elixir));
1299 assert_eq!(Language::from_extension("PDF"), Some(Language::Pdf));
1300
1301 assert_eq!(Language::from_extension("Rs"), Some(Language::Rust));
1303 assert_eq!(Language::from_extension("Py"), Some(Language::Python));
1304 assert_eq!(Language::from_extension("Js"), Some(Language::JavaScript));
1305 assert_eq!(Language::from_extension("Ts"), Some(Language::TypeScript));
1306 assert_eq!(Language::from_extension("TsX"), Some(Language::TypeScript));
1307 assert_eq!(Language::from_extension("Hs"), Some(Language::Haskell));
1308 assert_eq!(Language::from_extension("Go"), Some(Language::Go));
1309 assert_eq!(Language::from_extension("Java"), Some(Language::Java));
1310 assert_eq!(Language::from_extension("Cpp"), Some(Language::Cpp));
1311 assert_eq!(Language::from_extension("Rb"), Some(Language::Ruby));
1312 assert_eq!(Language::from_extension("Php"), Some(Language::Php));
1313 assert_eq!(Language::from_extension("Swift"), Some(Language::Swift));
1314 assert_eq!(Language::from_extension("Kt"), Some(Language::Kotlin));
1315 assert_eq!(Language::from_extension("Ex"), Some(Language::Elixir));
1316 assert_eq!(Language::from_extension("Pdf"), Some(Language::Pdf));
1317
1318 assert_eq!(Language::from_extension("UNKNOWN"), None);
1320 assert_eq!(Language::from_extension("Unknown"), None);
1321 }
1322
1323 #[test]
1324 fn test_language_from_path() {
1325 assert_eq!(
1326 Language::from_path(&PathBuf::from("test.rs")),
1327 Some(Language::Rust)
1328 );
1329 assert_eq!(
1330 Language::from_path(&PathBuf::from("test.py")),
1331 Some(Language::Python)
1332 );
1333 assert_eq!(
1334 Language::from_path(&PathBuf::from("test.js")),
1335 Some(Language::JavaScript)
1336 );
1337 assert_eq!(
1338 Language::from_path(&PathBuf::from("test.hs")),
1339 Some(Language::Haskell)
1340 );
1341 assert_eq!(
1342 Language::from_path(&PathBuf::from("test.lhs")),
1343 Some(Language::Haskell)
1344 );
1345 assert_eq!(
1346 Language::from_path(&PathBuf::from("test.go")),
1347 Some(Language::Go)
1348 );
1349 assert_eq!(Language::from_path(&PathBuf::from("test.unknown")), None); assert_eq!(Language::from_path(&PathBuf::from("noext")), None); }
1352
1353 #[test]
1354 fn test_language_from_path_case_insensitive() {
1355 assert_eq!(
1357 Language::from_path(&PathBuf::from("MAIN.RS")),
1358 Some(Language::Rust)
1359 );
1360 assert_eq!(
1361 Language::from_path(&PathBuf::from("app.PY")),
1362 Some(Language::Python)
1363 );
1364 assert_eq!(
1365 Language::from_path(&PathBuf::from("script.JS")),
1366 Some(Language::JavaScript)
1367 );
1368 assert_eq!(
1369 Language::from_path(&PathBuf::from("types.TS")),
1370 Some(Language::TypeScript)
1371 );
1372 assert_eq!(
1373 Language::from_path(&PathBuf::from("Component.TSX")),
1374 Some(Language::TypeScript)
1375 );
1376 assert_eq!(
1377 Language::from_path(&PathBuf::from("module.HS")),
1378 Some(Language::Haskell)
1379 );
1380 assert_eq!(
1381 Language::from_path(&PathBuf::from("server.GO")),
1382 Some(Language::Go)
1383 );
1384 assert_eq!(
1385 Language::from_path(&PathBuf::from("App.JAVA")),
1386 Some(Language::Java)
1387 );
1388 assert_eq!(
1389 Language::from_path(&PathBuf::from("main.C")),
1390 Some(Language::C)
1391 );
1392 assert_eq!(
1393 Language::from_path(&PathBuf::from("utils.CPP")),
1394 Some(Language::Cpp)
1395 );
1396 assert_eq!(
1397 Language::from_path(&PathBuf::from("Program.CS")),
1398 Some(Language::CSharp)
1399 );
1400 assert_eq!(
1401 Language::from_path(&PathBuf::from("script.RB")),
1402 Some(Language::Ruby)
1403 );
1404 assert_eq!(
1405 Language::from_path(&PathBuf::from("index.PHP")),
1406 Some(Language::Php)
1407 );
1408 assert_eq!(
1409 Language::from_path(&PathBuf::from("App.SWIFT")),
1410 Some(Language::Swift)
1411 );
1412 assert_eq!(
1413 Language::from_path(&PathBuf::from("Main.KT")),
1414 Some(Language::Kotlin)
1415 );
1416 assert_eq!(
1417 Language::from_path(&PathBuf::from("document.PDF")),
1418 Some(Language::Pdf)
1419 );
1420
1421 assert_eq!(
1423 Language::from_path(&PathBuf::from("config.Rs")),
1424 Some(Language::Rust)
1425 );
1426 assert_eq!(
1427 Language::from_path(&PathBuf::from("helper.Py")),
1428 Some(Language::Python)
1429 );
1430 assert_eq!(
1431 Language::from_path(&PathBuf::from("utils.Js")),
1432 Some(Language::JavaScript)
1433 );
1434 assert_eq!(
1435 Language::from_path(&PathBuf::from("interfaces.Ts")),
1436 Some(Language::TypeScript)
1437 );
1438 assert_eq!(
1439 Language::from_path(&PathBuf::from("Component.TsX")),
1440 Some(Language::TypeScript)
1441 );
1442 assert_eq!(
1443 Language::from_path(&PathBuf::from("main.Cpp")),
1444 Some(Language::Cpp)
1445 );
1446 assert_eq!(
1447 Language::from_path(&PathBuf::from("report.Pdf")),
1448 Some(Language::Pdf)
1449 );
1450
1451 assert_eq!(Language::from_path(&PathBuf::from("test.UNKNOWN")), None);
1453 assert_eq!(Language::from_path(&PathBuf::from("test.Unknown")), None);
1454 }
1455
1456 #[test]
1457 fn test_language_display() {
1458 assert_eq!(Language::Rust.to_string(), "rust");
1459 assert_eq!(Language::Python.to_string(), "python");
1460 assert_eq!(Language::JavaScript.to_string(), "javascript");
1461 assert_eq!(Language::TypeScript.to_string(), "typescript");
1462 assert_eq!(Language::Go.to_string(), "go");
1463 assert_eq!(Language::Java.to_string(), "java");
1464 }
1465
1466 #[test]
1467 fn test_create_ckignore_if_missing() {
1468 let temp_dir = TempDir::new().unwrap();
1469 let test_path = temp_dir.path();
1470
1471 let created = create_ckignore_if_missing(test_path).unwrap();
1473 assert!(created);
1474
1475 let ckignore_path = test_path.join(".ckignore");
1477 assert!(ckignore_path.exists());
1478
1479 let content = fs::read_to_string(&ckignore_path).unwrap();
1481 assert!(content.contains("*.png"));
1482 assert!(content.contains("*.json"));
1483 assert!(content.contains("*.yaml"));
1484 assert!(content.contains("# Images"));
1485 assert!(content.contains("# Config formats"));
1486
1487 let created_again = create_ckignore_if_missing(test_path).unwrap();
1489 assert!(!created_again);
1490 }
1491
1492 #[test]
1493 fn test_read_ckignore_patterns() {
1494 let temp_dir = TempDir::new().unwrap();
1495 let test_path = temp_dir.path();
1496
1497 let patterns = read_ckignore_patterns(test_path).unwrap();
1499 assert_eq!(patterns.len(), 0);
1500
1501 let ckignore_path = test_path.join(".ckignore");
1503 fs::write(
1504 &ckignore_path,
1505 r"# Comment line
1506*.png
1507*.jpg
1508
1509# Another comment
1510*.json
1511*.yaml
1512",
1513 )
1514 .unwrap();
1515
1516 let patterns = read_ckignore_patterns(test_path).unwrap();
1518 assert_eq!(patterns.len(), 4);
1519 assert!(patterns.contains(&"*.png".to_string()));
1520 assert!(patterns.contains(&"*.jpg".to_string()));
1521 assert!(patterns.contains(&"*.json".to_string()));
1522 assert!(patterns.contains(&"*.yaml".to_string()));
1523 assert!(!patterns.iter().any(|p| p.starts_with('#')));
1525 }
1526
1527 #[test]
1528 fn test_read_ckignore_patterns_with_empty_lines() {
1529 let temp_dir = TempDir::new().unwrap();
1530 let test_path = temp_dir.path();
1531
1532 let ckignore_path = test_path.join(".ckignore");
1533 fs::write(
1534 &ckignore_path,
1535 r"
1536*.png
1537
1538*.jpg
1539
1540
1541*.json
1542",
1543 )
1544 .unwrap();
1545
1546 let patterns = read_ckignore_patterns(test_path).unwrap();
1547 assert_eq!(patterns.len(), 3);
1548 assert!(patterns.contains(&"*.png".to_string()));
1549 assert!(patterns.contains(&"*.jpg".to_string()));
1550 assert!(patterns.contains(&"*.json".to_string()));
1551 }
1552
1553 #[test]
1554 fn test_get_default_ckignore_content() {
1555 let content = get_default_ckignore_content();
1556
1557 assert!(content.contains("*.png"));
1559 assert!(content.contains("*.jpg"));
1560 assert!(content.contains("*.mp4"));
1561 assert!(content.contains("*.mp3"));
1562 assert!(content.contains("*.exe"));
1563 assert!(content.contains("*.zip"));
1564 assert!(content.contains("*.db"));
1565 assert!(content.contains("*.json"));
1566 assert!(content.contains("*.yaml"));
1567
1568 assert!(content.contains("# Images"));
1570 assert!(content.contains("# Video"));
1571 assert!(content.contains("# Audio"));
1572 assert!(content.contains("# Config formats"));
1573
1574 assert!(content.contains("issue #27"));
1576 }
1577
1578 #[test]
1579 fn test_build_exclude_patterns_with_defaults() {
1580 let additional = vec!["*.custom".to_string(), "temp/".to_string()];
1582 let patterns = build_exclude_patterns(&additional, true);
1583
1584 assert!(patterns.contains(&"*.custom".to_string()));
1586 assert!(patterns.contains(&"temp/".to_string()));
1587
1588 assert!(patterns.iter().any(|p| p.contains(".git")));
1590 assert!(patterns.iter().any(|p| p.contains("node_modules")));
1591
1592 let custom_idx = patterns.iter().position(|p| p == "*.custom").unwrap();
1594 let default_idx = patterns.iter().position(|p| p.contains(".git")).unwrap();
1595 assert!(custom_idx < default_idx);
1596 }
1597
1598 #[test]
1599 fn test_build_exclude_patterns_without_defaults() {
1600 let additional = vec!["*.custom".to_string(), "temp/".to_string()];
1602 let patterns = build_exclude_patterns(&additional, false);
1603
1604 assert!(patterns.contains(&"*.custom".to_string()));
1606 assert!(patterns.contains(&"temp/".to_string()));
1607
1608 assert!(!patterns.iter().any(|p| p.contains(".git")));
1610 assert!(!patterns.iter().any(|p| p.contains("node_modules")));
1611
1612 assert_eq!(patterns.len(), 2);
1614 }
1615
1616 #[test]
1617 fn test_build_exclude_patterns_empty_additional() {
1618 let patterns = build_exclude_patterns(&[], true);
1620
1621 assert!(patterns.iter().any(|p| p.contains(".git")));
1623 assert!(!patterns.is_empty());
1624
1625 let patterns = build_exclude_patterns(&[], false);
1627
1628 assert!(patterns.is_empty());
1630 }
1631
1632 #[test]
1633 fn test_read_ckignore_edge_cases() {
1634 let temp_dir = TempDir::new().unwrap();
1635 let test_path = temp_dir.path();
1636
1637 let ckignore_path = test_path.join(".ckignore");
1639 fs::write(&ckignore_path, "").unwrap();
1640 let patterns = read_ckignore_patterns(test_path).unwrap();
1641 assert_eq!(patterns.len(), 0);
1642
1643 fs::write(&ckignore_path, "# Comment 1\n# Comment 2\n# Comment 3\n").unwrap();
1645 let patterns = read_ckignore_patterns(test_path).unwrap();
1646 assert_eq!(patterns.len(), 0);
1647
1648 fs::write(&ckignore_path, " \n\t\n \t \n").unwrap();
1650 let patterns = read_ckignore_patterns(test_path).unwrap();
1651 assert_eq!(patterns.len(), 0);
1652
1653 fs::write(
1655 &ckignore_path,
1656 "# Comment\n\n \n*.tmp \n *.log\n\n# Another comment\n",
1657 )
1658 .unwrap();
1659 let patterns = read_ckignore_patterns(test_path).unwrap();
1660 assert_eq!(patterns.len(), 2);
1661 assert!(patterns.contains(&"*.tmp".to_string()));
1662 assert!(patterns.contains(&"*.log".to_string()));
1663 assert!(!patterns.iter().any(|p| p.starts_with(' ')));
1665 assert!(!patterns.iter().any(|p| p.ends_with(' ')));
1666 }
1667
1668 #[test]
1672 #[serial]
1673 fn test_index_dir_unset_is_dot_ck() {
1674 unsafe { std::env::remove_var(INDEX_DIR_ENV) };
1675 let root = Path::new("/some/project");
1676 assert_eq!(index_dir(root), root.join(".ck"));
1677 assert_eq!(
1678 get_sidecar_path(root, &root.join("main.rs")).parent(),
1679 Some(root.join(".ck").as_path())
1680 );
1681 }
1682
1683 #[test]
1684 #[serial]
1685 fn test_index_dir_empty_is_treated_as_unset() {
1686 unsafe { std::env::set_var(INDEX_DIR_ENV, "") };
1687 let root = Path::new("/some/project");
1688 assert_eq!(index_dir(root), root.join(".ck"));
1689 unsafe { std::env::remove_var(INDEX_DIR_ENV) };
1690 }
1691
1692 #[test]
1693 #[serial]
1694 fn test_index_dir_set_uses_basename_and_hash() {
1695 let base = TempDir::new().unwrap();
1696 unsafe { std::env::set_var(INDEX_DIR_ENV, base.path()) };
1697
1698 let root = TempDir::new().unwrap();
1699 let dir = index_dir(root.path());
1700 unsafe { std::env::remove_var(INDEX_DIR_ENV) };
1701
1702 let base_abs = std::fs::canonicalize(base.path()).unwrap();
1705 let abs = std::fs::canonicalize(root.path()).unwrap();
1706 let basename = abs.file_name().unwrap().to_string_lossy().into_owned();
1707 let hash8 = blake3::hash(abs.to_string_lossy().as_bytes()).to_hex()[..8].to_string();
1708 let expected = base_abs.join(format!("{basename}-{hash8}"));
1709 assert_eq!(dir, expected);
1710
1711 assert!(dir.is_absolute());
1713 assert_eq!(dir.parent().unwrap(), base_abs);
1714 let name = dir.file_name().unwrap().to_string_lossy().into_owned();
1715 let suffix = name.rsplit('-').next().unwrap();
1716 assert_eq!(suffix.len(), 8);
1717 assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()));
1718 }
1719
1720 #[test]
1721 #[serial]
1722 fn test_index_dir_same_basename_different_roots_differ() {
1723 let base = TempDir::new().unwrap();
1724 unsafe { std::env::set_var(INDEX_DIR_ENV, base.path()) };
1725
1726 let parent_a = TempDir::new().unwrap();
1728 let parent_b = TempDir::new().unwrap();
1729 let root_a = parent_a.path().join("proj");
1730 let root_b = parent_b.path().join("proj");
1731 fs::create_dir_all(&root_a).unwrap();
1732 fs::create_dir_all(&root_b).unwrap();
1733
1734 let dir_a = index_dir(&root_a);
1735 let dir_b = index_dir(&root_b);
1736 unsafe { std::env::remove_var(INDEX_DIR_ENV) };
1737
1738 assert_ne!(dir_a, dir_b, "same basename must map to distinct dirs");
1739 assert!(
1740 dir_a
1741 .file_name()
1742 .unwrap()
1743 .to_string_lossy()
1744 .starts_with("proj-")
1745 );
1746 assert!(
1747 dir_b
1748 .file_name()
1749 .unwrap()
1750 .to_string_lossy()
1751 .starts_with("proj-")
1752 );
1753 }
1754
1755 #[test]
1756 #[serial]
1757 fn test_index_dir_absolute_base_is_cwd_independent() {
1758 let base = TempDir::new().unwrap();
1762 unsafe { std::env::set_var(INDEX_DIR_ENV, base.path()) };
1763 let root = TempDir::new().unwrap();
1764 let cwd1 = TempDir::new().unwrap();
1765 let cwd2 = TempDir::new().unwrap();
1766 let original = std::env::current_dir().ok();
1767
1768 std::env::set_current_dir(cwd1.path()).unwrap();
1769 let dir1 = index_dir(root.path());
1770 std::env::set_current_dir(cwd2.path()).unwrap();
1771 let dir2 = index_dir(root.path());
1772 if let Some(orig) = original {
1773 let _ = std::env::set_current_dir(orig);
1774 }
1775 unsafe { std::env::remove_var(INDEX_DIR_ENV) };
1776
1777 assert_eq!(
1778 dir1, dir2,
1779 "index_dir must not depend on the current directory"
1780 );
1781 assert!(dir1.is_absolute());
1782 }
1783
1784 #[test]
1785 #[serial]
1786 fn test_index_dir_relative_base_is_absolutized() {
1787 let root = TempDir::new().unwrap();
1792 unsafe { std::env::set_var(INDEX_DIR_ENV, "rel-index-base") };
1793 let dir = index_dir(root.path());
1794 unsafe { std::env::remove_var(INDEX_DIR_ENV) };
1795
1796 assert!(
1797 dir.is_absolute(),
1798 "relocated index_dir must be absolute even for a relative CK_INDEX_DIR"
1799 );
1800 assert!(dir.to_string_lossy().contains("rel-index-base"));
1801 }
1802
1803 #[test]
1804 #[serial]
1805 fn test_index_exists_tracks_index_dir() {
1806 unsafe { std::env::remove_var(INDEX_DIR_ENV) };
1807 let root = TempDir::new().unwrap();
1808 assert!(!index_exists(root.path()));
1809 fs::create_dir_all(index_dir(root.path())).unwrap();
1810 assert!(index_exists(root.path()));
1811 }
1812
1813 #[test]
1814 #[serial]
1815 fn test_index_root_marker_noop_when_unset() {
1816 unsafe { std::env::remove_var(INDEX_DIR_ENV) };
1817 let root = TempDir::new().unwrap();
1818 write_index_root_marker(root.path()).unwrap();
1821 assert!(check_index_root_marker(root.path()).is_ok());
1822 assert!(!root.path().join(".ck").join("root_path").exists());
1823 }
1824
1825 #[test]
1826 #[serial]
1827 fn test_check_index_root_marker_detects_collision() {
1828 let base = TempDir::new().unwrap();
1829 unsafe { std::env::set_var(INDEX_DIR_ENV, base.path()) };
1830 let parent = TempDir::new().unwrap();
1831 let root = parent.path().join("proj");
1832 fs::create_dir_all(&root).unwrap();
1833
1834 assert!(check_index_root_marker(&root).is_ok());
1836 write_index_root_marker(&root).unwrap();
1837 assert!(check_index_root_marker(&root).is_ok());
1838
1839 let dir = index_dir(&root);
1842 fs::write(dir.join("root_path"), b"/a/completely/different/root").unwrap();
1843 let result = check_index_root_marker(&root);
1844 unsafe { std::env::remove_var(INDEX_DIR_ENV) };
1845 assert!(
1846 result.is_err(),
1847 "a marker for a different root must be rejected"
1848 );
1849 }
1850}