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