Skip to main content

aft/
context.rs

1use std::cell::{Ref, RefCell, RefMut};
2use std::collections::{BTreeMap, BTreeSet};
3use std::io::{self, BufWriter};
4use std::path::{Component, Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{mpsc, Arc, Mutex, OnceLock};
7use std::time::{Duration, Instant};
8
9use lsp_types::FileChangeType;
10use notify::RecommendedWatcher;
11use rusqlite::Connection;
12
13use crate::backup::hash_session;
14use crate::backup::BackupStore;
15use crate::bash_background::{BgCompletion, BgTaskRegistry};
16use crate::callgraph::CallGraph;
17use crate::callgraph_store::{CallGraphStore, CallGraphStoreError};
18use crate::checkpoint::CheckpointStore;
19use crate::config::Config;
20use crate::harness::Harness;
21use crate::inspect::{
22    InspectCategory, InspectManager, InspectSnapshot, Tier2RefreshScheduler, Tier2TriggerReason,
23};
24use crate::language::LanguageProvider;
25use crate::lsp::manager::LspManager;
26use crate::lsp::registry::is_config_file_path_with_custom;
27use crate::parser::{SharedSymbolCache, SymbolCache};
28use crate::protocol::{
29    ConfigureWarningsFrame, ProgressFrame, PushFrame, StatusChangedFrame, StatusPayload,
30};
31use crate::watcher_filter::{SharedGitignore, WatcherDispatchEvent, WatcherThreadHandle};
32
33pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
34pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
35pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
36const STATUS_DEBOUNCE_MS: u64 = 1_000;
37
38/// Agent status-bar counts — the IDE-style "status bar" surfaced to the agent
39/// on every tool result (emit-on-change). `errors`/`warnings` are read LIVE
40/// from the continuously-drained LSP diagnostics store; the Tier-2 counts
41/// (`dead_code`/`unused_exports`/`duplicates`) and `todos` are last-known,
42/// refreshed when `aft_inspect` runs or a background Tier-2 scan completes.
43/// `tier2_stale` marks the Tier-2 counts as not-yet-reconciled with the latest
44/// edits (rendered with a `~` marker so the agent never reads them as live).
45#[derive(Debug, Clone, Default, PartialEq, Eq)]
46pub struct StatusBarCounts {
47    pub errors: usize,
48    pub warnings: usize,
49    pub dead_code: usize,
50    pub unused_exports: usize,
51    pub duplicates: usize,
52    pub todos: usize,
53    pub tier2_stale: bool,
54}
55
56/// Last-known Tier-2 + todos counts, refreshed off the hot path. `errors` and
57/// `warnings` are intentionally NOT cached here — they're read live per attach.
58///
59/// Each Tier-2 category is `Option`: `None` means "no scan has ever produced a
60/// count for this category", so we never fabricate a `0`. The bar is only
61/// surfaced once all three Tier-2 categories hold a real value — a partially
62/// completed cold scan (e.g. dead_code done, unused_exports/duplicates still
63/// running) must not render `D<real> U0 C0` and lie about project health (#1).
64#[derive(Debug, Clone, Default)]
65struct StatusBarTier2 {
66    dead_code: Option<usize>,
67    unused_exports: Option<usize>,
68    duplicates: Option<usize>,
69    todos: Option<usize>,
70    stale: bool,
71}
72
73pub struct StatusEmitter {
74    latest: Arc<Mutex<Option<StatusPayload>>>,
75    notify: mpsc::Sender<()>,
76}
77
78impl StatusEmitter {
79    fn new(progress_sender: SharedProgressSender) -> Self {
80        let (notify, rx) = mpsc::channel();
81        let latest = Arc::new(Mutex::new(None));
82        let latest_for_thread = Arc::clone(&latest);
83        std::thread::spawn(move || {
84            status_debounce_loop(rx, latest_for_thread, progress_sender);
85        });
86        Self { latest, notify }
87    }
88
89    pub fn signal(&self, snapshot: StatusPayload) {
90        if let Ok(mut latest) = self.latest.lock() {
91            *latest = Some(snapshot);
92        }
93        let _ = self.notify.send(());
94    }
95}
96
97fn status_debounce_loop(
98    rx: mpsc::Receiver<()>,
99    latest: Arc<Mutex<Option<StatusPayload>>>,
100    progress_sender: SharedProgressSender,
101) {
102    while rx.recv().is_ok() {
103        let deadline = Instant::now() + Duration::from_millis(STATUS_DEBOUNCE_MS);
104        while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
105            match rx.recv_timeout(remaining) {
106                Ok(()) => continue,
107                Err(mpsc::RecvTimeoutError::Timeout) => break,
108                Err(mpsc::RecvTimeoutError::Disconnected) => return,
109            }
110        }
111
112        let snapshot = latest.lock().ok().and_then(|mut latest| latest.take());
113        let Some(snapshot) = snapshot else { continue };
114        let sender = progress_sender
115            .lock()
116            .ok()
117            .and_then(|sender| sender.clone());
118        if let Some(sender) = sender {
119            sender(PushFrame::StatusChanged(StatusChangedFrame::new(
120                None, snapshot,
121            )));
122        }
123    }
124}
125use crate::cache_freshness::FileFreshness;
126use crate::search_index::SearchIndex;
127use crate::semantic_index::{EmbeddingEntry, SemanticIndex};
128
129// `SemanticIndexStatus::Ready` exposes a unique `refreshing` path list. Keep
130// per-path queue accounting separately so repeated edits to the same file do not
131// let an older refresh completion remove the path while newer work is pending.
132#[derive(Debug, Default)]
133struct SemanticRefreshAccounting {
134    pending: usize,
135    in_flight: usize,
136}
137
138static SEMANTIC_REFRESH_ACCOUNTING: OnceLock<Mutex<BTreeMap<PathBuf, SemanticRefreshAccounting>>> =
139    OnceLock::new();
140
141fn semantic_refresh_accounting() -> &'static Mutex<BTreeMap<PathBuf, SemanticRefreshAccounting>> {
142    SEMANTIC_REFRESH_ACCOUNTING.get_or_init(|| Mutex::new(BTreeMap::new()))
143}
144
145fn clear_semantic_refresh_accounting() {
146    if let Some(accounting) = SEMANTIC_REFRESH_ACCOUNTING.get() {
147        if let Ok(mut accounting) = accounting.lock() {
148            accounting.clear();
149        }
150    }
151}
152
153fn ensure_refreshing_path(refreshing: &mut Vec<PathBuf>, path: PathBuf) {
154    if !refreshing.iter().any(|existing| existing == &path) {
155        refreshing.push(path);
156        refreshing.sort();
157    }
158}
159
160fn remove_refreshing_path(refreshing: &mut Vec<PathBuf>, path: &Path) {
161    refreshing.retain(|existing| existing != path);
162}
163
164#[derive(Debug, Clone)]
165pub enum SemanticIndexStatus {
166    Disabled,
167    Building {
168        /// Cold-build only — index is not queryable.
169        stage: String,
170        files: Option<usize>,
171        entries_done: Option<usize>,
172        entries_total: Option<usize>,
173    },
174    Ready {
175        /// Files currently being re-embedded after recent edits. The index is
176        /// still queryable; results for these files may be temporarily missing.
177        refreshing: Vec<PathBuf>,
178    },
179    Failed(String),
180}
181
182impl SemanticIndexStatus {
183    pub fn ready() -> Self {
184        clear_semantic_refresh_accounting();
185        Self::Ready {
186            refreshing: Vec::new(),
187        }
188    }
189
190    pub fn add_refreshing_file(&mut self, path: PathBuf) {
191        if let Self::Ready { refreshing } = self {
192            if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
193                let state = accounting.entry(path.clone()).or_default();
194                state.pending = state.pending.saturating_add(1);
195            }
196            ensure_refreshing_path(refreshing, path);
197        }
198    }
199
200    pub fn start_refreshing_file(&mut self, path: PathBuf) {
201        if let Self::Ready { refreshing } = self {
202            if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
203                let state = accounting.entry(path.clone()).or_default();
204                if state.pending == 0 {
205                    state.pending = 1;
206                }
207                if state.in_flight == 0 {
208                    state.in_flight = state.pending;
209                }
210            }
211            ensure_refreshing_path(refreshing, path);
212        }
213    }
214
215    pub fn cancel_refreshing_file(&mut self, path: &Path) {
216        self.finish_refreshing_file(path, false);
217    }
218
219    pub fn complete_refreshing_file(&mut self, path: &Path) {
220        self.finish_refreshing_file(path, true);
221    }
222
223    pub fn remove_refreshing_file(&mut self, path: &Path) {
224        self.complete_refreshing_file(path);
225    }
226
227    fn finish_refreshing_file(&mut self, path: &Path, complete_in_flight: bool) {
228        if let Self::Ready { refreshing } = self {
229            let mut keep_refreshing = false;
230            let mut accounting_checked = false;
231            if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
232                accounting_checked = true;
233                if let Some(state) = accounting.get_mut(path) {
234                    let finished = if complete_in_flight {
235                        state.in_flight.max(1)
236                    } else {
237                        1
238                    };
239                    state.pending = state.pending.saturating_sub(finished);
240                    if complete_in_flight {
241                        state.in_flight = 0;
242                    } else {
243                        state.in_flight = state.in_flight.min(state.pending);
244                    }
245                    keep_refreshing = state.pending > 0;
246                    if !keep_refreshing {
247                        accounting.remove(path);
248                    }
249                }
250            }
251
252            if !accounting_checked || !keep_refreshing {
253                remove_refreshing_path(refreshing, path);
254            }
255        }
256    }
257
258    pub fn refreshing_count(&self) -> usize {
259        match self {
260            Self::Ready { refreshing } => refreshing.len(),
261            _ => 0,
262        }
263    }
264}
265
266pub enum SemanticIndexEvent {
267    Progress {
268        stage: String,
269        files: Option<usize>,
270        entries_done: Option<usize>,
271        entries_total: Option<usize>,
272    },
273    Ready(SemanticIndex),
274    Failed(String),
275}
276
277#[derive(Debug, Clone)]
278pub enum SemanticRefreshRequest {
279    Files {
280        paths: Vec<PathBuf>,
281    },
282    /// Refresh the whole semantic corpus on the refresh worker. The worker owns
283    /// the project walk so watcher/configure drains never do corpus-scale work
284    /// on the single dispatch thread before scheduling embedding.
285    Corpus,
286}
287
288#[derive(Debug)]
289pub enum SemanticRefreshEvent {
290    Started {
291        paths: Vec<PathBuf>,
292    },
293    CorpusStarted {
294        files: usize,
295    },
296    Completed {
297        added_entries: Vec<EmbeddingEntry>,
298        updated_metadata: Vec<(PathBuf, FileFreshness)>,
299        completed_paths: Vec<PathBuf>,
300    },
301    CorpusCompleted {
302        index: SemanticIndex,
303        changed: usize,
304        added: usize,
305        deleted: usize,
306        total_processed: usize,
307    },
308    Failed {
309        paths: Vec<PathBuf>,
310        error: String,
311    },
312    CorpusFailed {
313        error: String,
314    },
315}
316
317pub type SemanticRefreshWorkerSlot = Arc<Mutex<Option<std::thread::JoinHandle<()>>>>;
318
319/// Normalize a path by resolving `.` and `..` components lexically,
320/// without touching the filesystem. This prevents path traversal
321/// attacks when `fs::canonicalize` fails (e.g. for non-existent paths).
322fn normalize_path(path: &Path) -> PathBuf {
323    let mut result = PathBuf::new();
324    for component in path.components() {
325        match component {
326            Component::ParentDir => {
327                // Pop the last component unless we're at root or have no components
328                if !result.pop() {
329                    result.push(component);
330                }
331            }
332            Component::CurDir => {} // Skip `.`
333            _ => result.push(component),
334        }
335    }
336    result
337}
338
339fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
340    let mut existing = path.to_path_buf();
341    let mut tail_segments = Vec::new();
342
343    while !existing.exists() {
344        if let Some(name) = existing.file_name() {
345            tail_segments.push(name.to_owned());
346        } else {
347            break;
348        }
349
350        existing = match existing.parent() {
351            Some(parent) => parent.to_path_buf(),
352            None => break,
353        };
354    }
355
356    let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
357    for segment in tail_segments.into_iter().rev() {
358        resolved.push(segment);
359    }
360
361    resolved
362}
363
364fn path_error_response(
365    req_id: &str,
366    path: &Path,
367    resolved_root: &Path,
368) -> crate::protocol::Response {
369    crate::protocol::Response::error(
370        req_id,
371        "path_outside_root",
372        format!(
373            "path '{}' is outside the project root '{}'",
374            path.display(),
375            resolved_root.display()
376        ),
377    )
378}
379
380/// Walk `candidate` component-by-component. For any component that is a
381/// symlink on disk, iteratively follow the full chain (up to 40 hops) and
382/// reject if any hop's resolved target lies outside `resolved_root`.
383///
384/// This is the fallback path used when `fs::canonicalize` fails (e.g. on
385/// Linux with broken symlink chains pointing to non-existent destinations).
386/// On macOS `canonicalize` also fails for broken symlinks but the returned
387/// `/var/...` tempdir paths diverge from `resolved_root`'s `/private/var/...`
388/// form, so we must accept either form when deciding which symlinks to check.
389fn reject_escaping_symlink(
390    req_id: &str,
391    original_path: &Path,
392    candidate: &Path,
393    resolved_root: &Path,
394    raw_root: &Path,
395) -> Result<(), crate::protocol::Response> {
396    let mut current = PathBuf::new();
397
398    for component in candidate.components() {
399        current.push(component);
400
401        let Ok(metadata) = std::fs::symlink_metadata(&current) else {
402            continue;
403        };
404
405        if !metadata.file_type().is_symlink() {
406            continue;
407        }
408
409        // Only check symlinks that live inside the project root. This skips
410        // OS-level prefix symlinks (macOS /var → /private/var) that are not
411        // inside our project directory and whose "escaping" is harmless.
412        //
413        // We compare against BOTH the canonicalized root (resolved_root, e.g.
414        // /private/var/.../project) AND the raw root (e.g. /var/.../project)
415        // because tempdir() returns raw paths while fs::canonicalize returns
416        // the resolved form — and our `current` may be in either form.
417        let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
418        if !inside_root {
419            continue;
420        }
421
422        iterative_follow_chain(req_id, original_path, &current, resolved_root)?;
423    }
424
425    Ok(())
426}
427
428/// Iteratively follow a symlink chain from `link` and reject if any hop's
429/// resolved target is outside `resolved_root`. Depth-capped at 40 hops.
430fn iterative_follow_chain(
431    req_id: &str,
432    original_path: &Path,
433    start: &Path,
434    resolved_root: &Path,
435) -> Result<(), crate::protocol::Response> {
436    let mut link = start.to_path_buf();
437    let mut depth = 0usize;
438
439    loop {
440        if depth > 40 {
441            return Err(path_error_response(req_id, original_path, resolved_root));
442        }
443
444        let target = match std::fs::read_link(&link) {
445            Ok(t) => t,
446            Err(_) => {
447                // Can't read the link — treat as escaping to be safe.
448                return Err(path_error_response(req_id, original_path, resolved_root));
449            }
450        };
451
452        let resolved_target = if target.is_absolute() {
453            normalize_path(&target)
454        } else {
455            let parent = link.parent().unwrap_or_else(|| Path::new(""));
456            normalize_path(&parent.join(&target))
457        };
458
459        // Check boundary: use canonicalized target when available (handles
460        // macOS /var → /private/var aliasing), fall back to the normalized
461        // path when canonicalize fails (e.g. broken symlink on Linux).
462        let canonical_target =
463            std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
464
465        if !canonical_target.starts_with(resolved_root)
466            && !resolved_target.starts_with(resolved_root)
467        {
468            return Err(path_error_response(req_id, original_path, resolved_root));
469        }
470
471        // If the target is itself a symlink, follow the next hop.
472        match std::fs::symlink_metadata(&resolved_target) {
473            Ok(meta) if meta.file_type().is_symlink() => {
474                link = resolved_target;
475                depth += 1;
476            }
477            _ => break, // Non-symlink or non-existent target — chain ends here.
478        }
479    }
480
481    Ok(())
482}
483
484/// Shared application context threaded through all command handlers.
485///
486/// Holds the language provider, backup/checkpoint stores, configuration,
487/// and call graph engine. Constructed once at startup and passed by
488/// reference to `dispatch`.
489///
490/// Stores use `RefCell` for interior mutability — the binary is single-threaded
491/// (one request at a time on the stdin read loop) so runtime borrow checking
492/// is safe and never contended.
493pub struct AppContext {
494    provider: Box<dyn LanguageProvider>,
495    backup: RefCell<BackupStore>,
496    checkpoint: RefCell<CheckpointStore>,
497    db: RefCell<Option<Arc<Mutex<Connection>>>>,
498    config: RefCell<Config>,
499    pub harness: RefCell<Option<Harness>>,
500    canonical_cache_root: RefCell<Option<PathBuf>>,
501    is_worktree_bridge: RefCell<bool>,
502    git_common_dir: RefCell<Option<PathBuf>>,
503    /// Reasons (if any) why heavy AFT subsystems were auto-disabled for the
504    /// current project root. Populated by `handle_configure` based on the
505    /// canonical project root and synchronous file count. Each reason is a
506    /// stable machine-readable string suffix (`"home_root"`,
507    /// `"search_too_many_files:N"`, etc.) so the plugin can render distinct
508    /// degraded-mode UI states without re-deriving the reason locally.
509    /// Empty when the project is healthy / full-featured.
510    degraded_reasons: RefCell<Vec<String>>,
511    callgraph: RefCell<Option<CallGraph>>,
512    callgraph_store: RefCell<Option<CallGraphStore>>,
513    callgraph_store_force_rebuild: RefCell<bool>,
514    callgraph_store_rx: RefCell<Option<crossbeam_channel::Receiver<CallGraphStore>>>,
515    pending_callgraph_store_paths: RefCell<BTreeSet<PathBuf>>,
516    search_index: RefCell<Option<SearchIndex>>,
517    search_index_rx: RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>>,
518    pending_search_index_paths: RefCell<BTreeSet<PathBuf>>,
519    symbol_cache: SharedSymbolCache,
520    inspect_manager: Arc<InspectManager>,
521    tier2_refresh_scheduler: RefCell<Tier2RefreshScheduler>,
522    semantic_index: RefCell<Option<SemanticIndex>>,
523    semantic_index_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
524    semantic_index_status: RefCell<SemanticIndexStatus>,
525    pending_semantic_index_paths: RefCell<BTreeSet<PathBuf>>,
526    pending_semantic_corpus_refresh: RefCell<bool>,
527    semantic_refresh_tx: RefCell<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>,
528    semantic_refresh_event_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
529    semantic_refresh_worker: RefCell<Option<SemanticRefreshWorkerSlot>>,
530    semantic_embedding_model: RefCell<Option<crate::semantic_index::EmbeddingModel>>,
531    watcher: RefCell<Option<RecommendedWatcher>>,
532    watcher_rx: RefCell<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>>,
533    watcher_thread: RefCell<Option<WatcherThreadHandle>>,
534    lsp_manager: RefCell<LspManager>,
535    /// Shared registry of LSP child PIDs. Cloned and passed to the signal
536    /// handler so it can SIGKILL all children before aft exits, preventing
537    /// orphaned LSP processes when bridge.shutdown() SIGTERMs aft.
538    lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
539    stdout_writer: SharedStdoutWriter,
540    progress_sender: SharedProgressSender,
541    configure_generation: AtomicU64,
542    /// Last-seen value of `InspectManager::reuse_completion_count()`, so the
543    /// per-request inspect drain can detect watcher-driven Tier-2 scans that
544    /// finished since the previous tick and refresh the status bar (#3).
545    last_seen_reuse_completions: AtomicU64,
546    configure_warnings_tx: mpsc::Sender<(u64, ConfigureWarningsFrame)>,
547    configure_warnings_rx: mpsc::Receiver<(u64, ConfigureWarningsFrame)>,
548    status_emitter: StatusEmitter,
549    bash_background: BgTaskRegistry,
550    /// Thread-safe registry of TOML output filters. Lazy-built on first
551    /// access; populated atomically via `RwLock`. Shared between command
552    /// handlers (which use it through `filter_registry()` -> read guard) and
553    /// the `BgTaskRegistry` watchdog thread (which uses it through
554    /// `compress::compress_with_registry`). Reloaded when configure changes
555    /// the project root or storage_dir; see [`AppContext::reset_filter_registry`].
556    filter_registry: crate::compress::SharedFilterRegistry,
557    /// Set to true once the filter_registry has been populated. Avoids
558    /// double-loading on hot paths without holding a write lock.
559    filter_registry_loaded: std::sync::atomic::AtomicBool,
560    /// Live `experimental.bash.compress` flag, kept in sync with `config`
561    /// from the configure handler. Exposed via [`AppContext::bash_compress_flag`]
562    /// so the BgTaskRegistry's watchdog-thread compressor can read it without
563    /// holding the config refcell.
564    bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
565    /// Project gitignore matcher, rebuilt by [`AppContext::rebuild_gitignore`]
566    /// whenever `project_root` changes or a watcher event reports a
567    /// `.gitignore` write. Used by the watcher event filter to decide which
568    /// path-changes are interesting to AFT's caches. `None` when no project
569    /// root is configured or when the project has no gitignore files; in that
570    /// case the watcher falls back to a small hardcoded infra-directory skip.
571    gitignore: SharedGitignore,
572    gitignore_generation: Arc<AtomicU64>,
573    /// Last-known Tier-2 + todos counts for the agent status bar, refreshed off
574    /// the hot path (on `aft_inspect` reads and background Tier-2 completions).
575    /// Errors/warnings are read live and not stored here.
576    status_bar_tier2: RefCell<StatusBarTier2>,
577    /// Persistent TypeScript-project membership cache for the status-bar E/W
578    /// count. The bar reads E/W live on every tool result, so resolving the
579    /// nearest tsconfig (read + parse + glob-compile) per drain is too costly;
580    /// this memoizes per tsconfig dir. Invalidated wholesale on any
581    /// tsconfig-like watcher event and on `configure`. Owned here (not in
582    /// `DiagnosticsStore`, which stays raw policy-free) per the v0.35 council.
583    tsconfig_membership: RefCell<crate::lsp::tsconfig_membership::TsconfigMembershipCache>,
584}
585
586impl Drop for AppContext {
587    fn drop(&mut self) {
588        if let Some(runtime) = self.watcher_thread.get_mut().take() {
589            runtime.shutdown_and_join();
590        }
591    }
592}
593
594/// Result of requesting the persisted callgraph store for a store-backed op.
595///
596/// The five edge-query ops never block the request thread on a cold build:
597/// a genuine cold build is kicked off in the background and `Building` is
598/// returned so the agent retries, mirroring how semantic search reports a
599/// build in progress. Warm restarts open the on-disk DB synchronously, so
600/// `Building` is only ever seen during a true first cold build.
601pub enum CallgraphStoreAccess<'a> {
602    /// Store is resident and queryable.
603    Ready(RefMut<'a, CallGraphStore>),
604    /// A cold build is in flight (or was just started); retry shortly.
605    Building,
606    /// Not configured, or a read-only worktree whose store was never built.
607    Unavailable,
608    /// A store open/build check failed with a real error (DB/IO).
609    Error(CallGraphStoreError),
610}
611
612/// Inline wait window for a callgraph-store cold build before returning
613/// `Building`. Default `0` (pure-async: never block the request thread).
614/// Tests set `AFT_CALLGRAPH_BUILD_WAIT_MS` large so small fixture builds
615/// resolve to `Ready` synchronously and exercise query correctness directly.
616fn callgraph_build_wait_window() -> Duration {
617    std::env::var("AFT_CALLGRAPH_BUILD_WAIT_MS")
618        .ok()
619        .and_then(|raw| raw.parse::<u64>().ok())
620        .map(Duration::from_millis)
621        .unwrap_or(Duration::ZERO)
622}
623
624impl AppContext {
625    pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
626        let bash_compress_enabled = config.experimental_bash_compress;
627        let progress_sender = Arc::new(Mutex::new(None));
628        let stdout_writer = Arc::new(Mutex::new(BufWriter::new(io::stdout())));
629        let (configure_warnings_tx, configure_warnings_rx) = mpsc::channel();
630        let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
631        let symbol_cache = provider
632            .as_any()
633            .downcast_ref::<crate::parser::TreeSitterProvider>()
634            .map(|provider| provider.symbol_cache())
635            .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
636        let lsp_child_registry = crate::lsp::child_registry::LspChildRegistry::new();
637        let mut lsp_manager = LspManager::new();
638        lsp_manager.set_child_registry(lsp_child_registry.clone());
639        // Apply the configured diagnostic LRU cap (default 5000, 0 = unbounded)
640        // so the documented `lsp.diagnostic_cache_size` knob takes effect.
641        lsp_manager.set_diagnostic_capacity(config.diagnostic_cache_size);
642        AppContext {
643            provider,
644            backup: RefCell::new(BackupStore::new()),
645            checkpoint: RefCell::new(CheckpointStore::new()),
646            db: RefCell::new(None),
647            config: RefCell::new(config),
648            harness: RefCell::new(None),
649            canonical_cache_root: RefCell::new(None),
650            is_worktree_bridge: RefCell::new(false),
651            git_common_dir: RefCell::new(None),
652            degraded_reasons: RefCell::new(Vec::new()),
653            callgraph: RefCell::new(None),
654            callgraph_store: RefCell::new(None),
655            callgraph_store_force_rebuild: RefCell::new(false),
656            callgraph_store_rx: RefCell::new(None),
657            pending_callgraph_store_paths: RefCell::new(BTreeSet::new()),
658            search_index: RefCell::new(None),
659            search_index_rx: RefCell::new(None),
660            pending_search_index_paths: RefCell::new(BTreeSet::new()),
661            symbol_cache,
662            inspect_manager: Arc::new(InspectManager::new()),
663            tier2_refresh_scheduler: RefCell::new(Tier2RefreshScheduler::new()),
664            semantic_index: RefCell::new(None),
665            semantic_index_rx: RefCell::new(None),
666            semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
667            pending_semantic_index_paths: RefCell::new(BTreeSet::new()),
668            pending_semantic_corpus_refresh: RefCell::new(false),
669            semantic_refresh_tx: RefCell::new(None),
670            semantic_refresh_event_rx: RefCell::new(None),
671            semantic_refresh_worker: RefCell::new(None),
672            semantic_embedding_model: RefCell::new(None),
673            watcher: RefCell::new(None),
674            watcher_rx: RefCell::new(None),
675            watcher_thread: RefCell::new(None),
676            lsp_manager: RefCell::new(lsp_manager),
677            lsp_child_registry,
678            stdout_writer,
679            progress_sender: Arc::clone(&progress_sender),
680            configure_generation: AtomicU64::new(0),
681            last_seen_reuse_completions: AtomicU64::new(0),
682            configure_warnings_tx,
683            configure_warnings_rx,
684            status_emitter,
685            bash_background: BgTaskRegistry::new(progress_sender),
686            filter_registry: Arc::new(std::sync::RwLock::new(
687                crate::compress::toml_filter::FilterRegistry::default(),
688            )),
689            filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
690            bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
691            gitignore: Arc::new(std::sync::RwLock::new(None)),
692            gitignore_generation: Arc::new(AtomicU64::new(0)),
693            status_bar_tier2: RefCell::new(StatusBarTier2::default()),
694            tsconfig_membership: RefCell::new(
695                crate::lsp::tsconfig_membership::TsconfigMembershipCache::new(),
696            ),
697        }
698    }
699
700    /// Current agent status-bar counts. `errors`/`warnings` are read LIVE from
701    /// the LSP diagnostics store (continuously drained, no round-trip); the
702    /// Tier-2 + todos counts are the last-known cached values. Returns `None`
703    /// until the Tier-2 cache has been populated at least once, so we never
704    /// surface a bar that misleadingly claims "0 dead code" before any scan.
705    pub fn status_bar_counts(&self) -> Option<StatusBarCounts> {
706        let tier2 = self.status_bar_tier2.borrow();
707        // All three Tier-2 categories must hold a real value before the bar is
708        // surfaced — otherwise a partially-scanned cold run would render a
709        // fabricated `0` for the not-yet-completed categories (#1).
710        let (Some(dead_code), Some(unused_exports), Some(duplicates)) =
711            (tier2.dead_code, tier2.unused_exports, tier2.duplicates)
712        else {
713            return None;
714        };
715        let (errors, warnings) = self.status_bar_error_warning_counts();
716        Some(StatusBarCounts {
717            errors,
718            warnings,
719            dead_code,
720            unused_exports,
721            duplicates,
722            todos: tier2.todos.unwrap_or(0),
723            tier2_stale: tier2.stale,
724        })
725    }
726
727    /// Error/warning counts for the agent status bar, filtered to match
728    /// `aft_inspect`/`tsc` (v0.35 council): only diagnostics under the canonical
729    /// project root, with build-excluded TS/JS files skipped via the persistent
730    /// tsconfig-membership cache, and cross-server duplicates collapsed. Falls
731    /// back to the raw warm count before configure has set a canonical root.
732    fn status_bar_error_warning_counts(&self) -> (usize, usize) {
733        let Some(root) = self.canonical_cache_root_opt() else {
734            // Pre-configure: no project root to scope against. Raw count is the
735            // best available signal (and the bar is gated on Tier-2 anyway).
736            return self.lsp_manager.borrow().warm_error_warning_counts();
737        };
738        let mut membership = self.tsconfig_membership.borrow_mut();
739        self.lsp_manager
740            .borrow()
741            .filtered_error_warning_counts(|file| {
742                file.starts_with(&root) && !membership.should_skip_diagnostics(file)
743            })
744    }
745
746    /// Invalidate the status-bar tsconfig-membership cache. Called from the
747    /// watcher seam when a tsconfig-like file changes and from `configure`
748    /// when the project root changes, so the next bar count re-reads from disk.
749    pub fn clear_tsconfig_membership_cache(&self) {
750        self.tsconfig_membership.borrow_mut().clear();
751    }
752
753    /// Mark the status-bar Tier-2 counts stale (rendered with `~`) without
754    /// changing the numbers — called when the watcher sees a source-file change,
755    /// so the bar honestly signals the counts predate the latest edit until the
756    /// next background scan completes. Returns true only when the visible stale
757    /// bit flips. No-op before the first populate.
758    pub fn mark_status_bar_tier2_stale(&self) -> bool {
759        let mut tier2 = self.status_bar_tier2.borrow_mut();
760        // No-op before the first full populate (nothing real to mark stale).
761        if tier2.dead_code.is_some() && tier2.unused_exports.is_some() && tier2.duplicates.is_some()
762        {
763            let changed = !tier2.stale;
764            tier2.stale = true;
765            return changed;
766        }
767        false
768    }
769
770    /// Refresh the cached Tier-2 + todos counts for the status bar. Each count
771    /// is `Option`: `None` preserves the last-known value (the category wasn't
772    /// recomputed or has no real aggregate yet) so we never overwrite a real
773    /// count with a fabricated `0`. `stale` marks the Tier-2 numbers as
774    /// not-yet-reconciled with the latest edits.
775    pub fn update_status_bar_tier2(
776        &self,
777        dead_code: Option<usize>,
778        unused_exports: Option<usize>,
779        duplicates: Option<usize>,
780        todos: Option<usize>,
781        stale: bool,
782    ) {
783        let mut tier2 = self.status_bar_tier2.borrow_mut();
784        if let Some(dead_code) = dead_code {
785            tier2.dead_code = Some(dead_code);
786        }
787        if let Some(unused_exports) = unused_exports {
788            tier2.unused_exports = Some(unused_exports);
789        }
790        if let Some(duplicates) = duplicates {
791            tier2.duplicates = Some(duplicates);
792        }
793        if let Some(todos) = todos {
794            tier2.todos = Some(todos);
795        }
796        tier2.stale = stale;
797    }
798
799    /// Borrow the cached project gitignore matcher. Returns `None` when no
800    /// project_root is configured or when the project has no gitignore files.
801    pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
802        self.gitignore
803            .read()
804            .unwrap_or_else(|poisoned| poisoned.into_inner())
805            .clone()
806    }
807
808    /// Shared gitignore matcher handle for the watcher filter thread.
809    pub fn shared_gitignore(&self) -> SharedGitignore {
810        Arc::clone(&self.gitignore)
811    }
812
813    /// Monotonic generation bumped after every matcher rebuild/clear. The
814    /// watcher filter thread uses it to wait until the main thread has rebuilt
815    /// ignore rules after it reports an ignore-file change.
816    pub fn gitignore_generation(&self) -> Arc<AtomicU64> {
817        Arc::clone(&self.gitignore_generation)
818    }
819
820    fn set_gitignore(&self, matcher: Option<Arc<ignore::gitignore::Gitignore>>) {
821        *self
822            .gitignore
823            .write()
824            .unwrap_or_else(|poisoned| poisoned.into_inner()) = matcher;
825        self.gitignore_generation.fetch_add(1, Ordering::SeqCst);
826    }
827
828    /// Rebuild the gitignore matcher from the current `project_root` and
829    /// cache it. Called by the configure handler whenever the project root
830    /// changes, and by the watcher event drain when a `.gitignore` file
831    /// itself is modified.
832    ///
833    /// The builder honors:
834    /// - `<project_root>/.gitignore`
835    /// - Git's global excludes file (the same source used by `ignore::WalkBuilder`)
836    /// - the repository's real `info/exclude` file, resolved through Git's
837    ///   common dir for linked worktrees
838    /// - nested `.gitignore` files (each `.gitignore` discovered during
839    ///   the recursive walk)
840    ///
841    /// Stores `None` if there's no project_root or no matchable gitignore
842    /// files. Logs build errors but never fails configure.
843    /// Clear any cached gitignore matcher without rebuilding.
844    ///
845    /// Used by `handle_configure` in degraded mode (e.g. `project_root == $HOME`)
846    /// where running the gitignore-discovery walk would exceed the configure
847    /// budget. The watcher event filter falls back to the hardcoded infra-dir
848    /// skip list when no matcher is present.
849    pub fn clear_gitignore(&self) {
850        self.set_gitignore(None);
851    }
852
853    pub fn rebuild_gitignore(&self) {
854        use ignore::gitignore::GitignoreBuilder;
855        use std::path::Path;
856        let root_raw = match self.config().project_root.clone() {
857            Some(r) => r,
858            None => {
859                self.set_gitignore(None);
860                return;
861            }
862        };
863        // Canonicalize the root so symlink-prefix mismatches don't cause
864        // `Gitignore::matched_path_or_any_parents` to panic on watcher event
865        // paths. macOS routinely surfaces `/private/var/...` while `project_root`
866        // arrives as `/var/...` (a symlink to `/private/var`); the `ignore`
867        // crate's matcher panics when a query path isn't lexically under the
868        // matcher's root. Canonicalizing both ends (here for root, naturally
869        // for watcher events on macOS) keeps them in the same prefix space.
870        let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
871        let mut builder = GitignoreBuilder::new(&root);
872        // Git's global excludes file — keep the live watcher matcher aligned
873        // with the project walkers (`WalkBuilder::git_global(true)`). The
874        // ignore crate exposes the same path discovery it uses internally, so
875        // this handles the default XDG location and configured excludesFile.
876        if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
877            if global_ignore.is_file() {
878                if let Some(err) = builder.add(&global_ignore) {
879                    crate::slog_warn!(
880                        "global gitignore parse error in {}: {}",
881                        global_ignore.display(),
882                        err
883                    );
884                }
885            }
886        }
887        // Add root .gitignore (the most common case)
888        let root_ignore = Path::new(&root).join(".gitignore");
889        if root_ignore.exists() {
890            if let Some(err) = builder.add(&root_ignore) {
891                crate::slog_warn!(
892                    "gitignore parse error in {}: {}",
893                    root_ignore.display(),
894                    err
895                );
896            }
897        }
898        // Root .aftignore — AFT-specific ignores layered on top of .gitignore.
899        // Lets users exclude paths git can't (e.g. submodules) from AFT's
900        // walks/indexes. Honored by the watcher matcher too, so edits under an
901        // aftignored path don't trigger reindexing.
902        let root_aftignore = Path::new(&root).join(".aftignore");
903        if root_aftignore.exists() {
904            if let Some(err) = builder.add(&root_aftignore) {
905                crate::slog_warn!(
906                    "aftignore parse error in {}: {}",
907                    root_aftignore.display(),
908                    err
909                );
910            }
911        }
912        // .git/info/exclude — manually added because GitignoreBuilder::new()
913        // does not auto-discover it (verified against ignore-0.4.25 source).
914        // In linked worktrees this lives under the repository common dir, not
915        // under `<worktree>/.git/info/exclude` (where `.git` is only a file).
916        let info_exclude = self
917            .git_common_dir
918            .borrow()
919            .clone()
920            .unwrap_or_else(|| Path::new(&root).join(".git"))
921            .join("info")
922            .join("exclude");
923        if info_exclude.exists() {
924            if let Some(err) = builder.add(&info_exclude) {
925                crate::slog_warn!(
926                    "gitignore parse error in {}: {}",
927                    info_exclude.display(),
928                    err
929                );
930            }
931        }
932        // Walk the project to pick up nested .gitignore/.aftignore files at
933        // arbitrary depth. The main project walkers honor deeply nested ignore
934        // files, so the watcher matcher must do the same or live invalidation
935        // can disagree with startup indexing. Skip obvious infra dirs so we
936        // don't accidentally load a vendored repo's ignore file as ours.
937        let walker = ignore::WalkBuilder::new(&root)
938            .standard_filters(true)
939            // Hidden files are filtered by default, but `.gitignore` starts with
940            // `.` so we need to traverse "hidden" entries to find nested ones.
941            // No `max_depth`: nested `.gitignore`/`.aftignore` files are honored
942            // at arbitrary depth (see configure_watcher_honors_deep_nested_aftignore).
943            // The walk is pruned by standard gitignore filters plus the infra
944            // skip below; configure never runs this against `$HOME` (guarded by
945            // `home_match`), and tests use bounded roots rather than `/`.
946            .hidden(false)
947            .filter_entry(|entry| {
948                let name = entry.file_name().to_string_lossy();
949                !matches!(
950                    name.as_ref(),
951                    "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
952                )
953            })
954            .build();
955        for entry in walker.flatten() {
956            let file_name = entry.file_name();
957            let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
958            let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
959            if is_nested_gitignore || is_nested_aftignore {
960                if let Some(err) = builder.add(entry.path()) {
961                    crate::slog_warn!(
962                        "nested ignore parse error in {}: {}",
963                        entry.path().display(),
964                        err
965                    );
966                }
967            }
968        }
969        match builder.build() {
970            Ok(gi) => {
971                let count = gi.num_ignores();
972                if count > 0 {
973                    crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
974                    self.set_gitignore(Some(Arc::new(gi)));
975                } else {
976                    self.set_gitignore(None);
977                }
978            }
979            Err(err) => {
980                crate::slog_warn!("gitignore matcher build failed: {}", err);
981                self.set_gitignore(None);
982            }
983        }
984    }
985
986    /// Shared atomic mirror of `experimental.bash.compress`. Updated by the
987    /// configure handler. Read by the BgTaskRegistry compressor closure.
988    pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
989        Arc::clone(&self.bash_compress_flag)
990    }
991
992    /// Update the shared `bash_compress_flag` mirror. Call this from the
993    /// configure handler whenever `experimental.bash.compress` changes so the
994    /// BgTaskRegistry watchdog sees the new value on the next completion.
995    pub fn sync_bash_compress_flag(&self) {
996        let value = self.config().experimental_bash_compress;
997        self.bash_compress_flag
998            .store(value, std::sync::atomic::Ordering::Relaxed);
999    }
1000
1001    pub fn set_bash_compress_enabled(&self, enabled: bool) {
1002        self.config_mut().experimental_bash_compress = enabled;
1003        self.bash_compress_flag
1004            .store(enabled, std::sync::atomic::Ordering::Relaxed);
1005    }
1006
1007    /// Read-only access to the TOML filter registry, building it lazily on
1008    /// first use. Returns an `RwLockReadGuard` that callers can `lookup`
1009    /// against directly.
1010    pub fn filter_registry(
1011        &self,
1012    ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
1013        self.ensure_filter_registry_loaded();
1014        match self.filter_registry.read() {
1015            Ok(g) => g,
1016            Err(poisoned) => poisoned.into_inner(),
1017        }
1018    }
1019
1020    /// Returns the shared `Arc<RwLock<FilterRegistry>>` handle so threads
1021    /// outside `AppContext` (notably the bash watchdog) can read it without
1022    /// touching the rest of the context.
1023    pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
1024        self.ensure_filter_registry_loaded();
1025        Arc::clone(&self.filter_registry)
1026    }
1027
1028    /// Force a fresh load of the TOML filter registry. Called when configure
1029    /// changes the project root, storage_dir, or trust state so subsequent
1030    /// `compress::compress` calls pick up new filters.
1031    pub fn reset_filter_registry(&self) {
1032        let new_registry = crate::compress::build_registry_for_context(self);
1033        match self.filter_registry.write() {
1034            Ok(mut slot) => *slot = new_registry,
1035            Err(poisoned) => *poisoned.into_inner() = new_registry,
1036        }
1037        self.filter_registry_loaded
1038            .store(true, std::sync::atomic::Ordering::Release);
1039    }
1040
1041    fn ensure_filter_registry_loaded(&self) {
1042        use std::sync::atomic::Ordering;
1043        if self.filter_registry_loaded.load(Ordering::Acquire) {
1044            return;
1045        }
1046        // Build outside the lock to avoid blocking other readers during a
1047        // multi-file TOML parse.
1048        let new_registry = crate::compress::build_registry_for_context(self);
1049        if let Ok(mut slot) = self.filter_registry.write() {
1050            *slot = new_registry;
1051            self.filter_registry_loaded.store(true, Ordering::Release);
1052        }
1053    }
1054
1055    /// Clone the LSP child registry handle. Used by main.rs to give the
1056    /// signal handler thread a way to SIGKILL LSP children on shutdown.
1057    pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
1058        self.lsp_child_registry.clone()
1059    }
1060
1061    pub fn stdout_writer(&self) -> SharedStdoutWriter {
1062        Arc::clone(&self.stdout_writer)
1063    }
1064
1065    pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
1066        if let Ok(mut progress_sender) = self.progress_sender.lock() {
1067            *progress_sender = sender;
1068        }
1069    }
1070
1071    pub fn emit_progress(&self, frame: ProgressFrame) {
1072        let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
1073            return;
1074        };
1075        if let Some(sender) = progress_sender.as_ref() {
1076            sender(PushFrame::Progress(frame));
1077        }
1078    }
1079
1080    pub fn status_emitter(&self) -> &StatusEmitter {
1081        &self.status_emitter
1082    }
1083
1084    /// Get a clone of the current progress sender for use from background
1085    /// threads. Returns `None` when the main loop hasn't installed one (tests,
1086    /// CLI without push frames).
1087    ///
1088    /// Used by `configure`'s deferred file-walk thread to push warnings after
1089    /// configure has already returned, so configure latency stays sub-100 ms
1090    /// even on huge directories.
1091    pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
1092        self.progress_sender
1093            .lock()
1094            .ok()
1095            .and_then(|sender| sender.clone())
1096    }
1097
1098    pub fn advance_configure_generation(&self) -> u64 {
1099        self.configure_generation
1100            .fetch_add(1, Ordering::SeqCst)
1101            .wrapping_add(1)
1102    }
1103
1104    pub fn configure_generation(&self) -> u64 {
1105        self.configure_generation.load(Ordering::SeqCst)
1106    }
1107
1108    pub fn configure_warnings_sender(&self) -> mpsc::Sender<(u64, ConfigureWarningsFrame)> {
1109        self.configure_warnings_tx.clone()
1110    }
1111
1112    pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
1113        let mut warnings = Vec::new();
1114        while let Ok(warning) = self.configure_warnings_rx.try_recv() {
1115            warnings.push(warning);
1116        }
1117        warnings
1118    }
1119
1120    pub fn bash_background(&self) -> &BgTaskRegistry {
1121        &self.bash_background
1122    }
1123
1124    pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
1125        self.bash_background.drain_completions()
1126    }
1127
1128    /// Access the language provider.
1129    pub fn provider(&self) -> &dyn LanguageProvider {
1130        self.provider.as_ref()
1131    }
1132
1133    /// Access the backup store.
1134    pub fn backup(&self) -> &RefCell<BackupStore> {
1135        &self.backup
1136    }
1137
1138    /// Access the checkpoint store.
1139    pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
1140        &self.checkpoint
1141    }
1142
1143    pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
1144        *self.db.borrow_mut() = Some(conn);
1145    }
1146
1147    pub fn clear_db(&self) {
1148        *self.db.borrow_mut() = None;
1149    }
1150
1151    pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
1152        self.db.borrow().clone()
1153    }
1154
1155    /// Access the configuration (shared borrow).
1156    pub fn config(&self) -> Ref<'_, Config> {
1157        self.config.borrow()
1158    }
1159
1160    /// Access the configuration (mutable borrow).
1161    pub fn config_mut(&self) -> RefMut<'_, Config> {
1162        self.config.borrow_mut()
1163    }
1164
1165    pub fn set_harness(&self, harness: Harness) {
1166        *self.harness.borrow_mut() = Some(harness);
1167        self.bash_background.set_harness(harness);
1168    }
1169
1170    pub fn harness_opt(&self) -> Option<Harness> {
1171        *self.harness.borrow()
1172    }
1173
1174    pub fn harness(&self) -> Harness {
1175        self.harness_opt()
1176            .expect("harness set by configure before any tool call")
1177    }
1178
1179    pub fn storage_dir(&self) -> PathBuf {
1180        crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
1181    }
1182
1183    pub fn harness_dir(&self) -> PathBuf {
1184        self.storage_dir().join(self.harness().as_str())
1185    }
1186
1187    pub fn inspect_dir(&self) -> PathBuf {
1188        self.harness_dir().join("inspect")
1189    }
1190
1191    pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
1192        self.harness_dir()
1193            .join("bash-tasks")
1194            .join(hash_session(session_id))
1195    }
1196
1197    pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
1198        self.harness_dir()
1199            .join("backups")
1200            .join(hash_session(session_id))
1201            .join(path_hash)
1202    }
1203
1204    pub fn filters_dir(&self) -> PathBuf {
1205        self.harness_dir().join("filters")
1206    }
1207
1208    /// HOST-GLOBAL — NOT under harness_dir. Read by trust.rs across both harnesses.
1209    pub fn trust_file(&self) -> PathBuf {
1210        self.storage_dir().join("trusted-filter-projects.json")
1211    }
1212
1213    pub fn set_canonical_cache_root(&self, root: PathBuf) {
1214        debug_assert!(root.is_absolute());
1215        *self.canonical_cache_root.borrow_mut() = Some(root);
1216    }
1217
1218    pub fn canonical_cache_root(&self) -> PathBuf {
1219        self.canonical_cache_root
1220            .borrow()
1221            .clone()
1222            .expect("canonical_cache_root accessed before handle_configure")
1223    }
1224
1225    pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
1226        self.canonical_cache_root.borrow().clone()
1227    }
1228
1229    pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
1230        *self.is_worktree_bridge.borrow_mut() = is_worktree_bridge;
1231        *self.git_common_dir.borrow_mut() = git_common_dir;
1232    }
1233
1234    pub fn is_worktree_bridge(&self) -> bool {
1235        *self.is_worktree_bridge.borrow()
1236    }
1237
1238    pub fn git_common_dir(&self) -> Option<PathBuf> {
1239        self.git_common_dir.borrow().clone()
1240    }
1241
1242    /// Replace the current degraded-mode reasons. Empty vec = full-featured
1243    /// mode (no degradation). Called by `handle_configure` after deciding
1244    /// which subsystems to disable for this project root.
1245    pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
1246        *self.degraded_reasons.borrow_mut() = reasons;
1247    }
1248
1249    pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
1250        let reason = reason.into();
1251        let mut reasons = self.degraded_reasons.borrow_mut();
1252        if reasons.iter().any(|existing| existing == &reason) {
1253            return false;
1254        }
1255        reasons.push(reason);
1256        true
1257    }
1258
1259    /// Snapshot of current degraded-mode reasons. Order is stable
1260    /// (insertion order from `set_degraded_reasons`) so UI rendering and
1261    /// snapshot diffs are deterministic.
1262    pub fn degraded_reasons(&self) -> Vec<String> {
1263        self.degraded_reasons.borrow().clone()
1264    }
1265
1266    /// True iff at least one degraded reason is recorded.
1267    pub fn is_degraded(&self) -> bool {
1268        !self.degraded_reasons.borrow().is_empty()
1269    }
1270
1271    pub fn cache_role(&self) -> &'static str {
1272        if self.canonical_cache_root.borrow().is_none() {
1273            "not_initialized"
1274        } else if self.is_worktree_bridge() {
1275            "worktree"
1276        } else {
1277            "main"
1278        }
1279    }
1280
1281    /// Access the call graph engine.
1282    pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
1283        &self.callgraph
1284    }
1285
1286    /// Access the persisted call graph store.
1287    pub fn callgraph_store(&self) -> &RefCell<Option<CallGraphStore>> {
1288        &self.callgraph_store
1289    }
1290
1291    pub fn mark_callgraph_store_force_rebuild(&self) {
1292        *self.callgraph_store_force_rebuild.borrow_mut() = true;
1293    }
1294
1295    fn take_callgraph_store_force_rebuild(&self) -> bool {
1296        let force = *self.callgraph_store_force_rebuild.borrow();
1297        *self.callgraph_store_force_rebuild.borrow_mut() = false;
1298        force
1299    }
1300
1301    pub fn callgraph_store_dir(&self) -> PathBuf {
1302        match self.harness_opt() {
1303            Some(harness) => self.storage_dir().join(harness.as_str()).join("callgraph"),
1304            None => self.storage_dir().join("callgraph"),
1305        }
1306    }
1307
1308    pub fn ensure_callgraph_store(
1309        &self,
1310    ) -> Result<Option<RefMut<'_, CallGraphStore>>, CallGraphStoreError> {
1311        self.ensure_callgraph_store_with_flag(true)
1312    }
1313
1314    fn ensure_callgraph_store_with_flag(
1315        &self,
1316        respect_config_flag: bool,
1317    ) -> Result<Option<RefMut<'_, CallGraphStore>>, CallGraphStoreError> {
1318        if respect_config_flag && !self.config().callgraph_store {
1319            return Ok(None);
1320        }
1321        if self.callgraph_store.borrow().is_none() {
1322            let Some(project_root) = self.callgraph_project_root() else {
1323                return Ok(None);
1324            };
1325            let callgraph_dir = self.callgraph_store_dir();
1326            let force_rebuild = self.take_callgraph_store_force_rebuild();
1327            let store = if self.is_worktree_bridge() {
1328                CallGraphStore::open_readonly(callgraph_dir, project_root)?
1329            } else if force_rebuild {
1330                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1331                let (store, _stats) =
1332                    CallGraphStore::cold_build_with_lease(callgraph_dir, project_root, &files)?;
1333                Some(store)
1334            } else if CallGraphStore::needs_cold_build(&callgraph_dir, &project_root)? {
1335                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1336                let (store, _stats) =
1337                    CallGraphStore::ensure_built_with_lease(callgraph_dir, project_root, &files)?;
1338                Some(store)
1339            } else {
1340                Some(CallGraphStore::open(callgraph_dir, project_root)?)
1341            };
1342            *self.callgraph_store.borrow_mut() = store;
1343        }
1344        let borrow = self.callgraph_store.borrow_mut();
1345        Ok(RefMut::filter_map(borrow, Option::as_mut).ok())
1346    }
1347
1348    /// Resolve the project root used for the callgraph store: prefer the
1349    /// canonical cache root, falling back to the configured project root.
1350    fn callgraph_project_root(&self) -> Option<PathBuf> {
1351        self.canonical_cache_root_opt().or_else(|| {
1352            self.config()
1353                .project_root
1354                .clone()
1355                .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
1356        })
1357    }
1358
1359    /// Access the persisted callgraph store for the five store-backed edge-query
1360    /// ops **without ever blocking the request thread on a cold build**.
1361    ///
1362    /// - Store resident          -> `Ready`.
1363    /// - Warm on-disk DB present  -> opened synchronously (cheap) -> `Ready`.
1364    /// - Genuine cold build needed -> kicked off in the background, returns
1365    ///   `Building`; the watcher keeps the store fresh once it lands.
1366    /// - Worktree without a built store, or not configured -> `Unavailable`.
1367    ///
1368    /// A build already in flight (`callgraph_store_rx` set) also returns
1369    /// `Building` without starting a second build.
1370    /// Drop the resident callgraph store when another process (or a local cold
1371    /// rebuild) has published a newer generation, so the next access reopens via
1372    /// the pointer. No-op when no store is resident, a build is in flight, or the
1373    /// store is still current. Must run before serving ops AND before any
1374    /// incremental write, so every process converges on the current generation
1375    /// rather than writing to a stale one.
1376    pub fn revalidate_callgraph_store_generation(&self) {
1377        // Never disturb the store while a background build's result is pending
1378        // install (the rx-install path replaces it wholesale).
1379        if self.callgraph_store_rx.borrow().is_some() {
1380            return;
1381        }
1382        let superseded = self
1383            .callgraph_store
1384            .borrow()
1385            .as_ref()
1386            .is_some_and(|store| !store.is_current());
1387        if superseded {
1388            *self.callgraph_store.borrow_mut() = None;
1389        }
1390    }
1391
1392    pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess<'_> {
1393        // Converge to a newer generation another process (or a local cold
1394        // rebuild) may have published: if our resident store is superseded, drop
1395        // it so the open path below reopens via the pointer. Cheap pointer read.
1396        self.revalidate_callgraph_store_generation();
1397        if self.callgraph_store.borrow().is_some() {
1398            let borrow = self.callgraph_store.borrow_mut();
1399            return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1400                Some(store) => CallgraphStoreAccess::Ready(store),
1401                None => CallgraphStoreAccess::Unavailable,
1402            };
1403        }
1404
1405        // A background build is already running; don't start a second one.
1406        if self.callgraph_store_rx.borrow().is_some() {
1407            return CallgraphStoreAccess::Building;
1408        }
1409
1410        let Some(project_root) = self.callgraph_project_root() else {
1411            return CallgraphStoreAccess::Unavailable;
1412        };
1413        let callgraph_dir = self.callgraph_store_dir();
1414
1415        // Worktree bridges are read-only: open whatever the main checkout built,
1416        // never cold-build here.
1417        if self.is_worktree_bridge() {
1418            match CallGraphStore::open_readonly(callgraph_dir, project_root) {
1419                Ok(Some(store)) => {
1420                    *self.callgraph_store.borrow_mut() = Some(store);
1421                    let borrow = self.callgraph_store.borrow_mut();
1422                    return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1423                        Some(store) => CallgraphStoreAccess::Ready(store),
1424                        None => CallgraphStoreAccess::Unavailable,
1425                    };
1426                }
1427                Ok(None) | Err(_) => return CallgraphStoreAccess::Unavailable,
1428            }
1429        }
1430
1431        let force_rebuild = *self.callgraph_store_force_rebuild.borrow();
1432        // Warm path: a fresh on-disk DB exists -> open synchronously (cheap, no
1433        // "building" delay). Only a genuine cold build goes to the background.
1434        if !force_rebuild {
1435            match CallGraphStore::needs_cold_build(&callgraph_dir, &project_root) {
1436                Ok(false) => match CallGraphStore::open(callgraph_dir, project_root) {
1437                    Ok(store) => {
1438                        *self.callgraph_store.borrow_mut() = Some(store);
1439                        let borrow = self.callgraph_store.borrow_mut();
1440                        return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1441                            Some(store) => CallgraphStoreAccess::Ready(store),
1442                            None => CallgraphStoreAccess::Unavailable,
1443                        };
1444                    }
1445                    Err(error) => return CallgraphStoreAccess::Error(error),
1446                },
1447                Ok(true) => {}
1448                Err(error) => return CallgraphStoreAccess::Error(error),
1449            }
1450        }
1451
1452        // Cold build required: run it off the request thread and return
1453        // `Building` so the agent retries (the watcher keeps the store fresh
1454        // once it lands). By default this never blocks the request thread.
1455        //
1456        // `AFT_CALLGRAPH_BUILD_WAIT_MS` (default 0) optionally waits a bounded
1457        // window inline for the build to land before returning `Building`; tests
1458        // set it large so fixture builds resolve to `Ready` synchronously.
1459        self.spawn_callgraph_store_cold_build(project_root, callgraph_dir, force_rebuild);
1460
1461        let wait = callgraph_build_wait_window();
1462        if !wait.is_zero() {
1463            let received = {
1464                let rx_ref = self.callgraph_store_rx.borrow();
1465                let Some(rx) = rx_ref.as_ref() else {
1466                    return CallgraphStoreAccess::Building;
1467                };
1468                rx.recv_timeout(wait)
1469            };
1470            match received {
1471                Ok(store) => {
1472                    // Replay any source files the watcher saw during the wait so
1473                    // the installed store reflects mid-build edits (mirrors the
1474                    // drain install path). Empty in the common case.
1475                    let pending = self.take_pending_callgraph_store_paths();
1476                    if !pending.is_empty() {
1477                        if let Err(error) = store.refresh_files(&pending) {
1478                            crate::slog_warn!(
1479                                "callgraph store inline post-build refresh failed: {}",
1480                                error
1481                            );
1482                            let _ = store.mark_files_stale(&pending);
1483                        }
1484                    }
1485                    *self.callgraph_store.borrow_mut() = Some(store);
1486                    *self.callgraph_store_rx.borrow_mut() = None;
1487                    let borrow = self.callgraph_store.borrow_mut();
1488                    return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1489                        Some(store) => CallgraphStoreAccess::Ready(store),
1490                        None => CallgraphStoreAccess::Unavailable,
1491                    };
1492                }
1493                Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1494                Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
1495                    // Build failed before sending; clear the receiver so a later
1496                    // op restarts the build instead of waiting on a dead channel.
1497                    *self.callgraph_store_rx.borrow_mut() = None;
1498                }
1499            }
1500        }
1501        CallgraphStoreAccess::Building
1502    }
1503
1504    /// Spawn a background thread that cold-builds the callgraph store and sends
1505    /// the finished store over `callgraph_store_rx`. The main loop installs it
1506    /// via `drain_callgraph_store_events`. Mirrors the search-index build
1507    /// lifecycle (channel + drain).
1508    fn spawn_callgraph_store_cold_build(
1509        &self,
1510        project_root: PathBuf,
1511        callgraph_dir: PathBuf,
1512        force_rebuild: bool,
1513    ) {
1514        if force_rebuild {
1515            // Consume the force flag now so a follow-up request doesn't queue a
1516            // second forced build while this one is in flight.
1517            self.take_callgraph_store_force_rebuild();
1518        }
1519        let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStore>();
1520        *self.callgraph_store_rx.borrow_mut() = Some(rx);
1521        let session_id = crate::log_ctx::current_session();
1522        std::thread::spawn(move || {
1523            crate::log_ctx::with_session(session_id, || {
1524                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1525                let built = if force_rebuild {
1526                    CallGraphStore::cold_build_with_lease(callgraph_dir, project_root, &files)
1527                        .map(|(store, _)| store)
1528                } else {
1529                    CallGraphStore::ensure_built_with_lease(callgraph_dir, project_root, &files)
1530                        .map(|(store, _)| store)
1531                };
1532                match built {
1533                    Ok(store) => {
1534                        let _ = tx.send(store);
1535                    }
1536                    Err(error) => {
1537                        crate::slog_warn!("callgraph store cold build failed: {}", error);
1538                        // Dropping tx disconnects the channel; the drain clears
1539                        // the receiver so a later op can retry the build.
1540                    }
1541                }
1542            });
1543        });
1544    }
1545
1546    /// Access the callgraph-store background-build receiver (drained by the
1547    /// main loop once the cold build completes).
1548    pub fn callgraph_store_rx(
1549        &self,
1550    ) -> &RefCell<Option<crossbeam_channel::Receiver<CallGraphStore>>> {
1551        &self.callgraph_store_rx
1552    }
1553
1554    /// Record source-file paths that changed while a cold build was in flight,
1555    /// so they can be refreshed once the freshly-built store is installed.
1556    pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
1557    where
1558        I: IntoIterator<Item = PathBuf>,
1559    {
1560        self.pending_callgraph_store_paths
1561            .borrow_mut()
1562            .extend(paths);
1563    }
1564
1565    /// Take and clear the paths that changed during a background cold build.
1566    pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
1567        std::mem::take(&mut *self.pending_callgraph_store_paths.borrow_mut())
1568            .into_iter()
1569            .collect()
1570    }
1571
1572    /// Access the search index.
1573    pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
1574        &self.search_index
1575    }
1576
1577    /// Access the search-index build receiver.
1578    pub fn search_index_rx(&self) -> &RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>> {
1579        &self.search_index_rx
1580    }
1581
1582    pub fn add_pending_search_index_paths<I>(&self, paths: I)
1583    where
1584        I: IntoIterator<Item = PathBuf>,
1585    {
1586        self.pending_search_index_paths.borrow_mut().extend(paths);
1587    }
1588
1589    pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
1590        std::mem::take(&mut *self.pending_search_index_paths.borrow_mut())
1591            .into_iter()
1592            .collect()
1593    }
1594
1595    pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
1596    where
1597        I: IntoIterator<Item = PathBuf>,
1598    {
1599        self.pending_semantic_index_paths.borrow_mut().extend(paths);
1600    }
1601
1602    pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
1603        std::mem::take(&mut *self.pending_semantic_index_paths.borrow_mut())
1604            .into_iter()
1605            .collect()
1606    }
1607
1608    pub fn mark_pending_semantic_corpus_refresh(&self) {
1609        *self.pending_semantic_corpus_refresh.borrow_mut() = true;
1610    }
1611
1612    pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
1613        std::mem::take(&mut *self.pending_semantic_corpus_refresh.borrow_mut())
1614    }
1615
1616    pub fn clear_pending_index_updates(&self) {
1617        self.pending_search_index_paths.borrow_mut().clear();
1618        self.pending_callgraph_store_paths.borrow_mut().clear();
1619        self.pending_semantic_index_paths.borrow_mut().clear();
1620        *self.pending_semantic_corpus_refresh.borrow_mut() = false;
1621    }
1622
1623    pub fn inspect_manager(&self) -> Arc<InspectManager> {
1624        Arc::clone(&self.inspect_manager)
1625    }
1626
1627    /// Returns true when one or more watcher-driven (reuse-path) Tier-2 scans
1628    /// have completed since the last call, advancing the last-seen marker. The
1629    /// per-request inspect drain uses this to refresh the status bar after a
1630    /// background scan — those completions bypass `drain_completions`.
1631    pub fn take_new_reuse_completions(&self) -> bool {
1632        let current = self.inspect_manager.reuse_completion_count();
1633        let previous = self
1634            .last_seen_reuse_completions
1635            .swap(current, Ordering::SeqCst);
1636        current != previous
1637    }
1638
1639    pub fn reset_tier2_refresh_scheduler(&self) {
1640        self.reset_tier2_refresh_scheduler_at(Instant::now());
1641    }
1642
1643    #[doc(hidden)]
1644    pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
1645        self.tier2_refresh_scheduler
1646            .borrow_mut()
1647            .reset_after_configure(now);
1648    }
1649
1650    pub fn request_tier2_refresh_pull(&self) -> bool {
1651        self.tier2_refresh_scheduler
1652            .borrow_mut()
1653            .request_pull(!self.is_worktree_bridge())
1654    }
1655
1656    pub fn tick_tier2_refresh_scheduler(
1657        &self,
1658        changed_path_count: usize,
1659    ) -> Option<Tier2TriggerReason> {
1660        self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
1661    }
1662
1663    #[doc(hidden)]
1664    pub fn tick_tier2_refresh_scheduler_at(
1665        &self,
1666        now: Instant,
1667        changed_path_count: usize,
1668    ) -> Option<Tier2TriggerReason> {
1669        let manager = self.inspect_manager();
1670        let can_write = !self.is_worktree_bridge();
1671        let in_flight = manager.tier2_any_in_flight();
1672        let decision = self.tier2_refresh_scheduler.borrow_mut().tick(
1673            now,
1674            changed_path_count,
1675            can_write,
1676            in_flight,
1677        );
1678
1679        if let Some(reason) = decision {
1680            self.start_tier2_refresh(reason, manager);
1681        }
1682
1683        decision
1684    }
1685
1686    pub fn note_tier2_refresh_started(&self) {
1687        self.note_tier2_refresh_started_at(Instant::now());
1688    }
1689
1690    #[doc(hidden)]
1691    pub fn note_tier2_refresh_started_at(&self, now: Instant) {
1692        self.tier2_refresh_scheduler
1693            .borrow_mut()
1694            .note_external_scan_started(now);
1695    }
1696
1697    pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
1698        self.tier2_refresh_scheduler
1699            .borrow()
1700            .last_trigger_reason()
1701            .map(Tier2TriggerReason::as_str)
1702    }
1703
1704    #[doc(hidden)]
1705    pub fn tier2_pull_demand_pending(&self) -> bool {
1706        self.tier2_refresh_scheduler.borrow().pull_demand_pending()
1707    }
1708
1709    fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
1710        if self.is_worktree_bridge()
1711            || self
1712                .degraded_reasons
1713                .borrow()
1714                .iter()
1715                .any(|r| r == "home_root")
1716            || !self.config().inspect.enabled
1717        {
1718            return;
1719        }
1720        let Some(snapshot) = self.tier2_refresh_snapshot() else {
1721            return;
1722        };
1723        let categories = InspectCategory::active()
1724            .iter()
1725            .copied()
1726            .filter(|category| category.is_tier2())
1727            .collect::<Vec<_>>();
1728        let submission =
1729            manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
1730        if submission.has_new_work() {
1731            crate::slog_info!(
1732                "tier2 refresh scheduled: reason={}, categories={:?}",
1733                reason.as_str(),
1734                submission
1735                    .newly_queued_categories
1736                    .iter()
1737                    .map(|category| category.as_str())
1738                    .collect::<Vec<_>>()
1739            );
1740        }
1741        for error in submission.errors {
1742            crate::slog_warn!(
1743                "tier2 refresh schedule failed for {}: {}",
1744                error.category,
1745                error.message
1746            );
1747        }
1748    }
1749
1750    fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
1751        self.harness_opt()?;
1752        let config = self.config().clone();
1753        let project_root = config
1754            .project_root
1755            .clone()
1756            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1757        let project_root = std::fs::canonicalize(&project_root).unwrap_or(project_root);
1758        Some(InspectSnapshot::new(
1759            project_root,
1760            self.inspect_dir(),
1761            Arc::new(config),
1762            self.symbol_cache(),
1763        ))
1764    }
1765
1766    /// Access the shared symbol cache.
1767    pub fn symbol_cache(&self) -> SharedSymbolCache {
1768        Arc::clone(&self.symbol_cache)
1769    }
1770
1771    /// Clear the shared symbol cache and return the new active generation.
1772    pub fn reset_symbol_cache(&self) -> u64 {
1773        self.symbol_cache
1774            .write()
1775            .map(|mut cache| cache.reset())
1776            .unwrap_or(0)
1777    }
1778
1779    /// Access the semantic search index.
1780    pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
1781        &self.semantic_index
1782    }
1783
1784    /// Access the semantic-index build receiver.
1785    pub fn semantic_index_rx(
1786        &self,
1787    ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
1788        &self.semantic_index_rx
1789    }
1790
1791    pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
1792        &self.semantic_index_status
1793    }
1794
1795    pub fn install_semantic_refresh_worker(
1796        &self,
1797        sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
1798        event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
1799        worker_slot: SemanticRefreshWorkerSlot,
1800    ) {
1801        self.clear_semantic_refresh_worker();
1802        *self.semantic_refresh_tx.borrow_mut() = Some(sender);
1803        *self.semantic_refresh_event_rx.borrow_mut() = Some(event_rx);
1804        *self.semantic_refresh_worker.borrow_mut() = Some(worker_slot);
1805    }
1806
1807    pub fn clear_semantic_refresh_worker(&self) {
1808        *self.semantic_refresh_tx.borrow_mut() = None;
1809        *self.semantic_refresh_event_rx.borrow_mut() = None;
1810        if let Some(worker_slot) = self.semantic_refresh_worker.borrow_mut().take() {
1811            if let Ok(mut handle) = worker_slot.lock() {
1812                drop(handle.take());
1813            }
1814        }
1815    }
1816
1817    pub fn semantic_refresh_sender(
1818        &self,
1819    ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
1820        self.semantic_refresh_tx.borrow().clone()
1821    }
1822
1823    pub fn semantic_refresh_event_rx(
1824        &self,
1825    ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
1826        &self.semantic_refresh_event_rx
1827    }
1828
1829    /// Access the cached semantic embedding model.
1830    pub fn semantic_embedding_model(
1831        &self,
1832    ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
1833        &self.semantic_embedding_model
1834    }
1835
1836    /// Access the file watcher handle (kept alive to continue watching).
1837    pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
1838        &self.watcher
1839    }
1840
1841    /// Access the pre-filtered watcher event receiver.
1842    pub fn watcher_rx(
1843        &self,
1844    ) -> &RefCell<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>> {
1845        &self.watcher_rx
1846    }
1847
1848    /// Install a watcher filter thread and its dispatch receiver. The caller
1849    /// must have stopped any previous watcher runtime first.
1850    pub fn install_watcher_runtime(
1851        &self,
1852        rx: crossbeam_channel::Receiver<WatcherDispatchEvent>,
1853        runtime: WatcherThreadHandle,
1854    ) {
1855        *self.watcher_rx.borrow_mut() = Some(rx);
1856        *self.watcher_thread.borrow_mut() = Some(runtime);
1857    }
1858
1859    /// Stop the watcher filter thread (if any) and clear the dispatch receiver.
1860    /// Used on reconfigure, watcher failure, root deletion, and test teardown.
1861    pub fn stop_watcher_runtime(&self) {
1862        if let Some(runtime) = self.watcher_thread.borrow_mut().take() {
1863            runtime.shutdown_and_join();
1864        }
1865        *self.watcher_rx.borrow_mut() = None;
1866        *self.watcher.borrow_mut() = None;
1867    }
1868
1869    /// Access the LSP manager.
1870    pub fn lsp(&self) -> RefMut<'_, LspManager> {
1871        self.lsp_manager.borrow_mut()
1872    }
1873
1874    /// Notify LSP servers that a file was written.
1875    /// Call this after write_format_validate in command handlers.
1876    pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
1877        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1878            let config = self.config();
1879            if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
1880                crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1881            }
1882        }
1883    }
1884
1885    /// Drop cached LSP diagnostics for a deleted/renamed-away file so its
1886    /// errors/warnings don't linger in the warm set (no server republishes for
1887    /// a vanished path), keeping the status bar and `aft_inspect` honest.
1888    /// Returns true if any entry was removed. Best-effort: a contended borrow is
1889    /// skipped silently (the watcher drain retries on subsequent events).
1890    pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
1891        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1892            lsp.clear_diagnostics_for_file(file_path)
1893        } else {
1894            false
1895        }
1896    }
1897
1898    /// Notify LSP and optionally wait for diagnostics.
1899    ///
1900    /// Call this after `write_format_validate` when the request has `"diagnostics": true`.
1901    /// Sends didChange to the server, waits briefly for publishDiagnostics, and returns
1902    /// any diagnostics for the file. If no server is running, returns empty immediately.
1903    ///
1904    /// v0.17.3: this is the version-aware path. Pre-edit cached diagnostics
1905    /// are NEVER returned — only entries whose `version` matches the
1906    /// post-edit document version (or, for unversioned servers, whose
1907    /// `epoch` advanced past the pre-edit snapshot).
1908    pub fn lsp_notify_and_collect_diagnostics(
1909        &self,
1910        file_path: &Path,
1911        content: &str,
1912        timeout: std::time::Duration,
1913    ) -> crate::lsp::manager::PostEditWaitOutcome {
1914        let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
1915            return crate::lsp::manager::PostEditWaitOutcome::default();
1916        };
1917
1918        // Clear any queued notifications before this write so the wait loop only
1919        // observes diagnostics triggered by the current change.
1920        lsp.drain_events();
1921
1922        // Snapshot per-server epochs and document versions BEFORE sending
1923        // didChange so the wait loop can prove freshness without accepting
1924        // stale pre-edit publishes that arrived late.
1925        let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
1926
1927        // Send didChange/didOpen and capture per-server target version.
1928        let config = self.config();
1929        let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
1930        {
1931            Ok(v) => v,
1932            Err(e) => {
1933                crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1934                return crate::lsp::manager::PostEditWaitOutcome::default();
1935            }
1936        };
1937
1938        // No server matched this file — return an empty outcome that's
1939        // honestly `complete: true` (nothing to wait for).
1940        if expected_versions.is_empty() {
1941            return crate::lsp::manager::PostEditWaitOutcome::default();
1942        }
1943
1944        lsp.wait_for_post_edit_diagnostics(
1945            file_path,
1946            &config,
1947            &expected_versions,
1948            &pre_snapshot,
1949            timeout,
1950        )
1951    }
1952
1953    /// Collect custom server root_markers from user config for use in
1954    /// `is_config_file_path_with_custom` checks (#25).
1955    fn custom_lsp_root_markers(&self) -> Vec<String> {
1956        self.config()
1957            .lsp_servers
1958            .iter()
1959            .flat_map(|s| s.root_markers.iter().cloned())
1960            .collect()
1961    }
1962
1963    fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
1964        let custom_markers = self.custom_lsp_root_markers();
1965        let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
1966            .iter()
1967            .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
1968            .cloned()
1969            .map(|path| {
1970                let change_type = if path.exists() {
1971                    FileChangeType::CHANGED
1972                } else {
1973                    FileChangeType::DELETED
1974                };
1975                (path, change_type)
1976            })
1977            .collect();
1978
1979        self.notify_watched_config_events(&config_paths);
1980    }
1981
1982    fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
1983        let paths = params
1984            .get("multi_file_write_paths")
1985            .and_then(|value| value.as_array())?
1986            .iter()
1987            .filter_map(|value| value.as_str())
1988            .map(PathBuf::from)
1989            .collect::<Vec<_>>();
1990
1991        (!paths.is_empty()).then_some(paths)
1992    }
1993
1994    /// Parse config-file watched events from `multi_file_write_paths` when the
1995    /// array contains object entries `{ "path": "...", "type": "created|changed|deleted" }`.
1996    ///
1997    /// This handles the OBJECT variant of `multi_file_write_paths`. The STRING
1998    /// variant (bare path strings) is handled by `multi_file_write_paths()` and
1999    /// `notify_watched_config_files()`. Both variants read the same JSON key but
2000    /// with different per-entry schemas — they are NOT redundant.
2001    ///
2002    /// #18 note: in older code this function also existed alongside `multi_file_write_paths()`
2003    /// and was reachable via the `else if` branch when all entries were objects.
2004    /// Restoring both is correct.
2005    fn watched_file_events_from_params(
2006        params: &serde_json::Value,
2007        extra_markers: &[String],
2008    ) -> Option<Vec<(PathBuf, FileChangeType)>> {
2009        let events = params
2010            .get("multi_file_write_paths")
2011            .and_then(|value| value.as_array())?
2012            .iter()
2013            .filter_map(|entry| {
2014                // Only handle object entries — string entries go through multi_file_write_paths()
2015                let path = entry
2016                    .get("path")
2017                    .and_then(|value| value.as_str())
2018                    .map(PathBuf::from)?;
2019
2020                if !is_config_file_path_with_custom(&path, extra_markers) {
2021                    return None;
2022                }
2023
2024                let change_type = entry
2025                    .get("type")
2026                    .and_then(|value| value.as_str())
2027                    .and_then(Self::parse_file_change_type)
2028                    .unwrap_or_else(|| Self::change_type_from_current_state(&path));
2029
2030                Some((path, change_type))
2031            })
2032            .collect::<Vec<_>>();
2033
2034        (!events.is_empty()).then_some(events)
2035    }
2036
2037    fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
2038        match value {
2039            "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
2040            "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
2041            "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
2042            _ => None,
2043        }
2044    }
2045
2046    fn change_type_from_current_state(path: &Path) -> FileChangeType {
2047        if path.exists() {
2048            FileChangeType::CHANGED
2049        } else {
2050            FileChangeType::DELETED
2051        }
2052    }
2053
2054    fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
2055        if config_paths.is_empty() {
2056            return;
2057        }
2058
2059        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
2060            let config = self.config();
2061            if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
2062                crate::slog_warn!("watched-file sync error: {}", e);
2063            }
2064        }
2065    }
2066
2067    pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
2068        let custom_markers = self.custom_lsp_root_markers();
2069        if !is_config_file_path_with_custom(file_path, &custom_markers) {
2070            return;
2071        }
2072
2073        self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
2074    }
2075
2076    /// Post-write LSP hook for multi-file edits. When the patch includes
2077    /// config-file edits, notify active workspace servers via
2078    /// `workspace/didChangeWatchedFiles` before sending the per-document
2079    /// didOpen/didChange for the current file.
2080    pub fn lsp_post_multi_file_write(
2081        &self,
2082        file_path: &Path,
2083        content: &str,
2084        file_paths: &[PathBuf],
2085        params: &serde_json::Value,
2086    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2087        self.notify_watched_config_files(file_paths);
2088
2089        let wants_diagnostics = params
2090            .get("diagnostics")
2091            .and_then(|v| v.as_bool())
2092            .unwrap_or(false);
2093
2094        if !wants_diagnostics {
2095            self.lsp_notify_file_changed(file_path, content);
2096            return None;
2097        }
2098
2099        let wait_ms = params
2100            .get("wait_ms")
2101            .and_then(|v| v.as_u64())
2102            .unwrap_or(3000)
2103            .min(10_000);
2104
2105        Some(self.lsp_notify_and_collect_diagnostics(
2106            file_path,
2107            content,
2108            std::time::Duration::from_millis(wait_ms),
2109        ))
2110    }
2111
2112    /// Post-write LSP hook: notify server and optionally collect diagnostics.
2113    ///
2114    /// This is the single call site for all command handlers after `write_format_validate`.
2115    /// Behavior:
2116    /// - When `diagnostics: true` is in `params`, notifies the server, waits
2117    ///   until matching diagnostics arrive or the timeout expires, and returns
2118    ///   `Some(outcome)` with the verified-fresh diagnostics + per-server
2119    ///   status.
2120    /// - When `diagnostics: false` (or absent), just notifies (fire-and-forget)
2121    ///   and returns `None`. Callers must NOT wrap this in `Some(...)`; the
2122    ///   `None` is what tells the response builder to omit the LSP fields
2123    ///   entirely (preserves the no-diagnostics-requested response shape).
2124    ///
2125    /// v0.17.3: default `wait_ms` raised from 1500 to 3000 because real-world
2126    /// tsserver re-analysis on monorepo files routinely takes 2-5s. Still
2127    /// capped at 10000ms.
2128    pub fn lsp_post_write(
2129        &self,
2130        file_path: &Path,
2131        content: &str,
2132        params: &serde_json::Value,
2133    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2134        let wants_diagnostics = params
2135            .get("diagnostics")
2136            .and_then(|v| v.as_bool())
2137            .unwrap_or(false);
2138
2139        let custom_markers = self.custom_lsp_root_markers();
2140
2141        if !wants_diagnostics {
2142            if let Some(file_paths) = Self::multi_file_write_paths(params) {
2143                self.notify_watched_config_files(&file_paths);
2144            } else if let Some(config_events) =
2145                Self::watched_file_events_from_params(params, &custom_markers)
2146            {
2147                self.notify_watched_config_events(&config_events);
2148            }
2149            self.lsp_notify_file_changed(file_path, content);
2150            return None;
2151        }
2152
2153        let wait_ms = params
2154            .get("wait_ms")
2155            .and_then(|v| v.as_u64())
2156            .unwrap_or(3000)
2157            .min(10_000); // Cap at 10 seconds to prevent hangs from adversarial input
2158
2159        if let Some(file_paths) = Self::multi_file_write_paths(params) {
2160            return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
2161        }
2162
2163        if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
2164        {
2165            self.notify_watched_config_events(&config_events);
2166        }
2167
2168        Some(self.lsp_notify_and_collect_diagnostics(
2169            file_path,
2170            content,
2171            std::time::Duration::from_millis(wait_ms),
2172        ))
2173    }
2174
2175    /// Validate that a file path falls within the configured project root.
2176    ///
2177    /// When `project_root` is configured (normal plugin usage), this resolves the
2178    /// path and checks it starts with the root. Returns the canonicalized path on
2179    /// success, or an error response on violation.
2180    ///
2181    /// When no `project_root` is configured (direct CLI usage), all paths pass
2182    /// through unrestricted for backward compatibility.
2183    pub fn validate_path(
2184        &self,
2185        req_id: &str,
2186        path: &Path,
2187    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
2188        let config = self.config();
2189        // When restrict_to_project_root is false (default), allow all paths
2190        if !config.restrict_to_project_root {
2191            return Ok(path.to_path_buf());
2192        }
2193        let root = match &config.project_root {
2194            Some(r) => r.clone(),
2195            None => return Ok(path.to_path_buf()), // No root configured, allow all
2196        };
2197        drop(config);
2198
2199        // Keep the raw root for symlink-guard comparisons. On macOS, tempdir()
2200        // returns /var/... paths while canonicalize gives /private/var/...; we
2201        // need both forms so reject_escaping_symlink can recognise in-root
2202        // symlinks regardless of which prefix form `current` happens to have.
2203        let raw_root = root.clone();
2204        let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
2205
2206        // Resolve the path (follow symlinks, normalize ..). If canonicalization
2207        // fails (e.g. path does not exist or traverses a broken symlink), inspect
2208        // every existing component with lstat before falling back lexically so a
2209        // broken in-root symlink cannot be used to write outside project_root.
2210        let path_for_resolution = if path.is_relative() {
2211            raw_root.join(path)
2212        } else {
2213            path.to_path_buf()
2214        };
2215        let resolved = match std::fs::canonicalize(&path_for_resolution) {
2216            Ok(resolved) => resolved,
2217            Err(_) => {
2218                let normalized = normalize_path(&path_for_resolution);
2219                reject_escaping_symlink(
2220                    req_id,
2221                    &path_for_resolution,
2222                    &normalized,
2223                    &resolved_root,
2224                    &raw_root,
2225                )?;
2226                resolve_with_existing_ancestors(&normalized)
2227            }
2228        };
2229
2230        if !resolved.starts_with(&resolved_root) {
2231            return Err(path_error_response(req_id, path, &resolved_root));
2232        }
2233
2234        Ok(resolved)
2235    }
2236
2237    /// Count active LSP server instances.
2238    pub fn lsp_server_count(&self) -> usize {
2239        self.lsp_manager
2240            .try_borrow()
2241            .map(|lsp| lsp.server_count())
2242            .unwrap_or(0)
2243    }
2244
2245    /// Symbol cache statistics from the language provider.
2246    pub fn symbol_cache_stats(&self) -> serde_json::Value {
2247        let entries = self
2248            .symbol_cache
2249            .read()
2250            .map(|cache| cache.len())
2251            .unwrap_or(0);
2252        serde_json::json!({
2253            "local_entries": entries,
2254            "warm_entries": 0,
2255        })
2256    }
2257}
2258
2259#[cfg(test)]
2260mod status_emitter_tests {
2261    use super::*;
2262    use crate::parser::TreeSitterProvider;
2263
2264    fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
2265        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2266        let (tx, rx) = mpsc::channel();
2267        ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
2268            let _ = tx.send(frame);
2269        }))));
2270        (ctx, rx)
2271    }
2272
2273    #[test]
2274    fn status_emitter_signal_triggers_push() {
2275        let (ctx, rx) = ctx_with_frame_rx();
2276        ctx.status_emitter().signal(ctx.build_status_snapshot());
2277        let frame = rx
2278            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2279            .expect("status_changed push");
2280        assert!(matches!(frame, PushFrame::StatusChanged(_)));
2281    }
2282
2283    #[test]
2284    fn status_emitter_debounces_burst() {
2285        let (ctx, rx) = ctx_with_frame_rx();
2286        for _ in 0..10 {
2287            ctx.status_emitter().signal(ctx.build_status_snapshot());
2288        }
2289        let frame = rx
2290            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2291            .expect("status_changed push");
2292        assert!(matches!(frame, PushFrame::StatusChanged(_)));
2293        assert!(rx.try_recv().is_err());
2294    }
2295
2296    #[test]
2297    fn status_emitter_separate_windows_separate_pushes() {
2298        let (ctx, rx) = ctx_with_frame_rx();
2299        ctx.status_emitter().signal(ctx.build_status_snapshot());
2300        rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2301            .expect("first push");
2302        ctx.status_emitter().signal(ctx.build_status_snapshot());
2303        rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2304            .expect("second push");
2305    }
2306
2307    #[test]
2308    fn status_emitter_no_signal_no_push() {
2309        let (_ctx, rx) = ctx_with_frame_rx();
2310        assert!(rx
2311            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
2312            .is_err());
2313    }
2314
2315    #[test]
2316    fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
2317        let (ctx, rx) = ctx_with_frame_rx();
2318        drop(ctx);
2319        assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
2320    }
2321}
2322
2323#[cfg(test)]
2324mod status_bar_tests {
2325    use super::*;
2326    use crate::parser::TreeSitterProvider;
2327
2328    fn ctx() -> AppContext {
2329        AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
2330    }
2331
2332    #[test]
2333    fn status_bar_counts_none_until_tier2_populated() {
2334        let ctx = ctx();
2335        // No scan has run yet — never surface a bar claiming "0 dead code".
2336        assert!(ctx.status_bar_counts().is_none());
2337
2338        ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
2339        let counts = ctx.status_bar_counts().expect("populated");
2340        assert_eq!(counts.dead_code, 5);
2341        assert_eq!(counts.unused_exports, 3);
2342        assert_eq!(counts.duplicates, 7);
2343        assert_eq!(counts.todos, 2);
2344        assert!(!counts.tier2_stale);
2345        // Errors/warnings are read live from an empty LSP store → 0.
2346        assert_eq!(counts.errors, 0);
2347        assert_eq!(counts.warnings, 0);
2348    }
2349
2350    #[test]
2351    fn partial_tier2_does_not_fabricate_zeros() {
2352        let ctx = ctx();
2353        // Only dead_code has completed (the slow first serial category); the
2354        // other two are still in flight. The bar must stay suppressed rather
2355        // than render `D5 U0 C0` with fabricated zeros (#1).
2356        ctx.update_status_bar_tier2(Some(5), None, None, None, true);
2357        assert!(
2358            ctx.status_bar_counts().is_none(),
2359            "bar must not surface until all three Tier-2 categories are real"
2360        );
2361
2362        // Second category completes — still incomplete, still suppressed.
2363        ctx.update_status_bar_tier2(None, Some(3), None, None, true);
2364        assert!(ctx.status_bar_counts().is_none());
2365
2366        // Final category completes → bar surfaces with all real counts, and
2367        // none of them were ever fabricated.
2368        ctx.update_status_bar_tier2(None, None, Some(7), None, false);
2369        let counts = ctx.status_bar_counts().expect("all three real now");
2370        assert_eq!(counts.dead_code, 5);
2371        assert_eq!(counts.unused_exports, 3);
2372        assert_eq!(counts.duplicates, 7);
2373    }
2374
2375    #[test]
2376    fn update_with_none_todos_preserves_last_known_todos() {
2377        let ctx = ctx();
2378        ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
2379        // A background-scan refresh passes todos=None → todo count preserved.
2380        ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
2381        let counts = ctx.status_bar_counts().expect("populated");
2382        assert_eq!(counts.todos, 9);
2383        assert_eq!(counts.dead_code, 2);
2384    }
2385
2386    #[test]
2387    fn update_with_none_count_preserves_last_known_count() {
2388        let ctx = ctx();
2389        ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
2390        // A refresh that only recomputed dead_code preserves the other two
2391        // real counts rather than overwriting them with a fabricated 0.
2392        ctx.update_status_bar_tier2(Some(11), None, None, None, false);
2393        let counts = ctx.status_bar_counts().expect("populated");
2394        assert_eq!(counts.dead_code, 11);
2395        assert_eq!(counts.unused_exports, 20);
2396        assert_eq!(counts.duplicates, 30);
2397    }
2398
2399    #[test]
2400    fn mark_stale_sets_flag_only_after_populate() {
2401        let ctx = ctx();
2402        // No-op before first populate.
2403        ctx.mark_status_bar_tier2_stale();
2404        assert!(ctx.status_bar_counts().is_none());
2405
2406        ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), Some(0), false);
2407        ctx.mark_status_bar_tier2_stale();
2408        assert!(ctx.status_bar_counts().expect("populated").tier2_stale);
2409
2410        // A completed scan clears stale.
2411        ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), None, false);
2412        assert!(!ctx.status_bar_counts().expect("populated").tier2_stale);
2413    }
2414
2415    // End-to-end wiring: a diagnostic for a file inflates the status-bar `E`
2416    // count (read live from the warm LSP set); clearing that file's diagnostics
2417    // (the deleted-file path) drops it back. This is the AppContext glue between
2418    // the watcher-drain clear and the agent-visible bar.
2419    #[test]
2420    fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
2421        use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
2422        use crate::lsp::registry::ServerKind;
2423        use crate::lsp::roots::ServerKey;
2424
2425        let ctx = ctx();
2426        ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); // populate so the bar surfaces
2427
2428        let file = std::path::PathBuf::from("/proj/gone.ts");
2429        {
2430            let mut lsp = ctx.lsp();
2431            lsp.diagnostics_store_mut_for_test().publish(
2432                ServerKey {
2433                    kind: ServerKind::TypeScript,
2434                    root: std::path::PathBuf::from("/proj"),
2435                },
2436                file.clone(),
2437                vec![StoredDiagnostic {
2438                    file: file.clone(),
2439                    line: 1,
2440                    column: 1,
2441                    end_line: 1,
2442                    end_column: 2,
2443                    severity: DiagnosticSeverity::Error,
2444                    message: "boom".into(),
2445                    code: None,
2446                    source: None,
2447                }],
2448            );
2449        }
2450
2451        // Bar reflects the live warm-set error.
2452        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
2453
2454        // Clearing the (now-deleted) file's diagnostics drops the count.
2455        let removed = ctx.lsp_clear_diagnostics_for_file(&file);
2456        assert!(removed);
2457        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
2458    }
2459
2460    #[test]
2461    fn status_bar_filtered_counts_ignore_environmental_flap() {
2462        use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
2463        use crate::lsp::registry::ServerKind;
2464        use crate::lsp::roots::ServerKey;
2465
2466        let ctx = ctx();
2467        let root = if cfg!(windows) {
2468            std::path::PathBuf::from(r"C:\proj")
2469        } else {
2470            std::path::PathBuf::from("/proj")
2471        };
2472        ctx.set_canonical_cache_root(root.clone());
2473        ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
2474
2475        let file = root.join("aft.jsonc");
2476        let key = ServerKey {
2477            kind: ServerKind::TypeScript,
2478            root: root.clone(),
2479        };
2480        let env = StoredDiagnostic {
2481            file: file.clone(),
2482            line: 1,
2483            column: 1,
2484            end_line: 1,
2485            end_column: 2,
2486            severity: DiagnosticSeverity::Error,
2487            message: "Failed to load schema from https://example.com/schema.json".into(),
2488            code: None,
2489            source: Some("json".into()),
2490        };
2491
2492        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
2493
2494        {
2495            let mut lsp = ctx.lsp();
2496            lsp.diagnostics_store_mut_for_test()
2497                .publish(key.clone(), file.clone(), vec![env]);
2498        }
2499        assert_eq!(
2500            ctx.status_bar_counts().expect("populated").errors,
2501            0,
2502            "environmental publish must not change status-bar E"
2503        );
2504
2505        {
2506            let mut lsp = ctx.lsp();
2507            lsp.diagnostics_store_mut_for_test()
2508                .publish(key, file, vec![]);
2509        }
2510        assert_eq!(
2511            ctx.status_bar_counts().expect("populated").errors,
2512            0,
2513            "environmental clear must not change status-bar E"
2514        );
2515    }
2516}
2517
2518#[cfg(test)]
2519mod harness_path_tests {
2520    use super::*;
2521    use crate::harness::Harness;
2522    use crate::parser::TreeSitterProvider;
2523
2524    fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
2525        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2526        ctx.config_mut().storage_dir = Some(storage_dir);
2527        ctx.set_harness(harness);
2528        ctx
2529    }
2530
2531    #[test]
2532    fn harness_dir_resolves_correctly() {
2533        let storage = PathBuf::from("/tmp/cortexkit/aft");
2534        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2535
2536        assert_eq!(ctx.harness_dir(), storage.join("pi"));
2537    }
2538
2539    #[test]
2540    fn bash_tasks_dir_uses_hash_session() {
2541        let storage = PathBuf::from("/tmp/cortexkit/aft");
2542        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2543
2544        assert_eq!(
2545            ctx.bash_tasks_dir("ses_abc"),
2546            storage
2547                .join("opencode")
2548                .join("bash-tasks")
2549                .join(hash_session("ses_abc"))
2550        );
2551    }
2552
2553    #[test]
2554    fn backups_dir_includes_path_hash() {
2555        let storage = PathBuf::from("/tmp/cortexkit/aft");
2556        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2557
2558        assert_eq!(
2559            ctx.backups_dir("ses_abc", "pathhash"),
2560            storage
2561                .join("pi")
2562                .join("backups")
2563                .join(hash_session("ses_abc"))
2564                .join("pathhash")
2565        );
2566    }
2567
2568    #[test]
2569    fn filters_dir_under_harness() {
2570        let storage = PathBuf::from("/tmp/cortexkit/aft");
2571        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2572
2573        assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
2574    }
2575
2576    #[test]
2577    fn trust_file_is_host_global() {
2578        let storage = PathBuf::from("/tmp/cortexkit/aft");
2579        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2580
2581        assert_eq!(
2582            ctx.trust_file(),
2583            storage.join("trusted-filter-projects.json")
2584        );
2585    }
2586
2587    #[test]
2588    fn same_session_different_harness_resolve_different_paths() {
2589        let storage = PathBuf::from("/tmp/cortexkit/aft");
2590        let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2591        let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
2592
2593        assert_ne!(
2594            opencode.bash_tasks_dir("ses_same"),
2595            pi.bash_tasks_dir("ses_same")
2596        );
2597    }
2598}
2599
2600#[cfg(test)]
2601mod gitignore_tests {
2602    use super::*;
2603    use std::fs;
2604    use std::path::Path;
2605    use tempfile::TempDir;
2606
2607    fn make_ctx_with_root(root: &Path) -> AppContext {
2608        let provider = Box::new(crate::parser::TreeSitterProvider::new());
2609        let config = Config {
2610            project_root: Some(root.to_path_buf()),
2611            ..Config::default()
2612        };
2613        AppContext::new(provider, config)
2614    }
2615
2616    /// Helper: returns true when the matcher would skip `path` (as if it
2617    /// arrived via a watcher event for this project root). Canonicalizes
2618    /// the query path so symlink prefixes (e.g. macOS `/var` → `/private/var`)
2619    /// don't trip the `ignore` crate's "path is expected to be under the
2620    /// root" panic — production code does the same guard via
2621    /// `path.starts_with(matcher.path())` in `drain_watcher_events`.
2622    fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
2623        let Some(matcher) = ctx.gitignore() else {
2624            return false;
2625        };
2626        let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
2627        if !canonical.starts_with(matcher.path()) {
2628            return false;
2629        }
2630        let is_dir = canonical.is_dir();
2631        matcher
2632            .matched_path_or_any_parents(&canonical, is_dir)
2633            .is_ignore()
2634    }
2635
2636    /// Run `f` with global git-ignore discovery neutralized.
2637    ///
2638    /// `rebuild_gitignore` loads git's global excludes (the `ignore` crate
2639    /// resolves `$XDG_CONFIG_HOME/git/ignore`, falling back to
2640    /// `$HOME/.config/git/ignore`). A developer machine commonly has that file,
2641    /// so a "no project ignore → None" assertion is only deterministic when
2642    /// global discovery is pointed at an empty directory. Pointing
2643    /// `XDG_CONFIG_HOME` at a fresh tempdir does that without touching `HOME`
2644    /// (so it can't race the `HOME`-mutating configure tests). Serialized by a
2645    /// process-local mutex; env is restored before the closure result is used.
2646    fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
2647        use std::sync::{Mutex, OnceLock};
2648        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2649        let _guard = LOCK
2650            .get_or_init(|| Mutex::new(()))
2651            .lock()
2652            .unwrap_or_else(|e| e.into_inner());
2653        let tmp = TempDir::new().unwrap();
2654        let prev = std::env::var_os("XDG_CONFIG_HOME");
2655        // SAFETY: serialized by LOCK above; restored immediately after `f`.
2656        unsafe {
2657            std::env::set_var("XDG_CONFIG_HOME", tmp.path());
2658        }
2659        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
2660        unsafe {
2661            match prev {
2662                Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
2663                None => std::env::remove_var("XDG_CONFIG_HOME"),
2664            }
2665        }
2666        match result {
2667            Ok(r) => r,
2668            Err(p) => std::panic::resume_unwind(p),
2669        }
2670    }
2671
2672    #[test]
2673    fn rebuild_gitignore_returns_none_without_project_root() {
2674        let provider = Box::new(crate::parser::TreeSitterProvider::new());
2675        let ctx = AppContext::new(provider, Config::default());
2676        with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2677        assert!(ctx.gitignore().is_none());
2678    }
2679
2680    #[test]
2681    fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
2682        let tmp = TempDir::new().unwrap();
2683        let ctx = make_ctx_with_root(tmp.path());
2684        with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2685        assert!(ctx.gitignore().is_none());
2686    }
2687
2688    #[test]
2689    fn matcher_filters_files_in_ignored_dist_dir() {
2690        let tmp = TempDir::new().unwrap();
2691        fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
2692        fs::create_dir_all(tmp.path().join("dist")).unwrap();
2693        fs::create_dir_all(tmp.path().join("src")).unwrap();
2694        let dist_file = tmp.path().join("dist").join("bundle.js");
2695        let src_file = tmp.path().join("src").join("app.ts");
2696        fs::write(&dist_file, "x").unwrap();
2697        fs::write(&src_file, "y").unwrap();
2698
2699        let ctx = make_ctx_with_root(tmp.path());
2700        ctx.rebuild_gitignore();
2701
2702        assert!(ctx.gitignore().is_some());
2703        assert!(
2704            is_ignored(&ctx, &dist_file),
2705            "dist/bundle.js should be ignored"
2706        );
2707        assert!(
2708            !is_ignored(&ctx, &src_file),
2709            "src/app.ts should NOT be ignored"
2710        );
2711    }
2712
2713    #[test]
2714    fn matcher_handles_node_modules_and_target() {
2715        let tmp = TempDir::new().unwrap();
2716        fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
2717        fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
2718        fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
2719        let nm_file = tmp.path().join("node_modules/foo/index.js");
2720        let target_file = tmp.path().join("target/debug/aft");
2721        fs::write(&nm_file, "x").unwrap();
2722        fs::write(&target_file, "x").unwrap();
2723
2724        let ctx = make_ctx_with_root(tmp.path());
2725        ctx.rebuild_gitignore();
2726
2727        assert!(is_ignored(&ctx, &nm_file));
2728        assert!(is_ignored(&ctx, &target_file));
2729    }
2730
2731    #[test]
2732    fn matcher_honors_negation_pattern() {
2733        // .gitignore: ignore all *.log files EXCEPT important.log
2734        let tmp = TempDir::new().unwrap();
2735        fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
2736        let random_log = tmp.path().join("random.log");
2737        let important_log = tmp.path().join("important.log");
2738        fs::write(&random_log, "x").unwrap();
2739        fs::write(&important_log, "y").unwrap();
2740
2741        let ctx = make_ctx_with_root(tmp.path());
2742        ctx.rebuild_gitignore();
2743
2744        assert!(is_ignored(&ctx, &random_log));
2745        assert!(
2746            !is_ignored(&ctx, &important_log),
2747            "negation pattern should un-ignore important.log"
2748        );
2749    }
2750
2751    #[test]
2752    fn rebuild_picks_up_gitignore_changes() {
2753        let tmp = TempDir::new().unwrap();
2754        let ignore_path = tmp.path().join(".gitignore");
2755        fs::write(&ignore_path, "foo.txt\n").unwrap();
2756        let foo = tmp.path().join("foo.txt");
2757        let bar = tmp.path().join("bar.txt");
2758        fs::write(&foo, "").unwrap();
2759        fs::write(&bar, "").unwrap();
2760
2761        let ctx = make_ctx_with_root(tmp.path());
2762        ctx.rebuild_gitignore();
2763        assert!(is_ignored(&ctx, &foo));
2764        assert!(!is_ignored(&ctx, &bar));
2765
2766        // Now flip the rules: ignore bar.txt instead of foo.txt
2767        fs::write(&ignore_path, "bar.txt\n").unwrap();
2768        ctx.rebuild_gitignore();
2769        assert!(!is_ignored(&ctx, &foo));
2770        assert!(is_ignored(&ctx, &bar));
2771    }
2772
2773    #[test]
2774    fn gitignore_loads_info_exclude_when_present() {
2775        let tmp = TempDir::new().unwrap();
2776        let info_dir = tmp.path().join(".git/info");
2777        fs::create_dir_all(&info_dir).unwrap();
2778        fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
2779        let secrets = tmp.path().join("secrets.txt");
2780        let public = tmp.path().join("public.txt");
2781        fs::write(&secrets, "token").unwrap();
2782        fs::write(&public, "ok").unwrap();
2783
2784        let ctx = make_ctx_with_root(tmp.path());
2785        ctx.rebuild_gitignore();
2786
2787        assert!(is_ignored(&ctx, &secrets));
2788        assert!(!is_ignored(&ctx, &public));
2789    }
2790
2791    #[test]
2792    fn matcher_picks_up_nested_gitignore() {
2793        let tmp = TempDir::new().unwrap();
2794        // Root .gitignore is intentionally empty — only the nested one ignores
2795        fs::write(tmp.path().join(".gitignore"), "").unwrap();
2796        let sub = tmp.path().join("packages/foo");
2797        fs::create_dir_all(&sub).unwrap();
2798        fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
2799        let generated_file = sub.join("generated").join("out.js");
2800        fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
2801        fs::write(&generated_file, "x").unwrap();
2802
2803        let ctx = make_ctx_with_root(tmp.path());
2804        ctx.rebuild_gitignore();
2805
2806        assert!(
2807            is_ignored(&ctx, &generated_file),
2808            "nested gitignore in packages/foo/.gitignore should ignore generated/"
2809        );
2810    }
2811}