Skip to main content

lean_ctx/core/
index_orchestrator.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::{Arc, Mutex, OnceLock};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use serde::Serialize;
7
8use crate::core::bm25_index::BM25Index;
9use crate::core::graph_index::{self, ProjectIndex};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12enum State {
13    Idle,
14    Building,
15    Ready,
16    Failed,
17}
18
19#[derive(Debug, Clone)]
20struct Component {
21    state: State,
22    started_ms: Option<u64>,
23    finished_ms: Option<u64>,
24    duration_ms: Option<u64>,
25    last_error: Option<String>,
26    /// Human-readable outcome detail surfaced to operators (e.g. doc count +
27    /// persisted size, or the "not persisted: too large …" remedy). Independent
28    /// of `last_error` so a *successful* build can still carry a warning note.
29    note: Option<String>,
30}
31
32impl Component {
33    fn new() -> Self {
34        Self {
35            state: State::Idle,
36            started_ms: None,
37            finished_ms: None,
38            duration_ms: None,
39            last_error: None,
40            note: None,
41        }
42    }
43}
44
45#[derive(Debug)]
46struct ProjectBuild {
47    worker_running: bool,
48    /// Set the first time a heavy-index tool lazily pre-warms this root (#152).
49    /// Prevents re-triggering a full rebuild on every subsequent dispatch — the
50    /// tools' own `load_or_build` paths handle staleness from then on.
51    warm_triggered: bool,
52    graph: Component,
53    bm25: Component,
54}
55
56impl ProjectBuild {
57    fn new() -> Self {
58        Self {
59            worker_running: false,
60            warm_triggered: false,
61            graph: Component::new(),
62            bm25: Component::new(),
63        }
64    }
65}
66
67// Lock ordering (see rust/LOCK_ORDERING.md):
68//   L1 = REGISTRY outer Mutex  (the HashMap guard)
69//   L2 = per-project Arc<Mutex<ProjectBuild>>  (inner guard)
70//
71// Invariant: L1 must NEVER be held while locking L2.
72// `entry_for()` enforces this by cloning the Arc and dropping L1 before
73// the caller acquires L2.
74static REGISTRY: OnceLock<Mutex<HashMap<String, Arc<Mutex<ProjectBuild>>>>> = OnceLock::new();
75
76fn registry() -> &'static Mutex<HashMap<String, Arc<Mutex<ProjectBuild>>>> {
77    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
78}
79
80fn entry_for(project_root: &str) -> Arc<Mutex<ProjectBuild>> {
81    let mut map = registry()
82        .lock()
83        .unwrap_or_else(std::sync::PoisonError::into_inner);
84    map.entry(project_root.to_string())
85        .or_insert_with(|| Arc::new(Mutex::new(ProjectBuild::new())))
86        .clone()
87}
88
89fn now_ms() -> u64 {
90    SystemTime::now()
91        .duration_since(UNIX_EPOCH)
92        .unwrap_or_default()
93        .as_millis() as u64
94}
95
96fn start_component(c: &mut Component) {
97    c.state = State::Building;
98    c.started_ms = Some(now_ms());
99    c.finished_ms = None;
100    c.duration_ms = None;
101    c.last_error = None;
102    c.note = None;
103}
104
105fn finish_ok(c: &mut Component) {
106    c.state = State::Ready;
107    let end = now_ms();
108    c.finished_ms = Some(end);
109    c.duration_ms = c.started_ms.map(|s| end.saturating_sub(s));
110}
111
112fn finish_err(c: &mut Component, e: String) {
113    c.state = State::Failed;
114    let end = now_ms();
115    c.finished_ms = Some(end);
116    c.duration_ms = c.started_ms.map(|s| end.saturating_sub(s));
117    c.last_error = Some(e);
118}
119
120/// The index warmth a tool benefits from. Drives lazy, demand-driven warming
121/// (issue #152) so the server no longer scans the whole project eagerly on every
122/// `initialize` — a session that only uses `ctx_read`/`ctx_shell`/`ctx_tree`
123/// pays zero indexing cost.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum WarmNeed {
126    /// No prebuilt index needed.
127    None,
128    /// Only the resident line-search (trigram) index — cheap, used by `ctx_search`.
129    Search,
130    /// Full project indices (graph + BM25; this also warms the search index).
131    Heavy,
132}
133
134/// Classify a tool by the index warmth it benefits from. Unknown tools default
135/// to [`WarmNeed::None`]; a heavy tool mis-classified as `None` still works — it
136/// just builds its index synchronously on first use instead of being pre-warmed.
137#[must_use]
138pub fn warm_need_for_tool(tool: &str) -> WarmNeed {
139    match tool {
140        "ctx_search" => WarmNeed::Search,
141        // Tools that build/consume the graph, call-graph, BM25 or artifact index.
142        "ctx_graph"
143        | "ctx_callgraph"
144        | "ctx_routes"
145        | "ctx_repomap"
146        | "ctx_impact"
147        | "ctx_artifacts"
148        | "ctx_semantic_search"
149        | "ctx_provider"
150        | "ctx_compose"
151        | "ctx_review" => WarmNeed::Heavy,
152        _ => WarmNeed::None,
153    }
154}
155
156/// Lazily warm the indices a tool needs, deduped per root. Never blocks (all
157/// work is spawned in the background) and is safe to call on every dispatch.
158///
159/// Returns `true` only when this call is the *first* heavy pre-warm for `root`
160/// in this process — the caller can use that signal to warm secondary roots once
161/// without re-reading session state on every dispatch.
162pub fn ensure_warm_for_tool(project_root: &str, tool: &str) -> bool {
163    if project_root.is_empty() {
164        return false;
165    }
166    match warm_need_for_tool(tool) {
167        WarmNeed::None => false,
168        WarmNeed::Search => {
169            // The search index has its own TTL + background-rebuild dedup, so it
170            // is safe (and cheap) to nudge on every `ctx_search`.
171            crate::core::search_index::ensure_background(project_root, true, false);
172            false
173        }
174        WarmNeed::Heavy => {
175            let entry = entry_for(project_root);
176            let first_warm = {
177                let mut s = entry
178                    .lock()
179                    .unwrap_or_else(std::sync::PoisonError::into_inner);
180                if s.warm_triggered {
181                    false
182                } else {
183                    s.warm_triggered = true;
184                    true
185                }
186            };
187            if first_warm {
188                ensure_all_background(project_root);
189            }
190            first_warm
191        }
192    }
193}
194
195/// Stack size for background index workers. Large enough that deep ASTs and
196/// graph traversals cannot overflow it (the #378 SIGABRT class). The AST walks
197/// are iterative now too, so this is defense-in-depth.
198const INDEXER_STACK_BYTES: usize = 16 * 1024 * 1024;
199
200pub fn ensure_all_background(project_root: &str) {
201    let state = entry_for(project_root);
202    let should_spawn = {
203        let mut s = state
204            .lock()
205            .unwrap_or_else(std::sync::PoisonError::into_inner);
206        if s.worker_running {
207            false
208        } else {
209            s.worker_running = true;
210            true
211        }
212    };
213
214    if !should_spawn {
215        return;
216    }
217
218    let root = project_root.to_string();
219    let indexer = move || {
220        let state = entry_for(&root);
221
222        // Pre-warm the resident line-search index in parallel (own thread,
223        // deduped internally) so the first ctx_search hits the fast path.
224        crate::core::search_index::ensure_background(&root, true, false);
225
226        // Phase 1: Graph index — may produce a content cache from the file walk
227        {
228            let mut s = state
229                .lock()
230                .unwrap_or_else(std::sync::PoisonError::into_inner);
231            start_component(&mut s.graph);
232        }
233        let graph_result = std::panic::catch_unwind(|| {
234            let (idx, content_cache) = graph_index::scan_with_content_cache(&root);
235            // JSON index write is kept for backward compatibility with remaining
236            // direct ProjectIndex consumers. Will be removed when all consumers
237            // are migrated to GraphProvider/PropertyGraph. (OPT-14/15 Phase 6)
238            let _ = idx.save();
239            (idx, content_cache)
240        });
241        let content_cache = if let Ok((_idx, cache)) = graph_result {
242            let mut s = state
243                .lock()
244                .unwrap_or_else(std::sync::PoisonError::into_inner);
245            finish_ok(&mut s.graph);
246            cache
247        } else {
248            let mut s = state
249                .lock()
250                .unwrap_or_else(std::sync::PoisonError::into_inner);
251            finish_err(&mut s.graph, "graph index build panicked".to_string());
252            HashMap::new()
253        };
254
255        // Phase 2: BM25 index — reuses content from graph scan when available
256        {
257            let mut s = state
258                .lock()
259                .unwrap_or_else(std::sync::PoisonError::into_inner);
260            start_component(&mut s.bm25);
261        }
262        let bm = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
263            let root_pb = Path::new(&root);
264            let idx = if content_cache.is_empty() {
265                BM25Index::load_or_build(root_pb)
266            } else {
267                BM25Index::build_with_content_hint(root_pb, &content_cache)
268            };
269            let outcome = idx.save(root_pb);
270            (idx.doc_count, outcome)
271        }));
272        if let Ok((doc_count, save_res)) = bm {
273            let mut s = state
274                .lock()
275                .unwrap_or_else(std::sync::PoisonError::into_inner);
276            finish_ok(&mut s.bm25);
277            s.bm25.note = Some(bm25_build_note(doc_count, &save_res));
278        } else {
279            let mut s = state
280                .lock()
281                .unwrap_or_else(std::sync::PoisonError::into_inner);
282            finish_err(&mut s.bm25, "bm25 build panicked".to_string());
283        }
284
285        let mut s = state
286            .lock()
287            .unwrap_or_else(std::sync::PoisonError::into_inner);
288        s.worker_running = false;
289    };
290
291    // Indexing parses large ASTs and traverses graphs; give the worker a
292    // generous stack as defense-in-depth against deep-recursion overflow (the
293    // #378 SIGABRT class) and a name so it is identifiable in crash dumps.
294    let spawned = std::thread::Builder::new()
295        .name("leanctx-index".to_string())
296        .stack_size(INDEXER_STACK_BYTES)
297        .spawn(indexer);
298    if spawned.is_err() {
299        // The OS refused a new thread (rare). Clear the in-flight flag so a
300        // later trigger retries instead of assuming a build runs forever.
301        let mut s = state
302            .lock()
303            .unwrap_or_else(std::sync::PoisonError::into_inner);
304        s.worker_running = false;
305    }
306}
307
308/// Ensure background indexing for all extra roots (in addition to the primary).
309/// Each extra root that is not a subdirectory of `primary_root` gets its own
310/// graph + BM25 index. Capped at `MAX_EXTRA_ROOT_BUILDS` to prevent runaway.
311const MAX_EXTRA_ROOT_BUILDS: usize = 8;
312
313pub fn ensure_extra_roots_background(primary_root: &str, extra_roots: &[String]) {
314    let primary = Path::new(primary_root);
315    let mut built = 0;
316    for root in extra_roots {
317        if built >= MAX_EXTRA_ROOT_BUILDS {
318            break;
319        }
320        let rp = Path::new(root);
321        if !rp.is_dir() {
322            continue;
323        }
324        // Skip if extra_root is inside primary (already indexed by the primary scan)
325        if rp.starts_with(primary) {
326            continue;
327        }
328        // Skip if primary is inside this extra_root (avoid double-indexing the parent)
329        if primary.starts_with(rp) {
330            continue;
331        }
332        ensure_all_background(root);
333        built += 1;
334    }
335}
336
337/// Build a human-readable outcome note for a finished BM25 build, including the
338/// indexed chunk count and whether the index was persisted to disk. A
339/// "too large" refusal carries the exact remedy so the operator (or agent) is
340/// never left guessing why search/ranking stays cold (issue #249).
341fn bm25_build_note(
342    doc_count: usize,
343    save: &std::io::Result<crate::core::bm25_index::SaveOutcome>,
344) -> String {
345    use crate::core::bm25_index::SaveOutcome;
346    match save {
347        Ok(SaveOutcome::Persisted { compressed_bytes }) => format!(
348            "indexed {doc_count} chunks, {:.1} MB persisted",
349            *compressed_bytes as f64 / 1_048_576.0
350        ),
351        Ok(SaveOutcome::SkippedTooLarge {
352            compressed_bytes,
353            limit_bytes,
354        }) => format!(
355            "indexed {doc_count} chunks but NOT persisted to disk: compressed {:.1} MB exceeds the {:.0} MB cap. \
356             Raise it via LEAN_CTX_BM25_MAX_CACHE_MB (or bm25_max_cache_mb in config) or add extra_ignore_patterns, \
357             then run `lean-ctx reindex`. Until then the index is rebuilt from scratch on every cold start.",
358            *compressed_bytes as f64 / 1_048_576.0,
359            *limit_bytes as f64 / 1_048_576.0
360        ),
361        Err(e) => format!("indexed {doc_count} chunks but persisting failed: {e}"),
362    }
363}
364
365/// Lightweight, allocation-frugal snapshot of the BM25 component for the
366/// in-call composer/search messaging. Avoids the heavier [`disk_status`] walk.
367#[derive(Debug, Clone)]
368pub struct Bm25Summary {
369    pub state: &'static str,
370    /// While building: elapsed so far. Otherwise: last build duration.
371    pub elapsed_ms: Option<u64>,
372    pub note: Option<String>,
373    pub last_error: Option<String>,
374}
375
376pub fn bm25_summary(project_root: &str) -> Bm25Summary {
377    let entry = entry_for(project_root);
378    let s = entry
379        .lock()
380        .unwrap_or_else(std::sync::PoisonError::into_inner);
381    let c = &s.bm25;
382    let elapsed_ms = if matches!(c.state, State::Building) {
383        c.started_ms.map(|start| now_ms().saturating_sub(start))
384    } else {
385        c.duration_ms
386    };
387    Bm25Summary {
388        state: match c.state {
389            State::Idle => "idle",
390            State::Building => "building",
391            State::Ready => "ready",
392            State::Failed => "failed",
393        },
394        elapsed_ms,
395        note: c.note.clone(),
396        last_error: c.last_error.clone(),
397    }
398}
399
400pub fn try_load_graph_index(project_root: &str) -> Option<ProjectIndex> {
401    // Resident cache: avoids re-reading + zstd-decompressing + serde-parsing the
402    // on-disk index on every graph-touching query. Returns an in-memory clone.
403    crate::core::graph_cache::get_cached(project_root).map(|arc| (*arc).clone())
404}
405
406pub fn try_load_bm25_index(project_root: &str) -> Option<BM25Index> {
407    BM25Index::load(Path::new(project_root))
408}
409
410/// Returns true if any project is currently building its indices.
411pub fn is_building() -> bool {
412    let map = registry()
413        .lock()
414        .unwrap_or_else(std::sync::PoisonError::into_inner);
415    map.values().any(|entry| {
416        let s = entry
417            .lock()
418            .unwrap_or_else(std::sync::PoisonError::into_inner);
419        matches!(s.bm25.state, State::Building) || matches!(s.graph.state, State::Building)
420    })
421}
422
423#[derive(Debug, Serialize)]
424struct ComponentStatus<'a> {
425    state: &'a str,
426    started_ms: Option<u64>,
427    finished_ms: Option<u64>,
428    duration_ms: Option<u64>,
429    last_error: Option<&'a str>,
430    #[serde(skip_serializing_if = "Option::is_none")]
431    note: Option<&'a str>,
432}
433
434fn component_status(c: &Component) -> ComponentStatus<'_> {
435    ComponentStatus {
436        state: match c.state {
437            State::Idle => "idle",
438            State::Building => "building",
439            State::Ready => "ready",
440            State::Failed => "failed",
441        },
442        started_ms: c.started_ms,
443        finished_ms: c.finished_ms,
444        duration_ms: c.duration_ms,
445        last_error: c.last_error.as_deref(),
446        note: c.note.as_deref(),
447    }
448}
449
450#[derive(Debug, Serialize)]
451struct StatusResponse<'a> {
452    project_root: &'a str,
453    graph_index: ComponentStatus<'a>,
454    bm25_index: ComponentStatus<'a>,
455    disk: DiskStatusAll,
456}
457
458#[derive(Debug, Serialize, Default)]
459pub struct DiskStatus {
460    pub exists: bool,
461    pub size_bytes: Option<u64>,
462    pub file_count: Option<u64>,
463    pub modified_at: Option<String>,
464}
465
466#[derive(Debug, Serialize, Default)]
467pub struct DiskStatusAll {
468    pub graph_index: DiskStatus,
469    pub bm25_index: DiskStatus,
470    pub code_graph: DiskStatus,
471}
472
473fn disk_status_for_graph(project_root: &str) -> DiskStatus {
474    let Some(dir) = graph_index::ProjectIndex::index_dir(project_root) else {
475        return DiskStatus::default();
476    };
477    let zst = dir.join("index.json.zst");
478    let json = dir.join("index.json");
479    let path = if zst.exists() {
480        zst
481    } else if json.exists() {
482        json
483    } else {
484        return DiskStatus::default();
485    };
486    let meta = std::fs::metadata(&path).ok();
487    let file_count =
488        graph_index::ProjectIndex::load(project_root).map(|idx| idx.files.len() as u64);
489    DiskStatus {
490        exists: true,
491        size_bytes: meta.as_ref().map(std::fs::Metadata::len),
492        file_count,
493        modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
494    }
495}
496
497fn disk_status_for_bm25(project_root: &str) -> DiskStatus {
498    let root = Path::new(project_root);
499    let path = BM25Index::index_file_path(root);
500    if !path.exists() {
501        return DiskStatus::default();
502    }
503    let meta = std::fs::metadata(&path).ok();
504    DiskStatus {
505        exists: true,
506        size_bytes: meta.as_ref().map(std::fs::Metadata::len),
507        file_count: None,
508        modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
509    }
510}
511
512fn disk_status_for_code_graph(project_root: &str) -> DiskStatus {
513    let dir = crate::core::property_graph::graph_dir(project_root);
514    let db_path = dir.join("graph.db");
515    if !db_path.exists() {
516        return DiskStatus::default();
517    }
518    let meta = std::fs::metadata(&db_path).ok();
519    let node_count = crate::core::property_graph::CodeGraph::open(project_root)
520        .ok()
521        .and_then(|g| {
522            g.connection()
523                .query_row("SELECT count(*) FROM nodes", [], |r| r.get::<_, i64>(0))
524                .ok()
525                .map(|c| c as u64)
526        });
527    DiskStatus {
528        exists: true,
529        size_bytes: meta.as_ref().map(std::fs::Metadata::len),
530        file_count: node_count,
531        modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
532    }
533}
534
535fn format_time(t: SystemTime) -> String {
536    let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
537    let dt = chrono::DateTime::from_timestamp(secs as i64, 0);
538    dt.map_or_else(
539        || format!("{secs}"),
540        |d| d.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
541    )
542}
543
544pub fn disk_status(project_root: &str) -> DiskStatusAll {
545    DiskStatusAll {
546        graph_index: disk_status_for_graph(project_root),
547        bm25_index: disk_status_for_bm25(project_root),
548        code_graph: disk_status_for_code_graph(project_root),
549    }
550}
551
552pub fn status_json(project_root: &str) -> String {
553    let state = entry_for(project_root);
554    let s = state
555        .lock()
556        .unwrap_or_else(std::sync::PoisonError::into_inner);
557    let res = StatusResponse {
558        project_root,
559        graph_index: component_status(&s.graph),
560        bm25_index: component_status(&s.bm25),
561        disk: disk_status(project_root),
562    };
563    serde_json::to_string(&res).unwrap_or_else(|_| "{}".to_string())
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    #[test]
571    fn status_json_is_valid_json() {
572        let s = status_json("/tmp");
573        let _: serde_json::Value = serde_json::from_str(&s).unwrap();
574    }
575
576    #[test]
577    fn warm_need_classifies_tools() {
578        // Lightweight tools must never trigger a project scan (#152).
579        for light in [
580            "ctx_read",
581            "ctx_shell",
582            "ctx_tree",
583            "ctx_knowledge",
584            "unknown_tool",
585        ] {
586            assert_eq!(warm_need_for_tool(light), WarmNeed::None, "{light}");
587        }
588        // ctx_search only needs the cheap trigram index.
589        assert_eq!(warm_need_for_tool("ctx_search"), WarmNeed::Search);
590        // Graph / BM25 consumers need the full warm.
591        for heavy in [
592            "ctx_graph",
593            "ctx_callgraph",
594            "ctx_routes",
595            "ctx_repomap",
596            "ctx_impact",
597            "ctx_artifacts",
598            "ctx_semantic_search",
599            "ctx_provider",
600            "ctx_compose",
601            "ctx_review",
602        ] {
603            assert_eq!(warm_need_for_tool(heavy), WarmNeed::Heavy, "{heavy}");
604        }
605    }
606
607    #[test]
608    fn ensure_warm_lightweight_and_search_never_signal_first_warm() {
609        // None and Search must return false (no heavy pre-warm), and an empty
610        // root is always a no-op.
611        assert!(!ensure_warm_for_tool("", "ctx_graph"));
612        let tmp = tempfile::tempdir().unwrap();
613        let root = tmp.path().to_string_lossy().to_string();
614        assert!(!ensure_warm_for_tool(&root, "ctx_read"));
615        assert!(!ensure_warm_for_tool(&root, "ctx_search"));
616    }
617
618    #[test]
619    fn ensure_warm_heavy_is_once_per_root() {
620        // The first heavy pre-warm signals `true` (so the caller warms extra
621        // roots once); every subsequent call is a no-op `false`, preventing a
622        // rebuild-on-every-dispatch storm.
623        let tmp = tempfile::tempdir().unwrap();
624        let root = tmp.path().to_string_lossy().to_string();
625        assert!(
626            ensure_warm_for_tool(&root, "ctx_callgraph"),
627            "first heavy warm must signal true"
628        );
629        assert!(
630            !ensure_warm_for_tool(&root, "ctx_callgraph"),
631            "second heavy warm must be deduped to false"
632        );
633        assert!(
634            !ensure_warm_for_tool(&root, "ctx_semantic_search"),
635            "any later heavy tool on the same root is also deduped"
636        );
637    }
638
639    #[test]
640    fn build_note_persisted_reports_size() {
641        let note = bm25_build_note(
642            42,
643            &Ok(crate::core::bm25_index::SaveOutcome::Persisted {
644                compressed_bytes: 3 * 1024 * 1024,
645            }),
646        );
647        assert!(
648            note.contains("42 chunks"),
649            "note should report chunk count: {note}"
650        );
651        assert!(
652            note.contains("persisted"),
653            "note should report persistence: {note}"
654        );
655    }
656
657    #[test]
658    fn build_note_too_large_carries_remedy() {
659        let note = bm25_build_note(
660            1000,
661            &Ok(crate::core::bm25_index::SaveOutcome::SkippedTooLarge {
662                compressed_bytes: 600 * 1024 * 1024,
663                limit_bytes: 512 * 1024 * 1024,
664            }),
665        );
666        assert!(
667            note.contains("NOT persisted"),
668            "must flag non-persistence: {note}"
669        );
670        assert!(
671            note.contains("LEAN_CTX_BM25_MAX_CACHE_MB") && note.contains("reindex"),
672            "too-large note must carry an actionable remedy: {note}"
673        );
674    }
675
676    #[test]
677    fn build_note_persist_error_is_reported() {
678        let note = bm25_build_note(7, &Err(std::io::Error::other("disk full")));
679        assert!(note.contains("persisting failed"), "note: {note}");
680        assert!(
681            note.contains("disk full"),
682            "note should include the io error: {note}"
683        );
684    }
685
686    #[test]
687    fn bm25_summary_unknown_project_is_idle() {
688        let tmp = tempfile::tempdir().unwrap();
689        let summary = bm25_summary(tmp.path().to_string_lossy().as_ref());
690        assert_eq!(summary.state, "idle");
691        assert!(summary.note.is_none());
692        assert!(summary.last_error.is_none());
693    }
694
695    #[test]
696    fn extra_roots_skips_subdirs_of_primary() {
697        let tmp = tempfile::tempdir().unwrap();
698        let primary = tmp.path().join("primary");
699        std::fs::create_dir_all(&primary).unwrap();
700        let sub = primary.join("subdir");
701        std::fs::create_dir_all(&sub).unwrap();
702        let external = tmp.path().join("external");
703        std::fs::create_dir_all(&external).unwrap();
704
705        let primary_str = primary.to_string_lossy().to_string();
706        let extra = vec![
707            sub.to_string_lossy().to_string(),
708            external.to_string_lossy().to_string(),
709        ];
710
711        // Should not panic; subdirs are skipped, external is attempted
712        ensure_extra_roots_background(&primary_str, &extra);
713    }
714
715    #[test]
716    fn extra_roots_caps_at_max() {
717        let tmp = tempfile::tempdir().unwrap();
718        let primary = tmp.path().join("primary");
719        std::fs::create_dir_all(&primary).unwrap();
720
721        let mut extra = Vec::new();
722        for i in 0..20 {
723            let d = tmp.path().join(format!("ext-{i}"));
724            std::fs::create_dir_all(&d).unwrap();
725            extra.push(d.to_string_lossy().to_string());
726        }
727
728        let primary_str = primary.to_string_lossy().to_string();
729        // Should not spawn more than MAX_EXTRA_ROOT_BUILDS threads
730        ensure_extra_roots_background(&primary_str, &extra);
731    }
732}