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