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