Skip to main content

ck_core/
lib.rs

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        // Convert to lowercase for case-insensitive matching
65        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), // Assume C++ for headers
76            "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    /// Create a new Span with validation
134    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    /// Create a new Span without validation (for backward compatibility)
151    ///
152    /// # Safety
153    ///
154    /// The caller must ensure the span is valid. Use `new()` for validated construction.
155    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    /// Validate span invariants
170    pub fn validate(&self) -> Result<()> {
171        // Check for zero line numbers first (lines should be 1-indexed)
172        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        // Check byte range validity
185        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        // Check line range validity
193        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    /// Check if this span is valid
204    pub fn is_valid(&self) -> bool {
205        self.validate().is_ok()
206    }
207
208    /// Get byte length of the span
209    pub fn byte_len(&self) -> usize {
210        self.byte_end.saturating_sub(self.byte_start)
211    }
212
213    /// Get line count of the span
214    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/// Enhanced search results that include near-miss information for threshold queries
244#[derive(Debug, Clone)]
245pub struct SearchResults {
246    pub matches: Vec<SearchResult>,
247    /// The highest scoring result below the threshold (if any)
248    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/// Configuration for file collection during indexing and search operations.
300/// This struct encapsulates all settings related to which files should be included
301/// or excluded when traversing a directory tree.
302#[derive(Debug, Clone)]
303pub struct FileCollectionOptions {
304    /// Whether to respect .gitignore files
305    pub respect_gitignore: bool,
306    /// Whether to respect .ckignore files hierarchically
307    pub use_ckignore: bool,
308    /// Patterns to exclude files/directories
309    pub exclude_patterns: Vec<String>,
310    /// Whether to include hidden (dot-prefixed) files and directories
311    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, // Always use .ckignore for hierarchical ignore support
330            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    /// Whether to include hidden (dot-prefixed) files and directories
365    pub hidden: bool,
366    // Enhanced embedding options (search-time only)
367    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            // Enhanced embedding options (search-time only)
425            rerank: false,
426            rerank_model: None,
427            embedding_model: None,
428        }
429    }
430}
431
432/// Get default exclusion patterns for directories that should be skipped during search.
433/// These are common cache, build, and system directories that rarely contain user code.
434pub fn get_default_exclude_patterns() -> Vec<String> {
435    vec![
436        // ck's own index directory
437        ".ck".to_string(),
438        // AI/ML model cache directories
439        ".fastembed_cache".to_string(),
440        ".cache".to_string(),
441        "__pycache__".to_string(),
442        // Version control
443        ".git".to_string(),
444        ".svn".to_string(),
445        ".hg".to_string(),
446        // Build directories
447        "target".to_string(),       // Rust
448        "build".to_string(),        // Various
449        "dist".to_string(),         // JavaScript/Python
450        "node_modules".to_string(), // JavaScript
451        ".gradle".to_string(),      // Java
452        ".mvn".to_string(),         // Maven
453        "bin".to_string(),          // Various
454        "obj".to_string(),          // .NET
455        // Python virtual environments
456        "venv".to_string(),
457        ".venv".to_string(),
458        "env".to_string(),
459        ".env".to_string(),
460        "virtualenv".to_string(),
461        // IDE/Editor directories
462        ".vscode".to_string(),
463        ".idea".to_string(),
464        ".eclipse".to_string(),
465        // Temporary directories
466        "tmp".to_string(),
467        "temp".to_string(),
468        ".tmp".to_string(),
469    ]
470}
471
472/// Get default .ckignore file content
473pub 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
542/// Read and parse .ckignore file, returning patterns
543pub 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
562/// Create .ckignore file with default content if it doesn't exist
563pub 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); // Already exists
568    }
569
570    std::fs::write(&ckignore_path, get_default_ckignore_content()).map_err(CkError::Io)?;
571
572    Ok(true) // Created new file
573}
574
575/// Build exclusion patterns with proper priority ordering
576///
577/// This centralizes the pattern building logic used across CLI, TUI, and MCP interfaces
578/// to prevent drift and ensure consistent behavior.
579///
580/// Builds exclusion patterns for file collection by combining command-line
581/// excludes with default patterns. .ckignore files are now handled separately
582/// by WalkBuilder's hierarchical ignore system.
583///
584/// Priority order:
585/// 1. Additional excludes (from command-line or API calls)
586/// 2. Default patterns (if use_defaults is true)
587///
588/// Note: .ckignore files are loaded hierarchically by WalkBuilder, not here.
589///
590/// # Arguments
591/// * `additional_excludes` - Additional exclusion patterns (e.g., from CLI flags)
592/// * `use_defaults` - Whether to include default exclusion patterns
593///
594/// # Returns
595/// Combined list of exclusion patterns
596pub fn build_exclude_patterns(additional_excludes: &[String], use_defaults: bool) -> Vec<String> {
597    let mut patterns = Vec::new();
598
599    // 1. Add additional exclude patterns (e.g., from command-line)
600    patterns.extend(additional_excludes.iter().cloned());
601
602    // 2. Add defaults (lowest priority)
603    // Note: .ckignore files are now handled hierarchically by WalkBuilder
604    if use_defaults {
605        patterns.extend(get_default_exclude_patterns());
606    }
607
608    patterns
609}
610
611/// Environment variable that relocates ck's per-root index directories out of
612/// the source tree. See [`index_dir`].
613pub const INDEX_DIR_ENV: &str = "CK_INDEX_DIR";
614
615/// File written inside a relocated index directory recording the absolute
616/// search root it was built for, so a basename-hash collision under
617/// [`INDEX_DIR_ENV`] can be detected rather than silently sharing an index.
618const INDEX_ROOT_MARKER: &str = "root_path";
619
620/// The configured relocation base, or `None` when [`INDEX_DIR_ENV`] is unset or
621/// empty (in which case indexes live in-tree at `<root>/.ck`).
622fn 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
629/// Best-effort absolute form of `path`, stable across calls but not required to
630/// exist on disk.
631///
632/// Prefers [`std::fs::canonicalize`] (resolves symlinks, requires existence),
633/// falling back to [`std::path::absolute`] (lexical, anchored at the current
634/// directory, no filesystem access), and finally to `path` itself. Never
635/// panics.
636fn 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
642/// Returns the directory that holds ck's index and sidecar files for a search
643/// root.
644///
645/// By default this is `<root>/.ck`, keeping the index alongside the data it
646/// describes. When [`INDEX_DIR_ENV`] is set to a non-empty value, the index is
647/// relocated to `$CK_INDEX_DIR/<basename>-<hash8>`, where `<basename>` is the
648/// final component of `root` and `<hash8>` is the first 8 hex characters of the
649/// blake3 hash of the root's absolute path. This keeps repositories free of
650/// in-tree `.ck` directories while giving each root a stable, collision-
651/// resistant location even when two roots share a basename.
652///
653/// Both the relocation base and the root are absolutized, so the returned path
654/// is absolute and independent of the current directory (a relative
655/// `CK_INDEX_DIR` is anchored at the launch directory). The environment
656/// variable is read on every call, so callers (and tests) may change it at
657/// runtime. An empty value is treated as unset. This function never panics: if
658/// a path cannot be made absolute it degrades gracefully (see
659/// [`absolute_for_hash`]).
660pub 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
676/// Returns `true` if the index directory for `root` (see [`index_dir`]) exists.
677pub fn index_exists(root: &Path) -> bool {
678    index_dir(root).exists()
679}
680
681/// Record which search root a relocated index directory belongs to.
682///
683/// A no-op unless the index is relocated via [`INDEX_DIR_ENV`] — an in-tree
684/// `.ck` directory lives under its own root and cannot collide. Callers write
685/// the marker when they create or populate an index directory so a later
686/// [`check_index_root_marker`] can detect a basename-hash collision.
687pub 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
699/// Verify that an existing relocated index directory belongs to `root`.
700///
701/// A no-op unless the index is relocated via [`INDEX_DIR_ENV`]. Returns an
702/// error when the directory carries a marker for a different root — a basename
703/// hash collision under `CK_INDEX_DIR` — rather than silently serving another
704/// root's index. A missing marker (no directory yet, or an index built before
705/// markers existed) is tolerated.
706pub 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    // Stream the file in 64KB chunks to avoid loading entire file into memory
750    let mut buffer = [0u8; 65536]; // 64KB buffer
751    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
763/// Compute blake3 hash of chunk content for incremental indexing
764/// This enables us to detect which chunks have changed and only re-embed those
765///
766/// Hashes all fields that affect the chunk's display and meaning:
767/// - text: the main chunk content
768/// - leading_trivia: doc comments and comments before the chunk
769/// - trailing_trivia: comments after the chunk
770pub 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    // Hash the main text
778    hasher.update(text.as_bytes());
779
780    // Hash leading trivia (doc comments, preceding comments)
781    for trivia in leading_trivia {
782        hasher.update(trivia.as_bytes());
783    }
784
785    // Hash trailing trivia (following comments)
786    for trivia in trailing_trivia {
787        hasher.update(trivia.as_bytes());
788    }
789
790    hasher.finalize().to_hex().to_string()
791}
792
793/// PDF-specific utilities
794pub mod pdf {
795    use std::path::{Path, PathBuf};
796
797    /// Check if a file is a PDF by extension (optimized to avoid allocations)
798    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")) // Avoids allocation vs to_lowercase()
802            .unwrap_or(false)
803    }
804
805    /// Get path for cached PDF content
806    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        // Add .txt extension to the cached file
812        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"))); // Case insensitive
831            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"))); // No extension
834            assert!(!is_pdf_file(&PathBuf::from("pdf"))); // Just "pdf", no extension
835        }
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"); // Relative path
871
872            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        // Test valid span creation
891        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        // Same byte positions (empty span)
902        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        // Multi-line span
908        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        // Single line span
914        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        // Reversed byte range
923        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        // Reversed line range
936        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        // Zero line start
949        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        // Zero line end
958        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        // Test backward compatibility with unchecked creation
970        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()); // Should be invalid
976    }
977
978    #[test]
979    fn test_span_validation_methods() {
980        // Valid span
981        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        // Invalid span (reversed bytes)
986        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        // Invalid span (zero lines)
991        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        // Test byte_len
1001        assert_eq!(span.byte_len(), 15);
1002
1003        // Test line_count
1004        assert_eq!(span.line_count(), 4); // lines 5, 6, 7, 8
1005
1006        // Test with single-line span
1007        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        // Test with empty span
1012        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        // Ensure backward compatibility for existing code using struct literals
1020        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        // Test with snippet
1121        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        // Test without snippet
1137        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        // Same content should produce same hash
1179        assert_eq!(hash1, hash2);
1180        assert!(!hash1.is_empty());
1181
1182        // Different content should produce different hash
1183        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        // Create a file larger than the buffer size (64KB) to test streaming
1200        let large_content = "a".repeat(100_000); // 100KB content
1201        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        // Streaming hash should be consistent
1207        assert_eq!(hash1, hash2);
1208        assert!(!hash1.is_empty());
1209
1210        // Verify it's different from smaller content
1211        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, // txt is not a supported language
1233            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        // Test uppercase extensions - only for actually supported languages
1276        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        // Test mixed case extensions
1302        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        // Unknown extensions should still return None
1319        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); // unknown extensions return None
1350        assert_eq!(Language::from_path(&PathBuf::from("noext")), None); // no extension
1351    }
1352
1353    #[test]
1354    fn test_language_from_path_case_insensitive() {
1355        // Test uppercase extensions in file paths - only supported languages
1356        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        // Test mixed case extensions in file paths
1422        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        // Unknown extensions should still return None regardless of case
1452        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        // First creation should succeed
1472        let created = create_ckignore_if_missing(test_path).unwrap();
1473        assert!(created);
1474
1475        // Check that file exists
1476        let ckignore_path = test_path.join(".ckignore");
1477        assert!(ckignore_path.exists());
1478
1479        // Check content contains expected patterns
1480        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        // Second creation should return false (already exists)
1488        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        // Test with no .ckignore file
1498        let patterns = read_ckignore_patterns(test_path).unwrap();
1499        assert_eq!(patterns.len(), 0);
1500
1501        // Create a .ckignore file
1502        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        // Read patterns
1517        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        // Comments should be filtered out
1524        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        // Check that default content includes key patterns
1558        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        // Check that it has comments
1569        assert!(content.contains("# Images"));
1570        assert!(content.contains("# Video"));
1571        assert!(content.contains("# Audio"));
1572        assert!(content.contains("# Config formats"));
1573
1574        // Check for issue reference
1575        assert!(content.contains("issue #27"));
1576    }
1577
1578    #[test]
1579    fn test_build_exclude_patterns_with_defaults() {
1580        // Test with defaults enabled
1581        let additional = vec!["*.custom".to_string(), "temp/".to_string()];
1582        let patterns = build_exclude_patterns(&additional, true);
1583
1584        // Should include additional patterns
1585        assert!(patterns.contains(&"*.custom".to_string()));
1586        assert!(patterns.contains(&"temp/".to_string()));
1587
1588        // Should include default patterns (from get_default_exclude_patterns)
1589        assert!(patterns.iter().any(|p| p.contains(".git")));
1590        assert!(patterns.iter().any(|p| p.contains("node_modules")));
1591
1592        // Additional patterns should come before defaults
1593        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        // Test with defaults disabled
1601        let additional = vec!["*.custom".to_string(), "temp/".to_string()];
1602        let patterns = build_exclude_patterns(&additional, false);
1603
1604        // Should include additional patterns
1605        assert!(patterns.contains(&"*.custom".to_string()));
1606        assert!(patterns.contains(&"temp/".to_string()));
1607
1608        // Should NOT include default patterns
1609        assert!(!patterns.iter().any(|p| p.contains(".git")));
1610        assert!(!patterns.iter().any(|p| p.contains("node_modules")));
1611
1612        // Should only have the 2 additional patterns
1613        assert_eq!(patterns.len(), 2);
1614    }
1615
1616    #[test]
1617    fn test_build_exclude_patterns_empty_additional() {
1618        // Test with empty additional patterns and defaults enabled
1619        let patterns = build_exclude_patterns(&[], true);
1620
1621        // Should only have default patterns
1622        assert!(patterns.iter().any(|p| p.contains(".git")));
1623        assert!(!patterns.is_empty());
1624
1625        // Test with empty additional patterns and defaults disabled
1626        let patterns = build_exclude_patterns(&[], false);
1627
1628        // Should be empty
1629        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        // Test 1: Empty .ckignore file
1638        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        // Test 2: .ckignore with only comments
1644        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        // Test 3: .ckignore with only whitespace
1649        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        // Test 4: .ckignore with mixed content
1654        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        // Patterns should be trimmed
1664        assert!(!patterns.iter().any(|p| p.starts_with(' ')));
1665        assert!(!patterns.iter().any(|p| p.ends_with(' ')));
1666    }
1667
1668    // Tests that mutate CK_INDEX_DIR are #[serial]: the variable is
1669    // process-global and Rust runs tests in parallel threads by default.
1670
1671    #[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        // Recompute the documented formula against the canonicalized base and
1703        // root (index_dir absolutizes both).
1704        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        // Structural checks: absolute, lives directly under the base, 8-hex suffix.
1712        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        // Two distinct roots that share the basename "proj" must not collide.
1727        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        // With an absolute CK_INDEX_DIR, a given root maps to the same index dir
1759        // regardless of the process's current directory — so the index and its
1760        // write lock don't silently fork per cwd.
1761        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        // A relative CK_INDEX_DIR is anchored at the launch directory and
1788        // resolved to an absolute path, rather than a bare relative path that
1789        // downstream filesystem and lock operations would resolve against
1790        // whatever cwd each happened to run in.
1791        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        // With no relocation, the marker helpers do nothing and never create a
1819        // marker under the in-tree .ck (which can't collide anyway).
1820        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        // A markerless dir, then a self-marked dir, both validate.
1835        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        // A marker naming a different root (a basename-hash collision) is
1840        // rejected rather than silently reused.
1841        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}