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#[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#[derive(Debug, Clone)]
45pub enum IndexingProgress {
46 Starting { total_files: usize },
48 ProcessingFile {
50 file: String,
51 file_number: usize,
52 total_files: usize,
53 file_size: u64,
54 },
55 ChunkingFile { file: String, chunks_found: usize },
57 ProcessingChunk {
59 file: String,
60 chunk_number: usize,
61 total_chunks: usize,
62 chunk_size: usize,
63 },
64 FileComplete {
66 file: String,
67 chunks_processed: usize,
68 file_number: usize,
69 total_files: usize,
70 elapsed_ms: u64,
71 },
72 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
82static 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
92fn 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>, #[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 #[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 pub embedding_model: Option<String>,
146 pub embedding_dimensions: Option<usize>,
148 #[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, embedding_dimensions: None,
169 chunk_hash_version: Some(2), }
171 }
172}
173
174fn 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
182fn 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 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 ck_core::get_default_exclude_patterns;
218 let default_patterns = get_default_exclude_patterns();
219
220 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 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
251const INDEX_LOCK_FILE: &str = ".lock";
255
256pub struct IndexWriteLock {
263 _file: std::fs::File,
264}
265
266pub 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
302async 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 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 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 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 let sidecar_path = get_sidecar_path(path, file_path);
367 save_index_entry(&sidecar_path, &entry)?;
368
369 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 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 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 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 }
408 }
409 Err(e) => {
410 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 while let Ok((file_path, entry)) = rx.recv() {
427 let sidecar_path = get_sidecar_path(path, &file_path);
429 save_index_entry(&sidecar_path, &entry)?;
430
431 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 worker_handle
443 .join()
444 .map_err(|_| anyhow::anyhow!("Worker thread panicked"))?;
445 }
446
447 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, )
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 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 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 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 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 let _ = fs::remove_file(index_dir.join(INDEX_LOCK_FILE));
664 let _ = fs::remove_dir(&index_dir);
665 Ok(())
666}
667
668fn 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 let stats =
702 cleanup_validation::validate_and_cleanup_index(path, &index_dir, &mut manifest, options)?;
703
704 remove_empty_dirs(&index_dir)?;
708
709 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 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 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 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, )
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, compute_embeddings,
806 options,
807 model,
808 )
809 .await
810}
811
812pub 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 ck_core::check_index_root_marker(path)?;
828 let mut stats = UpdateStats::default();
829
830 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 INTERRUPTED.store(false, Ordering::SeqCst);
840
841 if force_rebuild {
842 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 let repo_root = find_repo_root(path)?;
853
854 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 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 let current_files = collect_files(path, options)?;
910
911 let mut files_to_update = Vec::new();
913 let mut manifest_changed = false;
914
915 for file_path in current_files {
916 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 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 if compute_embeddings {
984 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 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 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 stats.chunks_reused += file_chunks_reused;
1022 stats.chunks_embedded += file_chunks_embedded;
1023
1024 let sidecar_path = get_sidecar_path(path, file_path);
1026 save_index_entry(&sidecar_path, &entry)?;
1027
1028 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 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 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 let worker_handle = thread::spawn(move || {
1065 use rayon::prelude::*;
1066
1067 let result = files_clone.par_iter().try_for_each(|file_path| {
1069 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 return Err("receiver_dropped");
1079 }
1080 }
1081 Err(e) => {
1082 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 if let Err(reason) = result {
1099 tracing::debug!("Worker thread stopped due to: {}", reason);
1100 }
1101 });
1102
1103 let mut _processed_count = 0;
1105 while let Ok((file_path, entry)) = rx.recv() {
1106 if INTERRUPTED.load(Ordering::SeqCst) {
1108 eprintln!("Indexing interrupted. {_processed_count} files processed.");
1109 drop(rx); 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 let sidecar_path = get_sidecar_path(path, &file_path);
1121 save_index_entry(&sidecar_path, &entry)?;
1122
1123 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 worker_handle
1138 .join()
1139 .map_err(|_| anyhow::anyhow!("Worker thread panicked"))?;
1140 }
1141
1142 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 if !is_text_file(file_path) {
1177 return Err(anyhow::anyhow!("Binary file, skipping"));
1178 }
1179
1180 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 let content_path = preprocess_file(file_path, repo_root)?;
1207 let content = fs::read_to_string(&content_path)?;
1208
1209 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 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 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 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 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 let chunk_hash = compute_chunk_hash(
1274 &chunk.text,
1275 &chunk.metadata.leading_trivia,
1276 &chunk.metadata.trailing_trivia,
1277 );
1278
1279 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 chunks_reused += 1;
1285 cached_embedding.clone()
1286 } else {
1287 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 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 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 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 chunks_reused += 1;
1370 chunk_results.push((chunk, chunk_hash, Some(cached_embedding.clone())));
1371 } else {
1372 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 chunks_to_embed.push((chunk.text.clone(), chunk_results.len()));
1385 chunk_results.push((chunk, chunk_hash, None));
1386 }
1387 }
1388
1389 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 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 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 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
1613fn 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
1625fn 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
1631fn 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 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 if let Some(parent) = cache_path.parent() {
1649 fs::create_dir_all(parent)?;
1650 }
1651
1652 fs::write(&cache_path, extracted_text)?;
1654 }
1655
1656 Ok(cache_path) } else {
1658 Ok(file_path.to_path_buf()) }
1660}
1661
1662fn is_text_file(path: &Path) -> bool {
1663 if ck_core::pdf::is_pdf_file(path) {
1665 return true;
1666 }
1667
1668 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 bytes_read == 0 {
1678 return true;
1679 }
1680
1681 !buffer[..bytes_read].contains(&0)
1683 }
1684 Err(_) => false, }
1686 }
1687 Err(_) => false, }
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 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 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 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 Ok(Vec::new())
1788 }
1789 }
1790
1791 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 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 let test_file = test_path.join("test.txt");
1824 fs::write(&test_file, "hello world").unwrap();
1825
1826 let mut empty_embedder: Box<dyn ck_embed::Embedder> = Box::new(EmptyResultsEmbedder);
1828
1829 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 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 let test_file = test_path.join("test.txt");
1847 fs::write(&test_file, "hello world").unwrap();
1848
1849 let mut empty_embedder: Box<dyn ck_embed::Embedder> = Box::new(EmptyResultsEmbedder);
1851
1852 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 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 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 let mut mismatched_embedder: Box<dyn ck_embed::Embedder> =
1886 Box::new(MismatchedCountEmbedder);
1887
1888 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 let test_file = test_path.join("test.txt");
1906 fs::write(&test_file, "hello world").unwrap();
1907
1908 let dummy_embedder = ck_embed::DummyEmbedder::new();
1910 let mut boxed_embedder: Box<dyn ck_embed::Embedder> = Box::new(dummy_embedder);
1911
1912 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 for chunk in &entry.chunks {
1920 assert!(chunk.embedding.is_some());
1921 assert_eq!(chunk.embedding.as_ref().unwrap().len(), 384); }
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 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 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 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 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 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 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 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 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 let stats = get_index_stats(test_path).unwrap();
2066 assert_eq!(stats.total_files, 0);
2067
2068 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 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 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 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 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 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 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(); assert!(!is_text_file(&binary_file));
2144
2145 let empty_file = temp_path.join("empty.txt");
2147 File::create(&empty_file).unwrap();
2148 assert!(is_text_file(&empty_file));
2149
2150 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 let nested_dir = test_path.join("level1").join("level2").join("level3");
2162 fs::create_dir_all(&nested_dir).unwrap();
2163
2164 remove_empty_dirs(test_path).unwrap();
2166
2167 assert!(!nested_dir.exists());
2169 assert!(!test_path.join("level1").join("level2").exists());
2170 assert!(!test_path.join("level1").exists());
2171 }
2172
2173 #[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 fs::create_dir_all(test_path.join(".git/info")).unwrap();
2181
2182 fs::write(test_path.join("visible.txt"), "visible content").unwrap();
2184
2185 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 fs::write(test_path.join(".git/info/exclude"), "/excluded_dir\n").unwrap();
2192
2193 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 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 let temp_dir = TempDir::new().unwrap();
2226 let test_path = temp_dir.path();
2227
2228 fs::write(test_path.join(".gitignore"), "*.git\n").unwrap();
2230 fs::write(test_path.join(".ckignore"), "*.ck\n").unwrap();
2231
2232 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 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 assert!(
2254 file_names.contains(&"normal.txt".to_string()),
2255 "Should find normal.txt"
2256 );
2257
2258 assert!(
2260 file_names.contains(&"ignored_by_git.git".to_string()),
2261 "Should find .git file when respect_gitignore=false"
2262 );
2263
2264 assert!(
2266 !file_names.contains(&"ignored_by_ck.ck".to_string()),
2267 "Should NOT find .ck file when use_ckignore=true"
2268 );
2269
2270 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 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
2297mod cleanup_validation {
2303 use super::*;
2304 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 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 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 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 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 cleanup_orphaned_sidecars(index_dir, &standard_existing_files, manifest, &mut stats)?;
2345
2346 Ok(stats)
2347 }
2348
2349 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 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 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 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 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
2419mod path_utils {
2427 use super::*;
2428
2429 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 pub fn to_manifest_path(standard_path: &Path) -> PathBuf {
2440 PathBuf::from(".").join(standard_path)
2441 }
2442
2443 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 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 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 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}