1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use semtree_embed::Embedder;
5use semtree_parse::extract_file;
6use semtree_store::VectorStore;
7use tracing::{debug, warn};
8
9use crate::{ChunkRegistry, FileManifest, RagError};
10
11pub struct Indexer {
12 embedder: Arc<dyn Embedder>,
13 store: Arc<dyn VectorStore>,
14}
15
16impl Indexer {
17 pub fn new(embedder: Arc<dyn Embedder>, store: Arc<dyn VectorStore>) -> Self {
18 Self { embedder, store }
19 }
20
21 pub async fn index_file(
22 &self,
23 path: &Path,
24 registry: &mut ChunkRegistry,
25 manifest: Option<&mut FileManifest>,
26 ) -> Result<usize, RagError> {
27 let content = match std::fs::read_to_string(path) {
28 Ok(c) => c,
29 Err(e) => {
30 warn!("skipping {}: {e}", path.display());
31 return Ok(0);
32 }
33 };
34
35 if let Some(ref manifest) = manifest {
36 if !manifest.is_changed(path, &content) {
37 debug!("unchanged, skipping {}", path.display());
38 return Ok(0);
39 }
40 }
41
42 if let Some(ref manifest) = manifest {
44 for old_id in manifest.chunk_ids(path) {
45 registry.remove(old_id);
46 let _ = self.store.delete(old_id).await;
47 }
48 }
49
50 let mut chunks = match extract_file(path) {
51 Ok(c) => c,
52 Err(e) => {
53 warn!("skipping {}: {e}", path.display());
54 return Ok(0);
55 }
56 };
57
58 if chunks.is_empty() {
59 if let Some(manifest) = manifest {
60 manifest.record(path.to_path_buf(), &content, vec![]);
61 }
62 return Ok(0);
63 }
64
65 for chunk in &mut chunks {
66 chunk.path = path.to_path_buf();
67 }
68
69 let texts: Vec<&str> = chunks.iter().map(|c| c.content.as_str()).collect();
70 let embeddings = self.embedder.embed(&texts).await?;
71
72 let mut chunk_ids = Vec::with_capacity(chunks.len());
73 for (chunk, embedding) in chunks.iter().zip(embeddings.iter()) {
74 self.store.insert(&chunk.id, embedding).await?;
75 registry.insert(chunk.clone());
76 chunk_ids.push(chunk.id.clone());
77 debug!("indexed {}", chunk.name.as_deref().unwrap_or(&chunk.id));
78 }
79
80 if let Some(manifest) = manifest {
81 manifest.record(path.to_path_buf(), &content, chunk_ids);
82 }
83
84 Ok(chunks.len())
85 }
86
87 pub async fn index_dir(
90 &self,
91 dir: &Path,
92 registry: &mut ChunkRegistry,
93 manifest: Option<&mut FileManifest>,
94 on_progress: impl Fn(usize, usize),
95 ) -> Result<usize, RagError> {
96 let paths = collect_indexable_files(dir);
97 let total = paths.len();
98 let mut total_chunks = 0;
99
100 let mut manifest = manifest;
104
105 for (done, path) in paths.iter().enumerate() {
106 total_chunks += self
107 .index_file(path, registry, manifest.as_deref_mut())
108 .await?;
109 on_progress(done + 1, total);
110 }
111
112 if let Some(ref mut m) = manifest {
114 let stale: Vec<PathBuf> = m.paths().filter(|p| !p.exists()).cloned().collect();
115 for path in stale {
116 for old_id in m.chunk_ids(&path).to_vec() {
117 registry.remove(&old_id);
118 let _ = self.store.delete(&old_id).await;
119 }
120 m.remove(&path);
121 }
122 }
123
124 Ok(total_chunks)
125 }
126}
127
128pub fn collect_indexable_files(dir: &Path) -> Vec<PathBuf> {
130 let mut paths = Vec::new();
131 walkdir(dir, &mut paths);
132 paths
133}
134
135const IGNORED_DIRS: &[&str] = &[
136 "node_modules",
138 "vendor",
139 ".pnp",
140 "target",
142 "dist",
143 "build",
144 "out",
145 ".next",
146 ".nuxt",
147 "__pycache__",
148 ".pytest_cache",
149 ".mypy_cache",
150 ".ruff_cache",
151 ".venv",
153 "venv",
154 "env",
155 ".env",
156 ".git",
158 ".svn",
159 ".hg",
160 ".semtree",
162 ".idea",
164 ".vscode",
165 "coverage",
166 ".turbo",
167 ".cache",
168];
169
170fn is_ignored(dir_name: &str) -> bool {
171 IGNORED_DIRS.contains(&dir_name)
172}
173
174fn walkdir(dir: &Path, paths: &mut Vec<PathBuf>) {
175 if let Ok(entries) = std::fs::read_dir(dir) {
176 for entry in entries.flatten() {
177 let path = entry.path();
178 if path.is_dir() {
179 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
180 if !is_ignored(name) {
181 walkdir(&path, paths);
182 }
183 } else {
184 paths.push(path);
185 }
186 }
187 }
188}