Skip to main content

ck_index/
lib.rs

1use anyhow::Result;
2use ck_core::{
3    FileMetadata, Language, Span, compute_chunk_hash, compute_file_hash, get_sidecar_path,
4};
5use ignore::{WalkBuilder, overrides::OverrideBuilder};
6use rayon::prelude::*;
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet};
9use std::fs;
10use std::io::{Read, Write};
11use std::path::{Path, PathBuf};
12use std::sync::Once;
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::time::SystemTime;
15use tempfile::NamedTempFile;
16use walkdir::WalkDir;
17
18fn legacy_model_config(name: &str, dimensions: Option<usize>) -> ck_models::ModelConfig {
19    ck_models::ModelConfig {
20        name: name.to_string(),
21        provider: "fastembed".to_string(),
22        dimensions: dimensions.unwrap_or(384),
23        max_tokens: 8192,
24        description: "Legacy ck embedding model (inferred from manifest)".to_string(),
25    }
26}
27
28pub type ProgressCallback = Box<dyn Fn(&str) + Send + Sync>;
29
30/// Detailed progress information for embedding operations
31#[derive(Debug, Clone)]
32pub struct EmbeddingProgress {
33    pub file_name: String,
34    pub file_index: usize,
35    pub total_files: usize,
36    pub chunk_index: usize,
37    pub total_chunks: usize,
38    pub chunk_size: usize,
39}
40
41pub type DetailedProgressCallback = Box<dyn Fn(EmbeddingProgress) + Send + Sync>;
42
43/// Enhanced progress information for granular indexing feedback
44#[derive(Debug, Clone)]
45pub enum IndexingProgress {
46    /// Starting indexing process
47    Starting { total_files: usize },
48    /// Processing a specific file
49    ProcessingFile {
50        file: String,
51        file_number: usize,
52        total_files: usize,
53        file_size: u64,
54    },
55    /// Chunking a file
56    ChunkingFile { file: String, chunks_found: usize },
57    /// Processing chunk for embedding
58    ProcessingChunk {
59        file: String,
60        chunk_number: usize,
61        total_chunks: usize,
62        chunk_size: usize,
63    },
64    /// Finished processing a file
65    FileComplete {
66        file: String,
67        chunks_processed: usize,
68        file_number: usize,
69        total_files: usize,
70        elapsed_ms: u64,
71    },
72    /// Overall completion
73    Complete {
74        total_files: usize,
75        total_chunks: usize,
76        total_elapsed_ms: u64,
77    },
78}
79
80pub type EnhancedProgressCallback = Box<dyn Fn(IndexingProgress) + Send + Sync>;
81
82// Global interrupt flag
83static INTERRUPTED: AtomicBool = AtomicBool::new(false);
84static HANDLER_INIT: Once = Once::new();
85
86pub const INDEX_INTERRUPTED_MSG: &str = "Indexing interrupted by user";
87
88pub fn request_interrupt() {
89    INTERRUPTED.store(true, Ordering::SeqCst);
90}
91
92/// Build override patterns for excluding files during directory traversal
93fn build_overrides(
94    base_path: &Path,
95    exclude_patterns: &[String],
96) -> Result<ignore::overrides::Override> {
97    let mut builder = OverrideBuilder::new(base_path);
98
99    for pattern in exclude_patterns {
100        if pattern.starts_with('!') {
101            builder.add(pattern)?;
102        } else {
103            builder.add(&format!("!{pattern}"))?;
104        }
105    }
106
107    Ok(builder.build()?)
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct IndexEntry {
112    pub metadata: FileMetadata,
113    pub chunks: Vec<ChunkEntry>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct ChunkEntry {
118    pub span: Span,
119    pub embedding: Option<Vec<f32>>,
120    pub chunk_type: Option<String>, // "function", "class", "method", or None for generic
121    #[serde(default)]
122    pub breadcrumb: Option<String>,
123    #[serde(default)]
124    pub ancestry: Option<Vec<String>>,
125    #[serde(default)]
126    pub byte_length: Option<usize>,
127    #[serde(default)]
128    pub estimated_tokens: Option<usize>,
129    #[serde(default)]
130    pub leading_trivia: Option<Vec<String>>,
131    #[serde(default)]
132    pub trailing_trivia: Option<Vec<String>>,
133    /// Blake3 hash of the chunk text for incremental indexing
134    #[serde(default)]
135    pub chunk_hash: Option<String>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct IndexManifest {
140    pub version: String,
141    pub created: u64,
142    pub updated: u64,
143    pub files: HashMap<PathBuf, FileMetadata>,
144    /// Embedding model used for this index (added in v0.4.2+)
145    pub embedding_model: Option<String>,
146    /// Embedding model dimensions (for validation)
147    pub embedding_dimensions: Option<usize>,
148    /// Chunk hash version for incremental indexing
149    /// - v1 = blake3 of chunk text only
150    /// - v2 = blake3 of chunk text + leading_trivia + trailing_trivia
151    #[serde(default)]
152    pub chunk_hash_version: Option<u32>,
153}
154
155impl Default for IndexManifest {
156    fn default() -> Self {
157        let now = SystemTime::now()
158            .duration_since(SystemTime::UNIX_EPOCH)
159            .unwrap()
160            .as_secs();
161
162        Self {
163            version: "0.1.0".to_string(),
164            created: now,
165            updated: now,
166            files: HashMap::new(),
167            embedding_model: None, // Default to None for backward compatibility
168            embedding_dimensions: None,
169            chunk_hash_version: Some(2), // v2 = blake3 of chunk text + trivia
170        }
171    }
172}
173
174/// Common filtering logic for directory traversal entries
175fn should_include_file(entry: &ignore::DirEntry, index_dir: &Path) -> bool {
176    let path = entry.path();
177    entry.file_type().is_some_and(|ft| ft.is_file())
178        && is_text_file(path)
179        && !path.starts_with(index_dir)
180}
181
182/// Apply common filtering to a WalkBuilder iterator
183fn filter_and_collect_files(walker: ignore::Walk, index_dir: &Path) -> Vec<PathBuf> {
184    walker
185        .filter_map(std::result::Result::ok)
186        .filter(|entry| should_include_file(entry, index_dir))
187        .map(|entry| entry.path().to_path_buf())
188        .collect()
189}
190
191pub fn collect_files(
192    path: &Path,
193    options: &ck_core::FileCollectionOptions,
194) -> Result<Vec<PathBuf>> {
195    let index_dir = ck_core::index_dir(path);
196
197    if options.respect_gitignore {
198        let overrides = build_overrides(path, &options.exclude_patterns)?;
199        let mut walker_builder = WalkBuilder::new(path);
200        walker_builder
201            .git_ignore(true)
202            .git_global(true)
203            .git_exclude(true)
204            .hidden(!options.show_hidden);
205
206        // Add .ckignore support (hierarchical, like .gitignore)
207        if options.use_ckignore {
208            walker_builder.add_custom_ignore_filename(".ckignore");
209        }
210
211        walker_builder.overrides(overrides);
212        let walker = walker_builder.build();
213
214        Ok(filter_and_collect_files(walker, &index_dir))
215    } else {
216        // Use WalkBuilder without gitignore support, but still apply overrides
217        use ck_core::get_default_exclude_patterns;
218        let default_patterns = get_default_exclude_patterns();
219
220        // Combine default patterns with user exclude patterns
221        let mut all_patterns = default_patterns;
222        all_patterns.extend(options.exclude_patterns.iter().cloned());
223        let combined_overrides = build_overrides(path, &all_patterns)?;
224
225        let mut walker_builder = WalkBuilder::new(path);
226        walker_builder
227            .git_ignore(false)
228            .git_global(false)
229            .git_exclude(false)
230            .hidden(!options.show_hidden);
231
232        // Add .ckignore support even without gitignore
233        if options.use_ckignore {
234            walker_builder.add_custom_ignore_filename(".ckignore");
235        }
236
237        walker_builder.overrides(combined_overrides);
238        let walker = walker_builder.build();
239
240        Ok(filter_and_collect_files(walker, &index_dir))
241    }
242}
243
244fn collect_files_as_hashset(
245    path: &Path,
246    options: &ck_core::FileCollectionOptions,
247) -> Result<HashSet<PathBuf>> {
248    Ok(collect_files(path, options)?.into_iter().collect())
249}
250
251/// Name of the advisory lock file inside `.ck`, guarding against concurrent
252/// writers (two `ck` processes indexing the same directory would otherwise
253/// interleave manifest writes and silently lose each other's entries).
254const INDEX_LOCK_FILE: &str = ".lock";
255
256/// Held for the duration of any index mutation. The OS advisory lock is
257/// released when this is dropped (the file handle closes).
258///
259/// Readers take no lock: every manifest and sidecar write goes through
260/// `atomic_write` (temp file + rename), so a reader can never observe a
261/// partially written file — only writers conflict with writers.
262pub struct IndexWriteLock {
263    _file: std::fs::File,
264}
265
266/// Acquire an exclusive cross-process lock on the index directory, creating
267/// the directory if needed. Blocks (with a log message) if another process
268/// holds the lock.
269///
270/// Public so other layers writing inside `.ck` (e.g. ck-engine's tantivy
271/// index build) serialize with index mutations.
272pub fn acquire_index_write_lock(index_dir: &Path) -> Result<IndexWriteLock> {
273    use fs4::fs_std::FileExt;
274
275    fs::create_dir_all(index_dir)?;
276    let lock_path = index_dir.join(INDEX_LOCK_FILE);
277    let file = fs::OpenOptions::new()
278        .create(true)
279        .truncate(false)
280        .write(true)
281        .open(&lock_path)?;
282    if !file.try_lock_exclusive()? {
283        tracing::info!(
284            "Index {} is locked by another ck process; waiting for it to finish",
285            index_dir.display()
286        );
287        file.lock_exclusive()?;
288    }
289    Ok(IndexWriteLock { _file: file })
290}
291
292pub async fn index_directory(
293    path: &Path,
294    compute_embeddings: bool,
295    options: &ck_core::FileCollectionOptions,
296    model: Option<&str>,
297) -> Result<()> {
298    let _lock = acquire_index_write_lock(&ck_core::index_dir(path))?;
299    index_directory_inner(path, compute_embeddings, options, model).await
300}
301
302/// Body of [`index_directory`]; callers must hold the index write lock.
303async fn index_directory_inner(
304    path: &Path,
305    compute_embeddings: bool,
306    options: &ck_core::FileCollectionOptions,
307    model: Option<&str>,
308) -> Result<()> {
309    tracing::info!(
310        "index_directory called with compute_embeddings={}",
311        compute_embeddings
312    );
313    let index_dir = ck_core::index_dir(path);
314    fs::create_dir_all(&index_dir)?;
315    // Under CK_INDEX_DIR, two roots that share a basename hash to the same dir.
316    // Refuse to write into one already claimed by a different root, then stamp
317    // this root so later searches can detect the collision. No-op in-tree.
318    ck_core::check_index_root_marker(path)?;
319    ck_core::write_index_root_marker(path)?;
320
321    let manifest_path = index_dir.join("manifest.json");
322    let mut manifest = load_or_create_manifest(&manifest_path)?;
323    normalize_manifest_paths(&mut manifest, path);
324
325    // Handle model configuration for embeddings
326    let resolved_model = if compute_embeddings {
327        let model_registry = ck_models::ModelRegistry::default();
328        let (alias, config) = model_registry
329            .resolve(model)
330            .map_err(|e| anyhow::anyhow!(e.to_string()))?;
331
332        if let Some(existing_model) = &manifest.embedding_model
333            && existing_model != &config.name
334        {
335            return Err(anyhow::anyhow!(
336                "Model mismatch: Index was created with '{}', but you're trying to use '{}'. \
337                Please run 'ck --clean {}' to remove the old index, then rerun with the new model.",
338                existing_model,
339                config.name,
340                path.display()
341            ));
342        }
343
344        manifest.embedding_model = Some(config.name.clone());
345        manifest.embedding_dimensions = Some(config.dimensions);
346
347        Some((alias, config))
348    } else {
349        None
350    };
351
352    let files = collect_files(path, options)?;
353
354    if compute_embeddings {
355        // Sequential processing with small-batch embeddings for streaming performance
356        tracing::info!("Creating embedder for {} files", files.len());
357        let (_, config) = resolved_model
358            .as_ref()
359            .expect("resolved model must be present when computing embeddings");
360        let mut embedder = ck_embed::create_embedder_for_config(config, None)?;
361
362        for file_path in files.iter() {
363            match index_single_file(file_path, path, Some(&mut embedder)) {
364                Ok(entry) => {
365                    // Write sidecar immediately
366                    let sidecar_path = get_sidecar_path(path, file_path);
367                    save_index_entry(&sidecar_path, &entry)?;
368
369                    // Update and save manifest immediately
370                    let manifest_key = entry.metadata.path.clone();
371                    manifest.files.insert(manifest_key, entry.metadata);
372                    manifest.updated = SystemTime::now()
373                        .duration_since(SystemTime::UNIX_EPOCH)
374                        .unwrap()
375                        .as_secs();
376                    save_manifest(&manifest_path, &manifest)?;
377                }
378                Err(e) => {
379                    // Suppress warnings for binary files and UTF-8 errors in .git directories
380                    let error_msg = e.to_string();
381                    let is_binary_skip = error_msg.contains("Binary file, skipping");
382                    let is_utf8_error = error_msg.contains("stream did not contain valid UTF-8");
383                    let is_git_file = file_path.components().any(|c| c.as_os_str() == ".git");
384
385                    if !(is_binary_skip || is_utf8_error && is_git_file) {
386                        tracing::warn!("Failed to index {:?}: {}", file_path, e);
387                    }
388                }
389            }
390        }
391    } else {
392        // Parallel processing with streaming using producer-consumer pattern
393        use std::sync::mpsc;
394        use std::thread;
395
396        let (tx, rx) = mpsc::channel();
397        let files_clone = files.clone();
398        let path_clone = path.to_path_buf();
399
400        // Spawn worker thread for parallel processing
401        let worker_handle = thread::spawn(move || {
402            files_clone.par_iter().for_each(|file_path| {
403                match index_single_file(file_path, &path_clone, None) {
404                    Ok(entry) => {
405                        if tx.send((file_path.clone(), entry)).is_err() {
406                            // Receiver dropped, stop processing
407                        }
408                    }
409                    Err(e) => {
410                        // Suppress warnings for binary files and UTF-8 errors in .git directories
411                        let error_msg = e.to_string();
412                        let is_binary_skip = error_msg.contains("Binary file, skipping");
413                        let is_utf8_error =
414                            error_msg.contains("stream did not contain valid UTF-8");
415                        let is_git_file = file_path.components().any(|c| c.as_os_str() == ".git");
416
417                        if !(is_binary_skip || is_utf8_error && is_git_file) {
418                            tracing::warn!("Failed to index {:?}: {}", file_path, e);
419                        }
420                    }
421                }
422            });
423        });
424
425        // Main thread: stream results as they arrive
426        while let Ok((file_path, entry)) = rx.recv() {
427            // Write sidecar immediately
428            let sidecar_path = get_sidecar_path(path, &file_path);
429            save_index_entry(&sidecar_path, &entry)?;
430
431            // Update and save manifest immediately
432            let manifest_key = entry.metadata.path.clone();
433            manifest.files.insert(manifest_key, entry.metadata);
434            manifest.updated = SystemTime::now()
435                .duration_since(SystemTime::UNIX_EPOCH)
436                .unwrap()
437                .as_secs();
438            save_manifest(&manifest_path, &manifest)?;
439        }
440
441        // Wait for worker to complete
442        worker_handle
443            .join()
444            .map_err(|_| anyhow::anyhow!("Worker thread panicked"))?;
445    }
446
447    // Manifest is already updated after each file in streaming mode
448    // Only save manifest if using parallel processing (non-embedding case)
449    if !compute_embeddings {
450        manifest.updated = SystemTime::now()
451            .duration_since(SystemTime::UNIX_EPOCH)
452            .unwrap()
453            .as_secs();
454        save_manifest(&manifest_path, &manifest)?;
455    }
456
457    Ok(())
458}
459
460pub async fn index_file(file_path: &Path, compute_embeddings: bool) -> Result<()> {
461    let repo_root = find_repo_root(file_path)?;
462    let index_dir = ck_core::index_dir(&repo_root);
463    let _lock = acquire_index_write_lock(&index_dir)?;
464
465    let manifest_path = index_dir.join("manifest.json");
466    let mut manifest = load_or_create_manifest(&manifest_path)?;
467
468    let entry = if compute_embeddings {
469        let model_registry = ck_models::ModelRegistry::default();
470        let (alias, config) = if let Some(existing) = manifest.embedding_model.as_deref() {
471            match model_registry.resolve(Some(existing)) {
472                Ok(resolved) => resolved,
473                Err(_) => (
474                    existing.to_string(),
475                    legacy_model_config(existing, manifest.embedding_dimensions),
476                ),
477            }
478        } else {
479            model_registry
480                .resolve(None)
481                .map_err(|e| anyhow::anyhow!(e.to_string()))?
482        };
483
484        manifest.embedding_model = Some(config.name.clone());
485        manifest.embedding_dimensions = Some(config.dimensions);
486        tracing::debug!("Using embedding model '{}' ({})", config.name, alias);
487
488        let mut embedder = ck_embed::create_embedder_for_config(&config, None)?;
489        index_single_file(file_path, &repo_root, Some(&mut embedder))?
490    } else {
491        index_single_file(file_path, &repo_root, None)?
492    };
493    let sidecar_path = get_sidecar_path(&repo_root, file_path);
494
495    save_index_entry(&sidecar_path, &entry)?;
496    let manifest_key = entry.metadata.path.clone();
497    manifest.files.insert(manifest_key, entry.metadata);
498    manifest.updated = SystemTime::now()
499        .duration_since(SystemTime::UNIX_EPOCH)
500        .unwrap()
501        .as_secs();
502
503    save_manifest(&manifest_path, &manifest)?;
504
505    Ok(())
506}
507
508pub async fn update_index(
509    path: &Path,
510    compute_embeddings: bool,
511    options: &ck_core::FileCollectionOptions,
512) -> Result<()> {
513    let index_dir = ck_core::index_dir(path);
514    let index_existed = index_dir.exists();
515    let _lock = acquire_index_write_lock(&index_dir)?;
516    if !index_existed {
517        return index_directory_inner(
518            path,
519            compute_embeddings,
520            options,
521            None, // model - use existing from manifest for update
522        )
523        .await;
524    }
525
526    let manifest_path = index_dir.join("manifest.json");
527    let mut manifest = load_or_create_manifest(&manifest_path)?;
528
529    let files = collect_files(path, options)?;
530
531    let updates: Vec<(PathBuf, IndexEntry)> = if compute_embeddings {
532        // Sequential processing when computing embeddings (for memory efficiency)
533        let model_registry = ck_models::ModelRegistry::default();
534        let (alias, config) = if let Some(existing) = manifest.embedding_model.as_deref() {
535            match model_registry.resolve(Some(existing)) {
536                Ok(resolved) => resolved,
537                Err(_) => (
538                    existing.to_string(),
539                    legacy_model_config(existing, manifest.embedding_dimensions),
540                ),
541            }
542        } else {
543            model_registry
544                .resolve(None)
545                .map_err(|e| anyhow::anyhow!(e.to_string()))?
546        };
547
548        manifest.embedding_model = Some(config.name.clone());
549        manifest.embedding_dimensions = Some(config.dimensions);
550        tracing::debug!(
551            "Updating index with embedding model '{}' ({})",
552            config.name,
553            alias
554        );
555
556        let mut embedder = ck_embed::create_embedder_for_config(&config, None)?;
557        files
558            .iter()
559            .filter_map(|file_path| {
560                let manifest_key =
561                    path_utils::to_manifest_path(&path_utils::to_standard_path(file_path, path));
562
563                let needs_update = match manifest.files.get(&manifest_key) {
564                    Some(metadata) => match compute_file_hash(file_path) {
565                        Ok(hash) => hash != metadata.hash,
566                        Err(_) => false,
567                    },
568                    None => true,
569                };
570                if needs_update {
571                    match index_single_file(file_path, path, Some(&mut embedder)) {
572                        Ok(entry) => Some((file_path.clone(), entry)),
573                        Err(e) => {
574                            // Suppress warnings for binary files and UTF-8 errors in .git directories
575                            let error_msg = e.to_string();
576                            let is_binary_skip = error_msg.contains("Binary file, skipping");
577                            let is_utf8_error =
578                                error_msg.contains("stream did not contain valid UTF-8");
579                            let is_git_file =
580                                file_path.components().any(|c| c.as_os_str() == ".git");
581
582                            if !(is_binary_skip || is_utf8_error && is_git_file) {
583                                tracing::warn!("Failed to index {:?}: {}", file_path, e);
584                            }
585                            None
586                        }
587                    }
588                } else {
589                    None
590                }
591            })
592            .collect()
593    } else {
594        // Parallel processing when not computing embeddings
595        files
596            .par_iter()
597            .filter_map(|file_path| {
598                let manifest_key =
599                    path_utils::to_manifest_path(&path_utils::to_standard_path(file_path, path));
600
601                let needs_update = match manifest.files.get(&manifest_key) {
602                    Some(metadata) => match compute_file_hash(file_path) {
603                        Ok(hash) => hash != metadata.hash,
604                        Err(_) => false,
605                    },
606                    None => true,
607                };
608
609                if needs_update {
610                    match index_single_file(file_path, path, None) {
611                        Ok(entry) => Some((file_path.clone(), entry)),
612                        Err(e) => {
613                            // Suppress warnings for binary files and UTF-8 errors in .git directories
614                            let error_msg = e.to_string();
615                            let is_binary_skip = error_msg.contains("Binary file, skipping");
616                            let is_utf8_error =
617                                error_msg.contains("stream did not contain valid UTF-8");
618                            let is_git_file =
619                                file_path.components().any(|c| c.as_os_str() == ".git");
620
621                            if !(is_binary_skip || is_utf8_error && is_git_file) {
622                                tracing::warn!("Failed to index {:?}: {}", file_path, e);
623                            }
624                            None
625                        }
626                    }
627                } else {
628                    None
629                }
630            })
631            .collect()
632    };
633
634    for (file_path, entry) in updates {
635        let sidecar_path = get_sidecar_path(path, &file_path);
636        save_index_entry(&sidecar_path, &entry)?;
637        let manifest_key = entry.metadata.path.clone();
638        manifest.files.insert(manifest_key, entry.metadata);
639    }
640
641    if !manifest.files.is_empty() {
642        manifest.updated = SystemTime::now()
643            .duration_since(SystemTime::UNIX_EPOCH)
644            .unwrap()
645            .as_secs();
646        save_manifest(&manifest_path, &manifest)?;
647    }
648
649    Ok(())
650}
651
652pub fn clean_index(path: &Path) -> Result<()> {
653    let index_dir = ck_core::index_dir(path);
654    if !index_dir.exists() {
655        return Ok(());
656    }
657    let lock = acquire_index_write_lock(&index_dir)?;
658    clean_index_inner(&index_dir)?;
659    drop(lock);
660    // Best-effort: remove the lock file and the now-empty directory. If a
661    // concurrent process re-acquired the lock in the meantime, removal fails
662    // and we leave the directory to it (it is rebuilding the index anyway).
663    let _ = fs::remove_file(index_dir.join(INDEX_LOCK_FILE));
664    let _ = fs::remove_dir(&index_dir);
665    Ok(())
666}
667
668/// Remove all index contents except the lock file (which the caller holds
669/// open — deleting an open locked file would fail on Windows).
670fn clean_index_inner(index_dir: &Path) -> Result<()> {
671    for entry in fs::read_dir(index_dir)? {
672        let entry = entry?;
673        if entry.file_name().to_str() == Some(INDEX_LOCK_FILE) {
674            continue;
675        }
676        let entry_path = entry.path();
677        if entry.file_type()?.is_dir() {
678            fs::remove_dir_all(&entry_path)?;
679        } else {
680            fs::remove_file(&entry_path)?;
681        }
682    }
683    Ok(())
684}
685
686pub fn cleanup_index(
687    path: &Path,
688    options: &ck_core::FileCollectionOptions,
689) -> Result<CleanupStats> {
690    let index_dir = ck_core::index_dir(path);
691    if !index_dir.exists() {
692        return Ok(CleanupStats::default());
693    }
694    let _lock = acquire_index_write_lock(&index_dir)?;
695
696    let manifest_path = index_dir.join("manifest.json");
697    let mut manifest = load_or_create_manifest(&manifest_path)?;
698    normalize_manifest_paths(&mut manifest, path);
699
700    // Use the new unified cleanup validation
701    let stats =
702        cleanup_validation::validate_and_cleanup_index(path, &index_dir, &mut manifest, options)?;
703
704    // Content cache cleanup is now handled by the unified cleanup validation
705
706    // Remove empty directories in .ck
707    remove_empty_dirs(&index_dir)?;
708
709    // Update manifest if changes were made
710    if stats.orphaned_entries_removed > 0 {
711        manifest.updated = SystemTime::now()
712            .duration_since(SystemTime::UNIX_EPOCH)
713            .unwrap()
714            .as_secs();
715        save_manifest(&manifest_path, &manifest)?;
716    }
717
718    Ok(stats)
719}
720
721pub fn get_index_stats(path: &Path) -> Result<IndexStats> {
722    let index_dir = ck_core::index_dir(path);
723    if !index_dir.exists() {
724        return Ok(IndexStats::default());
725    }
726
727    let manifest_path = index_dir.join("manifest.json");
728    let mut manifest = load_or_create_manifest(&manifest_path)?;
729    normalize_manifest_paths(&mut manifest, path);
730
731    let mut stats = IndexStats {
732        total_files: manifest.files.len(),
733        index_created: manifest.created,
734        index_updated: manifest.updated,
735        ..Default::default()
736    };
737
738    // Calculate total chunks and size
739    for file_path in manifest.files.keys() {
740        let standard_path = path_utils::from_manifest_path(file_path);
741        let sidecar_path =
742            path_utils::get_sidecar_path_for_standard_path(&index_dir, &standard_path);
743        if sidecar_path.exists()
744            && let Ok(entry) = load_index_entry(&sidecar_path)
745        {
746            stats.total_chunks += entry.chunks.len();
747            stats.total_size_bytes += entry.metadata.size;
748
749            // Count embedded chunks
750            let embedded = entry
751                .chunks
752                .iter()
753                .filter(|c| c.embedding.is_some())
754                .count();
755            stats.embedded_chunks += embedded;
756        }
757    }
758
759    // Calculate index size on disk
760    if let Ok(entries) = WalkDir::new(&index_dir)
761        .into_iter()
762        .collect::<Result<Vec<_>, _>>()
763    {
764        for entry in entries {
765            if entry.file_type().is_file()
766                && let Ok(metadata) = entry.metadata()
767            {
768                stats.index_size_bytes += metadata.len();
769            }
770        }
771    }
772
773    Ok(stats)
774}
775
776pub async fn smart_update_index(
777    path: &Path,
778    compute_embeddings: bool,
779    options: &ck_core::FileCollectionOptions,
780) -> Result<UpdateStats> {
781    smart_update_index_with_progress(
782        path,
783        false,
784        None,
785        compute_embeddings,
786        options,
787        None, // model - use default for backward compatibility
788    )
789    .await
790}
791
792pub async fn smart_update_index_with_progress(
793    path: &Path,
794    force_rebuild: bool,
795    progress_callback: Option<ProgressCallback>,
796    compute_embeddings: bool,
797    options: &ck_core::FileCollectionOptions,
798    model: Option<&str>,
799) -> Result<UpdateStats> {
800    smart_update_index_with_detailed_progress(
801        path,
802        force_rebuild,
803        progress_callback,
804        None, // No detailed progress callback for backward compatibility
805        compute_embeddings,
806        options,
807        model,
808    )
809    .await
810}
811
812/// Enhanced indexing with detailed embedding progress
813pub async fn smart_update_index_with_detailed_progress(
814    path: &Path,
815    force_rebuild: bool,
816    progress_callback: Option<ProgressCallback>,
817    detailed_progress_callback: Option<DetailedProgressCallback>,
818    compute_embeddings: bool,
819    options: &ck_core::FileCollectionOptions,
820    model: Option<&str>,
821) -> Result<UpdateStats> {
822    let index_dir = ck_core::index_dir(path);
823    let _lock = acquire_index_write_lock(&index_dir)?;
824    // Guard against a CK_INDEX_DIR basename-hash collision before any
825    // destructive rebuild, so a colliding root's index isn't clobbered. No-op
826    // in-tree. The claim is written once the directory is established below.
827    ck_core::check_index_root_marker(path)?;
828    let mut stats = UpdateStats::default();
829
830    // Set up interrupt handler (only once per process)
831    HANDLER_INIT.call_once(|| {
832        let _ = ctrlc::set_handler(move || {
833            INTERRUPTED.store(true, Ordering::SeqCst);
834            eprintln!("\nIndexing interrupted by user. Cleaning up...");
835        });
836    });
837
838    // Reset interrupt flag for this indexing operation
839    INTERRUPTED.store(false, Ordering::SeqCst);
840
841    if force_rebuild {
842        // Use the unlocked variants: we already hold the index write lock,
843        // and a second acquisition on a fresh handle would self-deadlock.
844        clean_index_inner(&index_dir)?;
845        index_directory_inner(path, compute_embeddings, options, model).await?;
846        let index_stats = get_index_stats(path)?;
847        stats.files_indexed = index_stats.total_files;
848        return Ok(stats);
849    }
850
851    // Find repo root for path normalization
852    let repo_root = find_repo_root(path)?;
853
854    // Skip cleanup during incremental updates to avoid removing valid entries
855    // that may be outside the current search scope or have path normalization issues
856    // Cleanup should be done explicitly with --clean-orphans when needed
857
858    // Then perform incremental update
859    fs::create_dir_all(&index_dir)?;
860    ck_core::write_index_root_marker(path)?;
861    let manifest_path = index_dir.join("manifest.json");
862    let mut manifest = load_or_create_manifest(&manifest_path)?;
863    normalize_manifest_paths(&mut manifest, &repo_root);
864
865    // Handle model configuration for embeddings
866    let resolved_model = if compute_embeddings {
867        let model_registry = ck_models::ModelRegistry::default();
868
869        let resolved = if let Some(requested) = model {
870            model_registry
871                .resolve(Some(requested))
872                .map_err(|e| anyhow::anyhow!(e.to_string()))?
873        } else if let Some(existing_model) = &manifest.embedding_model {
874            match model_registry.resolve(Some(existing_model.as_str())) {
875                Ok(resolved) => resolved,
876                Err(_) => (
877                    existing_model.clone(),
878                    legacy_model_config(existing_model, manifest.embedding_dimensions),
879                ),
880            }
881        } else {
882            model_registry
883                .resolve(None)
884                .map_err(|e| anyhow::anyhow!(e.to_string()))?
885        };
886
887        if let Some(existing_model) = &manifest.embedding_model
888            && existing_model != &resolved.1.name
889        {
890            return Err(anyhow::anyhow!(
891                "Model mismatch: Index was created with '{}', but you're trying to use '{}'. \
892                    Please run 'ck --clean .' to remove the old index, then 'ck --index --model {}' to rebuild with the new model.",
893                existing_model,
894                resolved.1.name,
895                model.unwrap_or("default")
896            ));
897        }
898
899        manifest.embedding_model = Some(resolved.1.name.clone());
900        manifest.embedding_dimensions = Some(resolved.1.dimensions);
901
902        Some(resolved)
903    } else {
904        None
905    };
906
907    // For incremental updates, only process files in the search scope
908    // The cleanup phase already handled removing orphaned files from the entire repo
909    let current_files = collect_files(path, options)?;
910
911    // First pass: determine which files need updating and collect stats
912    let mut files_to_update = Vec::new();
913    let mut manifest_changed = false;
914
915    for file_path in current_files {
916        // Check for interrupt
917        if INTERRUPTED.load(Ordering::SeqCst) {
918            eprintln!("Indexing interrupted during file scanning.");
919            return Ok(stats);
920        }
921
922        let manifest_key =
923            path_utils::to_manifest_path(&path_utils::to_standard_path(&file_path, &repo_root));
924
925        if let Some(metadata) = manifest.files.get(&manifest_key) {
926            let fs_meta = match fs::metadata(&file_path) {
927                Ok(m) => m,
928                Err(_) => {
929                    stats.files_errored += 1;
930                    continue;
931                }
932            };
933
934            let fs_last_modified = match fs_meta.modified().and_then(|m| {
935                m.duration_since(SystemTime::UNIX_EPOCH)
936                    .map_err(|_| std::io::Error::other("Time error"))
937            }) {
938                Ok(dur) => dur.as_secs(),
939                Err(_) => {
940                    stats.files_errored += 1;
941                    continue;
942                }
943            };
944            let fs_size = fs_meta.len();
945
946            if fs_last_modified == metadata.last_modified && fs_size == metadata.size {
947                stats.files_up_to_date += 1;
948                continue;
949            }
950
951            let hash = match compute_file_hash(&file_path) {
952                Ok(h) => h,
953                Err(_) => {
954                    stats.files_errored += 1;
955                    continue;
956                }
957            };
958
959            if hash != metadata.hash {
960                stats.files_modified += 1;
961                files_to_update.push(file_path);
962            } else {
963                stats.files_up_to_date += 1;
964                // Convert to standardized path for manifest storage
965                let standard_path = path_utils::to_standard_path(&file_path, &repo_root);
966                let manifest_path = path_utils::to_manifest_path(&standard_path);
967                let new_metadata = FileMetadata {
968                    path: manifest_path.clone(),
969                    hash,
970                    last_modified: fs_last_modified,
971                    size: fs_size,
972                };
973                manifest.files.insert(manifest_path, new_metadata);
974                manifest_changed = true;
975            }
976        } else {
977            stats.files_added += 1;
978            files_to_update.push(file_path);
979        }
980    }
981
982    // Second pass: index the files that need updating
983    if compute_embeddings {
984        // Sequential processing with streaming - write each file immediately
985        let (_, config) = resolved_model
986            .as_ref()
987            .expect("resolved model must exist for embedding updates");
988        let mut embedder = ck_embed::create_embedder_for_config(config, None)?;
989        let mut _processed_count = 0;
990
991        for file_path in files_to_update.iter() {
992            // Check for interrupt
993            if INTERRUPTED.load(Ordering::SeqCst) {
994                eprintln!("Indexing interrupted. {_processed_count} files processed.");
995                break;
996            }
997
998            if let Some(ref callback) = progress_callback
999                && let Some(file_name) = file_path.file_name()
1000            {
1001                callback(&file_name.to_string_lossy());
1002            }
1003
1004            // Call detailed progress version if callback is provided, otherwise use regular version
1005            let result = if let Some(ref detailed_callback) = detailed_progress_callback {
1006                index_single_file_with_progress(
1007                    file_path,
1008                    path,
1009                    Some(&mut embedder),
1010                    Some(detailed_callback),
1011                    _processed_count,
1012                    files_to_update.len(),
1013                )
1014            } else {
1015                index_single_file_with_progress(file_path, path, Some(&mut embedder), None, 0, 1)
1016            };
1017
1018            match result {
1019                Ok((entry, file_chunks_reused, file_chunks_embedded)) => {
1020                    // Aggregate chunk statistics
1021                    stats.chunks_reused += file_chunks_reused;
1022                    stats.chunks_embedded += file_chunks_embedded;
1023
1024                    // Write sidecar immediately
1025                    let sidecar_path = get_sidecar_path(path, file_path);
1026                    save_index_entry(&sidecar_path, &entry)?;
1027
1028                    // Update and save manifest immediately
1029                    let manifest_key = entry.metadata.path.clone();
1030                    manifest.files.insert(manifest_key, entry.metadata);
1031                    manifest.updated = SystemTime::now()
1032                        .duration_since(SystemTime::UNIX_EPOCH)
1033                        .unwrap()
1034                        .as_secs();
1035                    save_manifest(&manifest_path, &manifest)?;
1036                    _processed_count += 1;
1037                }
1038                Err(e) => {
1039                    // Suppress warnings for binary files and UTF-8 errors in .git directories
1040                    let error_msg = e.to_string();
1041                    let is_binary_skip = error_msg.contains("Binary file, skipping");
1042                    let is_utf8_error = error_msg.contains("stream did not contain valid UTF-8");
1043                    let is_git_file = file_path.components().any(|c| c.as_os_str() == ".git");
1044
1045                    if !(is_binary_skip || is_utf8_error && is_git_file) {
1046                        tracing::warn!("Failed to index {:?}: {}", file_path, e);
1047                    }
1048                    stats.files_errored += 1;
1049                }
1050            }
1051        }
1052
1053        stats.files_indexed = _processed_count;
1054    } else {
1055        // Parallel processing with streaming using producer-consumer pattern
1056        use std::sync::mpsc;
1057        use std::thread;
1058
1059        let (tx, rx) = mpsc::channel();
1060        let files_clone = files_to_update.clone();
1061        let path_clone = path.to_path_buf();
1062
1063        // Spawn worker thread for parallel processing
1064        let worker_handle = thread::spawn(move || {
1065            use rayon::prelude::*;
1066
1067            // Use par_iter with try_for_each to allow early exit on interrupt
1068            let result = files_clone.par_iter().try_for_each(|file_path| {
1069                // Check for interrupt
1070                if INTERRUPTED.load(Ordering::SeqCst) {
1071                    return Err("interrupted");
1072                }
1073
1074                match index_single_file(file_path, &path_clone, None) {
1075                    Ok(entry) => {
1076                        if tx.send((file_path.clone(), entry)).is_err() {
1077                            // Receiver dropped, stop processing
1078                            return Err("receiver_dropped");
1079                        }
1080                    }
1081                    Err(e) => {
1082                        // Suppress warnings for binary files and UTF-8 errors in .git directories
1083                        let error_msg = e.to_string();
1084                        let is_binary_skip = error_msg.contains("Binary file, skipping");
1085                        let is_utf8_error =
1086                            error_msg.contains("stream did not contain valid UTF-8");
1087                        let is_git_file = file_path.components().any(|c| c.as_os_str() == ".git");
1088
1089                        if !(is_binary_skip || is_utf8_error && is_git_file) {
1090                            tracing::warn!("Failed to index {:?}: {}", file_path, e);
1091                        }
1092                    }
1093                }
1094                Ok(())
1095            });
1096
1097            // Log the result for debugging
1098            if let Err(reason) = result {
1099                tracing::debug!("Worker thread stopped due to: {}", reason);
1100            }
1101        });
1102
1103        // Main thread: stream results as they arrive
1104        let mut _processed_count = 0;
1105        while let Ok((file_path, entry)) = rx.recv() {
1106            // Check for interrupt
1107            if INTERRUPTED.load(Ordering::SeqCst) {
1108                eprintln!("Indexing interrupted. {_processed_count} files processed.");
1109                drop(rx); // Drop receiver to signal worker to stop
1110                break;
1111            }
1112
1113            if let Some(ref callback) = progress_callback
1114                && let Some(file_name) = file_path.file_name()
1115            {
1116                callback(&file_name.to_string_lossy());
1117            }
1118
1119            // Write sidecar immediately
1120            let sidecar_path = get_sidecar_path(path, &file_path);
1121            save_index_entry(&sidecar_path, &entry)?;
1122
1123            // Update and save manifest immediately
1124            let manifest_key = entry.metadata.path.clone();
1125            manifest.files.insert(manifest_key, entry.metadata);
1126            manifest.updated = SystemTime::now()
1127                .duration_since(SystemTime::UNIX_EPOCH)
1128                .unwrap()
1129                .as_secs();
1130            save_manifest(&manifest_path, &manifest)?;
1131            _processed_count += 1;
1132        }
1133
1134        stats.files_indexed = _processed_count;
1135
1136        // Wait for worker to complete
1137        worker_handle
1138            .join()
1139            .map_err(|_| anyhow::anyhow!("Worker thread panicked"))?;
1140    }
1141
1142    // For sequential processing (embeddings), manifest is already saved after each file
1143    // Only save manifest for parallel processing or if there were metadata-only changes
1144    if !compute_embeddings
1145        && (stats.files_indexed > 0 || stats.orphaned_files_removed > 0 || manifest_changed)
1146    {
1147        manifest.updated = SystemTime::now()
1148            .duration_since(SystemTime::UNIX_EPOCH)
1149            .unwrap()
1150            .as_secs();
1151        save_manifest(&manifest_path, &manifest)?;
1152    }
1153
1154    Ok(stats)
1155}
1156
1157fn index_single_file(
1158    file_path: &Path,
1159    repo_root: &Path,
1160    embedder: Option<&mut Box<dyn ck_embed::Embedder>>,
1161) -> Result<IndexEntry> {
1162    let (entry, _chunks_reused, _chunks_embedded) =
1163        index_single_file_with_progress(file_path, repo_root, embedder, None, 0, 1)?;
1164    Ok(entry)
1165}
1166
1167fn index_single_file_with_progress(
1168    file_path: &Path,
1169    repo_root: &Path,
1170    embedder: Option<&mut Box<dyn ck_embed::Embedder>>,
1171    detailed_progress: Option<&DetailedProgressCallback>,
1172    file_index: usize,
1173    total_files: usize,
1174) -> Result<(IndexEntry, usize, usize)> {
1175    // Skip binary files to avoid UTF-8 warnings
1176    if !is_text_file(file_path) {
1177        return Err(anyhow::anyhow!("Binary file, skipping"));
1178    }
1179
1180    // Build chunk cache from old sidecar if it exists (for chunk reuse)
1181    let chunk_cache: HashMap<String, Vec<f32>> = if embedder.is_some() {
1182        let sidecar_path = get_sidecar_path(repo_root, file_path);
1183        if sidecar_path.exists() {
1184            match load_index_entry(&sidecar_path) {
1185                Ok(old_entry) => old_entry
1186                    .chunks
1187                    .into_iter()
1188                    .filter_map(|chunk| {
1189                        if let (Some(hash), Some(embedding)) = (chunk.chunk_hash, chunk.embedding) {
1190                            Some((hash, embedding))
1191                        } else {
1192                            None
1193                        }
1194                    })
1195                    .collect(),
1196                Err(_) => HashMap::new(),
1197            }
1198        } else {
1199            HashMap::new()
1200        }
1201    } else {
1202        HashMap::new()
1203    };
1204
1205    // Preprocess file (extracts PDFs to cache, returns path to readable content)
1206    let content_path = preprocess_file(file_path, repo_root)?;
1207    let content = fs::read_to_string(&content_path)?;
1208
1209    // Always use the ORIGINAL file for hash and metadata
1210    let hash = compute_file_hash(file_path)?;
1211    let metadata = fs::metadata(file_path)?;
1212
1213    let standard_path = path_utils::to_standard_path(file_path, repo_root);
1214    let manifest_path = path_utils::to_manifest_path(&standard_path);
1215
1216    let file_metadata = FileMetadata {
1217        path: manifest_path,
1218        hash,
1219        last_modified: metadata
1220            .modified()?
1221            .duration_since(SystemTime::UNIX_EPOCH)?
1222            .as_secs(),
1223        size: metadata.len(),
1224    };
1225
1226    // Detect language for tree-sitter parsing
1227    let lang = if ck_core::pdf::is_pdf_file(file_path) {
1228        Some(Language::Pdf)
1229    } else {
1230        ck_core::Language::from_path(file_path)
1231    };
1232
1233    let model_name = embedder.as_ref().map(|e| e.model_name());
1234    let chunks = ck_chunk::chunk_text_with_model(&content, lang, model_name)?;
1235
1236    // Track chunk reuse statistics
1237    let mut chunks_reused = 0;
1238    let mut chunks_embedded = 0;
1239
1240    let chunk_entries: Vec<ChunkEntry> = if let Some(embedder) = embedder {
1241        let total_chunks = chunks.len();
1242        let file_name = file_path
1243            .file_name()
1244            .unwrap_or_default()
1245            .to_string_lossy()
1246            .to_string();
1247
1248        // Process chunks with progress reporting
1249        if let Some(ref callback) = detailed_progress {
1250            tracing::info!(
1251                "Computing embeddings for {} chunks in {:?}",
1252                total_chunks,
1253                file_path
1254            );
1255
1256            let mut chunk_entries = Vec::new();
1257            for (chunk_index, chunk) in chunks.into_iter().enumerate() {
1258                if INTERRUPTED.load(Ordering::SeqCst) {
1259                    return Err(anyhow::anyhow!(INDEX_INTERRUPTED_MSG));
1260                }
1261                // Report progress before processing chunk
1262                callback(EmbeddingProgress {
1263                    file_name: file_name.clone(),
1264                    file_index,
1265                    total_files,
1266                    chunk_index,
1267                    total_chunks,
1268                    chunk_size: chunk.text.len(),
1269                });
1270
1271                // Compute chunk hash for cache lookup or storage
1272                // Include trivia so that doc comment changes invalidate the cache
1273                let chunk_hash = compute_chunk_hash(
1274                    &chunk.text,
1275                    &chunk.metadata.leading_trivia,
1276                    &chunk.metadata.trailing_trivia,
1277                );
1278
1279                // Check cache first, but validate dimension matches current embedder
1280                let expected_dim = embedder.dim();
1281                let embedding = if let Some(cached_embedding) = chunk_cache.get(&chunk_hash) {
1282                    if cached_embedding.len() == expected_dim {
1283                        // Dimension matches, safe to reuse
1284                        chunks_reused += 1;
1285                        cached_embedding.clone()
1286                    } else {
1287                        // Dimension mismatch, re-embed (model changed)
1288                        chunks_embedded += 1;
1289                        tracing::warn!(
1290                            "Chunk in {:?} has cached embedding with dimension {} but current model expects {}. Re-embedding.",
1291                            file_path,
1292                            cached_embedding.len(),
1293                            expected_dim
1294                        );
1295                        let embeddings = embedder.embed(std::slice::from_ref(&chunk.text))?;
1296                        embeddings.into_iter().next().ok_or_else(|| {
1297                            anyhow::anyhow!(
1298                                "Embedder returned empty results for chunk {chunk_index} in file {file_path:?}. This may indicate an issue with the embedding model or chunk content."
1299                            )
1300                        })?
1301                    }
1302                } else {
1303                    // No cache hit, compute embedding
1304                    chunks_embedded += 1;
1305                    let embeddings = embedder.embed(std::slice::from_ref(&chunk.text))?;
1306                    embeddings.into_iter().next().ok_or_else(|| {
1307                        anyhow::anyhow!(
1308                            "Embedder returned empty results for chunk {chunk_index} in file {file_path:?}. This may indicate an issue with the embedding model or chunk content."
1309                        )
1310                    })?
1311                };
1312
1313                let chunk_type_str = match chunk.chunk_type {
1314                    ck_chunk::ChunkType::Function => Some("function".to_string()),
1315                    ck_chunk::ChunkType::Class => Some("class".to_string()),
1316                    ck_chunk::ChunkType::Method => Some("method".to_string()),
1317                    ck_chunk::ChunkType::Module => Some("module".to_string()),
1318                    ck_chunk::ChunkType::Text => None,
1319                };
1320
1321                let breadcrumb = chunk.metadata.breadcrumb.clone();
1322                let ancestry = if chunk.metadata.ancestry.is_empty() {
1323                    None
1324                } else {
1325                    Some(chunk.metadata.ancestry.clone())
1326                };
1327                let leading_trivia = if chunk.metadata.leading_trivia.is_empty() {
1328                    None
1329                } else {
1330                    Some(chunk.metadata.leading_trivia.clone())
1331                };
1332                let trailing_trivia = if chunk.metadata.trailing_trivia.is_empty() {
1333                    None
1334                } else {
1335                    Some(chunk.metadata.trailing_trivia.clone())
1336                };
1337
1338                chunk_entries.push(ChunkEntry {
1339                    span: chunk.span,
1340                    embedding: Some(embedding),
1341                    chunk_type: chunk_type_str,
1342                    breadcrumb,
1343                    ancestry,
1344                    byte_length: Some(chunk.metadata.byte_length),
1345                    estimated_tokens: Some(chunk.metadata.estimated_tokens),
1346                    leading_trivia,
1347                    trailing_trivia,
1348                    chunk_hash: Some(chunk_hash),
1349                });
1350            }
1351            chunk_entries
1352        } else {
1353            // Fallback to batch processing for backward compatibility
1354            // First, check which chunks have cached embeddings with dimension validation
1355            let expected_dim = embedder.dim();
1356            let mut chunks_to_embed = Vec::new();
1357            let mut chunk_results: Vec<(ck_chunk::Chunk, String, Option<Vec<f32>>)> = Vec::new();
1358
1359            for chunk in chunks {
1360                // Include trivia so that doc comment changes invalidate the cache
1361                let chunk_hash = compute_chunk_hash(
1362                    &chunk.text,
1363                    &chunk.metadata.leading_trivia,
1364                    &chunk.metadata.trailing_trivia,
1365                );
1366                if let Some(cached_embedding) = chunk_cache.get(&chunk_hash) {
1367                    if cached_embedding.len() == expected_dim {
1368                        // Dimension matches, safe to reuse
1369                        chunks_reused += 1;
1370                        chunk_results.push((chunk, chunk_hash, Some(cached_embedding.clone())));
1371                    } else {
1372                        // Dimension mismatch, need to re-embed
1373                        tracing::warn!(
1374                            "Chunk in {:?} has cached embedding with dimension {} but current model expects {}. Re-embedding.",
1375                            file_path,
1376                            cached_embedding.len(),
1377                            expected_dim
1378                        );
1379                        chunks_to_embed.push((chunk.text.clone(), chunk_results.len()));
1380                        chunk_results.push((chunk, chunk_hash, None));
1381                    }
1382                } else {
1383                    // No cache hit, need to embed
1384                    chunks_to_embed.push((chunk.text.clone(), chunk_results.len()));
1385                    chunk_results.push((chunk, chunk_hash, None));
1386                }
1387            }
1388
1389            // Batch embed only the chunks without cache hits
1390            if !chunks_to_embed.is_empty() {
1391                let texts: Vec<String> = chunks_to_embed
1392                    .iter()
1393                    .map(|(text, _)| text.clone())
1394                    .collect();
1395                tracing::info!(
1396                    "Computing embeddings for {}/{} chunks in {:?} ({} reused from cache)",
1397                    texts.len(),
1398                    chunk_results.len(),
1399                    file_path,
1400                    chunks_reused
1401                );
1402                let embeddings = embedder.embed(&texts)?;
1403
1404                if embeddings.len() != chunks_to_embed.len() {
1405                    return Err(anyhow::anyhow!(
1406                        "Embedder returned {} embeddings for {} chunks in file {:?}. Expected equal counts.",
1407                        embeddings.len(),
1408                        chunks_to_embed.len(),
1409                        file_path
1410                    ));
1411                }
1412
1413                chunks_embedded += embeddings.len();
1414
1415                // Fill in the computed embeddings
1416                for ((_, result_idx), embedding) in chunks_to_embed.into_iter().zip(embeddings) {
1417                    chunk_results[result_idx].2 = Some(embedding);
1418                }
1419            }
1420
1421            chunk_results
1422                .into_iter()
1423                .map(|(chunk, chunk_hash, embedding)| {
1424                    let embedding = embedding.expect("All chunks should have embeddings by now");
1425                    let chunk_type_str = match chunk.chunk_type {
1426                        ck_chunk::ChunkType::Function => Some("function".to_string()),
1427                        ck_chunk::ChunkType::Class => Some("class".to_string()),
1428                        ck_chunk::ChunkType::Method => Some("method".to_string()),
1429                        ck_chunk::ChunkType::Module => Some("module".to_string()),
1430                        ck_chunk::ChunkType::Text => None,
1431                    };
1432                    let breadcrumb = chunk.metadata.breadcrumb.clone();
1433                    let ancestry = if chunk.metadata.ancestry.is_empty() {
1434                        None
1435                    } else {
1436                        Some(chunk.metadata.ancestry.clone())
1437                    };
1438                    let leading_trivia = if chunk.metadata.leading_trivia.is_empty() {
1439                        None
1440                    } else {
1441                        Some(chunk.metadata.leading_trivia.clone())
1442                    };
1443                    let trailing_trivia = if chunk.metadata.trailing_trivia.is_empty() {
1444                        None
1445                    } else {
1446                        Some(chunk.metadata.trailing_trivia.clone())
1447                    };
1448                    ChunkEntry {
1449                        span: chunk.span,
1450                        embedding: Some(embedding),
1451                        chunk_type: chunk_type_str,
1452                        breadcrumb,
1453                        ancestry,
1454                        byte_length: Some(chunk.metadata.byte_length),
1455                        estimated_tokens: Some(chunk.metadata.estimated_tokens),
1456                        leading_trivia,
1457                        trailing_trivia,
1458                        chunk_hash: Some(chunk_hash),
1459                    }
1460                })
1461                .collect()
1462        }
1463    } else {
1464        // No embedder, just store spans without embeddings
1465        chunks
1466            .into_iter()
1467            .map(|chunk| {
1468                let chunk_type_str = match chunk.chunk_type {
1469                    ck_chunk::ChunkType::Function => Some("function".to_string()),
1470                    ck_chunk::ChunkType::Class => Some("class".to_string()),
1471                    ck_chunk::ChunkType::Method => Some("method".to_string()),
1472                    ck_chunk::ChunkType::Module => Some("module".to_string()),
1473                    ck_chunk::ChunkType::Text => None,
1474                };
1475                let breadcrumb = chunk.metadata.breadcrumb.clone();
1476                let ancestry = if chunk.metadata.ancestry.is_empty() {
1477                    None
1478                } else {
1479                    Some(chunk.metadata.ancestry.clone())
1480                };
1481                let leading_trivia = if chunk.metadata.leading_trivia.is_empty() {
1482                    None
1483                } else {
1484                    Some(chunk.metadata.leading_trivia.clone())
1485                };
1486                let trailing_trivia = if chunk.metadata.trailing_trivia.is_empty() {
1487                    None
1488                } else {
1489                    Some(chunk.metadata.trailing_trivia.clone())
1490                };
1491                ChunkEntry {
1492                    span: chunk.span,
1493                    embedding: None,
1494                    chunk_type: chunk_type_str,
1495                    breadcrumb,
1496                    ancestry,
1497                    byte_length: Some(chunk.metadata.byte_length),
1498                    estimated_tokens: Some(chunk.metadata.estimated_tokens),
1499                    leading_trivia: leading_trivia.clone(),
1500                    trailing_trivia: trailing_trivia.clone(),
1501                    chunk_hash: Some(compute_chunk_hash(
1502                        &chunk.text,
1503                        &chunk.metadata.leading_trivia,
1504                        &chunk.metadata.trailing_trivia,
1505                    )),
1506                }
1507            })
1508            .collect()
1509    };
1510
1511    Ok((
1512        IndexEntry {
1513            metadata: file_metadata,
1514            chunks: chunk_entries,
1515        },
1516        chunks_reused,
1517        chunks_embedded,
1518    ))
1519}
1520
1521fn load_or_create_manifest(path: &Path) -> Result<IndexManifest> {
1522    let mut manifest = if path.exists() {
1523        let data = fs::read(path)?;
1524        serde_json::from_slice(&data)?
1525    } else {
1526        IndexManifest::default()
1527    };
1528
1529    // Ensure chunk_hash_version is set to v2 if not already set
1530    // This handles manifests created before the field existed
1531    if manifest.chunk_hash_version.is_none() {
1532        manifest.chunk_hash_version = Some(2);
1533    }
1534
1535    Ok(manifest)
1536}
1537
1538fn normalize_manifest_paths(manifest: &mut IndexManifest, repo_root: &Path) {
1539    let original_entries = std::mem::take(&mut manifest.files);
1540    let mut normalized = HashMap::with_capacity(original_entries.len());
1541
1542    for (key, mut metadata) in original_entries {
1543        let standard_key = if key.is_absolute() {
1544            path_utils::to_standard_path(&key, repo_root)
1545        } else {
1546            path_utils::from_manifest_path(&key)
1547        };
1548        let manifest_key = path_utils::to_manifest_path(&standard_key);
1549
1550        let metadata_standard = if metadata.path.is_absolute() {
1551            path_utils::to_standard_path(&metadata.path, repo_root)
1552        } else {
1553            path_utils::from_manifest_path(&metadata.path)
1554        };
1555        metadata.path = path_utils::to_manifest_path(&metadata_standard);
1556
1557        normalized.insert(manifest_key, metadata);
1558    }
1559
1560    manifest.files = normalized;
1561}
1562
1563fn save_manifest(path: &Path, manifest: &IndexManifest) -> Result<()> {
1564    let data = serde_json::to_vec_pretty(manifest)?;
1565    atomic_write(path, &data)
1566}
1567
1568fn save_index_entry(path: &Path, entry: &IndexEntry) -> Result<()> {
1569    let data = bincode::serialize(entry)?;
1570    atomic_write(path, &data)
1571}
1572
1573fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
1574    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1575    fs::create_dir_all(parent)?;
1576
1577    let mut tmp = NamedTempFile::new_in(parent)?;
1578    tmp.write_all(data)?;
1579    tmp.as_file().sync_all()?;
1580
1581    if path.exists() {
1582        fs::remove_file(path)?;
1583    }
1584
1585    tmp.persist(path)?;
1586    Ok(())
1587}
1588
1589pub fn load_index_entry(path: &Path) -> Result<IndexEntry> {
1590    let data = fs::read(path)?;
1591    Ok(bincode::deserialize(&data)?)
1592}
1593
1594fn find_repo_root(path: &Path) -> Result<PathBuf> {
1595    let mut current = if path.is_file() {
1596        path.parent().unwrap_or(path)
1597    } else {
1598        path
1599    };
1600
1601    loop {
1602        if ck_core::index_exists(current) || current.join(".git").exists() {
1603            return Ok(current.to_path_buf());
1604        }
1605
1606        match current.parent() {
1607            Some(parent) => current = parent,
1608            None => return Ok(path.to_path_buf()),
1609        }
1610    }
1611}
1612
1613/// Check if content needs re-extraction
1614fn should_reextract(source_path: &Path, cache_path: &Path) -> Result<bool> {
1615    if !cache_path.exists() {
1616        return Ok(true);
1617    }
1618
1619    let source_modified = fs::metadata(source_path)?.modified()?;
1620    let cache_modified = fs::metadata(cache_path)?.modified()?;
1621
1622    Ok(source_modified > cache_modified)
1623}
1624
1625/// Extract text content from a PDF file
1626fn extract_pdf_text(path: &Path) -> Result<String> {
1627    pdf_extract::extract_text(path)
1628        .map_err(|e| anyhow::anyhow!("Failed to extract text from PDF {}: {}", path.display(), e))
1629}
1630
1631/// Preprocess a file if needed, returning path to readable content
1632/// For regular files: returns the original path (no preprocessing)
1633/// For PDFs: extracts text to cache, returns cache path
1634fn preprocess_file(file_path: &Path, repo_root: &Path) -> Result<PathBuf> {
1635    if ck_core::pdf::is_pdf_file(file_path) {
1636        let cache_path = ck_core::pdf::get_content_cache_path(repo_root, file_path);
1637
1638        // Check if re-extraction needed
1639        if should_reextract(file_path, &cache_path)? {
1640            tracing::debug!(
1641                "Extracting PDF content from {:?} to {:?}",
1642                file_path,
1643                cache_path
1644            );
1645            let extracted_text = extract_pdf_text(file_path)?;
1646
1647            // Ensure cache directory exists
1648            if let Some(parent) = cache_path.parent() {
1649                fs::create_dir_all(parent)?;
1650            }
1651
1652            // Write extracted text
1653            fs::write(&cache_path, extracted_text)?;
1654        }
1655
1656        Ok(cache_path) // Return path to extracted text
1657    } else {
1658        Ok(file_path.to_path_buf()) // Return original path for regular files
1659    }
1660}
1661
1662fn is_text_file(path: &Path) -> bool {
1663    // PDFs are considered indexable even though they're binary
1664    if ck_core::pdf::is_pdf_file(path) {
1665        return true;
1666    }
1667
1668    // Use NUL byte heuristic like ripgrep - read first 8KB and check for NUL bytes
1669    const BUFFER_SIZE: usize = 8192;
1670
1671    match std::fs::File::open(path) {
1672        Ok(mut file) => {
1673            let mut buffer = vec![0; BUFFER_SIZE];
1674            match file.read(&mut buffer) {
1675                Ok(bytes_read) => {
1676                    // If file is empty, consider it text
1677                    if bytes_read == 0 {
1678                        return true;
1679                    }
1680
1681                    // Check for NUL bytes in the read portion
1682                    !buffer[..bytes_read].contains(&0)
1683                }
1684                Err(_) => false, // If we can't read, assume binary
1685            }
1686        }
1687        Err(_) => false, // If we can't open, assume binary
1688    }
1689}
1690
1691#[cfg(test)]
1692fn sidecar_to_original_path(
1693    sidecar_path: &Path,
1694    index_dir: &Path,
1695    _repo_root: &Path,
1696) -> Option<PathBuf> {
1697    let relative_path = sidecar_path.strip_prefix(index_dir).ok()?;
1698    let original_path = relative_path.with_extension("");
1699
1700    // Handle the .ck extension removal
1701    if let Some(name) = original_path.file_name() {
1702        let name_str = name.to_string_lossy();
1703        if let Some(original_name) = name_str.strip_suffix(".ck") {
1704            let mut result = original_path.clone();
1705            result.set_file_name(original_name);
1706            return Some(result);
1707        }
1708    }
1709
1710    Some(original_path)
1711}
1712
1713fn remove_empty_dirs(dir: &Path) -> Result<()> {
1714    if !dir.is_dir() {
1715        return Ok(());
1716    }
1717
1718    for entry in fs::read_dir(dir)? {
1719        let entry = entry?;
1720        let path = entry.path();
1721        if path.is_dir() {
1722            remove_empty_dirs(&path)?;
1723            // Try to remove if now empty
1724            if fs::read_dir(&path)?.next().is_none() {
1725                let _ = fs::remove_dir(&path);
1726            }
1727        }
1728    }
1729
1730    Ok(())
1731}
1732
1733#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1734pub struct CleanupStats {
1735    pub orphaned_entries_removed: usize,
1736    pub orphaned_sidecars_removed: usize,
1737}
1738
1739#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1740pub struct IndexStats {
1741    pub total_files: usize,
1742    pub total_chunks: usize,
1743    pub embedded_chunks: usize,
1744    pub total_size_bytes: u64,
1745    pub index_size_bytes: u64,
1746    pub index_created: u64,
1747    pub index_updated: u64,
1748}
1749
1750#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1751pub struct UpdateStats {
1752    pub files_indexed: usize,
1753    pub files_added: usize,
1754    pub files_modified: usize,
1755    pub files_up_to_date: usize,
1756    pub files_errored: usize,
1757    pub orphaned_files_removed: usize,
1758    pub chunks_reused: usize,
1759    pub chunks_embedded: usize,
1760}
1761
1762#[cfg(test)]
1763mod tests {
1764    use super::*;
1765    use serial_test::serial;
1766    use std::fs;
1767    use tempfile::TempDir;
1768
1769    /// Test embedder that can return empty results to test error handling
1770    struct EmptyResultsEmbedder;
1771
1772    impl ck_embed::Embedder for EmptyResultsEmbedder {
1773        fn id(&self) -> &'static str {
1774            "empty-results-test"
1775        }
1776
1777        fn dim(&self) -> usize {
1778            384
1779        }
1780
1781        fn model_name(&self) -> &str {
1782            "test-empty-results"
1783        }
1784
1785        fn embed(&mut self, _texts: &[String]) -> Result<Vec<Vec<f32>>> {
1786            // Always return empty vector to trigger the panic scenario
1787            Ok(Vec::new())
1788        }
1789    }
1790
1791    /// Test embedder that returns mismatched count of embeddings
1792    struct MismatchedCountEmbedder;
1793
1794    impl ck_embed::Embedder for MismatchedCountEmbedder {
1795        fn id(&self) -> &'static str {
1796            "mismatched-count-test"
1797        }
1798
1799        fn dim(&self) -> usize {
1800            384
1801        }
1802
1803        fn model_name(&self) -> &str {
1804            "test-mismatched-count"
1805        }
1806
1807        fn embed(&mut self, texts: &[String]) -> Result<Vec<Vec<f32>>> {
1808            // Always return one less embedding than requested
1809            if texts.is_empty() {
1810                Ok(Vec::new())
1811            } else {
1812                Ok(vec![vec![0.0; self.dim()]; texts.len() - 1])
1813            }
1814        }
1815    }
1816
1817    #[test]
1818    fn test_index_single_file_handles_empty_embedding_results() {
1819        let temp_dir = TempDir::new().unwrap();
1820        let test_path = temp_dir.path();
1821
1822        // Create a simple test file
1823        let test_file = test_path.join("test.txt");
1824        fs::write(&test_file, "hello world").unwrap();
1825
1826        // Create an embedder that returns empty results
1827        let mut empty_embedder: Box<dyn ck_embed::Embedder> = Box::new(EmptyResultsEmbedder);
1828
1829        // This should return an error, not panic
1830        let result = index_single_file(&test_file, test_path, Some(&mut empty_embedder));
1831
1832        assert!(result.is_err());
1833        let error_msg = result.unwrap_err().to_string();
1834        // The empty embedder triggers the count mismatch error (0 embeddings for 1 chunk)
1835        assert!(error_msg.contains("Embedder returned 0 embeddings for 1 chunks"));
1836        assert!(error_msg.contains("Expected equal counts"));
1837        assert!(error_msg.contains("test.txt"));
1838    }
1839
1840    #[test]
1841    fn test_index_single_file_with_progress_handles_empty_embedding_results() {
1842        let temp_dir = TempDir::new().unwrap();
1843        let test_path = temp_dir.path();
1844
1845        // Create a simple test file
1846        let test_file = test_path.join("test.txt");
1847        fs::write(&test_file, "hello world").unwrap();
1848
1849        // Create an embedder that returns empty results
1850        let mut empty_embedder: Box<dyn ck_embed::Embedder> = Box::new(EmptyResultsEmbedder);
1851
1852        // Use the detailed progress callback to trigger the single-chunk processing path
1853        let dummy_callback: DetailedProgressCallback = Box::new(|_progress: EmbeddingProgress| {});
1854        let result = index_single_file_with_progress(
1855            &test_file,
1856            test_path,
1857            Some(&mut empty_embedder),
1858            Some(&dummy_callback),
1859            0,
1860            1,
1861        );
1862
1863        assert!(result.is_err());
1864        let error_msg = result.unwrap_err().to_string();
1865        // This should hit the single-chunk path and get the specific error
1866        assert!(error_msg.contains("Embedder returned empty results"));
1867        assert!(error_msg.contains("chunk 0"));
1868        assert!(error_msg.contains("test.txt"));
1869    }
1870
1871    #[test]
1872    fn test_index_single_file_handles_mismatched_embedding_count() {
1873        let temp_dir = TempDir::new().unwrap();
1874        let test_path = temp_dir.path();
1875
1876        // Create a test file with multiple chunks (use some code content)
1877        let test_file = test_path.join("test.rs");
1878        fs::write(
1879            &test_file,
1880            "fn main() {\n    println!(\"hello\");\n}\n\nfn other() {\n    println!(\"world\");\n}",
1881        )
1882        .unwrap();
1883
1884        // Create an embedder that returns mismatched count
1885        let mut mismatched_embedder: Box<dyn ck_embed::Embedder> =
1886            Box::new(MismatchedCountEmbedder);
1887
1888        // This should return an error, not silently mismatch
1889        let result = index_single_file(&test_file, test_path, Some(&mut mismatched_embedder));
1890
1891        assert!(result.is_err());
1892        let error_msg = result.unwrap_err().to_string();
1893        assert!(error_msg.contains("Embedder returned"));
1894        assert!(error_msg.contains("embeddings for"));
1895        assert!(error_msg.contains("chunks"));
1896        assert!(error_msg.contains("Expected equal counts"));
1897    }
1898
1899    #[test]
1900    fn test_index_single_file_with_valid_embedder_still_works() {
1901        let temp_dir = TempDir::new().unwrap();
1902        let test_path = temp_dir.path();
1903
1904        // Create a simple test file
1905        let test_file = test_path.join("test.txt");
1906        fs::write(&test_file, "hello world").unwrap();
1907
1908        // Create a dummy embedder that returns proper results
1909        let dummy_embedder = ck_embed::DummyEmbedder::new();
1910        let mut boxed_embedder: Box<dyn ck_embed::Embedder> = Box::new(dummy_embedder);
1911
1912        // This should work fine
1913        let result = index_single_file(&test_file, test_path, Some(&mut boxed_embedder));
1914
1915        assert!(result.is_ok());
1916        let entry = result.unwrap();
1917        assert!(!entry.chunks.is_empty());
1918        // Verify that embeddings are present
1919        for chunk in &entry.chunks {
1920            assert!(chunk.embedding.is_some());
1921            assert_eq!(chunk.embedding.as_ref().unwrap().len(), 384); // DummyEmbedder dimension
1922        }
1923    }
1924
1925    #[tokio::test]
1926    async fn test_smart_update_index() {
1927        let temp_dir = TempDir::new().unwrap();
1928        let test_path = temp_dir.path();
1929
1930        // Create initial file
1931        fs::write(test_path.join("file1.txt"), "initial content").unwrap();
1932
1933        let file_options = ck_core::FileCollectionOptions {
1934            respect_gitignore: true,
1935            use_ckignore: true,
1936            exclude_patterns: vec![],
1937            show_hidden: false,
1938        };
1939
1940        // First index
1941        let stats1 = smart_update_index(test_path, false, &file_options)
1942            .await
1943            .unwrap();
1944        assert_eq!(stats1.files_added, 1);
1945        assert_eq!(stats1.files_indexed, 1);
1946
1947        // No changes, should be up to date
1948        let stats2 = smart_update_index(test_path, false, &file_options)
1949            .await
1950            .unwrap();
1951        assert_eq!(stats2.files_up_to_date, 1);
1952        assert_eq!(stats2.files_indexed, 0);
1953
1954        // Modify file
1955        fs::write(test_path.join("file1.txt"), "modified content").unwrap();
1956        let stats3 = smart_update_index(test_path, false, &file_options)
1957            .await
1958            .unwrap();
1959        assert_eq!(stats3.files_modified, 1);
1960        assert_eq!(stats3.files_indexed, 1);
1961
1962        // Add new file
1963        fs::write(test_path.join("file2.txt"), "new file content").unwrap();
1964        let stats4 = smart_update_index(test_path, false, &file_options)
1965            .await
1966            .unwrap();
1967        assert_eq!(stats4.files_added, 1);
1968        assert_eq!(stats4.files_up_to_date, 1);
1969        assert_eq!(stats4.files_indexed, 1);
1970    }
1971
1972    #[test]
1973    #[serial]
1974    fn test_cleanup_index() {
1975        unsafe { std::env::remove_var(ck_core::INDEX_DIR_ENV) };
1976        let temp_dir = TempDir::new().unwrap();
1977        let test_path = temp_dir.path();
1978
1979        // Create index directory and manifest
1980        let index_dir = test_path.join(".ck");
1981        fs::create_dir_all(&index_dir).unwrap();
1982
1983        let mut manifest = IndexManifest::default();
1984        manifest.files.insert(
1985            test_path.join("deleted_file.txt"),
1986            FileMetadata {
1987                path: test_path.join("deleted_file.txt"),
1988                hash: "fake_hash".to_string(),
1989                last_modified: 0,
1990                size: 0,
1991            },
1992        );
1993
1994        let manifest_path = index_dir.join("manifest.json");
1995        save_manifest(&manifest_path, &manifest).unwrap();
1996
1997        // Cleanup should remove orphaned entry
1998        let file_options = ck_core::FileCollectionOptions {
1999            respect_gitignore: true,
2000            use_ckignore: true,
2001            exclude_patterns: vec![],
2002            show_hidden: false,
2003        };
2004        let stats = cleanup_index(test_path, &file_options).unwrap();
2005        assert_eq!(stats.orphaned_entries_removed, 1);
2006
2007        // Check that manifest was updated
2008        let updated_manifest = load_or_create_manifest(&manifest_path).unwrap();
2009        assert_eq!(updated_manifest.files.len(), 0);
2010    }
2011
2012    #[test]
2013    fn test_index_write_lock_blocks_concurrent_writer() {
2014        use std::sync::Arc;
2015        use std::sync::atomic::{AtomicBool, Ordering};
2016
2017        let temp_dir = TempDir::new().unwrap();
2018        let index_dir = temp_dir.path().join(".ck");
2019
2020        let lock = acquire_index_write_lock(&index_dir).unwrap();
2021        let released = Arc::new(AtomicBool::new(false));
2022
2023        let dir = index_dir.clone();
2024        let released_flag = released.clone();
2025        let waiter = std::thread::spawn(move || {
2026            let _lock = acquire_index_write_lock(&dir).unwrap();
2027            assert!(
2028                released_flag.load(Ordering::SeqCst),
2029                "second writer acquired the index lock while the first still held it"
2030            );
2031        });
2032
2033        std::thread::sleep(std::time::Duration::from_millis(200));
2034        released.store(true, Ordering::SeqCst);
2035        drop(lock);
2036        waiter.join().unwrap();
2037    }
2038
2039    #[test]
2040    #[serial]
2041    fn test_clean_index_removes_index_dir() {
2042        unsafe { std::env::remove_var(ck_core::INDEX_DIR_ENV) };
2043        let temp_dir = TempDir::new().unwrap();
2044        let test_path = temp_dir.path();
2045        let index_dir = test_path.join(".ck");
2046        fs::create_dir_all(index_dir.join("sub")).unwrap();
2047        fs::write(index_dir.join("manifest.json"), "{}").unwrap();
2048        fs::write(index_dir.join("sub").join("file.rs.ck"), "x").unwrap();
2049
2050        clean_index(test_path).unwrap();
2051        assert!(
2052            !index_dir.exists(),
2053            "clean_index should remove the .ck directory entirely"
2054        );
2055    }
2056
2057    #[test]
2058    #[serial]
2059    fn test_get_index_stats() {
2060        unsafe { std::env::remove_var(ck_core::INDEX_DIR_ENV) };
2061        let temp_dir = TempDir::new().unwrap();
2062        let test_path = temp_dir.path();
2063
2064        // No index exists
2065        let stats = get_index_stats(test_path).unwrap();
2066        assert_eq!(stats.total_files, 0);
2067
2068        // Create index
2069        let index_dir = test_path.join(".ck");
2070        fs::create_dir_all(&index_dir).unwrap();
2071
2072        let mut manifest = IndexManifest::default();
2073        manifest.files.insert(
2074            test_path.join("test.txt"),
2075            FileMetadata {
2076                path: test_path.join("test.txt"),
2077                hash: "test_hash".to_string(),
2078                last_modified: 1234567890,
2079                size: 100,
2080            },
2081        );
2082
2083        let manifest_path = index_dir.join("manifest.json");
2084        save_manifest(&manifest_path, &manifest).unwrap();
2085
2086        let stats = get_index_stats(test_path).unwrap();
2087        assert_eq!(stats.total_files, 1);
2088    }
2089
2090    #[test]
2091    fn test_sidecar_to_original_path() {
2092        let temp_dir = TempDir::new().unwrap();
2093        let index_dir = temp_dir.path().join(".ck");
2094
2095        // Test normal file
2096        let sidecar = index_dir.join("test.txt.ck");
2097        let original = sidecar_to_original_path(&sidecar, &index_dir, temp_dir.path());
2098        assert_eq!(original, Some(PathBuf::from("test.txt")));
2099
2100        // Test nested file
2101        let nested_sidecar = index_dir.join("src").join("main.rs.ck");
2102        let nested_original =
2103            sidecar_to_original_path(&nested_sidecar, &index_dir, temp_dir.path());
2104        assert_eq!(nested_original, Some(PathBuf::from("src/main.rs")));
2105    }
2106
2107    #[test]
2108    fn test_is_text_file() {
2109        use std::fs::File;
2110        use std::io::Write;
2111        use tempfile::TempDir;
2112
2113        let temp_dir = TempDir::new().unwrap();
2114        let temp_path = temp_dir.path();
2115
2116        // Create a text file (no NUL bytes)
2117        let text_file = temp_path.join("test.txt");
2118        let mut file = File::create(&text_file).unwrap();
2119        file.write_all(b"Hello world\nThis is text content")
2120            .unwrap();
2121        assert!(is_text_file(&text_file));
2122
2123        // Create a text file with unusual extension
2124        let log_file = temp_path.join("app.log");
2125        let mut file = File::create(&log_file).unwrap();
2126        file.write_all(b"2024-01-15 ERROR: Failed to connect")
2127            .unwrap();
2128        assert!(is_text_file(&log_file));
2129
2130        // Create a file without extension but with text content
2131        let no_ext_file = temp_path.join("README");
2132        let mut file = File::create(&no_ext_file).unwrap();
2133        file.write_all(b"This is a README file").unwrap();
2134        assert!(is_text_file(&no_ext_file));
2135
2136        // Create a binary file with NUL bytes
2137        let binary_file = temp_path.join("test.bin");
2138        let mut file = File::create(&binary_file).unwrap();
2139        file.write_all(&[
2140            0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x00, 0x57, 0x6F, 0x72, 0x6C, 0x64,
2141        ])
2142        .unwrap(); // "Hello\0World"
2143        assert!(!is_text_file(&binary_file));
2144
2145        // Create an empty file (should be considered text)
2146        let empty_file = temp_path.join("empty.txt");
2147        File::create(&empty_file).unwrap();
2148        assert!(is_text_file(&empty_file));
2149
2150        // Test non-existent file (should return false)
2151        let nonexistent = temp_path.join("nonexistent.txt");
2152        assert!(!is_text_file(&nonexistent));
2153    }
2154
2155    #[test]
2156    fn test_remove_empty_dirs() {
2157        let temp_dir = TempDir::new().unwrap();
2158        let test_path = temp_dir.path();
2159
2160        // Create nested empty directories
2161        let nested_dir = test_path.join("level1").join("level2").join("level3");
2162        fs::create_dir_all(&nested_dir).unwrap();
2163
2164        // Remove empty dirs
2165        remove_empty_dirs(test_path).unwrap();
2166
2167        // Check that empty dirs were removed
2168        assert!(!nested_dir.exists());
2169        assert!(!test_path.join("level1").join("level2").exists());
2170        assert!(!test_path.join("level1").exists());
2171    }
2172
2173    /// Tests that respect_gitignore=false disables .git/info/exclude patterns.
2174    #[test]
2175    fn test_no_ignore_disables_git_exclude() {
2176        let temp_dir = TempDir::new().unwrap();
2177        let test_path = temp_dir.path();
2178
2179        // Create .git/info directory structure
2180        fs::create_dir_all(test_path.join(".git/info")).unwrap();
2181
2182        // Create a visible file at root
2183        fs::write(test_path.join("visible.txt"), "visible content").unwrap();
2184
2185        // Create a directory that will be excluded via .git/info/exclude
2186        let excluded_dir = test_path.join("excluded_dir");
2187        fs::create_dir(&excluded_dir).unwrap();
2188        fs::write(excluded_dir.join("hidden.txt"), "hidden content").unwrap();
2189
2190        // Use .git/info/exclude (not .gitignore) to test git_exclude() behavior
2191        fs::write(test_path.join(".git/info/exclude"), "/excluded_dir\n").unwrap();
2192
2193        // With respect_gitignore=true, .git/info/exclude should be honored
2194        let options_respect = ck_core::FileCollectionOptions {
2195            respect_gitignore: true,
2196            use_ckignore: false,
2197            exclude_patterns: vec![],
2198            show_hidden: false,
2199        };
2200        let files = collect_files(test_path, &options_respect).unwrap();
2201        assert_eq!(
2202            files.len(),
2203            1,
2204            "With respect_gitignore=true, .git/info/exclude should hide files, found: {files:?}"
2205        );
2206
2207        // With respect_gitignore=false, .git/info/exclude should be ignored
2208        let options_no_ignore = ck_core::FileCollectionOptions {
2209            respect_gitignore: false,
2210            use_ckignore: false,
2211            exclude_patterns: vec![],
2212            show_hidden: false,
2213        };
2214        let files = collect_files(test_path, &options_no_ignore).unwrap();
2215        assert_eq!(
2216            files.len(),
2217            2,
2218            "With respect_gitignore=false, .git/info/exclude should be ignored, found: {files:?}"
2219        );
2220    }
2221
2222    #[test]
2223    fn test_ckignore_works_without_gitignore() {
2224        // Test that .ckignore is respected even when respect_gitignore is false
2225        let temp_dir = TempDir::new().unwrap();
2226        let test_path = temp_dir.path();
2227
2228        // Create .gitignore and .ckignore with different patterns
2229        fs::write(test_path.join(".gitignore"), "*.git\n").unwrap();
2230        fs::write(test_path.join(".ckignore"), "*.ck\n").unwrap();
2231
2232        // Create test files
2233        fs::write(test_path.join("normal.txt"), "normal content").unwrap();
2234        fs::write(test_path.join("ignored_by_git.git"), "git ignored").unwrap();
2235        fs::write(test_path.join("ignored_by_ck.ck"), "ck ignored").unwrap();
2236
2237        // Test with respect_gitignore=false, use_ckignore=true
2238        let options = ck_core::FileCollectionOptions {
2239            respect_gitignore: false,
2240            use_ckignore: true,
2241            exclude_patterns: vec![],
2242            show_hidden: false,
2243        };
2244
2245        let files = collect_files(test_path, &options).unwrap();
2246        let file_names: Vec<String> = files
2247            .iter()
2248            .filter_map(|p| p.file_name())
2249            .map(|n| n.to_string_lossy().to_string())
2250            .collect();
2251
2252        // Should find normal.txt
2253        assert!(
2254            file_names.contains(&"normal.txt".to_string()),
2255            "Should find normal.txt"
2256        );
2257
2258        // Should find .git file (gitignore not respected)
2259        assert!(
2260            file_names.contains(&"ignored_by_git.git".to_string()),
2261            "Should find .git file when respect_gitignore=false"
2262        );
2263
2264        // Should NOT find .ck file (ckignore is respected)
2265        assert!(
2266            !file_names.contains(&"ignored_by_ck.ck".to_string()),
2267            "Should NOT find .ck file when use_ckignore=true"
2268        );
2269
2270        // Test with both disabled
2271        let options_both_disabled = ck_core::FileCollectionOptions {
2272            respect_gitignore: false,
2273            use_ckignore: false,
2274            exclude_patterns: vec![],
2275            show_hidden: false,
2276        };
2277
2278        let files_all = collect_files(test_path, &options_both_disabled).unwrap();
2279        let file_names_all: Vec<String> = files_all
2280            .iter()
2281            .filter_map(|p| p.file_name())
2282            .map(|n| n.to_string_lossy().to_string())
2283            .collect();
2284
2285        // Should find ALL files when both are disabled
2286        assert!(
2287            file_names_all.contains(&"ignored_by_git.git".to_string()),
2288            "Should find .git file"
2289        );
2290        assert!(
2291            file_names_all.contains(&"ignored_by_ck.ck".to_string()),
2292            "Should find .ck file when use_ckignore=false"
2293        );
2294    }
2295}
2296
2297// ============================================================================
2298// Cleanup Validation Module
2299// ============================================================================
2300
2301/// Comprehensive cleanup and validation for the index
2302mod cleanup_validation {
2303    use super::*;
2304    // IndexManifest is defined in this module
2305
2306    /// Validates and cleans up the index to ensure consistency
2307    pub fn validate_and_cleanup_index(
2308        repo_root: &Path,
2309        index_dir: &Path,
2310        manifest: &mut IndexManifest,
2311        options: &ck_core::FileCollectionOptions,
2312    ) -> Result<CleanupStats> {
2313        let mut stats = CleanupStats::default();
2314
2315        // Step 1: Get all files that actually exist in the repository
2316        let existing_files = collect_files_as_hashset(repo_root, options)?;
2317        let standard_existing_files: HashSet<PathBuf> = existing_files
2318            .into_iter()
2319            .map(|path| path_utils::to_standard_path(&path, repo_root))
2320            .collect();
2321
2322        // Step 2: Validate manifest entries
2323        let manifest_entries: Vec<PathBuf> =
2324            manifest.files.keys().map(|k| k.to_path_buf()).collect();
2325        for manifest_path in manifest_entries {
2326            let standard_path = path_utils::from_manifest_path(&manifest_path);
2327
2328            // Check if file exists in reality
2329            if !standard_existing_files.contains(&standard_path) {
2330                remove_manifest_entry(manifest, &manifest_path, repo_root, index_dir, &mut stats)?;
2331                continue;
2332            }
2333
2334            // Check if sidecar file exists
2335            let sidecar_path =
2336                path_utils::get_sidecar_path_for_standard_path(index_dir, &standard_path);
2337            if !sidecar_path.exists() {
2338                remove_manifest_entry(manifest, &manifest_path, repo_root, index_dir, &mut stats)?;
2339                continue;
2340            }
2341        }
2342
2343        // Step 3: Clean up orphaned sidecar files
2344        cleanup_orphaned_sidecars(index_dir, &standard_existing_files, manifest, &mut stats)?;
2345
2346        Ok(stats)
2347    }
2348
2349    /// Remove a manifest entry and its associated files
2350    fn remove_manifest_entry(
2351        manifest: &mut IndexManifest,
2352        manifest_path: &Path,
2353        repo_root: &Path,
2354        index_dir: &Path,
2355        stats: &mut CleanupStats,
2356    ) -> Result<()> {
2357        manifest.files.remove(manifest_path);
2358
2359        // Remove sidecar file
2360        let standard_path = path_utils::from_manifest_path(manifest_path);
2361        let sidecar_path =
2362            path_utils::get_sidecar_path_for_standard_path(index_dir, &standard_path);
2363        if sidecar_path.exists() {
2364            fs::remove_file(&sidecar_path)?;
2365            stats.orphaned_sidecars_removed += 1;
2366        }
2367
2368        // Remove content cache for PDFs
2369        if ck_core::pdf::is_pdf_file(&standard_path) {
2370            let absolute_path = repo_root.join(&standard_path);
2371            let cache_path = ck_core::pdf::get_content_cache_path(repo_root, &absolute_path);
2372            if cache_path.exists() {
2373                fs::remove_file(&cache_path)?;
2374                tracing::debug!("Removed orphaned content cache: {:?}", cache_path);
2375            }
2376        }
2377
2378        stats.orphaned_entries_removed += 1;
2379        tracing::warn!("Removed manifest entry: {:?}", manifest_path);
2380        Ok(())
2381    }
2382
2383    /// Clean up sidecar files that don't have corresponding manifest entries
2384    fn cleanup_orphaned_sidecars(
2385        index_dir: &Path,
2386        standard_existing_files: &HashSet<PathBuf>,
2387        manifest: &IndexManifest,
2388        stats: &mut CleanupStats,
2389    ) -> Result<()> {
2390        if !index_dir.exists() {
2391            return Ok(());
2392        }
2393
2394        for entry in WalkDir::new(index_dir) {
2395            let entry = entry?;
2396            if entry.file_type().is_file() {
2397                let sidecar_path = entry.path();
2398                if sidecar_path.extension().and_then(|s| s.to_str()) == Some("ck")
2399                    && let Some(standard_path) =
2400                        path_utils::sidecar_to_standard_path(sidecar_path, index_dir)
2401                {
2402                    let manifest_path = path_utils::to_manifest_path(&standard_path);
2403
2404                    // Remove if file doesn't exist in reality or isn't in manifest
2405                    if !standard_existing_files.contains(&standard_path)
2406                        || !manifest.files.contains_key(&manifest_path)
2407                    {
2408                        fs::remove_file(sidecar_path)?;
2409                        stats.orphaned_sidecars_removed += 1;
2410                    }
2411                }
2412            }
2413        }
2414
2415        Ok(())
2416    }
2417}
2418
2419// ============================================================================
2420// Path Utilities Module
2421// ============================================================================
2422
2423/// Standardized path format for the indexing system.
2424/// All paths are stored as relative paths from the repository root without "./" prefix.
2425/// Example: "examples/code/api_client.js" instead of "./examples/code/api_client.js"
2426mod path_utils {
2427    use super::*;
2428
2429    /// Convert an absolute path to a standardized relative path from repo root
2430    pub fn to_standard_path(absolute_path: &Path, repo_root: &Path) -> PathBuf {
2431        if let Ok(relative) = absolute_path.strip_prefix(repo_root) {
2432            relative.to_path_buf()
2433        } else {
2434            absolute_path.to_path_buf()
2435        }
2436    }
2437
2438    /// Convert a standardized path to a manifest path (with "./" prefix for compatibility)
2439    pub fn to_manifest_path(standard_path: &Path) -> PathBuf {
2440        PathBuf::from(".").join(standard_path)
2441    }
2442
2443    /// Convert a manifest path (with "./" prefix) to a standardized path
2444    pub fn from_manifest_path(manifest_path: &Path) -> PathBuf {
2445        if let Ok(relative) = manifest_path.strip_prefix(".") {
2446            relative.to_path_buf()
2447        } else {
2448            manifest_path.to_path_buf()
2449        }
2450    }
2451
2452    /// Get the sidecar path for a standardized file path
2453    pub fn get_sidecar_path_for_standard_path(index_dir: &Path, standard_path: &Path) -> PathBuf {
2454        let sidecar_name = format!("{}.ck", standard_path.display());
2455        index_dir.join(sidecar_name)
2456    }
2457
2458    /// Convert a sidecar path back to a standardized original path
2459    pub fn sidecar_to_standard_path(sidecar_path: &Path, index_dir: &Path) -> Option<PathBuf> {
2460        let relative_path = sidecar_path.strip_prefix(index_dir).ok()?;
2461        let original_path = relative_path.with_extension("");
2462
2463        // Handle the .ck extension removal
2464        if let Some(name) = original_path.file_name() {
2465            let name_str = name.to_string_lossy();
2466            if let Some(original_name) = name_str.strip_suffix(".ck") {
2467                let mut result = original_path.clone();
2468                result.set_file_name(original_name);
2469                return Some(result);
2470            }
2471        }
2472
2473        Some(original_path)
2474    }
2475}