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) = CallGraphStore::cold_build_with_lease_chunked(
1332                    callgraph_dir,
1333                    project_root,
1334                    &files,
1335                    self.config().callgraph_chunk_size,
1336                )?;
1337                Some(store)
1338            } else if CallGraphStore::needs_cold_build(&callgraph_dir, &project_root)? {
1339                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1340                let (store, _stats) = CallGraphStore::ensure_built_with_lease_chunked(
1341                    callgraph_dir,
1342                    project_root,
1343                    &files,
1344                    self.config().callgraph_chunk_size,
1345                )?;
1346                Some(store)
1347            } else {
1348                Some(CallGraphStore::open(callgraph_dir, project_root)?)
1349            };
1350            *self.callgraph_store.borrow_mut() = store;
1351        }
1352        let borrow = self.callgraph_store.borrow_mut();
1353        Ok(RefMut::filter_map(borrow, Option::as_mut).ok())
1354    }
1355
1356    /// Resolve the project root used for the callgraph store: prefer the
1357    /// canonical cache root, falling back to the configured project root.
1358    fn callgraph_project_root(&self) -> Option<PathBuf> {
1359        self.canonical_cache_root_opt().or_else(|| {
1360            self.config()
1361                .project_root
1362                .clone()
1363                .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
1364        })
1365    }
1366
1367    /// Access the persisted callgraph store for the five store-backed edge-query
1368    /// ops **without ever blocking the request thread on a cold build**.
1369    ///
1370    /// - Store resident          -> `Ready`.
1371    /// - Warm on-disk DB present  -> opened synchronously (cheap) -> `Ready`.
1372    /// - Genuine cold build needed -> kicked off in the background, returns
1373    ///   `Building`; the watcher keeps the store fresh once it lands.
1374    /// - Worktree without a built store, or not configured -> `Unavailable`.
1375    ///
1376    /// A build already in flight (`callgraph_store_rx` set) also returns
1377    /// `Building` without starting a second build.
1378    /// Drop the resident callgraph store when another process (or a local cold
1379    /// rebuild) has published a newer generation, so the next access reopens via
1380    /// the pointer. No-op when no store is resident, a build is in flight, or the
1381    /// store is still current. Must run before serving ops AND before any
1382    /// incremental write, so every process converges on the current generation
1383    /// rather than writing to a stale one.
1384    pub fn revalidate_callgraph_store_generation(&self) {
1385        // Never disturb the store while a background build's result is pending
1386        // install (the rx-install path replaces it wholesale).
1387        if self.callgraph_store_rx.borrow().is_some() {
1388            return;
1389        }
1390        let superseded = self
1391            .callgraph_store
1392            .borrow()
1393            .as_ref()
1394            .is_some_and(|store| !store.is_current());
1395        if superseded {
1396            *self.callgraph_store.borrow_mut() = None;
1397        }
1398    }
1399
1400    pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess<'_> {
1401        // Converge to a newer generation another process (or a local cold
1402        // rebuild) may have published: if our resident store is superseded, drop
1403        // it so the open path below reopens via the pointer. Cheap pointer read.
1404        self.revalidate_callgraph_store_generation();
1405        if self.callgraph_store.borrow().is_some() {
1406            let borrow = self.callgraph_store.borrow_mut();
1407            return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1408                Some(store) => CallgraphStoreAccess::Ready(store),
1409                None => CallgraphStoreAccess::Unavailable,
1410            };
1411        }
1412
1413        // A background build is already running; don't start a second one.
1414        if self.callgraph_store_rx.borrow().is_some() {
1415            return CallgraphStoreAccess::Building;
1416        }
1417
1418        let Some(project_root) = self.callgraph_project_root() else {
1419            return CallgraphStoreAccess::Unavailable;
1420        };
1421        let callgraph_dir = self.callgraph_store_dir();
1422
1423        // Worktree bridges are read-only: open whatever the main checkout built,
1424        // never cold-build here.
1425        if self.is_worktree_bridge() {
1426            match CallGraphStore::open_readonly(callgraph_dir, project_root) {
1427                Ok(Some(store)) => {
1428                    *self.callgraph_store.borrow_mut() = Some(store);
1429                    let borrow = self.callgraph_store.borrow_mut();
1430                    return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1431                        Some(store) => CallgraphStoreAccess::Ready(store),
1432                        None => CallgraphStoreAccess::Unavailable,
1433                    };
1434                }
1435                Ok(None) | Err(_) => return CallgraphStoreAccess::Unavailable,
1436            }
1437        }
1438
1439        let force_rebuild = *self.callgraph_store_force_rebuild.borrow();
1440        // Warm path: a fresh on-disk DB exists -> open synchronously (cheap, no
1441        // "building" delay). Only a genuine cold build goes to the background.
1442        if !force_rebuild {
1443            match CallGraphStore::needs_cold_build(&callgraph_dir, &project_root) {
1444                Ok(false) => match CallGraphStore::open(callgraph_dir, project_root) {
1445                    Ok(store) => {
1446                        *self.callgraph_store.borrow_mut() = Some(store);
1447                        let borrow = self.callgraph_store.borrow_mut();
1448                        return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1449                            Some(store) => CallgraphStoreAccess::Ready(store),
1450                            None => CallgraphStoreAccess::Unavailable,
1451                        };
1452                    }
1453                    Err(error) => return CallgraphStoreAccess::Error(error),
1454                },
1455                Ok(true) => {}
1456                Err(error) => return CallgraphStoreAccess::Error(error),
1457            }
1458        }
1459
1460        // Cold build required: run it off the request thread and return
1461        // `Building` so the agent retries (the watcher keeps the store fresh
1462        // once it lands). By default this never blocks the request thread.
1463        //
1464        // `AFT_CALLGRAPH_BUILD_WAIT_MS` (default 0) optionally waits a bounded
1465        // window inline for the build to land before returning `Building`; tests
1466        // set it large so fixture builds resolve to `Ready` synchronously.
1467        self.spawn_callgraph_store_cold_build(project_root, callgraph_dir, force_rebuild);
1468
1469        let wait = callgraph_build_wait_window();
1470        if !wait.is_zero() {
1471            let received = {
1472                let rx_ref = self.callgraph_store_rx.borrow();
1473                let Some(rx) = rx_ref.as_ref() else {
1474                    return CallgraphStoreAccess::Building;
1475                };
1476                rx.recv_timeout(wait)
1477            };
1478            match received {
1479                Ok(store) => {
1480                    // Replay any source files the watcher saw during the wait so
1481                    // the installed store reflects mid-build edits (mirrors the
1482                    // drain install path). Empty in the common case.
1483                    let pending = self.take_pending_callgraph_store_paths();
1484                    if !pending.is_empty() {
1485                        if let Err(error) = store.refresh_files(&pending) {
1486                            crate::slog_warn!(
1487                                "callgraph store inline post-build refresh failed: {}",
1488                                error
1489                            );
1490                            let _ = store.mark_files_stale(&pending);
1491                        }
1492                    }
1493                    *self.callgraph_store.borrow_mut() = Some(store);
1494                    *self.callgraph_store_rx.borrow_mut() = None;
1495                    let borrow = self.callgraph_store.borrow_mut();
1496                    return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1497                        Some(store) => CallgraphStoreAccess::Ready(store),
1498                        None => CallgraphStoreAccess::Unavailable,
1499                    };
1500                }
1501                Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1502                Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
1503                    // Build failed before sending; clear the receiver so a later
1504                    // op restarts the build instead of waiting on a dead channel.
1505                    *self.callgraph_store_rx.borrow_mut() = None;
1506                }
1507            }
1508        }
1509        CallgraphStoreAccess::Building
1510    }
1511
1512    /// Spawn a background thread that cold-builds the callgraph store and sends
1513    /// the finished store over `callgraph_store_rx`. The main loop installs it
1514    /// via `drain_callgraph_store_events`. Mirrors the search-index build
1515    /// lifecycle (channel + drain).
1516    fn spawn_callgraph_store_cold_build(
1517        &self,
1518        project_root: PathBuf,
1519        callgraph_dir: PathBuf,
1520        force_rebuild: bool,
1521    ) {
1522        if force_rebuild {
1523            // Consume the force flag now so a follow-up request doesn't queue a
1524            // second forced build while this one is in flight.
1525            self.take_callgraph_store_force_rebuild();
1526        }
1527        let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStore>();
1528        *self.callgraph_store_rx.borrow_mut() = Some(rx);
1529        let session_id = crate::log_ctx::current_session();
1530        let chunk_size = self.config().callgraph_chunk_size;
1531        std::thread::spawn(move || {
1532            crate::log_ctx::with_session(session_id, || {
1533                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1534                let built = if force_rebuild {
1535                    CallGraphStore::cold_build_with_lease_chunked(
1536                        callgraph_dir,
1537                        project_root,
1538                        &files,
1539                        chunk_size,
1540                    )
1541                    .map(|(store, _)| store)
1542                } else {
1543                    CallGraphStore::ensure_built_with_lease_chunked(
1544                        callgraph_dir,
1545                        project_root,
1546                        &files,
1547                        chunk_size,
1548                    )
1549                    .map(|(store, _)| store)
1550                };
1551                match built {
1552                    Ok(store) => {
1553                        let _ = tx.send(store);
1554                    }
1555                    Err(error) => {
1556                        crate::slog_warn!("callgraph store cold build failed: {}", error);
1557                        // Dropping tx disconnects the channel; the drain clears
1558                        // the receiver so a later op can retry the build.
1559                    }
1560                }
1561            });
1562        });
1563    }
1564
1565    /// Access the callgraph-store background-build receiver (drained by the
1566    /// main loop once the cold build completes).
1567    pub fn callgraph_store_rx(
1568        &self,
1569    ) -> &RefCell<Option<crossbeam_channel::Receiver<CallGraphStore>>> {
1570        &self.callgraph_store_rx
1571    }
1572
1573    /// Record source-file paths that changed while a cold build was in flight,
1574    /// so they can be refreshed once the freshly-built store is installed.
1575    pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
1576    where
1577        I: IntoIterator<Item = PathBuf>,
1578    {
1579        self.pending_callgraph_store_paths
1580            .borrow_mut()
1581            .extend(paths);
1582    }
1583
1584    /// Take and clear the paths that changed during a background cold build.
1585    pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
1586        std::mem::take(&mut *self.pending_callgraph_store_paths.borrow_mut())
1587            .into_iter()
1588            .collect()
1589    }
1590
1591    /// Access the search index.
1592    pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
1593        &self.search_index
1594    }
1595
1596    /// Access the search-index build receiver.
1597    pub fn search_index_rx(&self) -> &RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>> {
1598        &self.search_index_rx
1599    }
1600
1601    pub fn add_pending_search_index_paths<I>(&self, paths: I)
1602    where
1603        I: IntoIterator<Item = PathBuf>,
1604    {
1605        self.pending_search_index_paths.borrow_mut().extend(paths);
1606    }
1607
1608    pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
1609        std::mem::take(&mut *self.pending_search_index_paths.borrow_mut())
1610            .into_iter()
1611            .collect()
1612    }
1613
1614    pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
1615    where
1616        I: IntoIterator<Item = PathBuf>,
1617    {
1618        self.pending_semantic_index_paths.borrow_mut().extend(paths);
1619    }
1620
1621    pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
1622        std::mem::take(&mut *self.pending_semantic_index_paths.borrow_mut())
1623            .into_iter()
1624            .collect()
1625    }
1626
1627    pub fn mark_pending_semantic_corpus_refresh(&self) {
1628        *self.pending_semantic_corpus_refresh.borrow_mut() = true;
1629    }
1630
1631    pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
1632        std::mem::take(&mut *self.pending_semantic_corpus_refresh.borrow_mut())
1633    }
1634
1635    pub fn clear_pending_index_updates(&self) {
1636        self.pending_search_index_paths.borrow_mut().clear();
1637        self.pending_callgraph_store_paths.borrow_mut().clear();
1638        self.pending_semantic_index_paths.borrow_mut().clear();
1639        *self.pending_semantic_corpus_refresh.borrow_mut() = false;
1640    }
1641
1642    pub fn inspect_manager(&self) -> Arc<InspectManager> {
1643        Arc::clone(&self.inspect_manager)
1644    }
1645
1646    /// Returns true when one or more watcher-driven (reuse-path) Tier-2 scans
1647    /// have completed since the last call, advancing the last-seen marker. The
1648    /// per-request inspect drain uses this to refresh the status bar after a
1649    /// background scan — those completions bypass `drain_completions`.
1650    pub fn take_new_reuse_completions(&self) -> bool {
1651        let current = self.inspect_manager.reuse_completion_count();
1652        let previous = self
1653            .last_seen_reuse_completions
1654            .swap(current, Ordering::SeqCst);
1655        current != previous
1656    }
1657
1658    pub fn reset_tier2_refresh_scheduler(&self) {
1659        self.reset_tier2_refresh_scheduler_at(Instant::now());
1660    }
1661
1662    #[doc(hidden)]
1663    pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
1664        self.tier2_refresh_scheduler
1665            .borrow_mut()
1666            .reset_after_configure(now);
1667    }
1668
1669    pub fn request_tier2_refresh_pull(&self) -> bool {
1670        self.tier2_refresh_scheduler
1671            .borrow_mut()
1672            .request_pull(!self.is_worktree_bridge())
1673    }
1674
1675    pub fn tick_tier2_refresh_scheduler(
1676        &self,
1677        changed_path_count: usize,
1678    ) -> Option<Tier2TriggerReason> {
1679        self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
1680    }
1681
1682    #[doc(hidden)]
1683    pub fn tick_tier2_refresh_scheduler_at(
1684        &self,
1685        now: Instant,
1686        changed_path_count: usize,
1687    ) -> Option<Tier2TriggerReason> {
1688        let manager = self.inspect_manager();
1689        let can_write = !self.is_worktree_bridge();
1690        let in_flight = manager.tier2_any_in_flight();
1691        let decision = self.tier2_refresh_scheduler.borrow_mut().tick(
1692            now,
1693            changed_path_count,
1694            can_write,
1695            in_flight,
1696        );
1697
1698        if let Some(reason) = decision {
1699            self.start_tier2_refresh(reason, manager);
1700        }
1701
1702        decision
1703    }
1704
1705    pub fn note_tier2_refresh_started(&self) {
1706        self.note_tier2_refresh_started_at(Instant::now());
1707    }
1708
1709    #[doc(hidden)]
1710    pub fn note_tier2_refresh_started_at(&self, now: Instant) {
1711        self.tier2_refresh_scheduler
1712            .borrow_mut()
1713            .note_external_scan_started(now);
1714    }
1715
1716    pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
1717        self.tier2_refresh_scheduler
1718            .borrow()
1719            .last_trigger_reason()
1720            .map(Tier2TriggerReason::as_str)
1721    }
1722
1723    #[doc(hidden)]
1724    pub fn tier2_pull_demand_pending(&self) -> bool {
1725        self.tier2_refresh_scheduler.borrow().pull_demand_pending()
1726    }
1727
1728    fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
1729        if self.is_worktree_bridge()
1730            || self
1731                .degraded_reasons
1732                .borrow()
1733                .iter()
1734                .any(|r| r == "home_root")
1735            || !self.config().inspect.enabled
1736        {
1737            return;
1738        }
1739        let Some(snapshot) = self.tier2_refresh_snapshot() else {
1740            return;
1741        };
1742        let categories = InspectCategory::active()
1743            .iter()
1744            .copied()
1745            .filter(|category| category.is_tier2())
1746            .collect::<Vec<_>>();
1747        let submission =
1748            manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
1749        if submission.has_new_work() {
1750            crate::slog_info!(
1751                "tier2 refresh scheduled: reason={}, categories={:?}",
1752                reason.as_str(),
1753                submission
1754                    .newly_queued_categories
1755                    .iter()
1756                    .map(|category| category.as_str())
1757                    .collect::<Vec<_>>()
1758            );
1759        }
1760        for error in submission.errors {
1761            crate::slog_warn!(
1762                "tier2 refresh schedule failed for {}: {}",
1763                error.category,
1764                error.message
1765            );
1766        }
1767    }
1768
1769    fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
1770        self.harness_opt()?;
1771        let config = self.config().clone();
1772        let project_root = config
1773            .project_root
1774            .clone()
1775            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1776        let project_root = std::fs::canonicalize(&project_root).unwrap_or(project_root);
1777        Some(InspectSnapshot::new(
1778            project_root,
1779            self.inspect_dir(),
1780            Arc::new(config),
1781            self.symbol_cache(),
1782        ))
1783    }
1784
1785    /// Access the shared symbol cache.
1786    pub fn symbol_cache(&self) -> SharedSymbolCache {
1787        Arc::clone(&self.symbol_cache)
1788    }
1789
1790    /// Clear the shared symbol cache and return the new active generation.
1791    pub fn reset_symbol_cache(&self) -> u64 {
1792        self.symbol_cache
1793            .write()
1794            .map(|mut cache| cache.reset())
1795            .unwrap_or(0)
1796    }
1797
1798    /// Access the semantic search index.
1799    pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
1800        &self.semantic_index
1801    }
1802
1803    /// Access the semantic-index build receiver.
1804    pub fn semantic_index_rx(
1805        &self,
1806    ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
1807        &self.semantic_index_rx
1808    }
1809
1810    pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
1811        &self.semantic_index_status
1812    }
1813
1814    pub fn install_semantic_refresh_worker(
1815        &self,
1816        sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
1817        event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
1818        worker_slot: SemanticRefreshWorkerSlot,
1819    ) {
1820        self.clear_semantic_refresh_worker();
1821        *self.semantic_refresh_tx.borrow_mut() = Some(sender);
1822        *self.semantic_refresh_event_rx.borrow_mut() = Some(event_rx);
1823        *self.semantic_refresh_worker.borrow_mut() = Some(worker_slot);
1824    }
1825
1826    pub fn clear_semantic_refresh_worker(&self) {
1827        *self.semantic_refresh_tx.borrow_mut() = None;
1828        *self.semantic_refresh_event_rx.borrow_mut() = None;
1829        if let Some(worker_slot) = self.semantic_refresh_worker.borrow_mut().take() {
1830            if let Ok(mut handle) = worker_slot.lock() {
1831                drop(handle.take());
1832            }
1833        }
1834    }
1835
1836    pub fn semantic_refresh_sender(
1837        &self,
1838    ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
1839        self.semantic_refresh_tx.borrow().clone()
1840    }
1841
1842    pub fn semantic_refresh_event_rx(
1843        &self,
1844    ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
1845        &self.semantic_refresh_event_rx
1846    }
1847
1848    /// Access the cached semantic embedding model.
1849    pub fn semantic_embedding_model(
1850        &self,
1851    ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
1852        &self.semantic_embedding_model
1853    }
1854
1855    /// Access the file watcher handle (kept alive to continue watching).
1856    pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
1857        &self.watcher
1858    }
1859
1860    /// Access the pre-filtered watcher event receiver.
1861    pub fn watcher_rx(
1862        &self,
1863    ) -> &RefCell<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>> {
1864        &self.watcher_rx
1865    }
1866
1867    /// Install a watcher filter thread and its dispatch receiver. The caller
1868    /// must have stopped any previous watcher runtime first.
1869    pub fn install_watcher_runtime(
1870        &self,
1871        rx: crossbeam_channel::Receiver<WatcherDispatchEvent>,
1872        runtime: WatcherThreadHandle,
1873    ) {
1874        *self.watcher_rx.borrow_mut() = Some(rx);
1875        *self.watcher_thread.borrow_mut() = Some(runtime);
1876    }
1877
1878    /// Stop the watcher filter thread (if any) and clear the dispatch receiver.
1879    /// Used on reconfigure, watcher failure, root deletion, and test teardown.
1880    pub fn stop_watcher_runtime(&self) {
1881        if let Some(runtime) = self.watcher_thread.borrow_mut().take() {
1882            runtime.shutdown_and_join();
1883        }
1884        *self.watcher_rx.borrow_mut() = None;
1885        *self.watcher.borrow_mut() = None;
1886    }
1887
1888    /// Access the LSP manager.
1889    pub fn lsp(&self) -> RefMut<'_, LspManager> {
1890        self.lsp_manager.borrow_mut()
1891    }
1892
1893    /// Notify LSP servers that a file was written.
1894    /// Call this after write_format_validate in command handlers.
1895    pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
1896        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1897            let config = self.config();
1898            if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
1899                crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1900            }
1901        }
1902    }
1903
1904    /// Drop cached LSP diagnostics for a deleted/renamed-away file so its
1905    /// errors/warnings don't linger in the warm set (no server republishes for
1906    /// a vanished path), keeping the status bar and `aft_inspect` honest.
1907    /// Returns true if any entry was removed. Best-effort: a contended borrow is
1908    /// skipped silently (the watcher drain retries on subsequent events).
1909    pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
1910        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1911            lsp.clear_diagnostics_for_file(file_path)
1912        } else {
1913            false
1914        }
1915    }
1916
1917    /// Notify LSP and optionally wait for diagnostics.
1918    ///
1919    /// Call this after `write_format_validate` when the request has `"diagnostics": true`.
1920    /// Sends didChange to the server, waits briefly for publishDiagnostics, and returns
1921    /// any diagnostics for the file. If no server is running, returns empty immediately.
1922    ///
1923    /// v0.17.3: this is the version-aware path. Pre-edit cached diagnostics
1924    /// are NEVER returned — only entries whose `version` matches the
1925    /// post-edit document version (or, for unversioned servers, whose
1926    /// `epoch` advanced past the pre-edit snapshot).
1927    pub fn lsp_notify_and_collect_diagnostics(
1928        &self,
1929        file_path: &Path,
1930        content: &str,
1931        timeout: std::time::Duration,
1932    ) -> crate::lsp::manager::PostEditWaitOutcome {
1933        let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
1934            return crate::lsp::manager::PostEditWaitOutcome::default();
1935        };
1936
1937        // Clear any queued notifications before this write so the wait loop only
1938        // observes diagnostics triggered by the current change.
1939        lsp.drain_events();
1940
1941        // Snapshot per-server epochs and document versions BEFORE sending
1942        // didChange so the wait loop can prove freshness without accepting
1943        // stale pre-edit publishes that arrived late.
1944        let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
1945
1946        // Send didChange/didOpen and capture per-server target version.
1947        let config = self.config();
1948        let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
1949        {
1950            Ok(v) => v,
1951            Err(e) => {
1952                crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1953                return crate::lsp::manager::PostEditWaitOutcome::default();
1954            }
1955        };
1956
1957        // No server matched this file — return an empty outcome that's
1958        // honestly `complete: true` (nothing to wait for).
1959        if expected_versions.is_empty() {
1960            return crate::lsp::manager::PostEditWaitOutcome::default();
1961        }
1962
1963        lsp.wait_for_post_edit_diagnostics(
1964            file_path,
1965            &config,
1966            &expected_versions,
1967            &pre_snapshot,
1968            timeout,
1969        )
1970    }
1971
1972    /// Collect custom server root_markers from user config for use in
1973    /// `is_config_file_path_with_custom` checks (#25).
1974    fn custom_lsp_root_markers(&self) -> Vec<String> {
1975        self.config()
1976            .lsp_servers
1977            .iter()
1978            .flat_map(|s| s.root_markers.iter().cloned())
1979            .collect()
1980    }
1981
1982    fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
1983        let custom_markers = self.custom_lsp_root_markers();
1984        let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
1985            .iter()
1986            .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
1987            .cloned()
1988            .map(|path| {
1989                let change_type = if path.exists() {
1990                    FileChangeType::CHANGED
1991                } else {
1992                    FileChangeType::DELETED
1993                };
1994                (path, change_type)
1995            })
1996            .collect();
1997
1998        self.notify_watched_config_events(&config_paths);
1999    }
2000
2001    fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
2002        let paths = params
2003            .get("multi_file_write_paths")
2004            .and_then(|value| value.as_array())?
2005            .iter()
2006            .filter_map(|value| value.as_str())
2007            .map(PathBuf::from)
2008            .collect::<Vec<_>>();
2009
2010        (!paths.is_empty()).then_some(paths)
2011    }
2012
2013    /// Parse config-file watched events from `multi_file_write_paths` when the
2014    /// array contains object entries `{ "path": "...", "type": "created|changed|deleted" }`.
2015    ///
2016    /// This handles the OBJECT variant of `multi_file_write_paths`. The STRING
2017    /// variant (bare path strings) is handled by `multi_file_write_paths()` and
2018    /// `notify_watched_config_files()`. Both variants read the same JSON key but
2019    /// with different per-entry schemas — they are NOT redundant.
2020    ///
2021    /// #18 note: in older code this function also existed alongside `multi_file_write_paths()`
2022    /// and was reachable via the `else if` branch when all entries were objects.
2023    /// Restoring both is correct.
2024    fn watched_file_events_from_params(
2025        params: &serde_json::Value,
2026        extra_markers: &[String],
2027    ) -> Option<Vec<(PathBuf, FileChangeType)>> {
2028        let events = params
2029            .get("multi_file_write_paths")
2030            .and_then(|value| value.as_array())?
2031            .iter()
2032            .filter_map(|entry| {
2033                // Only handle object entries — string entries go through multi_file_write_paths()
2034                let path = entry
2035                    .get("path")
2036                    .and_then(|value| value.as_str())
2037                    .map(PathBuf::from)?;
2038
2039                if !is_config_file_path_with_custom(&path, extra_markers) {
2040                    return None;
2041                }
2042
2043                let change_type = entry
2044                    .get("type")
2045                    .and_then(|value| value.as_str())
2046                    .and_then(Self::parse_file_change_type)
2047                    .unwrap_or_else(|| Self::change_type_from_current_state(&path));
2048
2049                Some((path, change_type))
2050            })
2051            .collect::<Vec<_>>();
2052
2053        (!events.is_empty()).then_some(events)
2054    }
2055
2056    fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
2057        match value {
2058            "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
2059            "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
2060            "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
2061            _ => None,
2062        }
2063    }
2064
2065    fn change_type_from_current_state(path: &Path) -> FileChangeType {
2066        if path.exists() {
2067            FileChangeType::CHANGED
2068        } else {
2069            FileChangeType::DELETED
2070        }
2071    }
2072
2073    fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
2074        if config_paths.is_empty() {
2075            return;
2076        }
2077
2078        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
2079            let config = self.config();
2080            if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
2081                crate::slog_warn!("watched-file sync error: {}", e);
2082            }
2083        }
2084    }
2085
2086    pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
2087        let custom_markers = self.custom_lsp_root_markers();
2088        if !is_config_file_path_with_custom(file_path, &custom_markers) {
2089            return;
2090        }
2091
2092        self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
2093    }
2094
2095    /// Post-write LSP hook for multi-file edits. When the patch includes
2096    /// config-file edits, notify active workspace servers via
2097    /// `workspace/didChangeWatchedFiles` before sending the per-document
2098    /// didOpen/didChange for the current file.
2099    pub fn lsp_post_multi_file_write(
2100        &self,
2101        file_path: &Path,
2102        content: &str,
2103        file_paths: &[PathBuf],
2104        params: &serde_json::Value,
2105    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2106        self.notify_watched_config_files(file_paths);
2107
2108        let wants_diagnostics = params
2109            .get("diagnostics")
2110            .and_then(|v| v.as_bool())
2111            .unwrap_or(false);
2112
2113        if !wants_diagnostics {
2114            self.lsp_notify_file_changed(file_path, content);
2115            return None;
2116        }
2117
2118        let wait_ms = params
2119            .get("wait_ms")
2120            .and_then(|v| v.as_u64())
2121            .unwrap_or(3000)
2122            .min(10_000);
2123
2124        Some(self.lsp_notify_and_collect_diagnostics(
2125            file_path,
2126            content,
2127            std::time::Duration::from_millis(wait_ms),
2128        ))
2129    }
2130
2131    /// Post-write LSP hook: notify server and optionally collect diagnostics.
2132    ///
2133    /// This is the single call site for all command handlers after `write_format_validate`.
2134    /// Behavior:
2135    /// - When `diagnostics: true` is in `params`, notifies the server, waits
2136    ///   until matching diagnostics arrive or the timeout expires, and returns
2137    ///   `Some(outcome)` with the verified-fresh diagnostics + per-server
2138    ///   status.
2139    /// - When `diagnostics: false` (or absent), just notifies (fire-and-forget)
2140    ///   and returns `None`. Callers must NOT wrap this in `Some(...)`; the
2141    ///   `None` is what tells the response builder to omit the LSP fields
2142    ///   entirely (preserves the no-diagnostics-requested response shape).
2143    ///
2144    /// v0.17.3: default `wait_ms` raised from 1500 to 3000 because real-world
2145    /// tsserver re-analysis on monorepo files routinely takes 2-5s. Still
2146    /// capped at 10000ms.
2147    pub fn lsp_post_write(
2148        &self,
2149        file_path: &Path,
2150        content: &str,
2151        params: &serde_json::Value,
2152    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2153        let wants_diagnostics = params
2154            .get("diagnostics")
2155            .and_then(|v| v.as_bool())
2156            .unwrap_or(false);
2157
2158        let custom_markers = self.custom_lsp_root_markers();
2159
2160        if !wants_diagnostics {
2161            if let Some(file_paths) = Self::multi_file_write_paths(params) {
2162                self.notify_watched_config_files(&file_paths);
2163            } else if let Some(config_events) =
2164                Self::watched_file_events_from_params(params, &custom_markers)
2165            {
2166                self.notify_watched_config_events(&config_events);
2167            }
2168            self.lsp_notify_file_changed(file_path, content);
2169            return None;
2170        }
2171
2172        let wait_ms = params
2173            .get("wait_ms")
2174            .and_then(|v| v.as_u64())
2175            .unwrap_or(3000)
2176            .min(10_000); // Cap at 10 seconds to prevent hangs from adversarial input
2177
2178        if let Some(file_paths) = Self::multi_file_write_paths(params) {
2179            return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
2180        }
2181
2182        if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
2183        {
2184            self.notify_watched_config_events(&config_events);
2185        }
2186
2187        Some(self.lsp_notify_and_collect_diagnostics(
2188            file_path,
2189            content,
2190            std::time::Duration::from_millis(wait_ms),
2191        ))
2192    }
2193
2194    /// Validate that a file path falls within the configured project root.
2195    ///
2196    /// When `project_root` is configured (normal plugin usage), this resolves the
2197    /// path and checks it starts with the root. Returns the canonicalized path on
2198    /// success, or an error response on violation.
2199    ///
2200    /// When no `project_root` is configured (direct CLI usage), all paths pass
2201    /// through unrestricted for backward compatibility.
2202    pub fn validate_path(
2203        &self,
2204        req_id: &str,
2205        path: &Path,
2206    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
2207        let config = self.config();
2208        // When restrict_to_project_root is false (default), allow all paths
2209        if !config.restrict_to_project_root {
2210            return Ok(path.to_path_buf());
2211        }
2212        let root = match &config.project_root {
2213            Some(r) => r.clone(),
2214            None => return Ok(path.to_path_buf()), // No root configured, allow all
2215        };
2216        drop(config);
2217
2218        // Keep the raw root for symlink-guard comparisons. On macOS, tempdir()
2219        // returns /var/... paths while canonicalize gives /private/var/...; we
2220        // need both forms so reject_escaping_symlink can recognise in-root
2221        // symlinks regardless of which prefix form `current` happens to have.
2222        let raw_root = root.clone();
2223        let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
2224
2225        // Resolve the path (follow symlinks, normalize ..). If canonicalization
2226        // fails (e.g. path does not exist or traverses a broken symlink), inspect
2227        // every existing component with lstat before falling back lexically so a
2228        // broken in-root symlink cannot be used to write outside project_root.
2229        let path_for_resolution = if path.is_relative() {
2230            raw_root.join(path)
2231        } else {
2232            path.to_path_buf()
2233        };
2234        let resolved = match std::fs::canonicalize(&path_for_resolution) {
2235            Ok(resolved) => resolved,
2236            Err(_) => {
2237                let normalized = normalize_path(&path_for_resolution);
2238                reject_escaping_symlink(
2239                    req_id,
2240                    &path_for_resolution,
2241                    &normalized,
2242                    &resolved_root,
2243                    &raw_root,
2244                )?;
2245                resolve_with_existing_ancestors(&normalized)
2246            }
2247        };
2248
2249        if !resolved.starts_with(&resolved_root) {
2250            return Err(path_error_response(req_id, path, &resolved_root));
2251        }
2252
2253        Ok(resolved)
2254    }
2255
2256    /// Count active LSP server instances.
2257    pub fn lsp_server_count(&self) -> usize {
2258        self.lsp_manager
2259            .try_borrow()
2260            .map(|lsp| lsp.server_count())
2261            .unwrap_or(0)
2262    }
2263
2264    /// Symbol cache statistics from the language provider.
2265    pub fn symbol_cache_stats(&self) -> serde_json::Value {
2266        let entries = self
2267            .symbol_cache
2268            .read()
2269            .map(|cache| cache.len())
2270            .unwrap_or(0);
2271        serde_json::json!({
2272            "local_entries": entries,
2273            "warm_entries": 0,
2274        })
2275    }
2276}
2277
2278#[cfg(test)]
2279mod status_emitter_tests {
2280    use super::*;
2281    use crate::parser::TreeSitterProvider;
2282
2283    fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
2284        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2285        let (tx, rx) = mpsc::channel();
2286        ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
2287            let _ = tx.send(frame);
2288        }))));
2289        (ctx, rx)
2290    }
2291
2292    #[test]
2293    fn status_emitter_signal_triggers_push() {
2294        let (ctx, rx) = ctx_with_frame_rx();
2295        ctx.status_emitter().signal(ctx.build_status_snapshot());
2296        let frame = rx
2297            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2298            .expect("status_changed push");
2299        assert!(matches!(frame, PushFrame::StatusChanged(_)));
2300    }
2301
2302    #[test]
2303    fn status_emitter_debounces_burst() {
2304        let (ctx, rx) = ctx_with_frame_rx();
2305        for _ in 0..10 {
2306            ctx.status_emitter().signal(ctx.build_status_snapshot());
2307        }
2308        let frame = rx
2309            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2310            .expect("status_changed push");
2311        assert!(matches!(frame, PushFrame::StatusChanged(_)));
2312        assert!(rx.try_recv().is_err());
2313    }
2314
2315    #[test]
2316    fn status_emitter_separate_windows_separate_pushes() {
2317        let (ctx, rx) = ctx_with_frame_rx();
2318        ctx.status_emitter().signal(ctx.build_status_snapshot());
2319        rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2320            .expect("first push");
2321        ctx.status_emitter().signal(ctx.build_status_snapshot());
2322        rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2323            .expect("second push");
2324    }
2325
2326    #[test]
2327    fn status_emitter_no_signal_no_push() {
2328        let (_ctx, rx) = ctx_with_frame_rx();
2329        assert!(rx
2330            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
2331            .is_err());
2332    }
2333
2334    #[test]
2335    fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
2336        let (ctx, rx) = ctx_with_frame_rx();
2337        drop(ctx);
2338        assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
2339    }
2340}
2341
2342#[cfg(test)]
2343mod status_bar_tests {
2344    use super::*;
2345    use crate::parser::TreeSitterProvider;
2346
2347    fn ctx() -> AppContext {
2348        AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
2349    }
2350
2351    #[test]
2352    fn status_bar_counts_none_until_tier2_populated() {
2353        let ctx = ctx();
2354        // No scan has run yet — never surface a bar claiming "0 dead code".
2355        assert!(ctx.status_bar_counts().is_none());
2356
2357        ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
2358        let counts = ctx.status_bar_counts().expect("populated");
2359        assert_eq!(counts.dead_code, 5);
2360        assert_eq!(counts.unused_exports, 3);
2361        assert_eq!(counts.duplicates, 7);
2362        assert_eq!(counts.todos, 2);
2363        assert!(!counts.tier2_stale);
2364        // Errors/warnings are read live from an empty LSP store → 0.
2365        assert_eq!(counts.errors, 0);
2366        assert_eq!(counts.warnings, 0);
2367    }
2368
2369    #[test]
2370    fn partial_tier2_does_not_fabricate_zeros() {
2371        let ctx = ctx();
2372        // Only dead_code has completed (the slow first serial category); the
2373        // other two are still in flight. The bar must stay suppressed rather
2374        // than render `D5 U0 C0` with fabricated zeros (#1).
2375        ctx.update_status_bar_tier2(Some(5), None, None, None, true);
2376        assert!(
2377            ctx.status_bar_counts().is_none(),
2378            "bar must not surface until all three Tier-2 categories are real"
2379        );
2380
2381        // Second category completes — still incomplete, still suppressed.
2382        ctx.update_status_bar_tier2(None, Some(3), None, None, true);
2383        assert!(ctx.status_bar_counts().is_none());
2384
2385        // Final category completes → bar surfaces with all real counts, and
2386        // none of them were ever fabricated.
2387        ctx.update_status_bar_tier2(None, None, Some(7), None, false);
2388        let counts = ctx.status_bar_counts().expect("all three real now");
2389        assert_eq!(counts.dead_code, 5);
2390        assert_eq!(counts.unused_exports, 3);
2391        assert_eq!(counts.duplicates, 7);
2392    }
2393
2394    #[test]
2395    fn update_with_none_todos_preserves_last_known_todos() {
2396        let ctx = ctx();
2397        ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
2398        // A background-scan refresh passes todos=None → todo count preserved.
2399        ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
2400        let counts = ctx.status_bar_counts().expect("populated");
2401        assert_eq!(counts.todos, 9);
2402        assert_eq!(counts.dead_code, 2);
2403    }
2404
2405    #[test]
2406    fn update_with_none_count_preserves_last_known_count() {
2407        let ctx = ctx();
2408        ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
2409        // A refresh that only recomputed dead_code preserves the other two
2410        // real counts rather than overwriting them with a fabricated 0.
2411        ctx.update_status_bar_tier2(Some(11), None, None, None, false);
2412        let counts = ctx.status_bar_counts().expect("populated");
2413        assert_eq!(counts.dead_code, 11);
2414        assert_eq!(counts.unused_exports, 20);
2415        assert_eq!(counts.duplicates, 30);
2416    }
2417
2418    #[test]
2419    fn mark_stale_sets_flag_only_after_populate() {
2420        let ctx = ctx();
2421        // No-op before first populate.
2422        ctx.mark_status_bar_tier2_stale();
2423        assert!(ctx.status_bar_counts().is_none());
2424
2425        ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), Some(0), false);
2426        ctx.mark_status_bar_tier2_stale();
2427        assert!(ctx.status_bar_counts().expect("populated").tier2_stale);
2428
2429        // A completed scan clears stale.
2430        ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), None, false);
2431        assert!(!ctx.status_bar_counts().expect("populated").tier2_stale);
2432    }
2433
2434    // End-to-end wiring: a diagnostic for a file inflates the status-bar `E`
2435    // count (read live from the warm LSP set); clearing that file's diagnostics
2436    // (the deleted-file path) drops it back. This is the AppContext glue between
2437    // the watcher-drain clear and the agent-visible bar.
2438    #[test]
2439    fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
2440        use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
2441        use crate::lsp::registry::ServerKind;
2442        use crate::lsp::roots::ServerKey;
2443
2444        let ctx = ctx();
2445        ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); // populate so the bar surfaces
2446
2447        let file = std::path::PathBuf::from("/proj/gone.ts");
2448        {
2449            let mut lsp = ctx.lsp();
2450            lsp.diagnostics_store_mut_for_test().publish(
2451                ServerKey {
2452                    kind: ServerKind::TypeScript,
2453                    root: std::path::PathBuf::from("/proj"),
2454                },
2455                file.clone(),
2456                vec![StoredDiagnostic {
2457                    file: file.clone(),
2458                    line: 1,
2459                    column: 1,
2460                    end_line: 1,
2461                    end_column: 2,
2462                    severity: DiagnosticSeverity::Error,
2463                    message: "boom".into(),
2464                    code: None,
2465                    source: None,
2466                }],
2467            );
2468        }
2469
2470        // Bar reflects the live warm-set error.
2471        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
2472
2473        // Clearing the (now-deleted) file's diagnostics drops the count.
2474        let removed = ctx.lsp_clear_diagnostics_for_file(&file);
2475        assert!(removed);
2476        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
2477    }
2478
2479    #[test]
2480    fn status_bar_filtered_counts_ignore_environmental_flap() {
2481        use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
2482        use crate::lsp::registry::ServerKind;
2483        use crate::lsp::roots::ServerKey;
2484
2485        let ctx = ctx();
2486        let root = if cfg!(windows) {
2487            std::path::PathBuf::from(r"C:\proj")
2488        } else {
2489            std::path::PathBuf::from("/proj")
2490        };
2491        ctx.set_canonical_cache_root(root.clone());
2492        ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
2493
2494        let file = root.join("aft.jsonc");
2495        let key = ServerKey {
2496            kind: ServerKind::TypeScript,
2497            root: root.clone(),
2498        };
2499        let env = StoredDiagnostic {
2500            file: file.clone(),
2501            line: 1,
2502            column: 1,
2503            end_line: 1,
2504            end_column: 2,
2505            severity: DiagnosticSeverity::Error,
2506            message: "Failed to load schema from https://example.com/schema.json".into(),
2507            code: None,
2508            source: Some("json".into()),
2509        };
2510
2511        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
2512
2513        {
2514            let mut lsp = ctx.lsp();
2515            lsp.diagnostics_store_mut_for_test()
2516                .publish(key.clone(), file.clone(), vec![env]);
2517        }
2518        assert_eq!(
2519            ctx.status_bar_counts().expect("populated").errors,
2520            0,
2521            "environmental publish must not change status-bar E"
2522        );
2523
2524        {
2525            let mut lsp = ctx.lsp();
2526            lsp.diagnostics_store_mut_for_test()
2527                .publish(key, file, vec![]);
2528        }
2529        assert_eq!(
2530            ctx.status_bar_counts().expect("populated").errors,
2531            0,
2532            "environmental clear must not change status-bar E"
2533        );
2534    }
2535}
2536
2537#[cfg(test)]
2538mod harness_path_tests {
2539    use super::*;
2540    use crate::harness::Harness;
2541    use crate::parser::TreeSitterProvider;
2542
2543    fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
2544        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2545        ctx.config_mut().storage_dir = Some(storage_dir);
2546        ctx.set_harness(harness);
2547        ctx
2548    }
2549
2550    #[test]
2551    fn harness_dir_resolves_correctly() {
2552        let storage = PathBuf::from("/tmp/cortexkit/aft");
2553        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2554
2555        assert_eq!(ctx.harness_dir(), storage.join("pi"));
2556    }
2557
2558    #[test]
2559    fn bash_tasks_dir_uses_hash_session() {
2560        let storage = PathBuf::from("/tmp/cortexkit/aft");
2561        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2562
2563        assert_eq!(
2564            ctx.bash_tasks_dir("ses_abc"),
2565            storage
2566                .join("opencode")
2567                .join("bash-tasks")
2568                .join(hash_session("ses_abc"))
2569        );
2570    }
2571
2572    #[test]
2573    fn backups_dir_includes_path_hash() {
2574        let storage = PathBuf::from("/tmp/cortexkit/aft");
2575        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2576
2577        assert_eq!(
2578            ctx.backups_dir("ses_abc", "pathhash"),
2579            storage
2580                .join("pi")
2581                .join("backups")
2582                .join(hash_session("ses_abc"))
2583                .join("pathhash")
2584        );
2585    }
2586
2587    #[test]
2588    fn filters_dir_under_harness() {
2589        let storage = PathBuf::from("/tmp/cortexkit/aft");
2590        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2591
2592        assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
2593    }
2594
2595    #[test]
2596    fn trust_file_is_host_global() {
2597        let storage = PathBuf::from("/tmp/cortexkit/aft");
2598        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2599
2600        assert_eq!(
2601            ctx.trust_file(),
2602            storage.join("trusted-filter-projects.json")
2603        );
2604    }
2605
2606    #[test]
2607    fn same_session_different_harness_resolve_different_paths() {
2608        let storage = PathBuf::from("/tmp/cortexkit/aft");
2609        let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2610        let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
2611
2612        assert_ne!(
2613            opencode.bash_tasks_dir("ses_same"),
2614            pi.bash_tasks_dir("ses_same")
2615        );
2616    }
2617}
2618
2619#[cfg(test)]
2620mod gitignore_tests {
2621    use super::*;
2622    use std::fs;
2623    use std::path::Path;
2624    use tempfile::TempDir;
2625
2626    fn make_ctx_with_root(root: &Path) -> AppContext {
2627        let provider = Box::new(crate::parser::TreeSitterProvider::new());
2628        let config = Config {
2629            project_root: Some(root.to_path_buf()),
2630            ..Config::default()
2631        };
2632        AppContext::new(provider, config)
2633    }
2634
2635    /// Helper: returns true when the matcher would skip `path` (as if it
2636    /// arrived via a watcher event for this project root). Canonicalizes
2637    /// the query path so symlink prefixes (e.g. macOS `/var` → `/private/var`)
2638    /// don't trip the `ignore` crate's "path is expected to be under the
2639    /// root" panic — production code does the same guard via
2640    /// `path.starts_with(matcher.path())` in `drain_watcher_events`.
2641    fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
2642        let Some(matcher) = ctx.gitignore() else {
2643            return false;
2644        };
2645        let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
2646        if !canonical.starts_with(matcher.path()) {
2647            return false;
2648        }
2649        let is_dir = canonical.is_dir();
2650        matcher
2651            .matched_path_or_any_parents(&canonical, is_dir)
2652            .is_ignore()
2653    }
2654
2655    /// Run `f` with global git-ignore discovery neutralized.
2656    ///
2657    /// `rebuild_gitignore` loads git's global excludes (the `ignore` crate
2658    /// resolves `$XDG_CONFIG_HOME/git/ignore`, falling back to
2659    /// `$HOME/.config/git/ignore`). A developer machine commonly has that file,
2660    /// so a "no project ignore → None" assertion is only deterministic when
2661    /// global discovery is pointed at an empty directory. Pointing
2662    /// `XDG_CONFIG_HOME` at a fresh tempdir does that without touching `HOME`
2663    /// (so it can't race the `HOME`-mutating configure tests). Serialized by a
2664    /// process-local mutex; env is restored before the closure result is used.
2665    fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
2666        use std::sync::{Mutex, OnceLock};
2667        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2668        let _guard = LOCK
2669            .get_or_init(|| Mutex::new(()))
2670            .lock()
2671            .unwrap_or_else(|e| e.into_inner());
2672        let tmp = TempDir::new().unwrap();
2673        let prev = std::env::var_os("XDG_CONFIG_HOME");
2674        // SAFETY: serialized by LOCK above; restored immediately after `f`.
2675        unsafe {
2676            std::env::set_var("XDG_CONFIG_HOME", tmp.path());
2677        }
2678        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
2679        unsafe {
2680            match prev {
2681                Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
2682                None => std::env::remove_var("XDG_CONFIG_HOME"),
2683            }
2684        }
2685        match result {
2686            Ok(r) => r,
2687            Err(p) => std::panic::resume_unwind(p),
2688        }
2689    }
2690
2691    #[test]
2692    fn rebuild_gitignore_returns_none_without_project_root() {
2693        let provider = Box::new(crate::parser::TreeSitterProvider::new());
2694        let ctx = AppContext::new(provider, Config::default());
2695        with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2696        assert!(ctx.gitignore().is_none());
2697    }
2698
2699    #[test]
2700    fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
2701        let tmp = TempDir::new().unwrap();
2702        let ctx = make_ctx_with_root(tmp.path());
2703        with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2704        assert!(ctx.gitignore().is_none());
2705    }
2706
2707    #[test]
2708    fn matcher_filters_files_in_ignored_dist_dir() {
2709        let tmp = TempDir::new().unwrap();
2710        fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
2711        fs::create_dir_all(tmp.path().join("dist")).unwrap();
2712        fs::create_dir_all(tmp.path().join("src")).unwrap();
2713        let dist_file = tmp.path().join("dist").join("bundle.js");
2714        let src_file = tmp.path().join("src").join("app.ts");
2715        fs::write(&dist_file, "x").unwrap();
2716        fs::write(&src_file, "y").unwrap();
2717
2718        let ctx = make_ctx_with_root(tmp.path());
2719        ctx.rebuild_gitignore();
2720
2721        assert!(ctx.gitignore().is_some());
2722        assert!(
2723            is_ignored(&ctx, &dist_file),
2724            "dist/bundle.js should be ignored"
2725        );
2726        assert!(
2727            !is_ignored(&ctx, &src_file),
2728            "src/app.ts should NOT be ignored"
2729        );
2730    }
2731
2732    #[test]
2733    fn matcher_handles_node_modules_and_target() {
2734        let tmp = TempDir::new().unwrap();
2735        fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
2736        fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
2737        fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
2738        let nm_file = tmp.path().join("node_modules/foo/index.js");
2739        let target_file = tmp.path().join("target/debug/aft");
2740        fs::write(&nm_file, "x").unwrap();
2741        fs::write(&target_file, "x").unwrap();
2742
2743        let ctx = make_ctx_with_root(tmp.path());
2744        ctx.rebuild_gitignore();
2745
2746        assert!(is_ignored(&ctx, &nm_file));
2747        assert!(is_ignored(&ctx, &target_file));
2748    }
2749
2750    #[test]
2751    fn matcher_honors_negation_pattern() {
2752        // .gitignore: ignore all *.log files EXCEPT important.log
2753        let tmp = TempDir::new().unwrap();
2754        fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
2755        let random_log = tmp.path().join("random.log");
2756        let important_log = tmp.path().join("important.log");
2757        fs::write(&random_log, "x").unwrap();
2758        fs::write(&important_log, "y").unwrap();
2759
2760        let ctx = make_ctx_with_root(tmp.path());
2761        ctx.rebuild_gitignore();
2762
2763        assert!(is_ignored(&ctx, &random_log));
2764        assert!(
2765            !is_ignored(&ctx, &important_log),
2766            "negation pattern should un-ignore important.log"
2767        );
2768    }
2769
2770    #[test]
2771    fn rebuild_picks_up_gitignore_changes() {
2772        let tmp = TempDir::new().unwrap();
2773        let ignore_path = tmp.path().join(".gitignore");
2774        fs::write(&ignore_path, "foo.txt\n").unwrap();
2775        let foo = tmp.path().join("foo.txt");
2776        let bar = tmp.path().join("bar.txt");
2777        fs::write(&foo, "").unwrap();
2778        fs::write(&bar, "").unwrap();
2779
2780        let ctx = make_ctx_with_root(tmp.path());
2781        ctx.rebuild_gitignore();
2782        assert!(is_ignored(&ctx, &foo));
2783        assert!(!is_ignored(&ctx, &bar));
2784
2785        // Now flip the rules: ignore bar.txt instead of foo.txt
2786        fs::write(&ignore_path, "bar.txt\n").unwrap();
2787        ctx.rebuild_gitignore();
2788        assert!(!is_ignored(&ctx, &foo));
2789        assert!(is_ignored(&ctx, &bar));
2790    }
2791
2792    #[test]
2793    fn gitignore_loads_info_exclude_when_present() {
2794        let tmp = TempDir::new().unwrap();
2795        let info_dir = tmp.path().join(".git/info");
2796        fs::create_dir_all(&info_dir).unwrap();
2797        fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
2798        let secrets = tmp.path().join("secrets.txt");
2799        let public = tmp.path().join("public.txt");
2800        fs::write(&secrets, "token").unwrap();
2801        fs::write(&public, "ok").unwrap();
2802
2803        let ctx = make_ctx_with_root(tmp.path());
2804        ctx.rebuild_gitignore();
2805
2806        assert!(is_ignored(&ctx, &secrets));
2807        assert!(!is_ignored(&ctx, &public));
2808    }
2809
2810    #[test]
2811    fn matcher_picks_up_nested_gitignore() {
2812        let tmp = TempDir::new().unwrap();
2813        // Root .gitignore is intentionally empty — only the nested one ignores
2814        fs::write(tmp.path().join(".gitignore"), "").unwrap();
2815        let sub = tmp.path().join("packages/foo");
2816        fs::create_dir_all(&sub).unwrap();
2817        fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
2818        let generated_file = sub.join("generated").join("out.js");
2819        fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
2820        fs::write(&generated_file, "x").unwrap();
2821
2822        let ctx = make_ctx_with_root(tmp.path());
2823        ctx.rebuild_gitignore();
2824
2825        assert!(
2826            is_ignored(&ctx, &generated_file),
2827            "nested gitignore in packages/foo/.gitignore should ignore generated/"
2828        );
2829    }
2830}