Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

src/index/mod.rs

//! Tantivy-backed full-text index over workspace corpora.
//!
//! Five corpora, one index, BM25 scoring. Lives at
//! `<workspace>/.nornir/cache/index/`. Incremental rebuild keyed on
//! file mtime: a file already indexed at its current mtime is skipped.
//!
//! Public API (used by both CLI and MCP tool):
//! - [`Index::open`] open or create the index dir.
//! - [`Index::build`] walk the workspace, add/update changed docs.
//! - [`Index::search`] BM25 search with optional corpus filter.
//! - [`Index::stats`] document counts per corpus.
//!
//! One umbrella MCP tool `search { corpus, query, limit }` exposes
//! this; we deliberately avoid one tool per corpus.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tantivy::collector::TopDocs;
use tantivy::query::QueryParser;
use tantivy::schema::*;
use tantivy::{
    Index as TIndex, IndexReader, IndexWriter, ReloadPolicy, TantivyDocument, Term, doc,
};

pub mod snapshot;
mod walker;
pub use walker::{Candidate, walk_workspace};

/// Logical buckets that classify every indexed file. Search can filter
/// to one corpus or hit all of them.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Corpus {
    Docs,
    Code,
    BenchHistory,
    Changelog,
    Config,
}

impl Corpus {
    pub fn as_str(self) -> &'static str {
        match self {
            Corpus::Docs => "docs",
            Corpus::Code => "code",
            Corpus::BenchHistory => "bench_history",
            Corpus::Changelog => "changelog",
            Corpus::Config => "config",
        }
    }
    pub fn parse(s: &str) -> Option<Self> {
        match s {
            "docs" => Some(Corpus::Docs),
            "code" => Some(Corpus::Code),
            "bench_history" | "bench-history" | "bench" => Some(Corpus::BenchHistory),
            "changelog" => Some(Corpus::Changelog),
            "config" => Some(Corpus::Config),
            _ => None,
        }
    }
    pub fn all() -> &'static [Corpus] {
        &[
            Corpus::Docs,
            Corpus::Code,
            Corpus::BenchHistory,
            Corpus::Changelog,
            Corpus::Config,
        ]
    }
}

/// One hit returned by [`Index::search`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hit {
    pub score: f32,
    pub corpus: String,
    pub repo: String,
    pub path: String,
    pub title: String,
    pub snippet: String,
    pub mtime: u64,
}

/// Counts summarising index state.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Stats {
    pub total: u64,
    pub by_corpus: Vec<(String, u64)>,
}

/// Counts returned by [`Index::build`].
#[derive(Debug, Default, Clone, Serialize)]
pub struct BuildStats {
    pub scanned: usize,
    pub added: usize,
    pub updated: usize,
    pub skipped_unchanged: usize,
    pub skipped_too_large: usize,
    pub errors: usize,
}

const MAX_FILE_BYTES: u64 = 1_048_576;
const SNIPPET_BYTES: usize = 400;
const WRITER_HEAP: usize = 50_000_000;

struct Fields {
    path: Field,
    corpus: Field,
    repo: Field,
    title: Field,
    body: Field,
    mtime: Field,
    snippet: Field,
}

fn build_schema() -> (Schema, Fields) {
    let mut sb = Schema::builder();
    let path = sb.add_text_field("path", STRING | STORED);
    let corpus = sb.add_text_field("corpus", STRING | STORED);
    let repo = sb.add_text_field("repo", STRING | STORED);
    let title = sb.add_text_field("title", TEXT | STORED);
    let body = sb.add_text_field("body", TEXT);
    let mtime = sb.add_u64_field("mtime", STORED | INDEXED);
    let snippet = sb.add_text_field("snippet", STORED);
    (
        sb.build(),
        Fields {
            path,
            corpus,
            repo,
            title,
            body,
            mtime,
            snippet,
        },
    )
}

/// Persistent FTS index living at `<workspace>/.nornir/cache/index/`.
pub struct Index {
    workspace_root: PathBuf,
    index_dir: PathBuf,
    tindex: TIndex,
    reader: IndexReader,
    fields: Fields,
    /// When non-empty, [`build`](Self::build) only walks these top-level
    /// directories (the configured `[repo.*]` names). Empty = walk all.
    repo_scope: Vec<String>,
}

impl Index {
    /// Open or create the index at the conventional path.
    pub fn open(workspace_root: &Path) -> Result<Self> {
        Self::open_at(workspace_root, &workspace_root.join(".nornir/cache/index"))
    }

    /// Restrict [`build`](Self::build) to the given top-level repo directory
    /// names (the `[repo.*]` keys). Root-level files are still indexed.
    pub fn with_repo_scope(mut self, repos: Vec<String>) -> Self {
        self.repo_scope = repos;
        self
    }

    /// Like [`open`] but with an explicit `index_dir`. Used by
    /// [`open_or_restore`] after a fresh restore.
    pub fn open_at(workspace_root: &Path, index_dir: &Path) -> Result<Self> {
        std::fs::create_dir_all(index_dir)
            .with_context(|| format!("create {}", index_dir.display()))?;
        let (schema, fields) = build_schema();
        let dir = tantivy::directory::MmapDirectory::open(index_dir)?;
        // The index is a CACHE of the workspace (`build` regenerates it), so an
        // index tantivy refuses as incompatible is wiped and recreated, never
        // misread and never a hard error. Written 2026-10-02 with the tantivy
        // 0.22 -> 0.26 bump: 0.26 still reads 0.22's format 6 (oldest supported
        // is 4), but it writes format 7, which a nornir built with tantivy 0.22
        // (max 6) refuses — so the step is one-way for snapshots restored from
        // iceberg, and the next format bump will hit this arm for real.
        let tindex = if TIndex::exists(&dir)? {
            match TIndex::open_in_dir(index_dir) {
                Ok(tindex) => tindex,
                Err(tantivy::TantivyError::IncompatibleIndex(why)) => {
                    eprintln!(
                        "⏃ index at {} is incompatible with this tantivy ({why:?}); rebuilding it",
                        index_dir.display()
                    );
                    drop(dir);
                    std::fs::remove_dir_all(index_dir)
                        .with_context(|| format!("remove {}", index_dir.display()))?;
                    std::fs::create_dir_all(index_dir)
                        .with_context(|| format!("create {}", index_dir.display()))?;
                    TIndex::create_in_dir(index_dir, schema)?
                }
                Err(e) => return Err(e.into()),
            }
        } else {
            TIndex::create_in_dir(index_dir, schema)?
        };
        let reader = tindex
            .reader_builder()
            .reload_policy(ReloadPolicy::OnCommitWithDelay)
            .try_into()?;
        Ok(Self {
            workspace_root: workspace_root.to_path_buf(),
            index_dir: index_dir.to_path_buf(),
            tindex,
            reader,
            fields,
            repo_scope: Vec::new(),
        })
    }

    /// Urðr cache-miss path: open the conventional index, and if the
    /// directory is empty (no Tantivy `meta.json`) **first** try to
    /// restore the latest snapshot for `repo` from iceberg. Falls
    /// through to a fresh empty index if iceberg has nothing for this
    /// repo either.
    ///
    /// `sha = Some(...)` pins to a specific release; `None` = latest.
    ///
    /// Returns `(index, restored)` — `restored == true` if the index
    /// was rehydrated from iceberg, `false` if the cache was already
    /// populated or both cache and iceberg were empty.
    pub fn open_or_restore(
        workspace_root: &Path,
        wh: &crate::warehouse::iceberg::IcebergWarehouse,
        repo: &str,
        sha: Option<&str>,
    ) -> Result<(Self, bool)> {
        let index_dir = workspace_root.join(".nornir/cache/index");
        Self::open_or_restore_at(workspace_root, &index_dir, wh, repo, sha)
    }

    /// Like [`open_or_restore`](Self::open_or_restore) but with an explicit
    /// `index_dir` so callers can honor `[storage].local_path` (two workspaces
    /// under one root must not share `.nornir/cache/index`).
    pub fn open_or_restore_at(
        workspace_root: &Path,
        index_dir: &Path,
        wh: &crate::warehouse::iceberg::IcebergWarehouse,
        repo: &str,
        sha: Option<&str>,
    ) -> Result<(Self, bool)> {
        let needs_restore = !index_dir.join("meta.json").exists();
        let mut restored = false;
        if needs_restore {
            match crate::index::snapshot::restore_from_iceberg(wh, repo, sha, index_dir) {
                Ok(snap) => {
                    eprintln!(
                        "⏃ urðr: restored {} ({} blob(s), sha={}) into {}",
                        snap.snapshot_id,
                        snap.blob_count,
                        &snap.git_sha[..snap.git_sha.len().min(12)],
                        index_dir.display(),
                    );
                    restored = true;
                }
                Err(e) => {
                    // Not an error — iceberg may legitimately have no
                    // snapshot for this repo yet. Fall through to
                    // building a fresh empty index.
                    eprintln!("⏃ urðr: no iceberg snapshot for `{repo}` ({e}); starting empty");
                }
            }
        }
        let idx = Self::open_at(workspace_root, index_dir)?;
        Ok((idx, restored))
    }

    pub fn index_dir(&self) -> &Path {
        &self.index_dir
    }

    /// Walk the workspace and bring the index up to date. Excludes are
    /// hard-coded build/VCS/tooling noise (`target/`, `.git/`, `node_modules/`,
    /// …) plus nornir's machine state (`.nornir/warehouse/`, `.nornir/cache/`);
    /// `.nornir/*.md` doc templates **are** indexed (as source). When a
    /// [`repo_scope`](Self::with_repo_scope) is set, only the configured
    /// `[repo.*]` directories are walked. Guard-forbidden paths remain
    /// indexed because "forbidden" means write-protected, not invisible.
    pub fn build(&self) -> Result<BuildStats> {
        self.build_scoped(&self.repo_scope)
    }

    /// Like [`build`](Self::build) but walks an explicit `scope` (top-level repo
    /// directory names) instead of this index's configured
    /// [`repo_scope`](Self::with_repo_scope). Used by **incremental republish**
    /// to rebuild only the *affected* members (changed ∪ dependents) while the
    /// caller-owned index handle keeps its full-workspace scope. An empty
    /// `scope` walks the whole workspace, exactly like an unscoped `build`.
    pub fn build_scoped(&self, scope: &[String]) -> Result<BuildStats> {
        // Serial feed (NO rayon — forbidden). tantivy's `IndexWriter` already runs
        // its own internal indexing threads, so the tokenize/index work is multicore
        // even though we feed documents serially (measured ~0.1s for a full
        // workspace). `add_document`/`delete_term` take `&self`.
        let mut writer: IndexWriter = self.tindex.writer(WRITER_HEAP)?;
        let mut stats = BuildStats::default();
        for cand in walk_workspace(&self.workspace_root, scope) {
            stats.scanned += 1;
            match self.upsert(&writer, &cand) {
                Ok(UpsertOutcome::Added) => stats.added += 1,
                Ok(UpsertOutcome::Updated) => stats.updated += 1,
                Ok(UpsertOutcome::SkippedUnchanged) => stats.skipped_unchanged += 1,
                Ok(UpsertOutcome::SkippedTooLarge) => stats.skipped_too_large += 1,
                Err(e) => {
                    eprintln!("index: {} :: {}", cand.path.display(), e);
                    stats.errors += 1;
                }
            }
        }
        writer.commit()?;
        self.reader.reload()?;
        self.report_build_status("build_scoped", &stats);
        Ok(stats)
    }

    /// Test-matrix self-report (L4) for a completed index write.
    ///
    /// The check is deliberately about the SEARCHABLE outcome, not about the
    /// walker's own bookkeeping: a build that scanned files must leave a reader
    /// that can see documents, and it must not have swallowed errors. Asserting
    /// on `stats` alone would be circular — the four outcome counters partition
    /// `scanned` by construction, so `added + updated + skipped == scanned` is
    /// always true and a row built on it would be **always green**. Reading
    /// `stats()` back through the reader is what makes it falsifiable: a commit
    /// that did not land, a reader that did not reload, or a corpus the walker
    /// silently pruned all turn this red.
    ///
    /// Emitted only where the write path actually completed (both callers commit
    /// first), never on an error return — an absent row beats a green one.
    fn report_build_status(&self, check: &str, stats: &BuildStats) {
        let searchable = self.stats().map(|s| s.total).unwrap_or(0);
        let ok = build_status_ok(stats.scanned, stats.errors, searchable);
        nornir_testmatrix::functional_status(
            "nornir/index",
            check,
            ok,
            &format!(
                "scanned={} added={} updated={} unchanged={} too_large={} errors={} \
                 searchable_docs={searchable}",
                stats.scanned,
                stats.added,
                stats.updated,
                stats.skipped_unchanged,
                stats.skipped_too_large,
                stats.errors
            ),
        );
    }

    /// Index an explicit list of files, all tagged with `corpus`. Used by the
    /// docs index, which must reach into `.nornir/` — a path the workspace
    /// walker deliberately prunes. Paths are keyed relative to this index's
    /// `workspace_root` (the repo root for the docs index). Missing or
    /// oversized files are skipped; mtime-unchanged files are left as-is.
    pub fn index_files(&self, repo: &str, corpus: Corpus, files: &[PathBuf]) -> Result<BuildStats> {
        let mut writer: IndexWriter = self.tindex.writer(WRITER_HEAP)?;
        let mut stats = BuildStats::default();
        for f in files {
            if !f.is_file() {
                continue;
            }
            let cand = Candidate {
                path: f.clone(),
                corpus,
                repo: repo.to_string(),
            };
            stats.scanned += 1;
            match self.upsert(&writer, &cand) {
                Ok(UpsertOutcome::Added) => stats.added += 1,
                Ok(UpsertOutcome::Updated) => stats.updated += 1,
                Ok(UpsertOutcome::SkippedUnchanged) => stats.skipped_unchanged += 1,
                Ok(UpsertOutcome::SkippedTooLarge) => stats.skipped_too_large += 1,
                Err(e) => {
                    eprintln!("docs-index: {} :: {}", f.display(), e);
                    stats.errors += 1;
                }
            }
        }
        writer.commit()?;
        self.reader.reload()?;
        self.report_build_status("index_files", &stats);
        Ok(stats)
    }

    fn upsert(&self, writer: &IndexWriter, cand: &Candidate) -> Result<UpsertOutcome> {
        let meta = std::fs::metadata(&cand.path)?;
        if meta.len() > MAX_FILE_BYTES {
            return Ok(UpsertOutcome::SkippedTooLarge);
        }
        let mtime = meta
            .modified()
            .ok()
            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
            .map(|d| d.as_secs())
            .unwrap_or(0);

        let path_str = cand
            .path
            .strip_prefix(&self.workspace_root)
            .unwrap_or(&cand.path)
            .to_string_lossy()
            .into_owned();

        let prev = self.lookup_mtime(&path_str)?;
        if prev == Some(mtime) {
            return Ok(UpsertOutcome::SkippedUnchanged);
        }
        writer.delete_term(Term::from_field_text(self.fields.path, &path_str));

        let body = std::fs::read_to_string(&cand.path).unwrap_or_default();
        let title = cand
            .path
            .file_name()
            .map(|s| s.to_string_lossy().into_owned())
            .unwrap_or_default();
        let snippet = make_snippet(&body);

        writer.add_document(doc!(
            self.fields.path => path_str,
            self.fields.corpus => cand.corpus.as_str(),
            self.fields.repo => cand.repo.clone(),
            self.fields.title => title,
            self.fields.body => body,
            self.fields.mtime => mtime,
            self.fields.snippet => snippet,
        ))?;
        Ok(if prev.is_some() {
            UpsertOutcome::Updated
        } else {
            UpsertOutcome::Added
        })
    }

    fn lookup_mtime(&self, path: &str) -> Result<Option<u64>> {
        let searcher = self.reader.searcher();
        let parser = QueryParser::for_index(&self.tindex, vec![self.fields.path]);
        let q = parser.parse_query(&format!("path:\"{}\"", escape_query(path)))?;
        let top = searcher.search(&q, &TopDocs::with_limit(1).order_by_score())?;
        if let Some((_, addr)) = top.first() {
            let d: TantivyDocument = searcher.doc(*addr)?;
            for v in d.get_all(self.fields.mtime) {
                if let Some(n) = v.as_u64() {
                    return Ok(Some(n));
                }
            }
        }
        Ok(None)
    }

    /// BM25 search over `body`+`title` with optional filters.
    pub fn search(
        &self,
        query: &str,
        corpus: Option<Corpus>,
        repo: Option<&str>,
        limit: usize,
    ) -> Result<Vec<Hit>> {
        let searcher = self.reader.searcher();
        let mut parser =
            QueryParser::for_index(&self.tindex, vec![self.fields.title, self.fields.body]);
        parser.set_conjunction_by_default();

        let mut q = format!("({})", query);
        if let Some(c) = corpus {
            q = format!("{} AND corpus:{}", q, c.as_str());
        }
        if let Some(r) = repo {
            q = format!("{} AND repo:\"{}\"", q, escape_query(r));
        }
        let parsed = parser
            .parse_query(&q)
            .with_context(|| format!("parse query: {q}"))?;
        let top = searcher.search(&parsed, &TopDocs::with_limit(limit.max(1)).order_by_score())?;

        let mut out = Vec::with_capacity(top.len());
        for (score, addr) in top {
            let d: TantivyDocument = searcher.doc(addr)?;
            out.push(Hit {
                score,
                corpus: first_text(&d, self.fields.corpus),
                repo: first_text(&d, self.fields.repo),
                path: first_text(&d, self.fields.path),
                title: first_text(&d, self.fields.title),
                snippet: first_text(&d, self.fields.snippet),
                mtime: first_u64(&d, self.fields.mtime),
            });
        }
        Ok(out)
    }

    /// Total + per-corpus document counts.
    pub fn stats(&self) -> Result<Stats> {
        let searcher = self.reader.searcher();
        let total = searcher.num_docs();
        let mut by_corpus = Vec::new();
        for c in Corpus::all() {
            let parser = QueryParser::for_index(&self.tindex, vec![self.fields.corpus]);
            let q = parser.parse_query(&format!("corpus:{}", c.as_str()))?;
            let n = searcher.search(&q, &tantivy::collector::Count)?;
            by_corpus.push((c.as_str().to_string(), n as u64));
        }
        Ok(Stats { total, by_corpus })
    }
}

/// `index stats` as a uniform [`crate::cli_outcome::CommandOutcome`] — the CLI
/// twin of the index's per-corpus document counts. `stats` is already fetched
/// from the canonical source (fat: [`Index::stats`]; thin: the `Index.Stats`
/// RPC rows projected into [`Stats`]) — this only SHAPES + renders, never opens
/// the index itself. `ok ⟺ total > 0`: an empty index (never built, or wiped)
/// has no searchable documents and is RED for the autopilot (a blank pane
/// equivalent — RAGNARÖK: empty is RED, never silently green). AUT9.
pub fn stats_outcome(stats: &Stats) -> crate::cli_outcome::CommandOutcome {
    use crate::cli_outcome::CommandOutcome;
    if stats.total == 0 {
        return CommandOutcome::fail(
            "index stats",
            "index is empty (0 documents) — run `nornir index build`",
        );
    }
    let mut human = format!("total: {}", stats.total);
    for (corpus, n) in &stats.by_corpus {
        human.push_str(&format!("\n  {:<14} {}", corpus, n));
    }
    let by_corpus: serde_json::Map<String, serde_json::Value> = stats
        .by_corpus
        .iter()
        .map(|(c, n)| (c.clone(), serde_json::json!(n)))
        .collect();
    let data = serde_json::json!({
        "total": stats.total,
        "by_corpus": by_corpus,
    });
    CommandOutcome::ok("index stats", data, human)
}

/// Pure test-matrix gate for [`Index::report_build_status`] — falsifiable without
/// a live tantivy index. A completed index write is a PASS only when it swallowed
/// no errors AND left searchable documents AND actually walked something.
///
/// The `scanned > 0` conjunct is the ★5 residual fix. The earlier fix dropped the
/// `stats.scanned == 0 ||` short-circuit that let a cold walker green over an
/// empty index; but reading `searchable` alone still greened a scoped/incremental
/// rebuild that walked **0 files over an already-warm index** — a scope/root/
/// symlink regression (`stats.scanned == 0`) sees `searchable = prior docs > 0`,
/// `errors == 0`, and reports GREEN having indexed nothing. Gating on
/// `scanned > 0` makes that no-op rebuild RED, while a legitimately-empty index
/// (nothing ever built) is already RED via `searchable == 0`.
fn build_status_ok(scanned: usize, errors: usize, searchable: u64) -> bool {
    errors == 0 && searchable > 0 && scanned > 0
}

enum UpsertOutcome {
    Added,
    Updated,
    SkippedUnchanged,
    SkippedTooLarge,
}

fn make_snippet(body: &str) -> String {
    let mut s = String::with_capacity(SNIPPET_BYTES.min(body.len()));
    for (i, ch) in body.char_indices() {
        if i >= SNIPPET_BYTES {
            break;
        }
        if ch == '\n' || ch == '\r' {
            s.push(' ');
        } else {
            s.push(ch);
        }
    }
    s
}

fn escape_query(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

fn first_text(d: &TantivyDocument, f: Field) -> String {
    d.get_first(f)
        .and_then(|v| v.as_str().map(|s| s.to_string()))
        .unwrap_or_default()
}

fn first_u64(d: &TantivyDocument, f: Field) -> u64 {
    d.get_first(f).and_then(|v| v.as_u64()).unwrap_or(0)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    fn write(p: &Path, body: &str) {
        if let Some(parent) = p.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(p, body).unwrap();
    }

    #[test]
    fn build_and_search_basic() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        write(
            &root.join("holger/README.md"),
            "# Holger\n\nthe central artifact server with a special compression mode.",
        );
        write(
            &root.join("znippy/src/lib.rs"),
            "//! znippy: fast parallel compression library.\nfn compress() {}",
        );
        write(
            &root.join("znippy/bench_history.jsonl"),
            "{\"date\":\"2026-05-24\",\"version\":\"0.7.2\",\"machine\":\"x\",\"cores\":1,\"results\":[]}\n",
        );
        write(
            &root.join("holger/CHANGELOG.md"),
            "# Changelog\n## 0.1.0\n- initial release of compression bits",
        );
        write(
            &root.join("holger/Cargo.toml"),
            "[package]\nname=\"holger\"\n",
        );

        let idx = Index::open(root).unwrap();
        let s = idx.build().unwrap();
        assert_eq!(s.added, 5, "added: {s:?}");
        assert_eq!(s.errors, 0);

        let hits = idx.search("compression", None, None, 10).unwrap();
        assert!(!hits.is_empty(), "expected hits for 'compression'");
        assert!(hits.iter().any(|h| h.path.ends_with("README.md")));

        let docs_only = idx
            .search("compression", Some(Corpus::Docs), None, 10)
            .unwrap();
        assert!(docs_only.iter().all(|h| h.corpus == "docs"));

        let code_only = idx
            .search("compress", Some(Corpus::Code), None, 10)
            .unwrap();
        assert!(code_only.iter().any(|h| h.path.ends_with("lib.rs")));

        let by_repo = idx.search("compression", None, Some("holger"), 10).unwrap();
        assert!(by_repo.iter().all(|h| h.repo == "holger"));
    }

    #[test]
    fn incremental_skips_unchanged() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        write(&root.join("holger/README.md"), "# Holger\n\nbody");

        let idx = Index::open(root).unwrap();
        let s1 = idx.build().unwrap();
        assert_eq!(s1.added, 1);

        let s2 = idx.build().unwrap();
        assert_eq!(s2.added, 0, "stats: {s2:?}");
        assert_eq!(s2.skipped_unchanged, 1);
    }

    #[test]
    fn stats_per_corpus() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        write(&root.join("holger/README.md"), "# x");
        write(&root.join("holger/CHANGELOG.md"), "# x");
        write(&root.join("holger/src/lib.rs"), "fn x(){}");

        let idx = Index::open(root).unwrap();
        idx.build().unwrap();
        let st = idx.stats().unwrap();
        assert_eq!(st.total, 3);
        let map: std::collections::HashMap<_, _> = st.by_corpus.iter().cloned().collect();
        assert_eq!(map["docs"], 1);
        assert_eq!(map["changelog"], 1);
        assert_eq!(map["code"], 1);
    }

    #[test]
    fn repo_scope_and_ignores_prune_the_walk() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        // Two configured repos + one stray top-level dir + a root-level file.
        write(&root.join("holger/README.md"), "# holger compression");
        write(&root.join("znippy/src/lib.rs"), "fn compress() {}");
        write(&root.join("scratch/notes.md"), "# random unrelated scratch");
        write(&root.join("WORKSPACE.md"), "# workspace root doc");
        // Python virtualenv noise inside a *configured* repo must still prune.
        write(&root.join("holger/.venv/site-packages/pkg/mod.py"), "x = 1");
        write(&root.join("holger/.venv/lib/thing.py"), "y = 2");

        let idx = Index::open(root)
            .unwrap()
            .with_repo_scope(vec!["holger".into(), "znippy".into()]);
        idx.build().unwrap();

        // scratch/ pruned (not a configured repo); .venv pruned; root file kept.
        let scratch = idx.search("scratch", None, None, 10).unwrap();
        assert!(
            scratch.is_empty(),
            "scratch dir should be out of scope: {scratch:?}"
        );
        let venv = idx.search("site-packages", None, None, 10).unwrap();
        assert!(venv.is_empty(), ".venv must be ignored");
        let ws = idx.search("workspace", None, None, 10).unwrap();
        assert!(
            ws.iter().any(|h| h.path.ends_with("WORKSPACE.md")),
            "root file kept"
        );

        // Configured repos are indexed.
        let stats = idx.stats().unwrap();
        assert_eq!(stats.total, 3, "holger README + znippy lib + WORKSPACE.md");
    }

    #[test]
    fn empty_scope_walks_everything() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        write(&root.join("holger/README.md"), "# a");
        write(&root.join("scratch/notes.md"), "# b");

        // No scope set => legacy behaviour, both dirs indexed.
        let idx = Index::open(root).unwrap();
        idx.build().unwrap();
        assert_eq!(idx.stats().unwrap().total, 2);
    }

    // ── AUT9: stats_outcome sannr check (inject real Stats, assert verdict) ──

    #[test]
    fn stats_outcome_empty_index_is_red() {
        let empty = Stats {
            total: 0,
            by_corpus: vec![],
        };
        let o = stats_outcome(&empty);
        assert_eq!(o.command, "index stats");
        assert!(
            !o.is_sannr(),
            "an empty index (0 docs) is RED for the autopilot"
        );
    }

    #[test]
    fn stats_outcome_populated_index_is_sannr() {
        let stats = Stats {
            total: 3,
            by_corpus: vec![
                ("docs".into(), 1),
                ("changelog".into(), 1),
                ("code".into(), 1),
            ],
        };
        let o = stats_outcome(&stats);
        assert!(o.is_sannr(), "a populated index is a true (sannr) outcome");
        assert_eq!(o.data["total"], serde_json::json!(3));
        assert_eq!(o.data["by_corpus"]["docs"], serde_json::json!(1));
        assert_eq!(o.data["by_corpus"]["code"], serde_json::json!(1));
        assert!(
            o.human.contains("total: 3"),
            "human rendering keeps the total line"
        );
    }

    // ── ★5: report_build_status gate — 0 scanned over a WARM index is RED ──

    /// The residual of the walker/index-build green-over-empty class: a
    /// scoped/incremental rebuild that walks 0 files over an already-populated
    /// index used to read GREEN (`errors == 0 && searchable > 0`). RED-on-empty
    /// first (0 scanned, warm index), then the inverse GREEN once real files are
    /// walked; plus the genuinely-cold empty index stays RED.
    #[test]
    fn build_status_zero_scanned_over_warm_index_is_red() {
        // RED: a no-op rebuild — 0 files walked — over a warm index (5 docs).
        assert!(
            !build_status_ok(0, 0, 5),
            "0 scanned over a warm index (searchable>0) must be RED — nothing was indexed"
        );
        // GREEN (inverse): the SAME warm index, but this build actually walked files.
        assert!(
            build_status_ok(3, 0, 5),
            "a build that scanned files, no errors, docs searchable → GREEN"
        );
        // Cold/empty index stays RED regardless of scan (pre-existing guarantee).
        assert!(!build_status_ok(0, 0, 0), "empty index is RED");
        // Errors always red, even with scan + searchable docs.
        assert!(
            !build_status_ok(3, 1, 5),
            "swallowed errors are never green"
        );
    }
}