Skip to main content

aft/
context.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::io::{self, BufWriter};
3use std::path::{Component, Path, PathBuf};
4use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
5use std::sync::{mpsc, Arc, Mutex, RwLock};
6use std::time::{Duration, Instant};
7
8use lsp_types::FileChangeType;
9use notify::RecommendedWatcher;
10use rusqlite::Connection;
11
12use crate::backup::hash_session;
13use crate::backup::BackupStore;
14use crate::bash_background::{BgCompletion, BgTaskRegistry};
15use crate::callgraph_store::{CallGraphStore, CallGraphStoreError};
16use crate::checkpoint::CheckpointStore;
17use crate::config::Config;
18use crate::harness::Harness;
19use crate::inspect::{
20    InspectCategory, InspectManager, InspectSnapshot, Tier2RefreshScheduler, Tier2TriggerReason,
21};
22use crate::language::LanguageProvider;
23use crate::lsp::manager::LspManager;
24use crate::lsp::registry::is_config_file_path_with_custom;
25use crate::parser::{SharedSymbolCache, SymbolCache, TreeSitterProvider};
26use crate::protocol::{
27    ConfigureWarningsFrame, ProgressFrame, PushFrame, StatusChangedFrame, StatusPayload,
28};
29use crate::watcher_filter::{SharedGitignore, WatcherDispatchEvent, WatcherThreadHandle};
30
31pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
32pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
33pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
34const STATUS_DEBOUNCE_MS: u64 = 1_000;
35
36/// Agent status-bar counts — the IDE-style "status bar" surfaced to the agent
37/// on every tool result (emit-on-change). `errors`/`warnings` are read LIVE
38/// from the continuously-drained LSP diagnostics store; the Tier-2 counts
39/// (`dead_code`/`unused_exports`/`duplicates`) and `todos` are last-known,
40/// refreshed when `aft_inspect` runs or a background Tier-2 scan completes.
41/// `tier2_stale` marks the Tier-2 counts as not-yet-reconciled with the latest
42/// edits (rendered with a `~` marker so the agent never reads them as live).
43#[derive(Debug, Clone, Default, PartialEq, Eq)]
44pub struct StatusBarCounts {
45    pub errors: usize,
46    pub warnings: usize,
47    pub dead_code: usize,
48    pub unused_exports: usize,
49    pub duplicates: usize,
50    pub todos: usize,
51    pub tier2_stale: bool,
52}
53
54/// Last-known Tier-2 + todos counts, refreshed off the hot path. `errors` and
55/// `warnings` are intentionally NOT cached here — they're read live per attach.
56///
57/// Each Tier-2 category is `Option`: `None` means "no scan has ever produced a
58/// count for this category", so we never fabricate a `0`. The bar is only
59/// surfaced once all three Tier-2 categories hold a real value — a partially
60/// completed cold scan (e.g. dead_code done, unused_exports/duplicates still
61/// running) must not render `D<real> U0 C0` and lie about project health (#1).
62#[derive(Debug, Clone, Default)]
63struct StatusBarTier2 {
64    dead_code: Option<usize>,
65    unused_exports: Option<usize>,
66    duplicates: Option<usize>,
67    todos: Option<usize>,
68    stale: bool,
69}
70
71pub struct StatusEmitter {
72    latest: Arc<Mutex<Option<StatusPayload>>>,
73    notify: mpsc::Sender<()>,
74}
75
76impl StatusEmitter {
77    fn new(progress_sender: SharedProgressSender) -> Self {
78        let (notify, rx) = mpsc::channel();
79        let latest = Arc::new(Mutex::new(None));
80        let latest_for_thread = Arc::clone(&latest);
81        std::thread::spawn(move || {
82            status_debounce_loop(rx, latest_for_thread, progress_sender);
83        });
84        Self { latest, notify }
85    }
86
87    pub fn signal(&self, snapshot: StatusPayload) {
88        if let Ok(mut latest) = self.latest.lock() {
89            *latest = Some(snapshot);
90        }
91        let _ = self.notify.send(());
92    }
93}
94
95fn status_debounce_loop(
96    rx: mpsc::Receiver<()>,
97    latest: Arc<Mutex<Option<StatusPayload>>>,
98    progress_sender: SharedProgressSender,
99) {
100    while rx.recv().is_ok() {
101        let deadline = Instant::now() + Duration::from_millis(STATUS_DEBOUNCE_MS);
102        while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
103            match rx.recv_timeout(remaining) {
104                Ok(()) => continue,
105                Err(mpsc::RecvTimeoutError::Timeout) => break,
106                Err(mpsc::RecvTimeoutError::Disconnected) => return,
107            }
108        }
109
110        let snapshot = latest.lock().ok().and_then(|mut latest| latest.take());
111        let Some(snapshot) = snapshot else { continue };
112        let sender = progress_sender
113            .lock()
114            .ok()
115            .and_then(|sender| sender.clone());
116        if let Some(sender) = sender {
117            sender(PushFrame::StatusChanged(StatusChangedFrame::new(
118                None, snapshot,
119            )));
120        }
121    }
122}
123use crate::cache_freshness::FileFreshness;
124use crate::search_index::SearchIndex;
125use crate::semantic_index::{EmbeddingEntry, SemanticIndex};
126
127// `SemanticIndexStatus::Ready` exposes a unique `refreshing` path list. Keep
128// per-path queue accounting separately so repeated edits to the same file do not
129// let an older refresh completion remove the path while newer work is pending.
130#[derive(Debug, Default, Clone)]
131#[doc(hidden)]
132pub struct SemanticRefreshAccounting {
133    #[doc(hidden)]
134    pub pending: usize,
135    #[doc(hidden)]
136    pub in_flight: usize,
137}
138
139#[derive(Debug, Default)]
140struct SemanticRefreshCircuit {
141    consecutive_transient_failures: AtomicUsize,
142    open: AtomicBool,
143    probe_in_flight: AtomicBool,
144    probe_ready: AtomicBool,
145}
146
147fn ensure_refreshing_path(refreshing: &mut Vec<PathBuf>, path: PathBuf) {
148    if !refreshing.iter().any(|existing| existing == &path) {
149        refreshing.push(path);
150        refreshing.sort();
151    }
152}
153
154fn remove_refreshing_path(refreshing: &mut Vec<PathBuf>, path: &Path) {
155    refreshing.retain(|existing| existing != path);
156}
157
158#[derive(Debug, Clone)]
159pub enum SemanticIndexStatus {
160    Disabled,
161    Building {
162        /// Cold-build only — index is not queryable.
163        stage: String,
164        files: Option<usize>,
165        entries_done: Option<usize>,
166        entries_total: Option<usize>,
167    },
168    Ready {
169        /// Files currently being re-embedded after recent edits. The index is
170        /// still queryable; results for these files may be temporarily missing.
171        refreshing: Vec<PathBuf>,
172        /// Per-root queue accounting for repeated refreshes of the same path.
173        /// Kept on the status value so two AppContexts in one process cannot
174        /// share refresh-completion state.
175        #[doc(hidden)]
176        accounting: BTreeMap<PathBuf, SemanticRefreshAccounting>,
177    },
178    Failed(String),
179}
180
181impl SemanticIndexStatus {
182    pub fn ready() -> Self {
183        Self::Ready {
184            refreshing: Vec::new(),
185            accounting: BTreeMap::new(),
186        }
187    }
188
189    pub fn add_refreshing_file(&mut self, path: PathBuf) {
190        if let Self::Ready {
191            refreshing,
192            accounting,
193        } = self
194        {
195            let state = accounting.entry(path.clone()).or_default();
196            state.pending = state.pending.saturating_add(1);
197            ensure_refreshing_path(refreshing, path);
198        }
199    }
200
201    pub fn start_refreshing_file(&mut self, path: PathBuf) {
202        if let Self::Ready {
203            refreshing,
204            accounting,
205        } = self
206        {
207            let state = accounting.entry(path.clone()).or_default();
208            if state.pending == 0 {
209                state.pending = 1;
210            }
211            if state.in_flight == 0 {
212                state.in_flight = state.pending;
213            }
214            ensure_refreshing_path(refreshing, path);
215        }
216    }
217
218    pub fn cancel_refreshing_file(&mut self, path: &Path) {
219        self.finish_refreshing_file(path, false);
220    }
221
222    pub fn complete_refreshing_file(&mut self, path: &Path) {
223        self.finish_refreshing_file(path, true);
224    }
225
226    pub fn remove_refreshing_file(&mut self, path: &Path) {
227        self.complete_refreshing_file(path);
228    }
229
230    fn finish_refreshing_file(&mut self, path: &Path, complete_in_flight: bool) {
231        if let Self::Ready {
232            refreshing,
233            accounting,
234        } = self
235        {
236            let mut keep_refreshing = false;
237            if let Some(state) = accounting.get_mut(path) {
238                let finished = if complete_in_flight {
239                    state.in_flight.max(1)
240                } else {
241                    1
242                };
243                state.pending = state.pending.saturating_sub(finished);
244                if complete_in_flight {
245                    state.in_flight = 0;
246                } else {
247                    state.in_flight = state.in_flight.min(state.pending);
248                }
249                keep_refreshing = state.pending > 0;
250                if !keep_refreshing {
251                    accounting.remove(path);
252                }
253            }
254
255            if !keep_refreshing {
256                remove_refreshing_path(refreshing, path);
257            }
258        }
259    }
260
261    pub fn refreshing_count(&self) -> usize {
262        match self {
263            Self::Ready { refreshing, .. } => refreshing.len(),
264            _ => 0,
265        }
266    }
267}
268
269pub enum SemanticIndexEvent {
270    Progress {
271        stage: String,
272        files: Option<usize>,
273        entries_done: Option<usize>,
274        entries_total: Option<usize>,
275    },
276    /// Emitted when the semantic worker avoids or pauses full project corpus
277    /// collection before reaching terminal Ready/Failed, such as after loading a
278    /// cached index or while waiting to retry an embedding backend with no vectors
279    /// retained. Work that was waiting for the full index can proceed.
280    ColdSeedGateCleared,
281    Ready(SemanticIndex),
282    Failed(String),
283}
284
285#[derive(Debug, Clone)]
286pub enum SemanticRefreshRequest {
287    Files {
288        paths: Vec<PathBuf>,
289    },
290    /// Refresh the whole semantic corpus on the refresh worker. The worker owns
291    /// the project walk so watcher/configure drains never do corpus-scale work
292    /// on the single dispatch thread before scheduling embedding.
293    Corpus,
294}
295
296#[derive(Debug)]
297pub enum SemanticRefreshEvent {
298    Started {
299        paths: Vec<PathBuf>,
300    },
301    CorpusStarted {
302        files: usize,
303    },
304    Completed {
305        added_entries: Vec<EmbeddingEntry>,
306        updated_metadata: Vec<(PathBuf, FileFreshness)>,
307        completed_paths: Vec<PathBuf>,
308    },
309    CorpusCompleted {
310        index: SemanticIndex,
311        changed: usize,
312        added: usize,
313        deleted: usize,
314        total_processed: usize,
315    },
316    Failed {
317        paths: Vec<PathBuf>,
318        error: String,
319    },
320    CorpusFailed {
321        error: String,
322    },
323}
324
325pub type SemanticRefreshWorkerSlot = Arc<Mutex<Option<std::thread::JoinHandle<()>>>>;
326
327/// Normalize a path by resolving `.` and `..` components lexically,
328/// without touching the filesystem. This prevents path traversal
329/// attacks when `fs::canonicalize` fails (e.g. for non-existent paths).
330fn normalize_path(path: &Path) -> PathBuf {
331    let mut result = PathBuf::new();
332    for component in path.components() {
333        match component {
334            Component::ParentDir => {
335                // Pop the last component unless we're at root or have no components
336                if !result.pop() {
337                    result.push(component);
338                }
339            }
340            Component::CurDir => {} // Skip `.`
341            _ => result.push(component),
342        }
343    }
344    result
345}
346
347fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
348    let mut existing = path.to_path_buf();
349    let mut tail_segments = Vec::new();
350
351    while !existing.exists() {
352        if let Some(name) = existing.file_name() {
353            tail_segments.push(name.to_owned());
354        } else {
355            break;
356        }
357
358        existing = match existing.parent() {
359            Some(parent) => parent.to_path_buf(),
360            None => break,
361        };
362    }
363
364    let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
365    for segment in tail_segments.into_iter().rev() {
366        resolved.push(segment);
367    }
368
369    resolved
370}
371
372fn path_error_response(
373    req_id: &str,
374    path: &Path,
375    resolved_root: &Path,
376) -> crate::protocol::Response {
377    crate::protocol::Response::error(
378        req_id,
379        "path_outside_root",
380        format!(
381            "path '{}' is outside the project root '{}'",
382            path.display(),
383            resolved_root.display()
384        ),
385    )
386}
387
388/// Walk `candidate` component-by-component. For any component that is a
389/// symlink on disk, iteratively follow the full chain (up to 40 hops) and
390/// reject if any hop's resolved target lies outside `resolved_root`.
391///
392/// This is the fallback path used when `fs::canonicalize` fails (e.g. on
393/// Linux with broken symlink chains pointing to non-existent destinations).
394/// On macOS `canonicalize` also fails for broken symlinks but the returned
395/// `/var/...` tempdir paths diverge from `resolved_root`'s `/private/var/...`
396/// form, so we must accept either form when deciding which symlinks to check.
397fn reject_escaping_symlink(
398    req_id: &str,
399    original_path: &Path,
400    candidate: &Path,
401    resolved_root: &Path,
402    raw_root: &Path,
403) -> Result<(), crate::protocol::Response> {
404    let mut current = PathBuf::new();
405
406    for component in candidate.components() {
407        current.push(component);
408
409        let Ok(metadata) = std::fs::symlink_metadata(&current) else {
410            continue;
411        };
412
413        if !metadata.file_type().is_symlink() {
414            continue;
415        }
416
417        // Only check symlinks that live inside the project root. This skips
418        // OS-level prefix symlinks (macOS /var → /private/var) that are not
419        // inside our project directory and whose "escaping" is harmless.
420        //
421        // We compare against BOTH the canonicalized root (resolved_root, e.g.
422        // /private/var/.../project) AND the raw root (e.g. /var/.../project)
423        // because tempdir() returns raw paths while fs::canonicalize returns
424        // the resolved form — and our `current` may be in either form.
425        let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
426        if !inside_root {
427            continue;
428        }
429
430        iterative_follow_chain(req_id, original_path, &current, resolved_root)?;
431    }
432
433    Ok(())
434}
435
436/// Iteratively follow a symlink chain from `link` and reject if any hop's
437/// resolved target is outside `resolved_root`. Depth-capped at 40 hops.
438fn iterative_follow_chain(
439    req_id: &str,
440    original_path: &Path,
441    start: &Path,
442    resolved_root: &Path,
443) -> Result<(), crate::protocol::Response> {
444    let mut link = start.to_path_buf();
445    let mut depth = 0usize;
446
447    loop {
448        if depth > 40 {
449            return Err(path_error_response(req_id, original_path, resolved_root));
450        }
451
452        let target = match std::fs::read_link(&link) {
453            Ok(t) => t,
454            Err(_) => {
455                // Can't read the link — treat as escaping to be safe.
456                return Err(path_error_response(req_id, original_path, resolved_root));
457            }
458        };
459
460        let resolved_target = if target.is_absolute() {
461            normalize_path(&target)
462        } else {
463            let parent = link.parent().unwrap_or_else(|| Path::new(""));
464            normalize_path(&parent.join(&target))
465        };
466
467        // Check boundary: use canonicalized target when available (handles
468        // macOS /var → /private/var aliasing), fall back to the normalized
469        // path when canonicalize fails (e.g. broken symlink on Linux).
470        let canonical_target =
471            std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
472
473        if !canonical_target.starts_with(resolved_root)
474            && !resolved_target.starts_with(resolved_root)
475        {
476            return Err(path_error_response(req_id, original_path, resolved_root));
477        }
478
479        // If the target is itself a symlink, follow the next hop.
480        match std::fs::symlink_metadata(&resolved_target) {
481            Ok(meta) if meta.file_type().is_symlink() => {
482                link = resolved_target;
483                depth += 1;
484            }
485            _ => break, // Non-symlink or non-existent target — chain ends here.
486        }
487    }
488
489    Ok(())
490}
491
492pub type LanguageProviderFactory = fn() -> Box<dyn LanguageProvider>;
493
494pub fn default_language_provider_factory() -> Box<dyn LanguageProvider> {
495    Box::new(TreeSitterProvider::new())
496}
497
498/// Process-global services shared by all project actors in this AFT process.
499///
500/// `App` owns only true process services. Per-root caches and the live
501/// language provider instance stay in [`AppContext`].
502pub struct App {
503    db: parking_lot::Mutex<Option<Arc<Mutex<Connection>>>>,
504    lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
505    stdout_writer: SharedStdoutWriter,
506    provider_factory: LanguageProviderFactory,
507}
508
509impl App {
510    pub fn new(provider_factory: LanguageProviderFactory) -> Self {
511        Self {
512            db: parking_lot::Mutex::new(None),
513            lsp_child_registry: crate::lsp::child_registry::LspChildRegistry::new(),
514            stdout_writer: Arc::new(Mutex::new(BufWriter::new(io::stdout()))),
515            provider_factory,
516        }
517    }
518
519    /// Create the shared process `App` handle required by the actor split.
520    pub fn shared(provider_factory: LanguageProviderFactory) -> Arc<Self> {
521        Arc::new(Self::new(provider_factory))
522    }
523
524    pub fn default_shared() -> Arc<Self> {
525        Self::shared(default_language_provider_factory)
526    }
527
528    pub fn create_provider(&self) -> Box<dyn LanguageProvider> {
529        (self.provider_factory)()
530    }
531
532    pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
533        self.lsp_child_registry.clone()
534    }
535
536    pub fn stdout_writer(&self) -> SharedStdoutWriter {
537        Arc::clone(&self.stdout_writer)
538    }
539
540    pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
541        *self.db.lock() = Some(conn);
542    }
543
544    pub fn clear_db(&self) {
545        *self.db.lock() = None;
546    }
547
548    pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
549        self.db.lock().clone()
550    }
551}
552
553impl Default for App {
554    fn default() -> Self {
555        Self::new(default_language_provider_factory)
556    }
557}
558
559const _: fn() = || {
560    fn assert_send_sync<T: Send + Sync>() {}
561    fn assert_send<T: Send>() {}
562
563    assert_send_sync::<App>();
564    assert_send_sync::<AppContext>();
565    assert_send::<crate::lsp::manager::LspManager>();
566    assert_send::<crate::semantic_index::EmbeddingModel>();
567};
568
569/// Shared application context threaded through all command handlers.
570///
571/// Holds the language provider, backup/checkpoint stores, and configuration.
572/// Constructed once at startup and passed by
573/// reference to `dispatch`.
574///
575/// Write-rarely stores use `parking_lot::Mutex` for interior mutability so this
576/// context can become thread-safe while preserving the current single-request
577/// dispatch behavior. `config` is a thread-safe owned snapshot so future
578/// read-only dispatch can hold configuration across other work without holding
579/// a lock guard.
580pub struct AppContext {
581    app: Arc<App>,
582    provider: Box<dyn LanguageProvider>,
583    backup: parking_lot::Mutex<BackupStore>,
584    checkpoint: parking_lot::Mutex<CheckpointStore>,
585    config: RwLock<Arc<Config>>,
586    pub harness: parking_lot::Mutex<Option<Harness>>,
587    canonical_cache_root: parking_lot::Mutex<Option<PathBuf>>,
588    is_worktree_bridge: parking_lot::Mutex<bool>,
589    git_common_dir: parking_lot::Mutex<Option<PathBuf>>,
590    /// Reasons (if any) why heavy AFT subsystems were auto-disabled for the
591    /// current project root. Populated by `handle_configure` based on the
592    /// canonical project root. Each reason is a stable machine-readable string
593    /// (e.g. `"home_root"`, `"watcher_unavailable"`) so the plugin can render
594    /// distinct degraded-mode UI states without re-deriving the reason locally.
595    /// Empty when the project is healthy / full-featured.
596    degraded_reasons: parking_lot::Mutex<Vec<String>>,
597    callgraph_store: RwLock<Option<Arc<CallGraphStore>>>,
598    callgraph_store_force_rebuild: parking_lot::Mutex<bool>,
599    callgraph_store_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStore>>>,
600    pending_callgraph_store_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
601    search_index: RwLock<Option<SearchIndex>>,
602    search_index_rx: RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>>,
603    pending_search_index_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
604    symbol_cache: SharedSymbolCache,
605    inspect_manager: Arc<InspectManager>,
606    tier2_refresh_scheduler: parking_lot::Mutex<Tier2RefreshScheduler>,
607    pending_tier2_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
608    semantic_index: RwLock<Option<SemanticIndex>>,
609    semantic_index_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
610    semantic_index_status: RwLock<SemanticIndexStatus>,
611    /// True while this context has a cold semantic seed scheduled or actively
612    /// collecting/embedding/persisting the full project corpus. The semantic
613    /// worker clears it as soon as it proves the cached/incremental path is in use.
614    semantic_cold_seed_active: Arc<AtomicBool>,
615    /// Monotonic generation that prevents a superseded semantic worker from
616    /// reopening the cold-seed gate after a later configure has reset it.
617    semantic_cold_seed_generation: Arc<AtomicU64>,
618    semantic_callgraph_warm_deferred: AtomicBool,
619    pending_semantic_index_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
620    pending_semantic_corpus_refresh: parking_lot::Mutex<bool>,
621    semantic_refresh_tx:
622        parking_lot::Mutex<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>,
623    semantic_refresh_event_rx:
624        parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
625    semantic_refresh_worker: parking_lot::Mutex<Option<SemanticRefreshWorkerSlot>>,
626    semantic_refresh_retry_attempts: parking_lot::Mutex<BTreeMap<PathBuf, usize>>,
627    semantic_refresh_circuit: Arc<SemanticRefreshCircuit>,
628    semantic_embedding_model: parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>>,
629    watcher: parking_lot::Mutex<Option<RecommendedWatcher>>,
630    watcher_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>>,
631    watcher_thread: parking_lot::Mutex<Option<WatcherThreadHandle>>,
632    lsp_manager: parking_lot::Mutex<LspManager>,
633    configure_generation: AtomicU64,
634    /// Last-seen value of `InspectManager::reuse_completion_count()`, so the
635    /// per-request inspect drain can detect watcher-driven Tier-2 scans that
636    /// finished since the previous tick and refresh the status bar (#3).
637    last_seen_reuse_completions: AtomicU64,
638    configure_warnings_tx: crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)>,
639    configure_warnings_rx: crossbeam_channel::Receiver<(u64, ConfigureWarningsFrame)>,
640    /// Per-context push sender slot. Status and background-bash emitters share
641    /// this Arc so a sender installed after construction is observed at emit time.
642    progress_sender: SharedProgressSender,
643    status_emitter: StatusEmitter,
644    /// Last status-bar payload attached to a tool response for this project root.
645    /// Deduping here (not in a process-global static) lets daemon roots emit the
646    /// same counts independently.
647    status_bar_last_emitted: RwLock<Option<StatusBarCounts>>,
648    bash_background: BgTaskRegistry,
649    /// Thread-safe registry of TOML output filters. Lazy-built on first
650    /// access; populated atomically via `RwLock`. Shared between command
651    /// handlers (which use it through `filter_registry()` -> read guard) and
652    /// the `BgTaskRegistry` watchdog thread (which uses it through
653    /// `compress::compress_with_registry`). Reloaded when configure changes
654    /// the project root or storage_dir; see [`AppContext::reset_filter_registry`].
655    filter_registry: crate::compress::SharedFilterRegistry,
656    /// Set to true once the filter_registry has been populated. Avoids
657    /// double-loading on hot paths without holding a write lock.
658    filter_registry_loaded: std::sync::atomic::AtomicBool,
659    /// Live `experimental.bash.compress` flag, kept in sync with `config`
660    /// from the configure handler. Exposed via [`AppContext::bash_compress_flag`]
661    /// so the BgTaskRegistry's watchdog-thread compressor can read it without
662    /// holding the config refcell.
663    bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
664    /// Project gitignore matcher, rebuilt by [`AppContext::rebuild_gitignore`]
665    /// whenever `project_root` changes or a watcher event reports a
666    /// `.gitignore` write. Used by the watcher event filter to decide which
667    /// path-changes are interesting to AFT's caches. `None` when no project
668    /// root is configured or when the project has no gitignore files; in that
669    /// case the watcher falls back to a small hardcoded infra-directory skip.
670    gitignore: SharedGitignore,
671    gitignore_generation: Arc<AtomicU64>,
672    /// Last-known Tier-2 + todos counts for the agent status bar, refreshed off
673    /// the hot path (on `aft_inspect` reads and background Tier-2 completions).
674    /// Errors/warnings are read live and not stored here.
675    status_bar_tier2: RwLock<StatusBarTier2>,
676    /// Persistent TypeScript-project membership cache for the status-bar E/W
677    /// count. The bar reads E/W live on every tool result, so resolving the
678    /// nearest tsconfig (read + parse + glob-compile) per drain is too costly;
679    /// this memoizes per tsconfig dir. Invalidated wholesale on any
680    /// tsconfig-like watcher event and on `configure`. Owned here (not in
681    /// `DiagnosticsStore`, which stays raw policy-free) per the v0.35 council.
682    tsconfig_membership:
683        parking_lot::Mutex<crate::lsp::tsconfig_membership::TsconfigMembershipCache>,
684}
685
686impl Drop for AppContext {
687    fn drop(&mut self) {
688        if let Some(runtime) = self.watcher_thread.get_mut().take() {
689            runtime.shutdown_and_join();
690        }
691    }
692}
693
694/// Result of requesting the persisted callgraph store for a store-backed op.
695///
696/// The five edge-query ops never block the request thread on a cold build:
697/// a genuine cold build is kicked off in the background and `Building` is
698/// returned so the agent retries, mirroring how semantic search reports a
699/// build in progress. Warm restarts open the on-disk DB synchronously, so
700/// `Building` is only ever seen during a true first cold build.
701pub enum CallgraphStoreAccess {
702    /// Store is resident and queryable.
703    Ready(Arc<CallGraphStore>),
704    /// A cold build is in flight (or was just started); retry shortly.
705    Building,
706    /// Not configured, or a read-only worktree whose store was never built.
707    Unavailable,
708    /// A store open/build check failed with a real error (DB/IO).
709    Error(CallGraphStoreError),
710}
711
712/// Inline wait window for a callgraph-store cold build before returning
713/// `Building`. Default `0` (pure-async: never block the request thread).
714/// Tests set `AFT_CALLGRAPH_BUILD_WAIT_MS` large so small fixture builds
715/// resolve to `Ready` synchronously and exercise query correctness directly.
716fn callgraph_build_wait_window() -> Duration {
717    std::env::var("AFT_CALLGRAPH_BUILD_WAIT_MS")
718        .ok()
719        .and_then(|raw| raw.parse::<u64>().ok())
720        .map(Duration::from_millis)
721        .unwrap_or(Duration::ZERO)
722}
723
724static CALLGRAPH_COLD_BUILD_SPAWN_COUNT: AtomicUsize = AtomicUsize::new(0);
725
726#[doc(hidden)]
727pub fn reset_callgraph_cold_build_spawn_count_for_test() {
728    CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
729}
730
731#[doc(hidden)]
732pub fn callgraph_cold_build_spawn_count_for_test() -> usize {
733    CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst)
734}
735
736impl AppContext {
737    pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
738        Self::with_app_and_provider(App::default_shared(), provider, config)
739    }
740
741    pub fn from_app(app: Arc<App>, config: Config) -> Self {
742        let provider = app.create_provider();
743        Self::with_app_and_provider(app, provider, config)
744    }
745
746    pub fn with_app_and_provider(
747        app: Arc<App>,
748        provider: Box<dyn LanguageProvider>,
749        config: Config,
750    ) -> Self {
751        let bash_compress_enabled = config.experimental_bash_compress;
752        let (configure_warnings_tx, configure_warnings_rx) = crossbeam_channel::unbounded();
753        let progress_sender: SharedProgressSender = Arc::new(Mutex::new(None));
754        let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
755        let symbol_cache = provider
756            .as_any()
757            .downcast_ref::<TreeSitterProvider>()
758            .map(|provider| provider.symbol_cache())
759            .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
760        let mut lsp_manager = LspManager::new();
761        lsp_manager.set_child_registry(app.lsp_child_registry());
762        // Apply the configured diagnostic LRU cap (default 5000, 0 = unbounded)
763        // so the documented `lsp.diagnostic_cache_size` knob takes effect.
764        lsp_manager.set_diagnostic_capacity(config.diagnostic_cache_size);
765        AppContext {
766            app: Arc::clone(&app),
767            provider,
768            backup: parking_lot::Mutex::new(BackupStore::new()),
769            checkpoint: parking_lot::Mutex::new(CheckpointStore::new()),
770            config: RwLock::new(Arc::new(config)),
771            harness: parking_lot::Mutex::new(None),
772            canonical_cache_root: parking_lot::Mutex::new(None),
773            is_worktree_bridge: parking_lot::Mutex::new(false),
774            git_common_dir: parking_lot::Mutex::new(None),
775            degraded_reasons: parking_lot::Mutex::new(Vec::new()),
776            callgraph_store: RwLock::new(None),
777            callgraph_store_force_rebuild: parking_lot::Mutex::new(false),
778            callgraph_store_rx: parking_lot::Mutex::new(None),
779            pending_callgraph_store_paths: parking_lot::Mutex::new(BTreeSet::new()),
780            search_index: RwLock::new(None),
781            search_index_rx: RwLock::new(None),
782            pending_search_index_paths: parking_lot::Mutex::new(BTreeSet::new()),
783            symbol_cache,
784            inspect_manager: Arc::new(InspectManager::new()),
785            tier2_refresh_scheduler: parking_lot::Mutex::new(Tier2RefreshScheduler::new()),
786            pending_tier2_paths: parking_lot::Mutex::new(BTreeSet::new()),
787            semantic_index: RwLock::new(None),
788            semantic_index_rx: parking_lot::Mutex::new(None),
789            semantic_index_status: RwLock::new(SemanticIndexStatus::Disabled),
790            semantic_cold_seed_active: Arc::new(AtomicBool::new(false)),
791            semantic_cold_seed_generation: Arc::new(AtomicU64::new(0)),
792            semantic_callgraph_warm_deferred: AtomicBool::new(false),
793            pending_semantic_index_paths: parking_lot::Mutex::new(BTreeSet::new()),
794            pending_semantic_corpus_refresh: parking_lot::Mutex::new(false),
795            semantic_refresh_tx: parking_lot::Mutex::new(None),
796            semantic_refresh_event_rx: parking_lot::Mutex::new(None),
797            semantic_refresh_worker: parking_lot::Mutex::new(None),
798            semantic_refresh_retry_attempts: parking_lot::Mutex::new(BTreeMap::new()),
799            semantic_refresh_circuit: Arc::new(SemanticRefreshCircuit::default()),
800            semantic_embedding_model: parking_lot::Mutex::new(None),
801            watcher: parking_lot::Mutex::new(None),
802            watcher_rx: parking_lot::Mutex::new(None),
803            watcher_thread: parking_lot::Mutex::new(None),
804            lsp_manager: parking_lot::Mutex::new(lsp_manager),
805            configure_generation: AtomicU64::new(0),
806            last_seen_reuse_completions: AtomicU64::new(0),
807            configure_warnings_tx,
808            configure_warnings_rx,
809            progress_sender: Arc::clone(&progress_sender),
810            status_emitter,
811            status_bar_last_emitted: RwLock::new(None),
812            bash_background: BgTaskRegistry::new(Arc::clone(&progress_sender)),
813            filter_registry: Arc::new(std::sync::RwLock::new(
814                crate::compress::toml_filter::FilterRegistry::default(),
815            )),
816            filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
817            bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
818            gitignore: Arc::new(std::sync::RwLock::new(None)),
819            gitignore_generation: Arc::new(AtomicU64::new(0)),
820            status_bar_tier2: RwLock::new(StatusBarTier2::default()),
821            tsconfig_membership: parking_lot::Mutex::new(
822                crate::lsp::tsconfig_membership::TsconfigMembershipCache::new(),
823            ),
824        }
825    }
826
827    /// Current agent status-bar counts. `errors`/`warnings` are read LIVE from
828    /// the LSP diagnostics store (continuously drained, no round-trip); the
829    /// Tier-2 + todos counts are the last-known cached values. Returns `None`
830    /// until the Tier-2 cache has been populated at least once, so we never
831    /// surface a bar that misleadingly claims "0 dead code" before any scan.
832    pub fn status_bar_counts(&self) -> Option<StatusBarCounts> {
833        // All three Tier-2 categories must hold a real value before the bar is
834        // surfaced — otherwise a partially-scanned cold run would render a
835        // fabricated `0` for the not-yet-completed categories (#1). Extract the
836        // values under a short read guard, drop it, then compute E/W (which
837        // touches other state) with no status-bar guard held.
838        let (dead_code, unused_exports, duplicates, todos, tier2_stale) = {
839            let tier2 = self
840                .status_bar_tier2
841                .read()
842                .unwrap_or_else(std::sync::PoisonError::into_inner);
843            let (Some(dead_code), Some(unused_exports), Some(duplicates)) =
844                (tier2.dead_code, tier2.unused_exports, tier2.duplicates)
845            else {
846                return None;
847            };
848            (
849                dead_code,
850                unused_exports,
851                duplicates,
852                tier2.todos.unwrap_or(0),
853                tier2.stale,
854            )
855        };
856        let (errors, warnings) = self.status_bar_error_warning_counts();
857        Some(StatusBarCounts {
858            errors,
859            warnings,
860            dead_code,
861            unused_exports,
862            duplicates,
863            todos,
864            tier2_stale,
865        })
866    }
867
868    pub fn should_emit_status_bar(&self, counts: &StatusBarCounts) -> bool {
869        let mut last = self
870            .status_bar_last_emitted
871            .write()
872            .unwrap_or_else(std::sync::PoisonError::into_inner);
873        if last.as_ref() == Some(counts) {
874            return false;
875        }
876        *last = Some(counts.clone());
877        true
878    }
879
880    /// Error/warning counts for the agent status bar, filtered to match
881    /// `aft_inspect`/`tsc` (v0.35 council): only diagnostics under the canonical
882    /// project root, with build-excluded TS/JS files skipped via the persistent
883    /// tsconfig-membership cache, and cross-server duplicates collapsed. Falls
884    /// back to the raw warm count before configure has set a canonical root.
885    fn status_bar_error_warning_counts(&self) -> (usize, usize) {
886        let Some(root) = self.canonical_cache_root_opt() else {
887            // Pre-configure: no project root to scope against. Raw count is the
888            // best available signal (and the bar is gated on Tier-2 anyway).
889            return self.lsp_manager.lock().warm_error_warning_counts();
890        };
891        let lsp = self.lsp_manager.lock();
892        let mut membership = self.tsconfig_membership.lock();
893        lsp.filtered_error_warning_counts(|file| {
894            file.starts_with(&root) && !membership.should_skip_diagnostics(file)
895        })
896    }
897
898    /// Invalidate the status-bar tsconfig-membership cache. Called from the
899    /// watcher seam when a tsconfig-like file changes and from `configure`
900    /// when the project root changes, so the next bar count re-reads from disk.
901    pub fn clear_tsconfig_membership_cache(&self) {
902        self.tsconfig_membership.lock().clear();
903    }
904
905    /// Mark the status-bar Tier-2 counts stale (rendered with `~`) without
906    /// changing the numbers — called when the watcher sees a source-file change,
907    /// so the bar honestly signals the counts predate the latest edit until the
908    /// next background scan completes. Returns true only when the visible stale
909    /// bit flips. No-op before the first populate.
910    pub fn mark_status_bar_tier2_stale(&self) -> bool {
911        let mut tier2 = self
912            .status_bar_tier2
913            .write()
914            .unwrap_or_else(std::sync::PoisonError::into_inner);
915        // No-op before the first full populate (nothing real to mark stale).
916        if tier2.dead_code.is_some() && tier2.unused_exports.is_some() && tier2.duplicates.is_some()
917        {
918            let changed = !tier2.stale;
919            tier2.stale = true;
920            return changed;
921        }
922        false
923    }
924
925    /// Refresh the cached Tier-2 + todos counts for the status bar. Each count
926    /// is `Option`: `None` preserves the last-known value (the category wasn't
927    /// recomputed or has no real aggregate yet) so we never overwrite a real
928    /// count with a fabricated `0`. `stale` marks the Tier-2 numbers as
929    /// not-yet-reconciled with the latest edits.
930    pub fn update_status_bar_tier2(
931        &self,
932        dead_code: Option<usize>,
933        unused_exports: Option<usize>,
934        duplicates: Option<usize>,
935        todos: Option<usize>,
936        stale: bool,
937    ) {
938        let mut tier2 = self
939            .status_bar_tier2
940            .write()
941            .unwrap_or_else(std::sync::PoisonError::into_inner);
942        if let Some(dead_code) = dead_code {
943            tier2.dead_code = Some(dead_code);
944        }
945        if let Some(unused_exports) = unused_exports {
946            tier2.unused_exports = Some(unused_exports);
947        }
948        if let Some(duplicates) = duplicates {
949            tier2.duplicates = Some(duplicates);
950        }
951        if let Some(todos) = todos {
952            tier2.todos = Some(todos);
953        }
954        tier2.stale = stale;
955    }
956
957    /// Borrow the cached project gitignore matcher. Returns `None` when no
958    /// project_root is configured or when the project has no gitignore files.
959    pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
960        self.gitignore
961            .read()
962            .unwrap_or_else(|poisoned| poisoned.into_inner())
963            .clone()
964    }
965
966    /// Shared gitignore matcher handle for the watcher filter thread.
967    pub fn shared_gitignore(&self) -> SharedGitignore {
968        Arc::clone(&self.gitignore)
969    }
970
971    /// Monotonic generation bumped after every matcher rebuild/clear. The
972    /// watcher filter thread uses it to wait until the main thread has rebuilt
973    /// ignore rules after it reports an ignore-file change.
974    pub fn gitignore_generation(&self) -> Arc<AtomicU64> {
975        Arc::clone(&self.gitignore_generation)
976    }
977
978    fn set_gitignore(&self, matcher: Option<Arc<ignore::gitignore::Gitignore>>) {
979        *self
980            .gitignore
981            .write()
982            .unwrap_or_else(|poisoned| poisoned.into_inner()) = matcher;
983        self.gitignore_generation.fetch_add(1, Ordering::SeqCst);
984    }
985
986    /// Rebuild the gitignore matcher from the current `project_root` and
987    /// cache it. Called by the configure handler whenever the project root
988    /// changes, and by the watcher event drain when a `.gitignore` file
989    /// itself is modified.
990    ///
991    /// The builder honors:
992    /// - `<project_root>/.gitignore`
993    /// - Git's global excludes file (the same source used by `ignore::WalkBuilder`)
994    /// - the repository's real `info/exclude` file, resolved through Git's
995    ///   common dir for linked worktrees
996    /// - nested `.gitignore` files (each `.gitignore` discovered during
997    ///   the recursive walk)
998    ///
999    /// Stores `None` if there's no project_root or no matchable gitignore
1000    /// files. Logs build errors but never fails configure.
1001    /// Clear any cached gitignore matcher without rebuilding.
1002    ///
1003    /// Used by `handle_configure` in degraded mode (e.g. `project_root == $HOME`)
1004    /// where running the gitignore-discovery walk would exceed the configure
1005    /// budget. The watcher event filter falls back to the hardcoded infra-dir
1006    /// skip list when no matcher is present.
1007    pub fn clear_gitignore(&self) {
1008        self.set_gitignore(None);
1009    }
1010
1011    pub fn rebuild_gitignore(&self) {
1012        use ignore::gitignore::GitignoreBuilder;
1013        use std::path::Path;
1014        let root_raw = match self.config().project_root.clone() {
1015            Some(r) => r,
1016            None => {
1017                self.set_gitignore(None);
1018                return;
1019            }
1020        };
1021        // Canonicalize the root so symlink-prefix mismatches don't cause
1022        // `Gitignore::matched_path_or_any_parents` to panic on watcher event
1023        // paths. macOS routinely surfaces `/private/var/...` while `project_root`
1024        // arrives as `/var/...` (a symlink to `/private/var`); the `ignore`
1025        // crate's matcher panics when a query path isn't lexically under the
1026        // matcher's root. Canonicalizing both ends (here for root, naturally
1027        // for watcher events on macOS) keeps them in the same prefix space.
1028        let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
1029        let mut builder = GitignoreBuilder::new(&root);
1030        // Git's global excludes file — keep the live watcher matcher aligned
1031        // with the project walkers (`WalkBuilder::git_global(true)`). The
1032        // ignore crate exposes the same path discovery it uses internally, so
1033        // this handles the default XDG location and configured excludesFile.
1034        if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
1035            if global_ignore.is_file() {
1036                if let Some(err) = builder.add(&global_ignore) {
1037                    crate::slog_warn!(
1038                        "global gitignore parse error in {}: {}",
1039                        global_ignore.display(),
1040                        err
1041                    );
1042                }
1043            }
1044        }
1045        // Add root .gitignore (the most common case)
1046        let root_ignore = Path::new(&root).join(".gitignore");
1047        if root_ignore.exists() {
1048            if let Some(err) = builder.add(&root_ignore) {
1049                crate::slog_warn!(
1050                    "gitignore parse error in {}: {}",
1051                    root_ignore.display(),
1052                    err
1053                );
1054            }
1055        }
1056        // Root .aftignore — AFT-specific ignores layered on top of .gitignore.
1057        // Lets users exclude paths git can't (e.g. submodules) from AFT's
1058        // walks/indexes. Honored by the watcher matcher too, so edits under an
1059        // aftignored path don't trigger reindexing.
1060        let root_aftignore = Path::new(&root).join(".aftignore");
1061        if root_aftignore.exists() {
1062            if let Some(err) = builder.add(&root_aftignore) {
1063                crate::slog_warn!(
1064                    "aftignore parse error in {}: {}",
1065                    root_aftignore.display(),
1066                    err
1067                );
1068            }
1069        }
1070        // .git/info/exclude — manually added because GitignoreBuilder::new()
1071        // does not auto-discover it (verified against ignore-0.4.25 source).
1072        // In linked worktrees this lives under the repository common dir, not
1073        // under `<worktree>/.git/info/exclude` (where `.git` is only a file).
1074        let info_exclude = self
1075            .git_common_dir
1076            .lock()
1077            .clone()
1078            .unwrap_or_else(|| Path::new(&root).join(".git"))
1079            .join("info")
1080            .join("exclude");
1081        if info_exclude.exists() {
1082            if let Some(err) = builder.add(&info_exclude) {
1083                crate::slog_warn!(
1084                    "gitignore parse error in {}: {}",
1085                    info_exclude.display(),
1086                    err
1087                );
1088            }
1089        }
1090        // Walk the project to pick up nested .gitignore/.aftignore files at
1091        // arbitrary depth. The main project walkers honor deeply nested ignore
1092        // files, so the watcher matcher must do the same or live invalidation
1093        // can disagree with startup indexing. Skip obvious infra dirs so we
1094        // don't accidentally load a vendored repo's ignore file as ours.
1095        let walker = ignore::WalkBuilder::new(&root)
1096            .standard_filters(true)
1097            // Hidden files are filtered by default, but `.gitignore` starts with
1098            // `.` so we need to traverse "hidden" entries to find nested ones.
1099            // No `max_depth`: nested `.gitignore`/`.aftignore` files are honored
1100            // at arbitrary depth (see configure_watcher_honors_deep_nested_aftignore).
1101            // The walk is pruned by standard gitignore filters plus the infra
1102            // skip below; configure never runs this against `$HOME` (guarded by
1103            // `home_match`), and tests use bounded roots rather than `/`.
1104            .hidden(false)
1105            .filter_entry(|entry| {
1106                let name = entry.file_name().to_string_lossy();
1107                !matches!(
1108                    name.as_ref(),
1109                    "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
1110                )
1111            })
1112            .build();
1113        for entry in walker.flatten() {
1114            let file_name = entry.file_name();
1115            let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
1116            let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
1117            if is_nested_gitignore || is_nested_aftignore {
1118                if let Some(err) = builder.add(entry.path()) {
1119                    crate::slog_warn!(
1120                        "nested ignore parse error in {}: {}",
1121                        entry.path().display(),
1122                        err
1123                    );
1124                }
1125            }
1126        }
1127        match builder.build() {
1128            Ok(gi) => {
1129                let count = gi.num_ignores();
1130                if count > 0 {
1131                    crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
1132                    self.set_gitignore(Some(Arc::new(gi)));
1133                } else {
1134                    self.set_gitignore(None);
1135                }
1136            }
1137            Err(err) => {
1138                crate::slog_warn!("gitignore matcher build failed: {}", err);
1139                self.set_gitignore(None);
1140            }
1141        }
1142    }
1143
1144    /// Shared atomic mirror of `experimental.bash.compress`. Updated by the
1145    /// configure handler. Read by the BgTaskRegistry compressor closure.
1146    pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
1147        Arc::clone(&self.bash_compress_flag)
1148    }
1149
1150    /// Update the shared `bash_compress_flag` mirror. Call this from the
1151    /// configure handler whenever `experimental.bash.compress` changes so the
1152    /// BgTaskRegistry watchdog sees the new value on the next completion.
1153    pub fn sync_bash_compress_flag(&self) {
1154        let value = self.config().experimental_bash_compress;
1155        self.bash_compress_flag
1156            .store(value, std::sync::atomic::Ordering::Relaxed);
1157    }
1158
1159    pub fn set_bash_compress_enabled(&self, enabled: bool) {
1160        self.update_config(|config| {
1161            config.experimental_bash_compress = enabled;
1162        });
1163        self.bash_compress_flag
1164            .store(enabled, std::sync::atomic::Ordering::Relaxed);
1165    }
1166
1167    /// Read-only access to the TOML filter registry, building it lazily on
1168    /// first use. Returns an `RwLockReadGuard` that callers can `lookup`
1169    /// against directly.
1170    pub fn filter_registry(
1171        &self,
1172    ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
1173        self.ensure_filter_registry_loaded();
1174        match self.filter_registry.read() {
1175            Ok(g) => g,
1176            Err(poisoned) => poisoned.into_inner(),
1177        }
1178    }
1179
1180    /// Returns the shared `Arc<RwLock<FilterRegistry>>` handle so threads
1181    /// outside `AppContext` (notably the bash watchdog) can read it without
1182    /// touching the rest of the context.
1183    pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
1184        self.ensure_filter_registry_loaded();
1185        Arc::clone(&self.filter_registry)
1186    }
1187
1188    /// Force a fresh load of the TOML filter registry. Called when configure
1189    /// changes the project root, storage_dir, or trust state so subsequent
1190    /// `compress::compress` calls pick up new filters.
1191    pub fn reset_filter_registry(&self) {
1192        let new_registry = crate::compress::build_registry_for_context(self);
1193        match self.filter_registry.write() {
1194            Ok(mut slot) => *slot = new_registry,
1195            Err(poisoned) => *poisoned.into_inner() = new_registry,
1196        }
1197        self.filter_registry_loaded
1198            .store(true, std::sync::atomic::Ordering::Release);
1199    }
1200
1201    fn ensure_filter_registry_loaded(&self) {
1202        use std::sync::atomic::Ordering;
1203        if self.filter_registry_loaded.load(Ordering::Acquire) {
1204            return;
1205        }
1206        // Build outside the lock to avoid blocking other readers during a
1207        // multi-file TOML parse.
1208        let new_registry = crate::compress::build_registry_for_context(self);
1209        if let Ok(mut slot) = self.filter_registry.write() {
1210            *slot = new_registry;
1211            self.filter_registry_loaded.store(true, Ordering::Release);
1212        }
1213    }
1214
1215    pub fn app(&self) -> Arc<App> {
1216        Arc::clone(&self.app)
1217    }
1218
1219    /// Clone the LSP child registry handle. Used by main.rs to give the
1220    /// signal handler thread a way to SIGKILL LSP children on shutdown.
1221    pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
1222        self.app.lsp_child_registry()
1223    }
1224
1225    pub fn stdout_writer(&self) -> SharedStdoutWriter {
1226        self.app.stdout_writer()
1227    }
1228
1229    pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
1230        if let Ok(mut progress_sender) = self.progress_sender.lock() {
1231            *progress_sender = sender;
1232        }
1233    }
1234
1235    pub fn emit_progress(&self, frame: ProgressFrame) {
1236        let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
1237            return;
1238        };
1239        if let Some(sender) = progress_sender.as_ref() {
1240            sender(PushFrame::Progress(frame));
1241        }
1242    }
1243
1244    pub fn status_emitter(&self) -> &StatusEmitter {
1245        &self.status_emitter
1246    }
1247
1248    /// Get a clone of the current progress sender for use from background
1249    /// threads. Returns `None` when the main loop hasn't installed one (tests,
1250    /// CLI without push frames).
1251    ///
1252    /// Used by `configure`'s deferred file-walk thread to push warnings after
1253    /// configure has already returned, so configure latency stays sub-100 ms
1254    /// even on huge directories.
1255    pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
1256        self.progress_sender
1257            .lock()
1258            .ok()
1259            .and_then(|sender| sender.clone())
1260    }
1261
1262    pub fn advance_configure_generation(&self) -> u64 {
1263        self.configure_generation
1264            .fetch_add(1, Ordering::SeqCst)
1265            .wrapping_add(1)
1266    }
1267
1268    pub fn configure_generation(&self) -> u64 {
1269        self.configure_generation.load(Ordering::SeqCst)
1270    }
1271
1272    pub fn configure_warnings_sender(
1273        &self,
1274    ) -> crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)> {
1275        self.configure_warnings_tx.clone()
1276    }
1277
1278    pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
1279        let mut warnings = Vec::new();
1280        while let Ok(warning) = self.configure_warnings_rx.try_recv() {
1281            warnings.push(warning);
1282        }
1283        warnings
1284    }
1285
1286    pub fn bash_background(&self) -> &BgTaskRegistry {
1287        &self.bash_background
1288    }
1289
1290    pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
1291        self.bash_background.drain_completions()
1292    }
1293
1294    /// Access the language provider.
1295    pub fn provider(&self) -> &dyn LanguageProvider {
1296        self.provider.as_ref()
1297    }
1298
1299    /// Access the backup store.
1300    pub fn backup(&self) -> &parking_lot::Mutex<BackupStore> {
1301        &self.backup
1302    }
1303
1304    /// Access the checkpoint store.
1305    pub fn checkpoint(&self) -> &parking_lot::Mutex<CheckpointStore> {
1306        &self.checkpoint
1307    }
1308
1309    pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
1310        self.app.set_db(conn);
1311    }
1312
1313    pub fn clear_db(&self) {
1314        self.app.clear_db();
1315    }
1316
1317    pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
1318        self.app.db()
1319    }
1320
1321    /// Access an owned configuration snapshot.
1322    pub fn config(&self) -> Arc<Config> {
1323        let guard = match self.config.read() {
1324            Ok(guard) => guard,
1325            Err(poisoned) => poisoned.into_inner(),
1326        };
1327        Arc::clone(&*guard)
1328    }
1329
1330    /// Atomically publish a fully-built configuration snapshot.
1331    pub fn set_config(&self, config: Config) {
1332        let next = Arc::new(config);
1333        match self.config.write() {
1334            Ok(mut guard) => *guard = next,
1335            Err(poisoned) => *poisoned.into_inner() = next,
1336        }
1337    }
1338
1339    /// Clone-mutate-publish the current configuration without returning a guard.
1340    pub fn update_config(&self, update: impl FnOnce(&mut Config)) {
1341        let mut next = self.config().as_ref().clone();
1342        update(&mut next);
1343        self.set_config(next);
1344    }
1345
1346    pub fn set_harness(&self, harness: Harness) {
1347        self.bash_background.set_harness(harness.clone());
1348        *self.harness.lock() = Some(harness);
1349    }
1350
1351    pub fn harness_opt(&self) -> Option<Harness> {
1352        self.harness.lock().clone()
1353    }
1354
1355    pub fn harness(&self) -> Harness {
1356        self.harness_opt()
1357            .expect("harness set by configure before any tool call")
1358    }
1359
1360    pub fn storage_dir(&self) -> PathBuf {
1361        crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
1362    }
1363
1364    pub fn harness_dir(&self) -> PathBuf {
1365        self.storage_dir().join(self.harness().storage_segment())
1366    }
1367
1368    pub fn inspect_dir(&self) -> PathBuf {
1369        self.harness_dir().join("inspect")
1370    }
1371
1372    pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
1373        self.harness_dir()
1374            .join("bash-tasks")
1375            .join(hash_session(session_id))
1376    }
1377
1378    pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
1379        self.harness_dir()
1380            .join("backups")
1381            .join(hash_session(session_id))
1382            .join(path_hash)
1383    }
1384
1385    pub fn filters_dir(&self) -> PathBuf {
1386        self.harness_dir().join("filters")
1387    }
1388
1389    /// HOST-GLOBAL — NOT under harness_dir. Read by trust.rs across both harnesses.
1390    pub fn trust_file(&self) -> PathBuf {
1391        self.storage_dir().join("trusted-filter-projects.json")
1392    }
1393
1394    pub fn set_canonical_cache_root(&self, root: PathBuf) {
1395        debug_assert!(root.is_absolute());
1396        *self.canonical_cache_root.lock() = Some(root);
1397    }
1398
1399    pub fn canonical_cache_root(&self) -> PathBuf {
1400        self.canonical_cache_root
1401            .lock()
1402            .clone()
1403            .expect("canonical_cache_root accessed before handle_configure")
1404    }
1405
1406    pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
1407        self.canonical_cache_root.lock().clone()
1408    }
1409
1410    pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
1411        *self.is_worktree_bridge.lock() = is_worktree_bridge;
1412        *self.git_common_dir.lock() = git_common_dir;
1413    }
1414
1415    pub fn is_worktree_bridge(&self) -> bool {
1416        *self.is_worktree_bridge.lock()
1417    }
1418
1419    pub fn git_common_dir(&self) -> Option<PathBuf> {
1420        self.git_common_dir.lock().clone()
1421    }
1422
1423    /// Replace the current degraded-mode reasons. Empty vec = full-featured
1424    /// mode (no degradation). Called by `handle_configure` after deciding
1425    /// which subsystems to disable for this project root.
1426    pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
1427        *self.degraded_reasons.lock() = reasons;
1428    }
1429
1430    pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
1431        let reason = reason.into();
1432        let mut reasons = self.degraded_reasons.lock();
1433        if reasons.iter().any(|existing| existing == &reason) {
1434            return false;
1435        }
1436        reasons.push(reason);
1437        true
1438    }
1439
1440    /// Snapshot of current degraded-mode reasons. Order is stable
1441    /// (insertion order from `set_degraded_reasons`) so UI rendering and
1442    /// snapshot diffs are deterministic.
1443    pub fn degraded_reasons(&self) -> Vec<String> {
1444        self.degraded_reasons.lock().clone()
1445    }
1446
1447    /// True iff at least one degraded reason is recorded.
1448    pub fn is_degraded(&self) -> bool {
1449        !self.degraded_reasons.lock().is_empty()
1450    }
1451
1452    pub fn cache_role(&self) -> &'static str {
1453        if self.canonical_cache_root.lock().is_none() {
1454            "not_initialized"
1455        } else if self.is_worktree_bridge() {
1456            "worktree"
1457        } else {
1458            "main"
1459        }
1460    }
1461
1462    /// Access the persisted call graph store.
1463    pub fn callgraph_store(&self) -> &RwLock<Option<Arc<CallGraphStore>>> {
1464        &self.callgraph_store
1465    }
1466
1467    pub fn mark_callgraph_store_force_rebuild(&self) {
1468        *self.callgraph_store_force_rebuild.lock() = true;
1469    }
1470
1471    fn take_callgraph_store_force_rebuild(&self) -> bool {
1472        let mut force = self.callgraph_store_force_rebuild.lock();
1473        let was_forced = *force;
1474        *force = false;
1475        was_forced
1476    }
1477
1478    pub fn callgraph_store_dir(&self) -> PathBuf {
1479        match self.harness_opt() {
1480            Some(harness) => self
1481                .storage_dir()
1482                .join(harness.storage_segment())
1483                .join("callgraph"),
1484            None => self.storage_dir().join("callgraph"),
1485        }
1486    }
1487
1488    pub fn ensure_callgraph_store(
1489        &self,
1490    ) -> Result<Option<Arc<CallGraphStore>>, CallGraphStoreError> {
1491        self.ensure_callgraph_store_with_flag(true)
1492    }
1493
1494    fn ensure_callgraph_store_with_flag(
1495        &self,
1496        respect_config_flag: bool,
1497    ) -> Result<Option<Arc<CallGraphStore>>, CallGraphStoreError> {
1498        if respect_config_flag && !self.config().callgraph_store {
1499            return Ok(None);
1500        }
1501        if let Some(store) = {
1502            let guard = self
1503                .callgraph_store
1504                .read()
1505                .unwrap_or_else(std::sync::PoisonError::into_inner);
1506            guard.as_ref().map(Arc::clone)
1507        } {
1508            return Ok(Some(store));
1509        }
1510
1511        let Some(project_root) = self.callgraph_project_root() else {
1512            return Ok(None);
1513        };
1514        let callgraph_dir = self.callgraph_store_dir();
1515        let force_rebuild = self.take_callgraph_store_force_rebuild();
1516        let store = if self.is_worktree_bridge() {
1517            CallGraphStore::open_readonly(callgraph_dir, project_root)?
1518        } else if force_rebuild {
1519            let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1520            let (store, _stats) = CallGraphStore::cold_build_with_lease_chunked(
1521                callgraph_dir,
1522                project_root,
1523                &files,
1524                self.config().callgraph_chunk_size,
1525            )?;
1526            Some(store)
1527        } else if CallGraphStore::needs_cold_build(&callgraph_dir, &project_root)? {
1528            let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1529            let (store, _stats) = CallGraphStore::ensure_built_with_lease_chunked(
1530                callgraph_dir,
1531                project_root,
1532                &files,
1533                self.config().callgraph_chunk_size,
1534            )?;
1535            Some(store)
1536        } else {
1537            Some(CallGraphStore::open(callgraph_dir, project_root)?)
1538        };
1539
1540        let Some(store) = store else {
1541            return Ok(None);
1542        };
1543        let store = Arc::new(store);
1544        {
1545            let mut guard = self
1546                .callgraph_store
1547                .write()
1548                .unwrap_or_else(std::sync::PoisonError::into_inner);
1549            *guard = Some(Arc::clone(&store));
1550        }
1551        Ok(Some(store))
1552    }
1553
1554    /// Resolve the project root used for the callgraph store: prefer the
1555    /// canonical cache root, falling back to the configured project root.
1556    fn callgraph_project_root(&self) -> Option<PathBuf> {
1557        self.canonical_cache_root_opt().or_else(|| {
1558            self.config()
1559                .project_root
1560                .clone()
1561                .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
1562        })
1563    }
1564
1565    /// Access the persisted callgraph store for the five store-backed edge-query
1566    /// ops **without ever blocking the request thread on a cold build**.
1567    ///
1568    /// - Store resident          -> `Ready`.
1569    /// - Warm on-disk DB present  -> opened synchronously (cheap) -> `Ready`.
1570    /// - Genuine cold build needed -> kicked off in the background, returns
1571    ///   `Building`; the watcher keeps the store fresh once it lands.
1572    /// - Worktree without a built store, or not configured -> `Unavailable`.
1573    ///
1574    /// A build already in flight (`callgraph_store_rx` set) also returns
1575    /// `Building` without starting a second build.
1576    /// Drop the resident callgraph store when another process (or a local cold
1577    /// rebuild) has published a newer generation, so the next access reopens via
1578    /// the pointer. No-op when no store is resident, a build is in flight, or the
1579    /// store is still current. Must run before serving ops AND before any
1580    /// incremental write, so every process converges on the current generation
1581    /// rather than writing to a stale one.
1582    pub fn revalidate_callgraph_store_generation(&self) {
1583        // Never disturb the store while a background build's result is pending
1584        // install (the rx-install path replaces it wholesale).
1585        if self.callgraph_store_rx.lock().is_some() {
1586            return;
1587        }
1588        let superseded = {
1589            let guard = self
1590                .callgraph_store
1591                .read()
1592                .unwrap_or_else(std::sync::PoisonError::into_inner);
1593            guard.as_ref().is_some_and(|store| !store.is_current())
1594        };
1595        if superseded {
1596            let mut guard = self
1597                .callgraph_store
1598                .write()
1599                .unwrap_or_else(std::sync::PoisonError::into_inner);
1600            *guard = None;
1601        }
1602    }
1603
1604    pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess {
1605        // Converge to a newer generation another process (or a local cold
1606        // rebuild) may have published: if our resident store is superseded, drop
1607        // it so the open path below reopens via the pointer. Cheap pointer read.
1608        self.revalidate_callgraph_store_generation();
1609        if let Some(store) = {
1610            let guard = self
1611                .callgraph_store
1612                .read()
1613                .unwrap_or_else(std::sync::PoisonError::into_inner);
1614            guard.as_ref().map(Arc::clone)
1615        } {
1616            return CallgraphStoreAccess::Ready(store);
1617        }
1618
1619        // A background build is already running; don't start a second one.
1620        if self.callgraph_store_rx.lock().is_some() {
1621            return CallgraphStoreAccess::Building;
1622        }
1623
1624        let Some(project_root) = self.callgraph_project_root() else {
1625            return CallgraphStoreAccess::Unavailable;
1626        };
1627        let callgraph_dir = self.callgraph_store_dir();
1628
1629        // Worktree bridges are read-only: open whatever the main checkout built,
1630        // never cold-build here.
1631        if self.is_worktree_bridge() {
1632            match CallGraphStore::open_readonly(callgraph_dir, project_root) {
1633                Ok(Some(store)) => {
1634                    let store = Arc::new(store);
1635                    {
1636                        let mut guard = self
1637                            .callgraph_store
1638                            .write()
1639                            .unwrap_or_else(std::sync::PoisonError::into_inner);
1640                        *guard = Some(Arc::clone(&store));
1641                    }
1642                    return CallgraphStoreAccess::Ready(store);
1643                }
1644                Ok(None) | Err(_) => return CallgraphStoreAccess::Unavailable,
1645            }
1646        }
1647
1648        let force_rebuild = *self.callgraph_store_force_rebuild.lock();
1649        // Warm path: a fresh on-disk DB exists -> open synchronously (cheap, no
1650        // "building" delay). Only a genuine cold build goes to the background.
1651        if !force_rebuild {
1652            match CallGraphStore::needs_cold_build(&callgraph_dir, &project_root) {
1653                Ok(false) => match CallGraphStore::open(callgraph_dir, project_root) {
1654                    Ok(store) => {
1655                        let store = Arc::new(store);
1656                        {
1657                            let mut guard = self
1658                                .callgraph_store
1659                                .write()
1660                                .unwrap_or_else(std::sync::PoisonError::into_inner);
1661                            *guard = Some(Arc::clone(&store));
1662                        }
1663                        return CallgraphStoreAccess::Ready(store);
1664                    }
1665                    Err(error) => return CallgraphStoreAccess::Error(error),
1666                },
1667                Ok(true) => {}
1668                Err(error) => return CallgraphStoreAccess::Error(error),
1669            }
1670        }
1671
1672        if self.semantic_cold_seed_active() {
1673            self.defer_callgraph_store_warm_for_semantic_cold_seed();
1674            return CallgraphStoreAccess::Building;
1675        }
1676
1677        // Cold build required: run it off the request thread and return
1678        // `Building` so the agent retries (the watcher keeps the store fresh
1679        // once it lands). By default this never blocks the request thread.
1680        //
1681        // `AFT_CALLGRAPH_BUILD_WAIT_MS` (default 0) optionally waits a bounded
1682        // window inline for the build to land before returning `Building`; tests
1683        // set it large so fixture builds resolve to `Ready` synchronously.
1684        if !self.spawn_callgraph_store_cold_build(project_root, callgraph_dir, force_rebuild) {
1685            return CallgraphStoreAccess::Building;
1686        }
1687
1688        let wait = callgraph_build_wait_window();
1689        if !wait.is_zero() {
1690            let received = {
1691                let rx_ref = self.callgraph_store_rx.lock();
1692                let Some(rx) = rx_ref.as_ref() else {
1693                    return CallgraphStoreAccess::Building;
1694                };
1695                rx.recv_timeout(wait)
1696            };
1697            match received {
1698                Ok(store) => {
1699                    // Replay any source files the watcher saw during the wait so
1700                    // the installed store reflects mid-build edits (mirrors the
1701                    // drain install path). Empty in the common case.
1702                    let pending = self.take_pending_callgraph_store_paths();
1703                    if !pending.is_empty() {
1704                        if let Err(error) = store.refresh_files(&pending) {
1705                            crate::slog_warn!(
1706                                "callgraph store inline post-build refresh failed: {}",
1707                                error
1708                            );
1709                            let _ = store.mark_files_stale(&pending);
1710                        }
1711                    }
1712                    let store = Arc::new(store);
1713                    {
1714                        let mut guard = self
1715                            .callgraph_store
1716                            .write()
1717                            .unwrap_or_else(std::sync::PoisonError::into_inner);
1718                        *guard = Some(Arc::clone(&store));
1719                    }
1720                    *self.callgraph_store_rx.lock() = None;
1721                    return CallgraphStoreAccess::Ready(store);
1722                }
1723                Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1724                Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
1725                    // Build failed before sending; clear the receiver so a later
1726                    // op restarts the build instead of waiting on a dead channel.
1727                    *self.callgraph_store_rx.lock() = None;
1728                }
1729            }
1730        }
1731        CallgraphStoreAccess::Building
1732    }
1733
1734    /// Atomically mark a cold build in-flight and spawn the background builder.
1735    ///
1736    /// The `callgraph_store_rx` lock covers the full check + receiver install +
1737    /// thread spawn sequence, so concurrent cold callers cannot both observe an
1738    /// empty in-flight slot and double-spawn builders. Returns `false` when
1739    /// another caller already has a build in flight.
1740    fn spawn_callgraph_store_cold_build(
1741        &self,
1742        project_root: PathBuf,
1743        callgraph_dir: PathBuf,
1744        force_rebuild: bool,
1745    ) -> bool {
1746        let session_id = crate::log_ctx::current_session();
1747        let chunk_size = self.config().callgraph_chunk_size;
1748
1749        let mut rx_guard = self.callgraph_store_rx.lock();
1750        if rx_guard.is_some() {
1751            return false;
1752        }
1753
1754        if force_rebuild {
1755            // Consume the force flag now so a follow-up request doesn't queue a
1756            // second forced build while this one is in flight.
1757            self.take_callgraph_store_force_rebuild();
1758        }
1759        let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStore>();
1760        *rx_guard = Some(rx);
1761
1762        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.fetch_add(1, Ordering::SeqCst);
1763
1764        std::thread::spawn(move || {
1765            crate::log_ctx::with_session(session_id, || {
1766                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1767                let built = if force_rebuild {
1768                    CallGraphStore::cold_build_with_lease_chunked(
1769                        callgraph_dir,
1770                        project_root,
1771                        &files,
1772                        chunk_size,
1773                    )
1774                    .map(|(store, _)| store)
1775                } else {
1776                    CallGraphStore::ensure_built_with_lease_chunked(
1777                        callgraph_dir,
1778                        project_root,
1779                        &files,
1780                        chunk_size,
1781                    )
1782                    .map(|(store, _)| store)
1783                };
1784                match built {
1785                    Ok(store) => {
1786                        let _ = tx.send(store);
1787                    }
1788                    Err(error) => {
1789                        crate::slog_warn!("callgraph store cold build failed: {}", error);
1790                        // Dropping tx disconnects the channel; the drain clears
1791                        // the receiver so a later op can retry the build.
1792                    }
1793                }
1794            });
1795        });
1796        true
1797    }
1798
1799    /// Access the callgraph-store background-build receiver (drained by the
1800    /// main loop once the cold build completes).
1801    pub fn callgraph_store_rx(
1802        &self,
1803    ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStore>>> {
1804        &self.callgraph_store_rx
1805    }
1806
1807    /// Record source-file paths that changed while a cold build was in flight,
1808    /// so they can be refreshed once the freshly-built store is installed.
1809    pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
1810    where
1811        I: IntoIterator<Item = PathBuf>,
1812    {
1813        self.pending_callgraph_store_paths.lock().extend(paths);
1814    }
1815
1816    /// Take and clear the paths that changed during a background cold build.
1817    pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
1818        std::mem::take(&mut *self.pending_callgraph_store_paths.lock())
1819            .into_iter()
1820            .collect()
1821    }
1822
1823    /// Access the search index.
1824    pub fn search_index(&self) -> &RwLock<Option<SearchIndex>> {
1825        &self.search_index
1826    }
1827
1828    /// Access the search-index build receiver.
1829    pub fn search_index_rx(&self) -> &RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>> {
1830        &self.search_index_rx
1831    }
1832
1833    pub fn add_pending_search_index_paths<I>(&self, paths: I)
1834    where
1835        I: IntoIterator<Item = PathBuf>,
1836    {
1837        self.pending_search_index_paths.lock().extend(paths);
1838    }
1839
1840    pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
1841        std::mem::take(&mut *self.pending_search_index_paths.lock())
1842            .into_iter()
1843            .collect()
1844    }
1845
1846    pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
1847    where
1848        I: IntoIterator<Item = PathBuf>,
1849    {
1850        self.pending_semantic_index_paths.lock().extend(paths);
1851    }
1852
1853    pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
1854        std::mem::take(&mut *self.pending_semantic_index_paths.lock())
1855            .into_iter()
1856            .collect()
1857    }
1858
1859    pub fn mark_pending_semantic_corpus_refresh(&self) {
1860        *self.pending_semantic_corpus_refresh.lock() = true;
1861    }
1862
1863    pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
1864        std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock())
1865    }
1866
1867    pub fn clear_pending_index_updates(&self) {
1868        self.pending_search_index_paths.lock().clear();
1869        self.pending_callgraph_store_paths.lock().clear();
1870        self.pending_tier2_paths.lock().clear();
1871        self.pending_semantic_index_paths.lock().clear();
1872        *self.pending_semantic_corpus_refresh.lock() = false;
1873    }
1874
1875    pub fn inspect_manager(&self) -> Arc<InspectManager> {
1876        Arc::clone(&self.inspect_manager)
1877    }
1878
1879    pub fn add_pending_tier2_paths<I>(&self, paths: I)
1880    where
1881        I: IntoIterator<Item = PathBuf>,
1882    {
1883        self.pending_tier2_paths.lock().extend(paths);
1884    }
1885
1886    pub fn pending_tier2_paths(&self) -> Vec<PathBuf> {
1887        self.pending_tier2_paths.lock().iter().cloned().collect()
1888    }
1889
1890    pub fn remove_pending_tier2_paths<I>(&self, paths: I)
1891    where
1892        I: IntoIterator<Item = PathBuf>,
1893    {
1894        let mut pending = self.pending_tier2_paths.lock();
1895        for path in paths {
1896            pending.remove(&path);
1897        }
1898    }
1899
1900    /// Returns true when one or more watcher-driven (reuse-path) Tier-2 scans
1901    /// have completed since the last call, advancing the last-seen marker. The
1902    /// per-request inspect drain uses this to refresh the status bar after a
1903    /// background scan — those completions bypass `drain_completions`.
1904    pub fn take_new_reuse_completions(&self) -> bool {
1905        let current = self.inspect_manager.reuse_completion_count();
1906        let previous = self
1907            .last_seen_reuse_completions
1908            .swap(current, Ordering::SeqCst);
1909        current != previous
1910    }
1911
1912    pub fn reset_tier2_refresh_scheduler(&self) {
1913        self.reset_tier2_refresh_scheduler_at(Instant::now());
1914    }
1915
1916    #[doc(hidden)]
1917    pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
1918        self.tier2_refresh_scheduler
1919            .lock()
1920            .reset_after_configure(now);
1921    }
1922
1923    pub fn request_tier2_refresh_pull(&self) -> bool {
1924        self.tier2_refresh_scheduler
1925            .lock()
1926            .request_pull(!self.is_worktree_bridge())
1927    }
1928
1929    pub fn tick_tier2_refresh_scheduler(
1930        &self,
1931        changed_path_count: usize,
1932    ) -> Option<Tier2TriggerReason> {
1933        self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
1934    }
1935
1936    #[doc(hidden)]
1937    pub fn tick_tier2_refresh_scheduler_at(
1938        &self,
1939        now: Instant,
1940        changed_path_count: usize,
1941    ) -> Option<Tier2TriggerReason> {
1942        let manager = self.inspect_manager();
1943        let can_write = !self.is_worktree_bridge();
1944        let in_flight = manager.tier2_any_in_flight();
1945        let semantic_cold_seed_active = self.semantic_cold_seed_active();
1946        let decision = self.tier2_refresh_scheduler.lock().tick_with_semantic_gate(
1947            now,
1948            changed_path_count,
1949            can_write,
1950            in_flight,
1951            semantic_cold_seed_active,
1952        );
1953
1954        if let Some(reason) = decision {
1955            self.start_tier2_refresh(reason, manager);
1956        }
1957
1958        decision
1959    }
1960
1961    pub fn note_tier2_refresh_started(&self) {
1962        self.note_tier2_refresh_started_at(Instant::now());
1963    }
1964
1965    #[doc(hidden)]
1966    pub fn note_tier2_refresh_started_at(&self, now: Instant) {
1967        self.tier2_refresh_scheduler
1968            .lock()
1969            .note_external_scan_started(now);
1970    }
1971
1972    pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
1973        self.tier2_refresh_scheduler
1974            .lock()
1975            .last_trigger_reason()
1976            .map(Tier2TriggerReason::as_str)
1977    }
1978
1979    #[doc(hidden)]
1980    pub fn tier2_pull_demand_pending(&self) -> bool {
1981        self.tier2_refresh_scheduler.lock().pull_demand_pending()
1982    }
1983
1984    fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
1985        if self.is_worktree_bridge()
1986            || self
1987                .degraded_reasons
1988                .lock()
1989                .iter()
1990                .any(|r| r == "home_root")
1991            || !self.config().inspect.enabled
1992        {
1993            return;
1994        }
1995        let Some(snapshot) = self.tier2_refresh_snapshot() else {
1996            return;
1997        };
1998        let categories = InspectCategory::active()
1999            .iter()
2000            .copied()
2001            .filter(|category| category.is_tier2())
2002            .collect::<Vec<_>>();
2003        let submission =
2004            manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
2005        if submission.has_new_work() {
2006            crate::slog_info!(
2007                "tier2 refresh scheduled: reason={}, categories={:?}",
2008                reason.as_str(),
2009                submission
2010                    .newly_queued_categories
2011                    .iter()
2012                    .map(|category| category.as_str())
2013                    .collect::<Vec<_>>()
2014            );
2015        }
2016        for error in submission.errors {
2017            crate::slog_warn!(
2018                "tier2 refresh schedule failed for {}: {}",
2019                error.category,
2020                error.message
2021            );
2022        }
2023    }
2024
2025    fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
2026        self.harness_opt()?;
2027        let config = self.config();
2028        let project_root = config
2029            .project_root
2030            .clone()
2031            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
2032        let project_root = std::fs::canonicalize(&project_root).unwrap_or(project_root);
2033        Some(InspectSnapshot::new(
2034            project_root,
2035            self.inspect_dir(),
2036            config,
2037            self.symbol_cache(),
2038        ))
2039    }
2040
2041    /// Access the shared symbol cache.
2042    pub fn symbol_cache(&self) -> SharedSymbolCache {
2043        Arc::clone(&self.symbol_cache)
2044    }
2045
2046    /// Clear the shared symbol cache and return the new active generation.
2047    pub fn reset_symbol_cache(&self) -> u64 {
2048        self.symbol_cache
2049            .write()
2050            .map(|mut cache| cache.reset())
2051            .unwrap_or(0)
2052    }
2053
2054    /// Access the semantic search index.
2055    pub fn semantic_index(&self) -> &RwLock<Option<SemanticIndex>> {
2056        &self.semantic_index
2057    }
2058
2059    /// Access the semantic-index build receiver.
2060    pub fn semantic_index_rx(
2061        &self,
2062    ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
2063        &self.semantic_index_rx
2064    }
2065
2066    pub fn semantic_index_status(&self) -> &RwLock<SemanticIndexStatus> {
2067        &self.semantic_index_status
2068    }
2069
2070    /// Reset this context's cold semantic seed gate for a newly accepted
2071    /// configure and return the generation token for the worker being spawned.
2072    pub fn reset_semantic_cold_seed_gate_for_configure(&self) -> u64 {
2073        self.semantic_cold_seed_active
2074            .store(false, Ordering::SeqCst);
2075        self.semantic_callgraph_warm_deferred
2076            .store(false, Ordering::SeqCst);
2077        self.semantic_cold_seed_generation
2078            .fetch_add(1, Ordering::SeqCst)
2079            .wrapping_add(1)
2080    }
2081
2082    pub fn semantic_cold_seed_active_flag(&self) -> Arc<AtomicBool> {
2083        Arc::clone(&self.semantic_cold_seed_active)
2084    }
2085
2086    pub fn semantic_cold_seed_generation_flag(&self) -> Arc<AtomicU64> {
2087        Arc::clone(&self.semantic_cold_seed_generation)
2088    }
2089
2090    pub fn semantic_cold_seed_active(&self) -> bool {
2091        self.semantic_cold_seed_active.load(Ordering::SeqCst)
2092    }
2093
2094    pub fn schedule_semantic_cold_seed_gate_for_configure(&self) {
2095        self.semantic_cold_seed_active.store(true, Ordering::SeqCst);
2096    }
2097
2098    pub fn defer_callgraph_store_warm_for_semantic_cold_seed(&self) {
2099        self.semantic_callgraph_warm_deferred
2100            .store(true, Ordering::SeqCst);
2101    }
2102
2103    fn semantic_callgraph_warm_deferred(&self) -> bool {
2104        self.semantic_callgraph_warm_deferred.load(Ordering::SeqCst)
2105    }
2106
2107    /// Clear the cold-seed gate and resume work that was intentionally held back
2108    /// while the full semantic corpus was accumulating. This entry point is used
2109    /// by the code that drains events from the semantic worker.
2110    pub fn clear_semantic_cold_seed_gate_and_resume_deferred_work(&self) {
2111        self.resume_semantic_cold_seed_deferred_work(false);
2112    }
2113
2114    /// Resume work after the semantic worker has already cleared the atomic gate
2115    /// itself, such as on cached-index load or before a retry backoff sleep.
2116    pub fn resume_deferred_work_after_semantic_cold_seed_gate_cleared(&self) {
2117        self.resume_semantic_cold_seed_deferred_work(true);
2118    }
2119
2120    fn resume_semantic_cold_seed_deferred_work(&self, force: bool) {
2121        let was_active = self.semantic_cold_seed_active.swap(false, Ordering::SeqCst);
2122        let had_deferred_callgraph = self.semantic_callgraph_warm_deferred();
2123
2124        if force || was_active || had_deferred_callgraph {
2125            let _ = self.request_tier2_refresh_pull();
2126        }
2127
2128        if self
2129            .semantic_callgraph_warm_deferred
2130            .swap(false, Ordering::SeqCst)
2131        {
2132            if !self.config().callgraph_store
2133                || self
2134                    .degraded_reasons
2135                    .lock()
2136                    .iter()
2137                    .any(|reason| reason == "home_root")
2138            {
2139                return;
2140            }
2141
2142            match self.callgraph_store_for_ops() {
2143                CallgraphStoreAccess::Ready(_) => {
2144                    crate::slog_debug!(
2145                        "deferred callgraph store warm completed after semantic cold seed gate cleared"
2146                    );
2147                }
2148                CallgraphStoreAccess::Building => {
2149                    crate::slog_info!(
2150                        "deferred callgraph store warm scheduled after semantic cold seed gate cleared"
2151                    );
2152                }
2153                CallgraphStoreAccess::Unavailable => {
2154                    crate::slog_info!(
2155                        "deferred callgraph store warm unavailable after semantic cold seed gate cleared"
2156                    );
2157                }
2158                CallgraphStoreAccess::Error(error) => {
2159                    crate::slog_warn!(
2160                        "deferred callgraph store warm failed after semantic cold seed gate cleared: {}",
2161                        error
2162                    );
2163                }
2164            }
2165        }
2166    }
2167
2168    #[doc(hidden)]
2169    pub fn set_semantic_cold_seed_active_for_test(&self, active: bool) {
2170        self.semantic_cold_seed_active
2171            .store(active, Ordering::SeqCst);
2172    }
2173
2174    #[doc(hidden)]
2175    pub fn semantic_callgraph_warm_deferred_for_test(&self) -> bool {
2176        self.semantic_callgraph_warm_deferred()
2177    }
2178
2179    pub fn install_semantic_refresh_worker(
2180        &self,
2181        sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
2182        event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
2183        worker_slot: SemanticRefreshWorkerSlot,
2184    ) {
2185        self.clear_semantic_refresh_worker();
2186        *self.semantic_refresh_tx.lock() = Some(sender);
2187        *self.semantic_refresh_event_rx.lock() = Some(event_rx);
2188        *self.semantic_refresh_worker.lock() = Some(worker_slot);
2189    }
2190
2191    pub fn clear_semantic_refresh_worker(&self) {
2192        *self.semantic_refresh_tx.lock() = None;
2193        *self.semantic_refresh_event_rx.lock() = None;
2194        if let Some(worker_slot) = self.semantic_refresh_worker.lock().take() {
2195            if let Ok(mut handle) = worker_slot.lock() {
2196                drop(handle.take());
2197            }
2198        }
2199    }
2200
2201    pub fn semantic_refresh_sender(
2202        &self,
2203    ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
2204        self.semantic_refresh_tx.lock().clone()
2205    }
2206
2207    pub fn semantic_refresh_event_rx(
2208        &self,
2209    ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
2210        &self.semantic_refresh_event_rx
2211    }
2212
2213    pub fn with_semantic_refresh_retry_attempts_mut<R>(
2214        &self,
2215        f: impl FnOnce(&mut BTreeMap<PathBuf, usize>) -> R,
2216    ) -> R {
2217        let mut attempts = self.semantic_refresh_retry_attempts.lock();
2218        f(&mut attempts)
2219    }
2220
2221    pub fn clear_semantic_refresh_retry_attempts(&self, paths: &[PathBuf]) {
2222        let mut attempts = self.semantic_refresh_retry_attempts.lock();
2223        for path in paths {
2224            attempts.remove(path);
2225        }
2226    }
2227
2228    pub fn clear_all_semantic_refresh_retry_attempts(&self) {
2229        self.semantic_refresh_retry_attempts.lock().clear();
2230    }
2231
2232    pub fn semantic_refresh_circuit_is_open(&self) -> bool {
2233        self.semantic_refresh_circuit.open.load(Ordering::SeqCst)
2234    }
2235
2236    pub fn record_semantic_refresh_transient_failure(&self, trip_threshold: usize) -> bool {
2237        let failures = self
2238            .semantic_refresh_circuit
2239            .consecutive_transient_failures
2240            .fetch_add(1, Ordering::SeqCst)
2241            .saturating_add(1);
2242        if failures >= trip_threshold
2243            && !self
2244                .semantic_refresh_circuit
2245                .open
2246                .swap(true, Ordering::SeqCst)
2247        {
2248            crate::slog_warn!(
2249                "embedding backend appears down; suspending active retries, will resume on next change or successful probe"
2250            );
2251        }
2252        self.semantic_refresh_circuit_is_open()
2253    }
2254
2255    pub fn reset_semantic_refresh_transient_failure_count(&self) {
2256        self.semantic_refresh_circuit
2257            .consecutive_transient_failures
2258            .store(0, Ordering::SeqCst);
2259    }
2260
2261    pub fn reset_semantic_refresh_circuit_after_success(&self) {
2262        self.reset_semantic_refresh_transient_failure_count();
2263        self.semantic_refresh_circuit
2264            .probe_ready
2265            .store(false, Ordering::SeqCst);
2266        if self
2267            .semantic_refresh_circuit
2268            .open
2269            .swap(false, Ordering::SeqCst)
2270        {
2271            crate::slog_info!("embedding backend recovered; resuming normal refresh retries");
2272        }
2273    }
2274
2275    pub fn semantic_refresh_transient_failure_count(&self) -> usize {
2276        self.semantic_refresh_circuit
2277            .consecutive_transient_failures
2278            .load(Ordering::SeqCst)
2279    }
2280
2281    pub fn semantic_refresh_probe_is_scheduled(&self) -> bool {
2282        self.semantic_refresh_circuit
2283            .probe_in_flight
2284            .load(Ordering::SeqCst)
2285            || self
2286                .semantic_refresh_circuit
2287                .probe_ready
2288                .load(Ordering::SeqCst)
2289    }
2290
2291    pub fn take_semantic_refresh_probe_ready(&self) -> bool {
2292        self.semantic_refresh_circuit
2293            .probe_ready
2294            .swap(false, Ordering::SeqCst)
2295    }
2296
2297    pub fn ensure_semantic_refresh_probe_scheduled(&self, delay: Duration) {
2298        if self
2299            .semantic_refresh_circuit
2300            .probe_ready
2301            .load(Ordering::SeqCst)
2302        {
2303            return;
2304        }
2305        if self
2306            .semantic_refresh_circuit
2307            .probe_in_flight
2308            .swap(true, Ordering::SeqCst)
2309        {
2310            return;
2311        }
2312        if self
2313            .semantic_refresh_circuit
2314            .probe_ready
2315            .load(Ordering::SeqCst)
2316        {
2317            self.semantic_refresh_circuit
2318                .probe_in_flight
2319                .store(false, Ordering::SeqCst);
2320            return;
2321        }
2322
2323        let circuit = Arc::clone(&self.semantic_refresh_circuit);
2324        let session_id = crate::log_ctx::current_session();
2325        std::thread::spawn(move || {
2326            crate::log_ctx::with_session(session_id, || {
2327                std::thread::sleep(delay);
2328                circuit.probe_ready.store(true, Ordering::SeqCst);
2329                circuit.probe_in_flight.store(false, Ordering::SeqCst);
2330            });
2331        });
2332    }
2333
2334    /// Access the cached semantic embedding model.
2335    pub fn semantic_embedding_model(
2336        &self,
2337    ) -> &parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>> {
2338        &self.semantic_embedding_model
2339    }
2340
2341    /// Access the file watcher handle (kept alive to continue watching).
2342    pub fn watcher(&self) -> &parking_lot::Mutex<Option<RecommendedWatcher>> {
2343        &self.watcher
2344    }
2345
2346    /// Access the pre-filtered watcher event receiver.
2347    pub fn watcher_rx(
2348        &self,
2349    ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>> {
2350        &self.watcher_rx
2351    }
2352
2353    /// Install a watcher filter thread and its dispatch receiver. The caller
2354    /// must have stopped any previous watcher runtime first.
2355    pub fn install_watcher_runtime(
2356        &self,
2357        rx: crossbeam_channel::Receiver<WatcherDispatchEvent>,
2358        runtime: WatcherThreadHandle,
2359    ) {
2360        *self.watcher_rx.lock() = Some(rx);
2361        *self.watcher_thread.lock() = Some(runtime);
2362    }
2363
2364    /// Stop the watcher filter thread (if any) and clear the dispatch receiver.
2365    /// Used on reconfigure, watcher failure, root deletion, and test teardown.
2366    pub fn stop_watcher_runtime(&self) {
2367        if let Some(runtime) = self.watcher_thread.lock().take() {
2368            runtime.shutdown_and_join();
2369        }
2370        *self.watcher_rx.lock() = None;
2371        *self.watcher.lock() = None;
2372    }
2373
2374    /// Access the LSP manager.
2375    pub fn lsp(&self) -> parking_lot::MutexGuard<'_, LspManager> {
2376        self.lsp_manager.lock()
2377    }
2378
2379    /// Notify LSP servers that a file was written.
2380    /// Call this after write_format_validate in command handlers.
2381    pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
2382        let config = self.config();
2383        if let Some(mut lsp) = self.lsp_manager.try_lock() {
2384            if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
2385                crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
2386            }
2387        }
2388    }
2389
2390    /// Drop cached LSP diagnostics for a deleted/renamed-away file so its
2391    /// errors/warnings don't linger in the warm set (no server republishes for
2392    /// a vanished path), keeping the status bar and `aft_inspect` honest.
2393    /// Returns true if any entry was removed. Best-effort: a contended borrow is
2394    /// skipped silently (the watcher drain retries on subsequent events).
2395    pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
2396        if let Some(mut lsp) = self.lsp_manager.try_lock() {
2397            lsp.clear_diagnostics_for_file(file_path)
2398        } else {
2399            false
2400        }
2401    }
2402
2403    /// Notify LSP and optionally wait for diagnostics.
2404    ///
2405    /// Call this after `write_format_validate` when the request has `"diagnostics": true`.
2406    /// Sends didChange to the server, waits briefly for publishDiagnostics, and returns
2407    /// any diagnostics for the file. If no server is running, returns empty immediately.
2408    ///
2409    /// v0.17.3: this is the version-aware path. Pre-edit cached diagnostics
2410    /// are NEVER returned — only entries whose `version` matches the
2411    /// post-edit document version (or, for unversioned servers, whose
2412    /// `epoch` advanced past the pre-edit snapshot).
2413    pub fn lsp_notify_and_collect_diagnostics(
2414        &self,
2415        file_path: &Path,
2416        content: &str,
2417        timeout: std::time::Duration,
2418    ) -> crate::lsp::manager::PostEditWaitOutcome {
2419        let config = self.config();
2420        let Some(mut lsp) = self.lsp_manager.try_lock() else {
2421            return crate::lsp::manager::PostEditWaitOutcome::default();
2422        };
2423
2424        // Clear any queued notifications before this write so the wait loop only
2425        // observes diagnostics triggered by the current change.
2426        lsp.drain_events();
2427
2428        // Snapshot per-server epochs and document versions BEFORE sending
2429        // didChange so the wait loop can prove freshness without accepting
2430        // stale pre-edit publishes that arrived late.
2431        let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
2432
2433        // Send didChange/didOpen and capture per-server target version.
2434        let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
2435        {
2436            Ok(v) => v,
2437            Err(e) => {
2438                crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
2439                return crate::lsp::manager::PostEditWaitOutcome::default();
2440            }
2441        };
2442
2443        // No server matched this file — return an empty outcome that's
2444        // honestly `complete: true` (nothing to wait for).
2445        if expected_versions.is_empty() {
2446            return crate::lsp::manager::PostEditWaitOutcome::default();
2447        }
2448
2449        lsp.wait_for_post_edit_diagnostics(
2450            file_path,
2451            &config,
2452            &expected_versions,
2453            &pre_snapshot,
2454            timeout,
2455        )
2456    }
2457
2458    /// Collect custom server root_markers from user config for use in
2459    /// `is_config_file_path_with_custom` checks (#25).
2460    fn custom_lsp_root_markers(&self) -> Vec<String> {
2461        self.config()
2462            .lsp_servers
2463            .iter()
2464            .flat_map(|s| s.root_markers.iter().cloned())
2465            .collect()
2466    }
2467
2468    fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
2469        let custom_markers = self.custom_lsp_root_markers();
2470        let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
2471            .iter()
2472            .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
2473            .cloned()
2474            .map(|path| {
2475                let change_type = if path.exists() {
2476                    FileChangeType::CHANGED
2477                } else {
2478                    FileChangeType::DELETED
2479                };
2480                (path, change_type)
2481            })
2482            .collect();
2483
2484        self.notify_watched_config_events(&config_paths);
2485    }
2486
2487    fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
2488        let paths = params
2489            .get("multi_file_write_paths")
2490            .and_then(|value| value.as_array())?
2491            .iter()
2492            .filter_map(|value| value.as_str())
2493            .map(PathBuf::from)
2494            .collect::<Vec<_>>();
2495
2496        (!paths.is_empty()).then_some(paths)
2497    }
2498
2499    /// Parse config-file watched events from `multi_file_write_paths` when the
2500    /// array contains object entries `{ "path": "...", "type": "created|changed|deleted" }`.
2501    ///
2502    /// This handles the OBJECT variant of `multi_file_write_paths`. The STRING
2503    /// variant (bare path strings) is handled by `multi_file_write_paths()` and
2504    /// `notify_watched_config_files()`. Both variants read the same JSON key but
2505    /// with different per-entry schemas — they are NOT redundant.
2506    ///
2507    /// #18 note: in older code this function also existed alongside `multi_file_write_paths()`
2508    /// and was reachable via the `else if` branch when all entries were objects.
2509    /// Restoring both is correct.
2510    fn watched_file_events_from_params(
2511        params: &serde_json::Value,
2512        extra_markers: &[String],
2513    ) -> Option<Vec<(PathBuf, FileChangeType)>> {
2514        let events = params
2515            .get("multi_file_write_paths")
2516            .and_then(|value| value.as_array())?
2517            .iter()
2518            .filter_map(|entry| {
2519                // Only handle object entries — string entries go through multi_file_write_paths()
2520                let path = entry
2521                    .get("path")
2522                    .and_then(|value| value.as_str())
2523                    .map(PathBuf::from)?;
2524
2525                if !is_config_file_path_with_custom(&path, extra_markers) {
2526                    return None;
2527                }
2528
2529                let change_type = entry
2530                    .get("type")
2531                    .and_then(|value| value.as_str())
2532                    .and_then(Self::parse_file_change_type)
2533                    .unwrap_or_else(|| Self::change_type_from_current_state(&path));
2534
2535                Some((path, change_type))
2536            })
2537            .collect::<Vec<_>>();
2538
2539        (!events.is_empty()).then_some(events)
2540    }
2541
2542    fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
2543        match value {
2544            "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
2545            "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
2546            "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
2547            _ => None,
2548        }
2549    }
2550
2551    fn change_type_from_current_state(path: &Path) -> FileChangeType {
2552        if path.exists() {
2553            FileChangeType::CHANGED
2554        } else {
2555            FileChangeType::DELETED
2556        }
2557    }
2558
2559    fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
2560        if config_paths.is_empty() {
2561            return;
2562        }
2563
2564        let config = self.config();
2565        if let Some(mut lsp) = self.lsp_manager.try_lock() {
2566            if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
2567                crate::slog_warn!("watched-file sync error: {}", e);
2568            }
2569        }
2570    }
2571
2572    pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
2573        let custom_markers = self.custom_lsp_root_markers();
2574        if !is_config_file_path_with_custom(file_path, &custom_markers) {
2575            return;
2576        }
2577
2578        self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
2579    }
2580
2581    /// Post-write LSP hook for multi-file edits. When the patch includes
2582    /// config-file edits, notify active workspace servers via
2583    /// `workspace/didChangeWatchedFiles` before sending the per-document
2584    /// didOpen/didChange for the current file.
2585    pub fn lsp_post_multi_file_write(
2586        &self,
2587        file_path: &Path,
2588        content: &str,
2589        file_paths: &[PathBuf],
2590        params: &serde_json::Value,
2591    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2592        self.notify_watched_config_files(file_paths);
2593        self.add_pending_tier2_paths(file_paths.iter().cloned());
2594        let _ = self.mark_status_bar_tier2_stale();
2595
2596        let wants_diagnostics = params
2597            .get("diagnostics")
2598            .and_then(|v| v.as_bool())
2599            .unwrap_or(false);
2600
2601        if !wants_diagnostics {
2602            self.lsp_notify_file_changed(file_path, content);
2603            return None;
2604        }
2605
2606        let wait_ms = params
2607            .get("wait_ms")
2608            .and_then(|v| v.as_u64())
2609            .unwrap_or(3000)
2610            .min(10_000);
2611
2612        Some(self.lsp_notify_and_collect_diagnostics(
2613            file_path,
2614            content,
2615            std::time::Duration::from_millis(wait_ms),
2616        ))
2617    }
2618
2619    /// Post-write LSP hook: notify server and optionally collect diagnostics.
2620    ///
2621    /// This is the single call site for all command handlers after `write_format_validate`.
2622    /// Behavior:
2623    /// - When `diagnostics: true` is in `params`, notifies the server, waits
2624    ///   until matching diagnostics arrive or the timeout expires, and returns
2625    ///   `Some(outcome)` with the verified-fresh diagnostics + per-server
2626    ///   status.
2627    /// - When `diagnostics: false` (or absent), just notifies (fire-and-forget)
2628    ///   and returns `None`. Callers must NOT wrap this in `Some(...)`; the
2629    ///   `None` is what tells the response builder to omit the LSP fields
2630    ///   entirely (preserves the no-diagnostics-requested response shape).
2631    ///
2632    /// v0.17.3: default `wait_ms` raised from 1500 to 3000 because real-world
2633    /// tsserver re-analysis on monorepo files routinely takes 2-5s. Still
2634    /// capped at 10000ms.
2635    pub fn lsp_post_write(
2636        &self,
2637        file_path: &Path,
2638        content: &str,
2639        params: &serde_json::Value,
2640    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2641        let wants_diagnostics = params
2642            .get("diagnostics")
2643            .and_then(|v| v.as_bool())
2644            .unwrap_or(false);
2645
2646        let custom_markers = self.custom_lsp_root_markers();
2647        if let Some(file_paths) = Self::multi_file_write_paths(params) {
2648            self.add_pending_tier2_paths(file_paths);
2649        } else {
2650            self.add_pending_tier2_paths([file_path.to_path_buf()]);
2651        }
2652        let _ = self.mark_status_bar_tier2_stale();
2653
2654        if !wants_diagnostics {
2655            if let Some(file_paths) = Self::multi_file_write_paths(params) {
2656                self.notify_watched_config_files(&file_paths);
2657            } else if let Some(config_events) =
2658                Self::watched_file_events_from_params(params, &custom_markers)
2659            {
2660                self.notify_watched_config_events(&config_events);
2661            }
2662            self.lsp_notify_file_changed(file_path, content);
2663            return None;
2664        }
2665
2666        let wait_ms = params
2667            .get("wait_ms")
2668            .and_then(|v| v.as_u64())
2669            .unwrap_or(3000)
2670            .min(10_000); // Cap at 10 seconds to prevent hangs from adversarial input
2671
2672        if let Some(file_paths) = Self::multi_file_write_paths(params) {
2673            return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
2674        }
2675
2676        if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
2677        {
2678            self.notify_watched_config_events(&config_events);
2679        }
2680
2681        Some(self.lsp_notify_and_collect_diagnostics(
2682            file_path,
2683            content,
2684            std::time::Duration::from_millis(wait_ms),
2685        ))
2686    }
2687
2688    /// Validate that a file path falls within the configured project root.
2689    ///
2690    /// When `project_root` is configured (normal plugin usage), this resolves the
2691    /// path and checks it starts with the root. Returns the canonicalized path on
2692    /// success, or an error response on violation.
2693    ///
2694    /// When no `project_root` is configured (direct CLI usage), all paths pass
2695    /// through unrestricted for backward compatibility.
2696    pub fn validate_path(
2697        &self,
2698        req_id: &str,
2699        path: &Path,
2700    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
2701        let config = self.config();
2702        // When restrict_to_project_root is false (default), allow all paths
2703        if !config.restrict_to_project_root {
2704            return Ok(path.to_path_buf());
2705        }
2706        let root = match &config.project_root {
2707            Some(r) => r.clone(),
2708            None => return Ok(path.to_path_buf()), // No root configured, allow all
2709        };
2710        drop(config);
2711
2712        // Keep the raw root for symlink-guard comparisons. On macOS, tempdir()
2713        // returns /var/... paths while canonicalize gives /private/var/...; we
2714        // need both forms so reject_escaping_symlink can recognise in-root
2715        // symlinks regardless of which prefix form `current` happens to have.
2716        let raw_root = root.clone();
2717        let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
2718
2719        // Resolve the path (follow symlinks, normalize ..). If canonicalization
2720        // fails (e.g. path does not exist or traverses a broken symlink), inspect
2721        // every existing component with lstat before falling back lexically so a
2722        // broken in-root symlink cannot be used to write outside project_root.
2723        let path_for_resolution = if path.is_relative() {
2724            raw_root.join(path)
2725        } else {
2726            path.to_path_buf()
2727        };
2728        let resolved = match std::fs::canonicalize(&path_for_resolution) {
2729            Ok(resolved) => resolved,
2730            Err(_) => {
2731                let normalized = normalize_path(&path_for_resolution);
2732                reject_escaping_symlink(
2733                    req_id,
2734                    &path_for_resolution,
2735                    &normalized,
2736                    &resolved_root,
2737                    &raw_root,
2738                )?;
2739                resolve_with_existing_ancestors(&normalized)
2740            }
2741        };
2742
2743        if !resolved.starts_with(&resolved_root) {
2744            return Err(path_error_response(req_id, path, &resolved_root));
2745        }
2746
2747        Ok(resolved)
2748    }
2749
2750    /// Count active LSP server instances.
2751    pub fn lsp_server_count(&self) -> usize {
2752        self.lsp_manager
2753            .try_lock()
2754            .map(|lsp| lsp.server_count())
2755            .unwrap_or(0)
2756    }
2757
2758    /// Symbol cache statistics from the language provider.
2759    pub fn symbol_cache_stats(&self) -> serde_json::Value {
2760        let entries = self
2761            .symbol_cache
2762            .read()
2763            .map(|cache| cache.len())
2764            .unwrap_or(0);
2765        serde_json::json!({
2766            "local_entries": entries,
2767            "warm_entries": 0,
2768        })
2769    }
2770}
2771
2772#[cfg(test)]
2773mod callgraph_store_for_ops_tests {
2774    use super::*;
2775    use crate::parser::TreeSitterProvider;
2776    use std::ffi::OsString;
2777    use std::sync::{Barrier, Mutex as StdMutex, MutexGuard, OnceLock};
2778    use tempfile::TempDir;
2779
2780    struct CallgraphWaitWindowEnvGuard {
2781        _guard: MutexGuard<'static, ()>,
2782        previous: Option<OsString>,
2783    }
2784
2785    impl Drop for CallgraphWaitWindowEnvGuard {
2786        fn drop(&mut self) {
2787            // SAFETY: serialized by the process-local guard held for this
2788            // helper's lifetime, and restored before the guard is released.
2789            unsafe {
2790                match &self.previous {
2791                    Some(value) => std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", value),
2792                    None => std::env::remove_var("AFT_CALLGRAPH_BUILD_WAIT_MS"),
2793                }
2794            }
2795        }
2796    }
2797
2798    fn force_async_callgraph_builds() -> CallgraphWaitWindowEnvGuard {
2799        static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
2800        let guard = LOCK
2801            .get_or_init(|| StdMutex::new(()))
2802            .lock()
2803            .unwrap_or_else(|error| error.into_inner());
2804        let previous = std::env::var_os("AFT_CALLGRAPH_BUILD_WAIT_MS");
2805        // SAFETY: serialized by LOCK above and restored by the returned guard.
2806        unsafe {
2807            std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", "0");
2808        }
2809        CallgraphWaitWindowEnvGuard {
2810            _guard: guard,
2811            previous,
2812        }
2813    }
2814
2815    fn cold_build_context() -> Arc<AppContext> {
2816        let project = TempDir::new().expect("project tempdir");
2817        let storage = TempDir::new().expect("storage tempdir");
2818        let source_dir = project.path().join("src");
2819        std::fs::create_dir_all(&source_dir).expect("source dir");
2820        std::fs::write(
2821            source_dir.join("lib.rs"),
2822            "pub fn caller() { callee(); }\npub fn callee() {}\n",
2823        )
2824        .expect("source file");
2825
2826        Arc::new(AppContext::new(
2827            Box::new(TreeSitterProvider::new()),
2828            Config {
2829                project_root: Some(project.keep()),
2830                storage_dir: Some(storage.keep()),
2831                callgraph_chunk_size: 1,
2832                ..Config::default()
2833            },
2834        ))
2835    }
2836
2837    fn empty_semantic_index_for_ctx(ctx: &AppContext) -> SemanticIndex {
2838        let project_root = ctx
2839            .config()
2840            .project_root
2841            .clone()
2842            .expect("test context has a project root");
2843        let files: Vec<PathBuf> = Vec::new();
2844        let mut embed = |_texts: Vec<String>| -> Result<Vec<Vec<f32>>, String> { Ok(Vec::new()) };
2845        SemanticIndex::build(&project_root, &files, &mut embed, 1)
2846            .expect("empty semantic index should build")
2847    }
2848
2849    #[test]
2850    fn semantic_ready_event_resumes_deferred_callgraph_and_tier2() {
2851        let _env_guard = force_async_callgraph_builds();
2852        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
2853        let ctx = cold_build_context();
2854        let (tx, rx) = crossbeam_channel::unbounded();
2855        *ctx.semantic_index_rx().lock() = Some(rx);
2856        ctx.schedule_semantic_cold_seed_gate_for_configure();
2857
2858        assert!(matches!(
2859            ctx.callgraph_store_for_ops(),
2860            CallgraphStoreAccess::Building
2861        ));
2862        assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
2863        tx.send(SemanticIndexEvent::Ready(empty_semantic_index_for_ctx(
2864            &ctx,
2865        )))
2866        .expect("send ready event");
2867
2868        crate::runtime_drain::drain_semantic_index_events(&ctx);
2869
2870        assert!(
2871            !ctx.semantic_cold_seed_active(),
2872            "semantic Ready must clear the scheduled cold gate"
2873        );
2874        assert!(
2875            ctx.tier2_pull_demand_pending(),
2876            "semantic Ready must resume deferred Tier-2 work"
2877        );
2878        assert_eq!(
2879            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
2880            1,
2881            "semantic Ready must resume the deferred callgraph warm"
2882        );
2883        let rx = ctx
2884            .callgraph_store_rx
2885            .lock()
2886            .as_ref()
2887            .cloned()
2888            .expect("ready resume should install an in-flight callgraph receiver");
2889        rx.recv_timeout(Duration::from_secs(30))
2890            .expect("background cold build should complete");
2891        *ctx.callgraph_store_rx.lock() = None;
2892    }
2893
2894    #[test]
2895    fn semantic_gate_cleared_event_resumes_deferred_callgraph_and_tier2() {
2896        let _env_guard = force_async_callgraph_builds();
2897        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
2898        let ctx = cold_build_context();
2899        ctx.schedule_semantic_cold_seed_gate_for_configure();
2900
2901        assert!(matches!(
2902            ctx.callgraph_store_for_ops(),
2903            CallgraphStoreAccess::Building
2904        ));
2905        assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
2906        ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
2907
2908        assert!(
2909            !ctx.semantic_cold_seed_active(),
2910            "cached-load or retry-wait clear must reopen the semantic cold gate"
2911        );
2912        assert!(
2913            ctx.tier2_pull_demand_pending(),
2914            "cached-load or retry-wait clear must resume deferred Tier-2 work"
2915        );
2916        assert_eq!(
2917            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
2918            1,
2919            "cached-load or retry-wait clear must resume deferred callgraph warm"
2920        );
2921        let rx = ctx
2922            .callgraph_store_rx
2923            .lock()
2924            .as_ref()
2925            .cloned()
2926            .expect("gate-clear resume should install an in-flight callgraph receiver");
2927        rx.recv_timeout(Duration::from_secs(30))
2928            .expect("background cold build should complete");
2929        *ctx.callgraph_store_rx.lock() = None;
2930    }
2931
2932    #[test]
2933    fn semantic_cold_seed_gate_defers_callgraph_cold_spawn_until_resume() {
2934        let _env_guard = force_async_callgraph_builds();
2935        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
2936        let ctx = cold_build_context();
2937
2938        ctx.set_semantic_cold_seed_active_for_test(true);
2939        assert!(
2940            matches!(
2941                ctx.callgraph_store_for_ops(),
2942                CallgraphStoreAccess::Building
2943            ),
2944            "callgraph ops should degrade as building while the semantic cold gate is active"
2945        );
2946        assert_eq!(
2947            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
2948            0,
2949            "semantic cold gate must not spawn a competing callgraph cold build"
2950        );
2951        assert!(ctx.semantic_callgraph_warm_deferred_for_test());
2952
2953        ctx.clear_semantic_cold_seed_gate_and_resume_deferred_work();
2954        assert_eq!(
2955            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
2956            1,
2957            "clearing the semantic cold gate should resume the deferred callgraph warm"
2958        );
2959
2960        let rx = ctx
2961            .callgraph_store_rx
2962            .lock()
2963            .as_ref()
2964            .cloned()
2965            .expect("deferred warm should install an in-flight receiver");
2966        rx.recv_timeout(Duration::from_secs(30))
2967            .expect("background cold build should complete");
2968        *ctx.callgraph_store_rx.lock() = None;
2969    }
2970
2971    #[test]
2972    fn semantic_cold_seed_gate_clear_requests_tier2_pull() {
2973        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2974        ctx.schedule_semantic_cold_seed_gate_for_configure();
2975
2976        ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
2977
2978        assert!(
2979            !ctx.semantic_cold_seed_active(),
2980            "retry-wait or cached-load events must reopen the semantic cold gate"
2981        );
2982        assert!(
2983            ctx.tier2_pull_demand_pending(),
2984            "clearing the semantic cold gate should kick a Tier-2 pull refresh"
2985        );
2986    }
2987
2988    #[test]
2989    fn semantic_failed_event_clears_scheduled_gate_and_requests_tier2_pull() {
2990        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2991        let (tx, rx) = crossbeam_channel::unbounded();
2992        *ctx.semantic_index_rx().lock() = Some(rx);
2993        ctx.schedule_semantic_cold_seed_gate_for_configure();
2994        tx.send(SemanticIndexEvent::Failed(
2995            "embedding backend failed".to_string(),
2996        ))
2997        .expect("send failed event");
2998
2999        crate::runtime_drain::drain_semantic_index_events(&ctx);
3000
3001        assert!(
3002            !ctx.semantic_cold_seed_active(),
3003            "semantic Failed must clear the scheduled cold gate"
3004        );
3005        assert!(
3006            ctx.tier2_pull_demand_pending(),
3007            "semantic Failed must resume deferred Tier-2 work"
3008        );
3009    }
3010
3011    #[test]
3012    fn semantic_disconnect_clears_scheduled_gate_and_requests_tier2_pull() {
3013        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3014        let (tx, rx) = crossbeam_channel::unbounded::<SemanticIndexEvent>();
3015        *ctx.semantic_index_rx().lock() = Some(rx);
3016        ctx.schedule_semantic_cold_seed_gate_for_configure();
3017        drop(tx);
3018
3019        crate::runtime_drain::drain_semantic_index_events(&ctx);
3020
3021        assert!(
3022            !ctx.semantic_cold_seed_active(),
3023            "semantic worker disconnect must clear the scheduled cold gate"
3024        );
3025        assert!(
3026            ctx.tier2_pull_demand_pending(),
3027            "semantic worker disconnect must resume deferred Tier-2 work"
3028        );
3029    }
3030
3031    #[test]
3032    fn semantic_cold_seed_gate_is_per_context_for_tier2_scheduler() {
3033        let ctx_a = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3034        let ctx_b = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3035        let base = Instant::now();
3036        ctx_a.reset_tier2_refresh_scheduler_at(base);
3037        ctx_b.reset_tier2_refresh_scheduler_at(base);
3038        ctx_a.set_semantic_cold_seed_active_for_test(true);
3039
3040        assert_eq!(
3041            ctx_a.tick_tier2_refresh_scheduler_at(
3042                base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
3043                0,
3044            ),
3045            None,
3046            "root A should defer Tier-2 while its semantic cold seed is active"
3047        );
3048        assert_eq!(
3049            ctx_b.tick_tier2_refresh_scheduler_at(
3050                base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
3051                0,
3052            ),
3053            Some(Tier2TriggerReason::ConfigureWarm),
3054            "root B must not inherit root A's semantic cold gate"
3055        );
3056    }
3057
3058    #[test]
3059    fn concurrent_cold_callgraph_store_for_ops_spawns_one_build() {
3060        let _env_guard = force_async_callgraph_builds();
3061        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
3062
3063        let project = TempDir::new().expect("project tempdir");
3064        let storage = TempDir::new().expect("storage tempdir");
3065        let source_dir = project.path().join("src");
3066        std::fs::create_dir_all(&source_dir).expect("source dir");
3067        std::fs::write(
3068            source_dir.join("lib.rs"),
3069            "pub fn caller() { callee(); }\npub fn callee() {}\n",
3070        )
3071        .expect("source file");
3072
3073        let ctx = Arc::new(AppContext::new(
3074            Box::new(TreeSitterProvider::new()),
3075            Config {
3076                project_root: Some(project.path().to_path_buf()),
3077                storage_dir: Some(storage.path().to_path_buf()),
3078                callgraph_chunk_size: 1,
3079                ..Config::default()
3080            },
3081        ));
3082
3083        let barrier = Arc::new(Barrier::new(3));
3084        let handles = (0..2)
3085            .map(|_| {
3086                let ctx = Arc::clone(&ctx);
3087                let barrier = Arc::clone(&barrier);
3088                std::thread::spawn(move || {
3089                    barrier.wait();
3090                    matches!(
3091                        ctx.callgraph_store_for_ops(),
3092                        CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
3093                    )
3094                })
3095            })
3096            .collect::<Vec<_>>();
3097
3098        barrier.wait();
3099        for handle in handles {
3100            assert!(
3101                handle.join().expect("callgraph caller thread"),
3102                "cold callgraph ops should report Building or observe the installed store"
3103            );
3104        }
3105
3106        assert_eq!(
3107            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3108            1,
3109            "concurrent cold callers must share one background build"
3110        );
3111
3112        let rx = ctx
3113            .callgraph_store_rx
3114            .lock()
3115            .as_ref()
3116            .cloned()
3117            .expect("in-flight receiver installed before spawn");
3118        rx.recv_timeout(Duration::from_secs(30))
3119            .expect("background cold build should complete");
3120        *ctx.callgraph_store_rx.lock() = None;
3121    }
3122}
3123
3124#[cfg(test)]
3125mod status_emitter_tests {
3126    use super::*;
3127    use crate::parser::TreeSitterProvider;
3128
3129    fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
3130        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3131        let (tx, rx) = mpsc::channel();
3132        ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
3133            let _ = tx.send(frame);
3134        }))));
3135        (ctx, rx)
3136    }
3137
3138    #[test]
3139    fn status_emitter_signal_triggers_push() {
3140        let (ctx, rx) = ctx_with_frame_rx();
3141        ctx.status_emitter().signal(ctx.build_status_snapshot());
3142        let frame = rx
3143            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3144            .expect("status_changed push");
3145        assert!(matches!(frame, PushFrame::StatusChanged(_)));
3146    }
3147
3148    #[test]
3149    fn status_emitter_debounces_burst() {
3150        let (ctx, rx) = ctx_with_frame_rx();
3151        for _ in 0..10 {
3152            ctx.status_emitter().signal(ctx.build_status_snapshot());
3153        }
3154        let frame = rx
3155            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3156            .expect("status_changed push");
3157        assert!(matches!(frame, PushFrame::StatusChanged(_)));
3158        assert!(rx.try_recv().is_err());
3159    }
3160
3161    #[test]
3162    fn status_emitter_separate_windows_separate_pushes() {
3163        let (ctx, rx) = ctx_with_frame_rx();
3164        ctx.status_emitter().signal(ctx.build_status_snapshot());
3165        rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3166            .expect("first push");
3167        ctx.status_emitter().signal(ctx.build_status_snapshot());
3168        rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3169            .expect("second push");
3170    }
3171
3172    #[test]
3173    fn status_emitter_no_signal_no_push() {
3174        let (_ctx, rx) = ctx_with_frame_rx();
3175        assert!(rx
3176            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
3177            .is_err());
3178    }
3179
3180    #[test]
3181    fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
3182        let (ctx, rx) = ctx_with_frame_rx();
3183        drop(ctx);
3184        assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
3185    }
3186
3187    #[test]
3188    fn progress_sender_slot_is_per_context_for_shared_app() {
3189        let app = App::default_shared();
3190        let ctx_a = AppContext::from_app(Arc::clone(&app), Config::default());
3191        let ctx_b = AppContext::from_app(app, Config::default());
3192        let (tx_a, rx_a) = mpsc::channel();
3193        let (tx_b, rx_b) = mpsc::channel();
3194
3195        ctx_a.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
3196            let _ = tx_a.send(frame);
3197        }))));
3198        ctx_b.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
3199            let _ = tx_b.send(frame);
3200        }))));
3201
3202        ctx_a.emit_progress(ProgressFrame {
3203            frame_type: "progress",
3204            request_id: "ctx-a".to_string(),
3205            kind: crate::protocol::ProgressKind::Stdout,
3206            chunk: "a".to_string(),
3207        });
3208        ctx_b.emit_progress(ProgressFrame {
3209            frame_type: "progress",
3210            request_id: "ctx-b".to_string(),
3211            kind: crate::protocol::ProgressKind::Stdout,
3212            chunk: "b".to_string(),
3213        });
3214
3215        match rx_a
3216            .recv_timeout(Duration::from_millis(50))
3217            .expect("ctx A progress frame")
3218        {
3219            PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-a"),
3220            other => panic!("unexpected frame for ctx A: {other:?}"),
3221        }
3222        assert!(rx_a.try_recv().is_err());
3223
3224        match rx_b
3225            .recv_timeout(Duration::from_millis(50))
3226            .expect("ctx B progress frame")
3227        {
3228            PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-b"),
3229            other => panic!("unexpected frame for ctx B: {other:?}"),
3230        }
3231        assert!(rx_b.try_recv().is_err());
3232    }
3233}
3234
3235#[cfg(test)]
3236mod status_bar_tests {
3237    use super::*;
3238    use crate::parser::TreeSitterProvider;
3239
3240    fn ctx() -> AppContext {
3241        AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
3242    }
3243
3244    #[test]
3245    fn status_bar_counts_none_until_tier2_populated() {
3246        let ctx = ctx();
3247        // No scan has run yet — never surface a bar claiming "0 dead code".
3248        assert!(ctx.status_bar_counts().is_none());
3249
3250        ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
3251        let counts = ctx.status_bar_counts().expect("populated");
3252        assert_eq!(counts.dead_code, 5);
3253        assert_eq!(counts.unused_exports, 3);
3254        assert_eq!(counts.duplicates, 7);
3255        assert_eq!(counts.todos, 2);
3256        assert!(!counts.tier2_stale);
3257        // Errors/warnings are read live from an empty LSP store → 0.
3258        assert_eq!(counts.errors, 0);
3259        assert_eq!(counts.warnings, 0);
3260    }
3261
3262    #[test]
3263    fn partial_tier2_does_not_fabricate_zeros() {
3264        let ctx = ctx();
3265        // Only dead_code has completed (the slow first serial category); the
3266        // other two are still in flight. The bar must stay suppressed rather
3267        // than render `D5 U0 C0` with fabricated zeros (#1).
3268        ctx.update_status_bar_tier2(Some(5), None, None, None, true);
3269        assert!(
3270            ctx.status_bar_counts().is_none(),
3271            "bar must not surface until all three Tier-2 categories are real"
3272        );
3273
3274        // Second category completes — still incomplete, still suppressed.
3275        ctx.update_status_bar_tier2(None, Some(3), None, None, true);
3276        assert!(ctx.status_bar_counts().is_none());
3277
3278        // Final category completes → bar surfaces with all real counts, and
3279        // none of them were ever fabricated.
3280        ctx.update_status_bar_tier2(None, None, Some(7), None, false);
3281        let counts = ctx.status_bar_counts().expect("all three real now");
3282        assert_eq!(counts.dead_code, 5);
3283        assert_eq!(counts.unused_exports, 3);
3284        assert_eq!(counts.duplicates, 7);
3285    }
3286
3287    #[test]
3288    fn update_with_none_todos_preserves_last_known_todos() {
3289        let ctx = ctx();
3290        ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
3291        // A background-scan refresh passes todos=None → todo count preserved.
3292        ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
3293        let counts = ctx.status_bar_counts().expect("populated");
3294        assert_eq!(counts.todos, 9);
3295        assert_eq!(counts.dead_code, 2);
3296    }
3297
3298    #[test]
3299    fn update_with_none_count_preserves_last_known_count() {
3300        let ctx = ctx();
3301        ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
3302        // A refresh that only recomputed dead_code preserves the other two
3303        // real counts rather than overwriting them with a fabricated 0.
3304        ctx.update_status_bar_tier2(Some(11), None, None, None, false);
3305        let counts = ctx.status_bar_counts().expect("populated");
3306        assert_eq!(counts.dead_code, 11);
3307        assert_eq!(counts.unused_exports, 20);
3308        assert_eq!(counts.duplicates, 30);
3309    }
3310
3311    #[test]
3312    fn mark_stale_sets_flag_only_after_populate() {
3313        let ctx = ctx();
3314        // No-op before first populate.
3315        ctx.mark_status_bar_tier2_stale();
3316        assert!(ctx.status_bar_counts().is_none());
3317
3318        ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), Some(0), false);
3319        ctx.mark_status_bar_tier2_stale();
3320        assert!(ctx.status_bar_counts().expect("populated").tier2_stale);
3321
3322        // A completed scan clears stale.
3323        ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), None, false);
3324        assert!(!ctx.status_bar_counts().expect("populated").tier2_stale);
3325    }
3326
3327    // End-to-end wiring: a diagnostic for a file inflates the status-bar `E`
3328    // count (read live from the warm LSP set); clearing that file's diagnostics
3329    // (the deleted-file path) drops it back. This is the AppContext glue between
3330    // the watcher-drain clear and the agent-visible bar.
3331    #[test]
3332    fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
3333        use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
3334        use crate::lsp::registry::ServerKind;
3335        use crate::lsp::roots::ServerKey;
3336
3337        let ctx = ctx();
3338        ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); // populate so the bar surfaces
3339
3340        let file = std::path::PathBuf::from("/proj/gone.ts");
3341        {
3342            let mut lsp = ctx.lsp();
3343            lsp.diagnostics_store_mut_for_test().publish(
3344                ServerKey {
3345                    kind: ServerKind::TypeScript,
3346                    root: std::path::PathBuf::from("/proj"),
3347                },
3348                file.clone(),
3349                vec![StoredDiagnostic {
3350                    file: file.clone(),
3351                    line: 1,
3352                    column: 1,
3353                    end_line: 1,
3354                    end_column: 2,
3355                    severity: DiagnosticSeverity::Error,
3356                    message: "boom".into(),
3357                    code: None,
3358                    source: None,
3359                }],
3360            );
3361        }
3362
3363        // Bar reflects the live warm-set error.
3364        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
3365
3366        // Clearing the (now-deleted) file's diagnostics drops the count.
3367        let removed = ctx.lsp_clear_diagnostics_for_file(&file);
3368        assert!(removed);
3369        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
3370    }
3371
3372    #[test]
3373    fn status_bar_filtered_counts_ignore_environmental_flap() {
3374        use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
3375        use crate::lsp::registry::ServerKind;
3376        use crate::lsp::roots::ServerKey;
3377
3378        let ctx = ctx();
3379        let root = if cfg!(windows) {
3380            std::path::PathBuf::from(r"C:\proj")
3381        } else {
3382            std::path::PathBuf::from("/proj")
3383        };
3384        ctx.set_canonical_cache_root(root.clone());
3385        ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
3386
3387        let file = root.join("aft.jsonc");
3388        let key = ServerKey {
3389            kind: ServerKind::TypeScript,
3390            root: root.clone(),
3391        };
3392        let env = StoredDiagnostic {
3393            file: file.clone(),
3394            line: 1,
3395            column: 1,
3396            end_line: 1,
3397            end_column: 2,
3398            severity: DiagnosticSeverity::Error,
3399            message: "Failed to load schema from https://example.com/schema.json".into(),
3400            code: None,
3401            source: Some("json".into()),
3402        };
3403
3404        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
3405
3406        {
3407            let mut lsp = ctx.lsp();
3408            lsp.diagnostics_store_mut_for_test()
3409                .publish(key.clone(), file.clone(), vec![env]);
3410        }
3411        assert_eq!(
3412            ctx.status_bar_counts().expect("populated").errors,
3413            0,
3414            "environmental publish must not change status-bar E"
3415        );
3416
3417        {
3418            let mut lsp = ctx.lsp();
3419            lsp.diagnostics_store_mut_for_test()
3420                .publish(key, file, vec![]);
3421        }
3422        assert_eq!(
3423            ctx.status_bar_counts().expect("populated").errors,
3424            0,
3425            "environmental clear must not change status-bar E"
3426        );
3427    }
3428}
3429
3430#[cfg(test)]
3431mod harness_path_tests {
3432    use super::*;
3433    use crate::harness::Harness;
3434    use crate::parser::TreeSitterProvider;
3435
3436    fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
3437        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3438        ctx.update_config(|config| {
3439            config.storage_dir = Some(storage_dir);
3440        });
3441        ctx.set_harness(harness);
3442        ctx
3443    }
3444
3445    #[test]
3446    fn harness_dir_resolves_correctly() {
3447        let storage = PathBuf::from("/tmp/cortexkit/aft");
3448        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
3449
3450        assert_eq!(ctx.harness_dir(), storage.join("pi"));
3451    }
3452
3453    #[test]
3454    fn bash_tasks_dir_uses_hash_session() {
3455        let storage = PathBuf::from("/tmp/cortexkit/aft");
3456        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
3457
3458        assert_eq!(
3459            ctx.bash_tasks_dir("ses_abc"),
3460            storage
3461                .join("opencode")
3462                .join("bash-tasks")
3463                .join(hash_session("ses_abc"))
3464        );
3465    }
3466
3467    #[test]
3468    fn backups_dir_includes_path_hash() {
3469        let storage = PathBuf::from("/tmp/cortexkit/aft");
3470        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
3471
3472        assert_eq!(
3473            ctx.backups_dir("ses_abc", "pathhash"),
3474            storage
3475                .join("pi")
3476                .join("backups")
3477                .join(hash_session("ses_abc"))
3478                .join("pathhash")
3479        );
3480    }
3481
3482    #[test]
3483    fn filters_dir_under_harness() {
3484        let storage = PathBuf::from("/tmp/cortexkit/aft");
3485        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
3486
3487        assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
3488    }
3489
3490    #[test]
3491    fn trust_file_is_host_global() {
3492        let storage = PathBuf::from("/tmp/cortexkit/aft");
3493        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
3494
3495        assert_eq!(
3496            ctx.trust_file(),
3497            storage.join("trusted-filter-projects.json")
3498        );
3499    }
3500
3501    #[test]
3502    fn same_session_different_harness_resolve_different_paths() {
3503        let storage = PathBuf::from("/tmp/cortexkit/aft");
3504        let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
3505        let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
3506
3507        assert_ne!(
3508            opencode.bash_tasks_dir("ses_same"),
3509            pi.bash_tasks_dir("ses_same")
3510        );
3511    }
3512}
3513
3514#[cfg(test)]
3515mod gitignore_tests {
3516    use super::*;
3517    use std::fs;
3518    use std::path::Path;
3519    use tempfile::TempDir;
3520
3521    fn make_ctx_with_root(root: &Path) -> AppContext {
3522        let provider = Box::new(crate::parser::TreeSitterProvider::new());
3523        let config = Config {
3524            project_root: Some(root.to_path_buf()),
3525            ..Config::default()
3526        };
3527        AppContext::new(provider, config)
3528    }
3529
3530    /// Helper: returns true when the matcher would skip `path` (as if it
3531    /// arrived via a watcher event for this project root). Canonicalizes
3532    /// the query path so symlink prefixes (e.g. macOS `/var` → `/private/var`)
3533    /// don't trip the `ignore` crate's "path is expected to be under the
3534    /// root" panic — production code does the same guard via
3535    /// `path.starts_with(matcher.path())` in `drain_watcher_events`.
3536    fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
3537        let Some(matcher) = ctx.gitignore() else {
3538            return false;
3539        };
3540        let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
3541        if !canonical.starts_with(matcher.path()) {
3542            return false;
3543        }
3544        let is_dir = canonical.is_dir();
3545        matcher
3546            .matched_path_or_any_parents(&canonical, is_dir)
3547            .is_ignore()
3548    }
3549
3550    /// Run `f` with global git-ignore discovery neutralized.
3551    ///
3552    /// `rebuild_gitignore` loads git's global excludes (the `ignore` crate
3553    /// resolves `$XDG_CONFIG_HOME/git/ignore`, falling back to
3554    /// `$HOME/.config/git/ignore`). A developer machine commonly has that file,
3555    /// so a "no project ignore → None" assertion is only deterministic when
3556    /// global discovery is pointed at an empty directory. Pointing
3557    /// `XDG_CONFIG_HOME` at a fresh tempdir does that without touching `HOME`
3558    /// (so it can't race the `HOME`-mutating configure tests). Serialized by a
3559    /// process-local mutex; env is restored before the closure result is used.
3560    fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
3561        use std::sync::{Mutex, OnceLock};
3562        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
3563        let _guard = LOCK
3564            .get_or_init(|| Mutex::new(()))
3565            .lock()
3566            .unwrap_or_else(|e| e.into_inner());
3567        let tmp = TempDir::new().unwrap();
3568        let prev = std::env::var_os("XDG_CONFIG_HOME");
3569        // SAFETY: serialized by LOCK above; restored immediately after `f`.
3570        unsafe {
3571            std::env::set_var("XDG_CONFIG_HOME", tmp.path());
3572        }
3573        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
3574        unsafe {
3575            match prev {
3576                Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
3577                None => std::env::remove_var("XDG_CONFIG_HOME"),
3578            }
3579        }
3580        match result {
3581            Ok(r) => r,
3582            Err(p) => std::panic::resume_unwind(p),
3583        }
3584    }
3585
3586    #[test]
3587    fn rebuild_gitignore_returns_none_without_project_root() {
3588        let provider = Box::new(crate::parser::TreeSitterProvider::new());
3589        let ctx = AppContext::new(provider, Config::default());
3590        with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
3591        assert!(ctx.gitignore().is_none());
3592    }
3593
3594    #[test]
3595    fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
3596        let tmp = TempDir::new().unwrap();
3597        let ctx = make_ctx_with_root(tmp.path());
3598        with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
3599        assert!(ctx.gitignore().is_none());
3600    }
3601
3602    #[test]
3603    fn matcher_filters_files_in_ignored_dist_dir() {
3604        let tmp = TempDir::new().unwrap();
3605        fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
3606        fs::create_dir_all(tmp.path().join("dist")).unwrap();
3607        fs::create_dir_all(tmp.path().join("src")).unwrap();
3608        let dist_file = tmp.path().join("dist").join("bundle.js");
3609        let src_file = tmp.path().join("src").join("app.ts");
3610        fs::write(&dist_file, "x").unwrap();
3611        fs::write(&src_file, "y").unwrap();
3612
3613        let ctx = make_ctx_with_root(tmp.path());
3614        ctx.rebuild_gitignore();
3615
3616        assert!(ctx.gitignore().is_some());
3617        assert!(
3618            is_ignored(&ctx, &dist_file),
3619            "dist/bundle.js should be ignored"
3620        );
3621        assert!(
3622            !is_ignored(&ctx, &src_file),
3623            "src/app.ts should NOT be ignored"
3624        );
3625    }
3626
3627    #[test]
3628    fn matcher_handles_node_modules_and_target() {
3629        let tmp = TempDir::new().unwrap();
3630        fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
3631        fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
3632        fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
3633        let nm_file = tmp.path().join("node_modules/foo/index.js");
3634        let target_file = tmp.path().join("target/debug/aft");
3635        fs::write(&nm_file, "x").unwrap();
3636        fs::write(&target_file, "x").unwrap();
3637
3638        let ctx = make_ctx_with_root(tmp.path());
3639        ctx.rebuild_gitignore();
3640
3641        assert!(is_ignored(&ctx, &nm_file));
3642        assert!(is_ignored(&ctx, &target_file));
3643    }
3644
3645    #[test]
3646    fn matcher_honors_negation_pattern() {
3647        // .gitignore: ignore all *.log files EXCEPT important.log
3648        let tmp = TempDir::new().unwrap();
3649        fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
3650        let random_log = tmp.path().join("random.log");
3651        let important_log = tmp.path().join("important.log");
3652        fs::write(&random_log, "x").unwrap();
3653        fs::write(&important_log, "y").unwrap();
3654
3655        let ctx = make_ctx_with_root(tmp.path());
3656        ctx.rebuild_gitignore();
3657
3658        assert!(is_ignored(&ctx, &random_log));
3659        assert!(
3660            !is_ignored(&ctx, &important_log),
3661            "negation pattern should un-ignore important.log"
3662        );
3663    }
3664
3665    #[test]
3666    fn rebuild_picks_up_gitignore_changes() {
3667        let tmp = TempDir::new().unwrap();
3668        let ignore_path = tmp.path().join(".gitignore");
3669        fs::write(&ignore_path, "foo.txt\n").unwrap();
3670        let foo = tmp.path().join("foo.txt");
3671        let bar = tmp.path().join("bar.txt");
3672        fs::write(&foo, "").unwrap();
3673        fs::write(&bar, "").unwrap();
3674
3675        let ctx = make_ctx_with_root(tmp.path());
3676        ctx.rebuild_gitignore();
3677        assert!(is_ignored(&ctx, &foo));
3678        assert!(!is_ignored(&ctx, &bar));
3679
3680        // Now flip the rules: ignore bar.txt instead of foo.txt
3681        fs::write(&ignore_path, "bar.txt\n").unwrap();
3682        ctx.rebuild_gitignore();
3683        assert!(!is_ignored(&ctx, &foo));
3684        assert!(is_ignored(&ctx, &bar));
3685    }
3686
3687    #[test]
3688    fn gitignore_loads_info_exclude_when_present() {
3689        let tmp = TempDir::new().unwrap();
3690        let info_dir = tmp.path().join(".git/info");
3691        fs::create_dir_all(&info_dir).unwrap();
3692        fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
3693        let secrets = tmp.path().join("secrets.txt");
3694        let public = tmp.path().join("public.txt");
3695        fs::write(&secrets, "token").unwrap();
3696        fs::write(&public, "ok").unwrap();
3697
3698        let ctx = make_ctx_with_root(tmp.path());
3699        ctx.rebuild_gitignore();
3700
3701        assert!(is_ignored(&ctx, &secrets));
3702        assert!(!is_ignored(&ctx, &public));
3703    }
3704
3705    #[test]
3706    fn matcher_picks_up_nested_gitignore() {
3707        let tmp = TempDir::new().unwrap();
3708        // Root .gitignore is intentionally empty — only the nested one ignores
3709        fs::write(tmp.path().join(".gitignore"), "").unwrap();
3710        let sub = tmp.path().join("packages/foo");
3711        fs::create_dir_all(&sub).unwrap();
3712        fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
3713        let generated_file = sub.join("generated").join("out.js");
3714        fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
3715        fs::write(&generated_file, "x").unwrap();
3716
3717        let ctx = make_ctx_with_root(tmp.path());
3718        ctx.rebuild_gitignore();
3719
3720        assert!(
3721            is_ignored(&ctx, &generated_file),
3722            "nested gitignore in packages/foo/.gitignore should ignore generated/"
3723        );
3724    }
3725}