Skip to main content

lean_ctx/core/
artifact_index.rs

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                // Forward slashes on every platform: these strings are index
277                // keys and must match `ResolvedArtifact::path` semantics.
278                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
358/// Read an artifact as indexable text. PDFs go through the panic-safe
359/// `pdf-extract` wrapper (GL#1132) — a scanned/image-only or malformed PDF
360/// yields a warning instead of aborting the corpus build; everything else is
361/// read as UTF-8 like before.
362fn 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    /// Assemble a minimal, syntactically valid single-page PDF whose content
439    /// stream draws `text` — offsets in the xref table are computed, so the
440    /// fixture stays valid however the text changes.
441    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        // Determinism (#498): rebuilding the unchanged corpus yields the same
552        // chunk sequence, byte for byte.
553        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}