1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::time::UNIX_EPOCH;
4
5use crate::core::bm25_index::{BM25Index, ChunkKind, CodeChunk, IndexedFileState};
6
7const MAX_ARTIFACT_BYTES: u64 = 2_000_000;
8const MAX_CHUNKS_PER_FILE: usize = 50;
9
10pub fn index_file_path(project_root: &Path) -> PathBuf {
11 let code_idx = BM25Index::index_file_path(project_root);
12 let dir = code_idx.parent().unwrap_or_else(|| Path::new("."));
13 dir.join("bm25_artifacts_index.json")
14}
15
16pub fn load(project_root: &Path) -> Option<BM25Index> {
17 let path = index_file_path(project_root);
18 let data = std::fs::read_to_string(path).ok()?;
19 serde_json::from_str(&data).ok()
20}
21
22pub fn save(project_root: &Path, idx: &BM25Index) -> std::io::Result<()> {
23 let path = index_file_path(project_root);
24 if let Some(parent) = path.parent() {
25 std::fs::create_dir_all(parent)?;
26 }
27 let data = serde_json::to_string(idx).map_err(std::io::Error::other)?;
28 let tmp = path.with_extension("json.tmp");
29 std::fs::write(&tmp, data)?;
30 std::fs::rename(&tmp, &path)?;
31 Ok(())
32}
33
34pub fn load_or_build(project_root: &Path) -> (BM25Index, Vec<String>) {
35 let (files_now, mut warnings) = list_artifact_files(project_root);
36 if files_now.is_empty() {
37 return (load(project_root).unwrap_or_default(), warnings);
38 }
39
40 if let Some(prev) = load(project_root) {
41 if !index_looks_stale(&prev, project_root, &files_now) {
42 return (prev, warnings);
43 }
44 let rebuilt = if prev.files.is_empty() {
45 build_full(project_root, &files_now, &mut warnings)
46 } else {
47 rebuild_incremental(project_root, &prev, &files_now, &mut warnings)
48 };
49 let _ = save(project_root, &rebuilt);
50 return (rebuilt, warnings);
51 }
52
53 let built = build_full(project_root, &files_now, &mut warnings);
54 let _ = save(project_root, &built);
55 (built, warnings)
56}
57
58pub fn rebuild_from_scratch(project_root: &Path) -> (BM25Index, Vec<String>) {
59 let (files_now, mut warnings) = list_artifact_files(project_root);
60 let idx = build_full(project_root, &files_now, &mut warnings);
61 let _ = save(project_root, &idx);
62 (idx, warnings)
63}
64
65fn index_looks_stale(idx: &BM25Index, project_root: &Path, files_now: &[String]) -> bool {
66 if files_now.is_empty() {
67 return false;
68 }
69 if idx.files.is_empty() {
70 return true;
71 }
72
73 let now_set: HashSet<&str> = files_now.iter().map(String::as_str).collect();
74
75 for (rel, old_state) in &idx.files {
76 let abs = project_root.join(rel);
77 if !abs.exists() {
78 return true;
79 }
80 let Some(cur) = file_state(&abs) else {
81 return true;
82 };
83 if &cur != old_state {
84 return true;
85 }
86 if !now_set.contains(rel.as_str()) {
87 return true;
88 }
89 }
90
91 for rel in files_now {
92 if !idx.files.contains_key(rel) {
93 return true;
94 }
95 }
96
97 false
98}
99
100fn build_full(project_root: &Path, files: &[String], warnings: &mut Vec<String>) -> BM25Index {
101 let mut idx = BM25Index::new();
102
103 for rel in files {
104 let abs = project_root.join(rel);
105 let Some(state) = file_state(&abs) else {
106 continue;
107 };
108 let content = match read_artifact_text(&abs) {
109 Ok(s) => s,
110 Err(e) => {
111 warnings.push(format!("artifact read failed: {rel} ({e})"));
112 continue;
113 }
114 };
115
116 let mut chunks = extract_artifact_chunks(rel, &content);
117 chunks.sort_by(|a, b| {
118 a.start_line
119 .cmp(&b.start_line)
120 .then_with(|| a.end_line.cmp(&b.end_line))
121 .then_with(|| a.symbol_name.cmp(&b.symbol_name))
122 });
123 for chunk in chunks {
124 add_chunk(&mut idx, chunk);
125 }
126 idx.files.insert(rel.clone(), state);
127 }
128
129 finalize(&mut idx);
130 idx
131}
132
133fn rebuild_incremental(
134 project_root: &Path,
135 prev: &BM25Index,
136 files: &[String],
137 warnings: &mut Vec<String>,
138) -> BM25Index {
139 let mut old_by_file: HashMap<String, Vec<CodeChunk>> = HashMap::new();
140 for c in &prev.chunks {
141 old_by_file
142 .entry(c.file_path.clone())
143 .or_default()
144 .push(c.clone());
145 }
146 for v in old_by_file.values_mut() {
147 v.sort_by(|a, b| {
148 a.start_line
149 .cmp(&b.start_line)
150 .then_with(|| a.end_line.cmp(&b.end_line))
151 .then_with(|| a.symbol_name.cmp(&b.symbol_name))
152 });
153 }
154
155 let mut idx = BM25Index::new();
156
157 for rel in files {
158 let abs = project_root.join(rel);
159 let Some(state) = file_state(&abs) else {
160 continue;
161 };
162
163 let unchanged = prev.files.get(rel).is_some_and(|old| *old == state);
164 if unchanged && let Some(chunks) = old_by_file.get(rel) {
165 for chunk in chunks {
166 add_chunk(&mut idx, chunk.clone());
167 }
168 idx.files.insert(rel.clone(), state);
169 continue;
170 }
171
172 let content = match read_artifact_text(&abs) {
173 Ok(s) => s,
174 Err(e) => {
175 warnings.push(format!("artifact read failed: {rel} ({e})"));
176 continue;
177 }
178 };
179
180 let mut chunks = extract_artifact_chunks(rel, &content);
181 chunks.sort_by(|a, b| {
182 a.start_line
183 .cmp(&b.start_line)
184 .then_with(|| a.end_line.cmp(&b.end_line))
185 .then_with(|| a.symbol_name.cmp(&b.symbol_name))
186 });
187 for chunk in chunks {
188 add_chunk(&mut idx, chunk);
189 }
190 idx.files.insert(rel.clone(), state);
191 }
192
193 finalize(&mut idx);
194 idx
195}
196
197fn add_chunk(idx: &mut BM25Index, chunk: CodeChunk) {
198 let chunk_idx = idx.chunks.len();
199 let tokens = crate::core::bm25_index::tokenize_for_index(&chunk.content);
200 for token in &tokens {
201 let lower = token.to_lowercase();
202 idx.inverted
203 .entry(lower)
204 .or_default()
205 .push((chunk_idx, 1.0));
206 }
207 idx.chunks.push(CodeChunk {
208 token_count: tokens.len(),
209 tokens: Vec::new(),
210 ..chunk
211 });
212}
213
214fn finalize(idx: &mut BM25Index) {
215 idx.doc_count = idx.chunks.len();
216 if idx.doc_count == 0 {
217 idx.avg_doc_len = 0.0;
218 idx.doc_freqs.clear();
219 return;
220 }
221
222 let total_len: usize = idx.chunks.iter().map(|c| c.token_count).sum();
223 idx.avg_doc_len = total_len as f64 / idx.doc_count as f64;
224
225 idx.doc_freqs.clear();
226 for (term, postings) in &idx.inverted {
227 let unique_docs: HashSet<usize> = postings.iter().map(|(i, _)| *i).collect();
228 idx.doc_freqs.insert(term.clone(), unique_docs.len());
229 }
230}
231
232fn list_artifact_files(project_root: &Path) -> (Vec<String>, Vec<String>) {
233 let resolved = crate::core::artifacts::load_resolved(project_root);
234 let mut warnings = resolved.warnings;
235
236 let cfg = crate::core::config::Config::load();
237 let extra_ignores: Vec<glob::Pattern> = cfg
238 .extra_ignore_patterns
239 .iter()
240 .filter_map(|p| glob::Pattern::new(p).ok())
241 .collect();
242
243 let mut files: Vec<String> = Vec::new();
244 for a in resolved.artifacts {
245 if !a.exists {
246 warnings.push(format!("artifact missing: {} ({})", a.name, a.path));
247 continue;
248 }
249
250 let abs = project_root.join(&a.path);
251 if a.is_dir {
252 let walker = ignore::WalkBuilder::new(&abs)
253 .hidden(true)
254 .git_ignore(true)
255 .git_global(true)
256 .git_exclude(true)
257 .require_git(false)
258 .filter_entry(crate::core::walk_filter::keep_entry)
259 .build();
260 for entry in walker.flatten() {
261 let path = entry.path();
262 if !path.is_file() {
263 continue;
264 }
265 if path.components().any(|c| c.as_os_str() == ".git") {
266 continue;
267 }
268 if !is_artifact_text_file(path) {
269 continue;
270 }
271 if let Ok(meta) = path.metadata()
272 && meta.len() > MAX_ARTIFACT_BYTES
273 {
274 continue;
275 }
276 let rel = path
279 .strip_prefix(project_root)
280 .unwrap_or(path)
281 .to_string_lossy()
282 .replace('\\', "/");
283 if rel.is_empty() {
284 continue;
285 }
286 if extra_ignores.iter().any(|p| p.matches(&rel)) {
287 continue;
288 }
289 files.push(rel);
290 }
291 } else {
292 if !abs.is_file() {
293 continue;
294 }
295 if !is_artifact_text_file(&abs) {
296 continue;
297 }
298 if let Ok(meta) = abs.metadata()
299 && meta.len() > MAX_ARTIFACT_BYTES
300 {
301 continue;
302 }
303 if extra_ignores.iter().any(|p| p.matches(&a.path)) {
304 continue;
305 }
306 files.push(a.path);
307 }
308 }
309
310 files.sort();
311 files.dedup();
312 (files, warnings)
313}
314
315fn is_artifact_text_file(path: &Path) -> bool {
316 let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
317 if name.eq_ignore_ascii_case("Dockerfile") {
318 return true;
319 }
320 if name.eq_ignore_ascii_case(".env") {
321 return false;
322 }
323
324 let ext = path
325 .extension()
326 .and_then(|e| e.to_str())
327 .unwrap_or("")
328 .to_lowercase();
329 matches!(
330 ext.as_str(),
331 "md" | "mdx"
332 | "txt"
333 | "json"
334 | "yaml"
335 | "yml"
336 | "toml"
337 | "sql"
338 | "proto"
339 | "tf"
340 | "tfvars"
341 | "hcl"
342 | "rego"
343 | "graphql"
344 | "gql"
345 | "sh"
346 | "bash"
347 | "zsh"
348 | "pdf"
349 )
350}
351
352fn is_pdf(path: &Path) -> bool {
353 path.extension()
354 .and_then(|e| e.to_str())
355 .is_some_and(|e| e.eq_ignore_ascii_case("pdf"))
356}
357
358fn read_artifact_text(path: &Path) -> Result<String, String> {
363 if is_pdf(path) {
364 let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
365 return crate::core::web::pdf::extract_text(&bytes);
366 }
367 std::fs::read_to_string(path).map_err(|e| e.to_string())
368}
369
370fn file_state(path: &Path) -> Option<IndexedFileState> {
371 let meta = path.metadata().ok()?;
372 let size_bytes = meta.len();
373 let mtime_ms = meta
374 .modified()
375 .ok()
376 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
377 .map(|d| d.as_millis() as u64)?;
378 Some(IndexedFileState {
379 mtime_ms,
380 size_bytes,
381 })
382}
383
384fn extract_artifact_chunks(file_path: &str, content: &str) -> Vec<CodeChunk> {
385 let lines: Vec<&str> = content.lines().collect();
386 if lines.is_empty() {
387 return Vec::new();
388 }
389
390 let bytes = content.as_bytes();
391 let rk_chunks = crate::core::rabin_karp::chunk(content);
392 if !rk_chunks.is_empty() && rk_chunks.len() <= 200 {
393 let mut out: Vec<CodeChunk> = Vec::new();
394 for (idx, c) in rk_chunks.into_iter().take(MAX_CHUNKS_PER_FILE).enumerate() {
395 let end = (c.offset + c.length).min(bytes.len());
396 let slice = &bytes[c.offset..end];
397 let chunk_text = String::from_utf8_lossy(slice).into_owned();
398 let token_count = crate::core::bm25_index::tokenize_for_index(&chunk_text).len();
399 let start_line = 1 + bytecount::count(&bytes[..c.offset], b'\n');
400 let end_line = start_line + bytecount::count(slice, b'\n');
401 out.push(CodeChunk {
402 file_path: file_path.to_string(),
403 symbol_name: format!("{file_path}#chunk-{idx}"),
404 kind: ChunkKind::Other,
405 start_line,
406 end_line: end_line.max(start_line),
407 content: chunk_text,
408 tokens: Vec::new(),
409 token_count,
410 });
411 }
412 return out;
413 }
414
415 let token_count = crate::core::bm25_index::tokenize_for_index(content).len();
416 let snippet = lines
417 .iter()
418 .take(50)
419 .copied()
420 .collect::<Vec<_>>()
421 .join("\n");
422 vec![CodeChunk {
423 file_path: file_path.to_string(),
424 symbol_name: file_path.to_string(),
425 kind: ChunkKind::Other,
426 start_line: 1,
427 end_line: lines.len(),
428 content: snippet,
429 tokens: Vec::new(),
430 token_count,
431 }]
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 fn tiny_pdf(text: &str) -> Vec<u8> {
442 let stream = format!("BT /F1 12 Tf 72 720 Td ({text}) Tj ET");
443 let objects = [
444 "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
445 "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
446 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R \
447 /Resources << /Font << /F1 5 0 R >> >> >>"
448 .to_string(),
449 format!(
450 "<< /Length {} >>\nstream\n{stream}\nendstream",
451 stream.len()
452 ),
453 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
454 ];
455
456 let mut out = String::from("%PDF-1.4\n");
457 let mut offsets = Vec::with_capacity(objects.len());
458 for (i, body) in objects.iter().enumerate() {
459 offsets.push(out.len());
460 out.push_str(&format!("{} 0 obj\n{body}\nendobj\n", i + 1));
461 }
462 let xref_at = out.len();
463 out.push_str(&format!("xref\n0 {}\n", objects.len() + 1));
464 out.push_str("0000000000 65535 f \n");
465 for off in &offsets {
466 out.push_str(&format!("{off:010} 00000 n \n"));
467 }
468 out.push_str(&format!(
469 "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_at}\n%%EOF\n",
470 objects.len() + 1
471 ));
472 out.into_bytes()
473 }
474
475 fn project_with_docs(files: &[(&str, &[u8])]) -> tempfile::TempDir {
476 let dir = tempfile::tempdir().unwrap();
477 let docs = dir.path().join("docs");
478 std::fs::create_dir_all(&docs).unwrap();
479 for (name, bytes) in files {
480 std::fs::write(docs.join(name), bytes).unwrap();
481 }
482 std::fs::write(
483 dir.path().join(".lean-ctx-artifacts.json"),
484 r#"{"artifacts":[{"name":"docs","path":"docs","description":"doc corpus"}]}"#,
485 )
486 .unwrap();
487 dir
488 }
489
490 #[test]
491 fn artifact_text_file_accepts_pdf_and_rejects_env() {
492 assert!(is_artifact_text_file(Path::new("docs/spec.pdf")));
493 assert!(is_artifact_text_file(Path::new("docs/Spec.PDF")));
494 assert!(is_artifact_text_file(Path::new("notes.md")));
495 assert!(!is_artifact_text_file(Path::new(".env")));
496 assert!(!is_artifact_text_file(Path::new("logo.png")));
497 }
498
499 #[test]
500 fn read_artifact_text_extracts_pdf_body() {
501 let dir = tempfile::tempdir().unwrap();
502 let pdf_path = dir.path().join("spec.pdf");
503 std::fs::write(&pdf_path, tiny_pdf("Latency budget is 42ms")).unwrap();
504
505 let text = read_artifact_text(&pdf_path).unwrap();
506 assert!(
507 text.contains("Latency budget is 42ms"),
508 "extracted: {text:?}"
509 );
510 }
511
512 #[test]
513 fn read_artifact_text_reports_malformed_pdf_instead_of_panicking() {
514 let dir = tempfile::tempdir().unwrap();
515 let bad = dir.path().join("broken.pdf");
516 std::fs::write(&bad, b"%PDF-1.4\ngarbage without structure").unwrap();
517
518 let err = read_artifact_text(&bad).unwrap_err();
519 assert!(!err.is_empty());
520 }
521
522 #[test]
523 fn corpus_build_indexes_markdown_and_pdf_deterministically() {
524 let dir = project_with_docs(&[
525 (
526 "runbook.md",
527 b"# Incident runbook\nRotate the signing key quarterly.".as_slice(),
528 ),
529 ("spec.pdf", &tiny_pdf("Latency budget is 42ms")),
530 ]);
531
532 let (files, warnings) = list_artifact_files(dir.path());
533 assert!(warnings.is_empty(), "{warnings:?}");
534 assert_eq!(
535 files,
536 vec!["docs/runbook.md".to_string(), "docs/spec.pdf".to_string()]
537 );
538
539 let mut w = Vec::new();
540 let idx = build_full(dir.path(), &files, &mut w);
541 assert!(w.is_empty(), "{w:?}");
542
543 let md_hits = idx.search("signing key quarterly", 5);
544 assert!(md_hits.iter().any(|r| r.file_path == "docs/runbook.md"));
545 let pdf_hits = idx.search("latency budget", 5);
546 assert!(
547 pdf_hits.iter().any(|r| r.file_path == "docs/spec.pdf"),
548 "pdf chunk not found: {pdf_hits:?}"
549 );
550
551 let mut w2 = Vec::new();
554 let idx2 = build_full(dir.path(), &files, &mut w2);
555 let flat = |i: &BM25Index| {
556 i.chunks
557 .iter()
558 .map(|c| {
559 format!(
560 "{}|{}|{}|{}|{}",
561 c.file_path, c.symbol_name, c.start_line, c.end_line, c.content
562 )
563 })
564 .collect::<Vec<_>>()
565 };
566 assert_eq!(flat(&idx), flat(&idx2));
567 }
568
569 #[test]
570 fn incremental_rebuild_skips_unchanged_pdf() {
571 let dir = project_with_docs(&[("spec.pdf", &tiny_pdf("Latency budget is 42ms"))]);
572 let (files, _) = list_artifact_files(dir.path());
573
574 let mut w = Vec::new();
575 let full = build_full(dir.path(), &files, &mut w);
576 let rebuilt = rebuild_incremental(dir.path(), &full, &files, &mut w);
577 assert!(w.is_empty(), "{w:?}");
578 assert_eq!(full.chunks.len(), rebuilt.chunks.len());
579 assert!(rebuilt.files.contains_key("docs/spec.pdf"));
580 }
581}