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            || self
1665                .degraded_reasons
1666                .borrow()
1667                .iter()
1668                .any(|r| r == "home_root")
1669            || !self.config().inspect.enabled
1670        {
1671            return;
1672        }
1673        let Some(snapshot) = self.tier2_refresh_snapshot() else {
1674            return;
1675        };
1676        let categories = InspectCategory::active()
1677            .iter()
1678            .copied()
1679            .filter(|category| category.is_tier2())
1680            .collect::<Vec<_>>();
1681        let submission =
1682            manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
1683        if submission.has_new_work() {
1684            crate::slog_info!(
1685                "tier2 refresh scheduled: reason={}, categories={:?}",
1686                reason.as_str(),
1687                submission
1688                    .newly_queued_categories
1689                    .iter()
1690                    .map(|category| category.as_str())
1691                    .collect::<Vec<_>>()
1692            );
1693        }
1694        for error in submission.errors {
1695            crate::slog_warn!(
1696                "tier2 refresh schedule failed for {}: {}",
1697                error.category,
1698                error.message
1699            );
1700        }
1701    }
1702
1703    fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
1704        self.harness_opt()?;
1705        let config = self.config().clone();
1706        let project_root = config
1707            .project_root
1708            .clone()
1709            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1710        let project_root = std::fs::canonicalize(&project_root).unwrap_or(project_root);
1711        Some(InspectSnapshot::new(
1712            project_root,
1713            self.inspect_dir(),
1714            Arc::new(config),
1715            self.symbol_cache(),
1716        ))
1717    }
1718
1719    /// Access the shared symbol cache.
1720    pub fn symbol_cache(&self) -> SharedSymbolCache {
1721        Arc::clone(&self.symbol_cache)
1722    }
1723
1724    /// Clear the shared symbol cache and return the new active generation.
1725    pub fn reset_symbol_cache(&self) -> u64 {
1726        self.symbol_cache
1727            .write()
1728            .map(|mut cache| cache.reset())
1729            .unwrap_or(0)
1730    }
1731
1732    /// Access the semantic search index.
1733    pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
1734        &self.semantic_index
1735    }
1736
1737    /// Access the semantic-index build receiver.
1738    pub fn semantic_index_rx(
1739        &self,
1740    ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
1741        &self.semantic_index_rx
1742    }
1743
1744    pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
1745        &self.semantic_index_status
1746    }
1747
1748    pub fn install_semantic_refresh_worker(
1749        &self,
1750        sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
1751        event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
1752        worker_slot: SemanticRefreshWorkerSlot,
1753    ) {
1754        self.clear_semantic_refresh_worker();
1755        *self.semantic_refresh_tx.borrow_mut() = Some(sender);
1756        *self.semantic_refresh_event_rx.borrow_mut() = Some(event_rx);
1757        *self.semantic_refresh_worker.borrow_mut() = Some(worker_slot);
1758    }
1759
1760    pub fn clear_semantic_refresh_worker(&self) {
1761        *self.semantic_refresh_tx.borrow_mut() = None;
1762        *self.semantic_refresh_event_rx.borrow_mut() = None;
1763        if let Some(worker_slot) = self.semantic_refresh_worker.borrow_mut().take() {
1764            if let Ok(mut handle) = worker_slot.lock() {
1765                drop(handle.take());
1766            }
1767        }
1768    }
1769
1770    pub fn semantic_refresh_sender(
1771        &self,
1772    ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
1773        self.semantic_refresh_tx.borrow().clone()
1774    }
1775
1776    pub fn semantic_refresh_event_rx(
1777        &self,
1778    ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
1779        &self.semantic_refresh_event_rx
1780    }
1781
1782    /// Access the cached semantic embedding model.
1783    pub fn semantic_embedding_model(
1784        &self,
1785    ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
1786        &self.semantic_embedding_model
1787    }
1788
1789    /// Access the file watcher handle (kept alive to continue watching).
1790    pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
1791        &self.watcher
1792    }
1793
1794    /// Access the watcher event receiver.
1795    pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
1796        &self.watcher_rx
1797    }
1798
1799    /// Access the LSP manager.
1800    pub fn lsp(&self) -> RefMut<'_, LspManager> {
1801        self.lsp_manager.borrow_mut()
1802    }
1803
1804    /// Notify LSP servers that a file was written.
1805    /// Call this after write_format_validate in command handlers.
1806    pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
1807        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1808            let config = self.config();
1809            if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
1810                crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1811            }
1812        }
1813    }
1814
1815    /// Drop cached LSP diagnostics for a deleted/renamed-away file so its
1816    /// errors/warnings don't linger in the warm set (no server republishes for
1817    /// a vanished path), keeping the status bar and `aft_inspect` honest.
1818    /// Returns true if any entry was removed. Best-effort: a contended borrow is
1819    /// skipped silently (the watcher drain retries on subsequent events).
1820    pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
1821        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1822            lsp.clear_diagnostics_for_file(file_path)
1823        } else {
1824            false
1825        }
1826    }
1827
1828    /// Notify LSP and optionally wait for diagnostics.
1829    ///
1830    /// Call this after `write_format_validate` when the request has `"diagnostics": true`.
1831    /// Sends didChange to the server, waits briefly for publishDiagnostics, and returns
1832    /// any diagnostics for the file. If no server is running, returns empty immediately.
1833    ///
1834    /// v0.17.3: this is the version-aware path. Pre-edit cached diagnostics
1835    /// are NEVER returned — only entries whose `version` matches the
1836    /// post-edit document version (or, for unversioned servers, whose
1837    /// `epoch` advanced past the pre-edit snapshot).
1838    pub fn lsp_notify_and_collect_diagnostics(
1839        &self,
1840        file_path: &Path,
1841        content: &str,
1842        timeout: std::time::Duration,
1843    ) -> crate::lsp::manager::PostEditWaitOutcome {
1844        let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
1845            return crate::lsp::manager::PostEditWaitOutcome::default();
1846        };
1847
1848        // Clear any queued notifications before this write so the wait loop only
1849        // observes diagnostics triggered by the current change.
1850        lsp.drain_events();
1851
1852        // Snapshot per-server epochs and document versions BEFORE sending
1853        // didChange so the wait loop can prove freshness without accepting
1854        // stale pre-edit publishes that arrived late.
1855        let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
1856
1857        // Send didChange/didOpen and capture per-server target version.
1858        let config = self.config();
1859        let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
1860        {
1861            Ok(v) => v,
1862            Err(e) => {
1863                crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1864                return crate::lsp::manager::PostEditWaitOutcome::default();
1865            }
1866        };
1867
1868        // No server matched this file — return an empty outcome that's
1869        // honestly `complete: true` (nothing to wait for).
1870        if expected_versions.is_empty() {
1871            return crate::lsp::manager::PostEditWaitOutcome::default();
1872        }
1873
1874        lsp.wait_for_post_edit_diagnostics(
1875            file_path,
1876            &config,
1877            &expected_versions,
1878            &pre_snapshot,
1879            timeout,
1880        )
1881    }
1882
1883    /// Collect custom server root_markers from user config for use in
1884    /// `is_config_file_path_with_custom` checks (#25).
1885    fn custom_lsp_root_markers(&self) -> Vec<String> {
1886        self.config()
1887            .lsp_servers
1888            .iter()
1889            .flat_map(|s| s.root_markers.iter().cloned())
1890            .collect()
1891    }
1892
1893    fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
1894        let custom_markers = self.custom_lsp_root_markers();
1895        let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
1896            .iter()
1897            .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
1898            .cloned()
1899            .map(|path| {
1900                let change_type = if path.exists() {
1901                    FileChangeType::CHANGED
1902                } else {
1903                    FileChangeType::DELETED
1904                };
1905                (path, change_type)
1906            })
1907            .collect();
1908
1909        self.notify_watched_config_events(&config_paths);
1910    }
1911
1912    fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
1913        let paths = params
1914            .get("multi_file_write_paths")
1915            .and_then(|value| value.as_array())?
1916            .iter()
1917            .filter_map(|value| value.as_str())
1918            .map(PathBuf::from)
1919            .collect::<Vec<_>>();
1920
1921        (!paths.is_empty()).then_some(paths)
1922    }
1923
1924    /// Parse config-file watched events from `multi_file_write_paths` when the
1925    /// array contains object entries `{ "path": "...", "type": "created|changed|deleted" }`.
1926    ///
1927    /// This handles the OBJECT variant of `multi_file_write_paths`. The STRING
1928    /// variant (bare path strings) is handled by `multi_file_write_paths()` and
1929    /// `notify_watched_config_files()`. Both variants read the same JSON key but
1930    /// with different per-entry schemas — they are NOT redundant.
1931    ///
1932    /// #18 note: in older code this function also existed alongside `multi_file_write_paths()`
1933    /// and was reachable via the `else if` branch when all entries were objects.
1934    /// Restoring both is correct.
1935    fn watched_file_events_from_params(
1936        params: &serde_json::Value,
1937        extra_markers: &[String],
1938    ) -> Option<Vec<(PathBuf, FileChangeType)>> {
1939        let events = params
1940            .get("multi_file_write_paths")
1941            .and_then(|value| value.as_array())?
1942            .iter()
1943            .filter_map(|entry| {
1944                // Only handle object entries — string entries go through multi_file_write_paths()
1945                let path = entry
1946                    .get("path")
1947                    .and_then(|value| value.as_str())
1948                    .map(PathBuf::from)?;
1949
1950                if !is_config_file_path_with_custom(&path, extra_markers) {
1951                    return None;
1952                }
1953
1954                let change_type = entry
1955                    .get("type")
1956                    .and_then(|value| value.as_str())
1957                    .and_then(Self::parse_file_change_type)
1958                    .unwrap_or_else(|| Self::change_type_from_current_state(&path));
1959
1960                Some((path, change_type))
1961            })
1962            .collect::<Vec<_>>();
1963
1964        (!events.is_empty()).then_some(events)
1965    }
1966
1967    fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
1968        match value {
1969            "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
1970            "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
1971            "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
1972            _ => None,
1973        }
1974    }
1975
1976    fn change_type_from_current_state(path: &Path) -> FileChangeType {
1977        if path.exists() {
1978            FileChangeType::CHANGED
1979        } else {
1980            FileChangeType::DELETED
1981        }
1982    }
1983
1984    fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
1985        if config_paths.is_empty() {
1986            return;
1987        }
1988
1989        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1990            let config = self.config();
1991            if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
1992                crate::slog_warn!("watched-file sync error: {}", e);
1993            }
1994        }
1995    }
1996
1997    pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
1998        let custom_markers = self.custom_lsp_root_markers();
1999        if !is_config_file_path_with_custom(file_path, &custom_markers) {
2000            return;
2001        }
2002
2003        self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
2004    }
2005
2006    /// Post-write LSP hook for multi-file edits. When the patch includes
2007    /// config-file edits, notify active workspace servers via
2008    /// `workspace/didChangeWatchedFiles` before sending the per-document
2009    /// didOpen/didChange for the current file.
2010    pub fn lsp_post_multi_file_write(
2011        &self,
2012        file_path: &Path,
2013        content: &str,
2014        file_paths: &[PathBuf],
2015        params: &serde_json::Value,
2016    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2017        self.notify_watched_config_files(file_paths);
2018
2019        let wants_diagnostics = params
2020            .get("diagnostics")
2021            .and_then(|v| v.as_bool())
2022            .unwrap_or(false);
2023
2024        if !wants_diagnostics {
2025            self.lsp_notify_file_changed(file_path, content);
2026            return None;
2027        }
2028
2029        let wait_ms = params
2030            .get("wait_ms")
2031            .and_then(|v| v.as_u64())
2032            .unwrap_or(3000)
2033            .min(10_000);
2034
2035        Some(self.lsp_notify_and_collect_diagnostics(
2036            file_path,
2037            content,
2038            std::time::Duration::from_millis(wait_ms),
2039        ))
2040    }
2041
2042    /// Post-write LSP hook: notify server and optionally collect diagnostics.
2043    ///
2044    /// This is the single call site for all command handlers after `write_format_validate`.
2045    /// Behavior:
2046    /// - When `diagnostics: true` is in `params`, notifies the server, waits
2047    ///   until matching diagnostics arrive or the timeout expires, and returns
2048    ///   `Some(outcome)` with the verified-fresh diagnostics + per-server
2049    ///   status.
2050    /// - When `diagnostics: false` (or absent), just notifies (fire-and-forget)
2051    ///   and returns `None`. Callers must NOT wrap this in `Some(...)`; the
2052    ///   `None` is what tells the response builder to omit the LSP fields
2053    ///   entirely (preserves the no-diagnostics-requested response shape).
2054    ///
2055    /// v0.17.3: default `wait_ms` raised from 1500 to 3000 because real-world
2056    /// tsserver re-analysis on monorepo files routinely takes 2-5s. Still
2057    /// capped at 10000ms.
2058    pub fn lsp_post_write(
2059        &self,
2060        file_path: &Path,
2061        content: &str,
2062        params: &serde_json::Value,
2063    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2064        let wants_diagnostics = params
2065            .get("diagnostics")
2066            .and_then(|v| v.as_bool())
2067            .unwrap_or(false);
2068
2069        let custom_markers = self.custom_lsp_root_markers();
2070
2071        if !wants_diagnostics {
2072            if let Some(file_paths) = Self::multi_file_write_paths(params) {
2073                self.notify_watched_config_files(&file_paths);
2074            } else if let Some(config_events) =
2075                Self::watched_file_events_from_params(params, &custom_markers)
2076            {
2077                self.notify_watched_config_events(&config_events);
2078            }
2079            self.lsp_notify_file_changed(file_path, content);
2080            return None;
2081        }
2082
2083        let wait_ms = params
2084            .get("wait_ms")
2085            .and_then(|v| v.as_u64())
2086            .unwrap_or(3000)
2087            .min(10_000); // Cap at 10 seconds to prevent hangs from adversarial input
2088
2089        if let Some(file_paths) = Self::multi_file_write_paths(params) {
2090            return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
2091        }
2092
2093        if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
2094        {
2095            self.notify_watched_config_events(&config_events);
2096        }
2097
2098        Some(self.lsp_notify_and_collect_diagnostics(
2099            file_path,
2100            content,
2101            std::time::Duration::from_millis(wait_ms),
2102        ))
2103    }
2104
2105    /// Validate that a file path falls within the configured project root.
2106    ///
2107    /// When `project_root` is configured (normal plugin usage), this resolves the
2108    /// path and checks it starts with the root. Returns the canonicalized path on
2109    /// success, or an error response on violation.
2110    ///
2111    /// When no `project_root` is configured (direct CLI usage), all paths pass
2112    /// through unrestricted for backward compatibility.
2113    pub fn validate_path(
2114        &self,
2115        req_id: &str,
2116        path: &Path,
2117    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
2118        let config = self.config();
2119        // When restrict_to_project_root is false (default), allow all paths
2120        if !config.restrict_to_project_root {
2121            return Ok(path.to_path_buf());
2122        }
2123        let root = match &config.project_root {
2124            Some(r) => r.clone(),
2125            None => return Ok(path.to_path_buf()), // No root configured, allow all
2126        };
2127        drop(config);
2128
2129        // Keep the raw root for symlink-guard comparisons. On macOS, tempdir()
2130        // returns /var/... paths while canonicalize gives /private/var/...; we
2131        // need both forms so reject_escaping_symlink can recognise in-root
2132        // symlinks regardless of which prefix form `current` happens to have.
2133        let raw_root = root.clone();
2134        let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
2135
2136        // Resolve the path (follow symlinks, normalize ..). If canonicalization
2137        // fails (e.g. path does not exist or traverses a broken symlink), inspect
2138        // every existing component with lstat before falling back lexically so a
2139        // broken in-root symlink cannot be used to write outside project_root.
2140        let path_for_resolution = if path.is_relative() {
2141            raw_root.join(path)
2142        } else {
2143            path.to_path_buf()
2144        };
2145        let resolved = match std::fs::canonicalize(&path_for_resolution) {
2146            Ok(resolved) => resolved,
2147            Err(_) => {
2148                let normalized = normalize_path(&path_for_resolution);
2149                reject_escaping_symlink(
2150                    req_id,
2151                    &path_for_resolution,
2152                    &normalized,
2153                    &resolved_root,
2154                    &raw_root,
2155                )?;
2156                resolve_with_existing_ancestors(&normalized)
2157            }
2158        };
2159
2160        if !resolved.starts_with(&resolved_root) {
2161            return Err(path_error_response(req_id, path, &resolved_root));
2162        }
2163
2164        Ok(resolved)
2165    }
2166
2167    /// Count active LSP server instances.
2168    pub fn lsp_server_count(&self) -> usize {
2169        self.lsp_manager
2170            .try_borrow()
2171            .map(|lsp| lsp.server_count())
2172            .unwrap_or(0)
2173    }
2174
2175    /// Symbol cache statistics from the language provider.
2176    pub fn symbol_cache_stats(&self) -> serde_json::Value {
2177        let entries = self
2178            .symbol_cache
2179            .read()
2180            .map(|cache| cache.len())
2181            .unwrap_or(0);
2182        serde_json::json!({
2183            "local_entries": entries,
2184            "warm_entries": 0,
2185        })
2186    }
2187}
2188
2189#[cfg(test)]
2190mod status_emitter_tests {
2191    use super::*;
2192    use crate::parser::TreeSitterProvider;
2193
2194    fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
2195        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2196        let (tx, rx) = mpsc::channel();
2197        ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
2198            let _ = tx.send(frame);
2199        }))));
2200        (ctx, rx)
2201    }
2202
2203    #[test]
2204    fn status_emitter_signal_triggers_push() {
2205        let (ctx, rx) = ctx_with_frame_rx();
2206        ctx.status_emitter().signal(ctx.build_status_snapshot());
2207        let frame = rx
2208            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2209            .expect("status_changed push");
2210        assert!(matches!(frame, PushFrame::StatusChanged(_)));
2211    }
2212
2213    #[test]
2214    fn status_emitter_debounces_burst() {
2215        let (ctx, rx) = ctx_with_frame_rx();
2216        for _ in 0..10 {
2217            ctx.status_emitter().signal(ctx.build_status_snapshot());
2218        }
2219        let frame = rx
2220            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2221            .expect("status_changed push");
2222        assert!(matches!(frame, PushFrame::StatusChanged(_)));
2223        assert!(rx.try_recv().is_err());
2224    }
2225
2226    #[test]
2227    fn status_emitter_separate_windows_separate_pushes() {
2228        let (ctx, rx) = ctx_with_frame_rx();
2229        ctx.status_emitter().signal(ctx.build_status_snapshot());
2230        rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2231            .expect("first push");
2232        ctx.status_emitter().signal(ctx.build_status_snapshot());
2233        rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2234            .expect("second push");
2235    }
2236
2237    #[test]
2238    fn status_emitter_no_signal_no_push() {
2239        let (_ctx, rx) = ctx_with_frame_rx();
2240        assert!(rx
2241            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
2242            .is_err());
2243    }
2244
2245    #[test]
2246    fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
2247        let (ctx, rx) = ctx_with_frame_rx();
2248        drop(ctx);
2249        assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
2250    }
2251}
2252
2253#[cfg(test)]
2254mod status_bar_tests {
2255    use super::*;
2256    use crate::parser::TreeSitterProvider;
2257
2258    fn ctx() -> AppContext {
2259        AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
2260    }
2261
2262    #[test]
2263    fn status_bar_counts_none_until_tier2_populated() {
2264        let ctx = ctx();
2265        // No scan has run yet — never surface a bar claiming "0 dead code".
2266        assert!(ctx.status_bar_counts().is_none());
2267
2268        ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
2269        let counts = ctx.status_bar_counts().expect("populated");
2270        assert_eq!(counts.dead_code, 5);
2271        assert_eq!(counts.unused_exports, 3);
2272        assert_eq!(counts.duplicates, 7);
2273        assert_eq!(counts.todos, 2);
2274        assert!(!counts.tier2_stale);
2275        // Errors/warnings are read live from an empty LSP store → 0.
2276        assert_eq!(counts.errors, 0);
2277        assert_eq!(counts.warnings, 0);
2278    }
2279
2280    #[test]
2281    fn partial_tier2_does_not_fabricate_zeros() {
2282        let ctx = ctx();
2283        // Only dead_code has completed (the slow first serial category); the
2284        // other two are still in flight. The bar must stay suppressed rather
2285        // than render `D5 U0 C0` with fabricated zeros (#1).
2286        ctx.update_status_bar_tier2(Some(5), None, None, None, true);
2287        assert!(
2288            ctx.status_bar_counts().is_none(),
2289            "bar must not surface until all three Tier-2 categories are real"
2290        );
2291
2292        // Second category completes — still incomplete, still suppressed.
2293        ctx.update_status_bar_tier2(None, Some(3), None, None, true);
2294        assert!(ctx.status_bar_counts().is_none());
2295
2296        // Final category completes → bar surfaces with all real counts, and
2297        // none of them were ever fabricated.
2298        ctx.update_status_bar_tier2(None, None, Some(7), None, false);
2299        let counts = ctx.status_bar_counts().expect("all three real now");
2300        assert_eq!(counts.dead_code, 5);
2301        assert_eq!(counts.unused_exports, 3);
2302        assert_eq!(counts.duplicates, 7);
2303    }
2304
2305    #[test]
2306    fn update_with_none_todos_preserves_last_known_todos() {
2307        let ctx = ctx();
2308        ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
2309        // A background-scan refresh passes todos=None → todo count preserved.
2310        ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
2311        let counts = ctx.status_bar_counts().expect("populated");
2312        assert_eq!(counts.todos, 9);
2313        assert_eq!(counts.dead_code, 2);
2314    }
2315
2316    #[test]
2317    fn update_with_none_count_preserves_last_known_count() {
2318        let ctx = ctx();
2319        ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
2320        // A refresh that only recomputed dead_code preserves the other two
2321        // real counts rather than overwriting them with a fabricated 0.
2322        ctx.update_status_bar_tier2(Some(11), None, None, None, false);
2323        let counts = ctx.status_bar_counts().expect("populated");
2324        assert_eq!(counts.dead_code, 11);
2325        assert_eq!(counts.unused_exports, 20);
2326        assert_eq!(counts.duplicates, 30);
2327    }
2328
2329    #[test]
2330    fn mark_stale_sets_flag_only_after_populate() {
2331        let ctx = ctx();
2332        // No-op before first populate.
2333        ctx.mark_status_bar_tier2_stale();
2334        assert!(ctx.status_bar_counts().is_none());
2335
2336        ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), Some(0), false);
2337        ctx.mark_status_bar_tier2_stale();
2338        assert!(ctx.status_bar_counts().expect("populated").tier2_stale);
2339
2340        // A completed scan clears stale.
2341        ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), None, false);
2342        assert!(!ctx.status_bar_counts().expect("populated").tier2_stale);
2343    }
2344
2345    // End-to-end wiring: a diagnostic for a file inflates the status-bar `E`
2346    // count (read live from the warm LSP set); clearing that file's diagnostics
2347    // (the deleted-file path) drops it back. This is the AppContext glue between
2348    // the watcher-drain clear and the agent-visible bar.
2349    #[test]
2350    fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
2351        use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
2352        use crate::lsp::registry::ServerKind;
2353        use crate::lsp::roots::ServerKey;
2354
2355        let ctx = ctx();
2356        ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); // populate so the bar surfaces
2357
2358        let file = std::path::PathBuf::from("/proj/gone.ts");
2359        {
2360            let mut lsp = ctx.lsp();
2361            lsp.diagnostics_store_mut_for_test().publish(
2362                ServerKey {
2363                    kind: ServerKind::TypeScript,
2364                    root: std::path::PathBuf::from("/proj"),
2365                },
2366                file.clone(),
2367                vec![StoredDiagnostic {
2368                    file: file.clone(),
2369                    line: 1,
2370                    column: 1,
2371                    end_line: 1,
2372                    end_column: 2,
2373                    severity: DiagnosticSeverity::Error,
2374                    message: "boom".into(),
2375                    code: None,
2376                    source: None,
2377                }],
2378            );
2379        }
2380
2381        // Bar reflects the live warm-set error.
2382        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
2383
2384        // Clearing the (now-deleted) file's diagnostics drops the count.
2385        let removed = ctx.lsp_clear_diagnostics_for_file(&file);
2386        assert!(removed);
2387        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
2388    }
2389}
2390
2391#[cfg(test)]
2392mod harness_path_tests {
2393    use super::*;
2394    use crate::harness::Harness;
2395    use crate::parser::TreeSitterProvider;
2396
2397    fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
2398        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2399        ctx.config_mut().storage_dir = Some(storage_dir);
2400        ctx.set_harness(harness);
2401        ctx
2402    }
2403
2404    #[test]
2405    fn harness_dir_resolves_correctly() {
2406        let storage = PathBuf::from("/tmp/cortexkit/aft");
2407        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2408
2409        assert_eq!(ctx.harness_dir(), storage.join("pi"));
2410    }
2411
2412    #[test]
2413    fn bash_tasks_dir_uses_hash_session() {
2414        let storage = PathBuf::from("/tmp/cortexkit/aft");
2415        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2416
2417        assert_eq!(
2418            ctx.bash_tasks_dir("ses_abc"),
2419            storage
2420                .join("opencode")
2421                .join("bash-tasks")
2422                .join(hash_session("ses_abc"))
2423        );
2424    }
2425
2426    #[test]
2427    fn backups_dir_includes_path_hash() {
2428        let storage = PathBuf::from("/tmp/cortexkit/aft");
2429        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2430
2431        assert_eq!(
2432            ctx.backups_dir("ses_abc", "pathhash"),
2433            storage
2434                .join("pi")
2435                .join("backups")
2436                .join(hash_session("ses_abc"))
2437                .join("pathhash")
2438        );
2439    }
2440
2441    #[test]
2442    fn filters_dir_under_harness() {
2443        let storage = PathBuf::from("/tmp/cortexkit/aft");
2444        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2445
2446        assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
2447    }
2448
2449    #[test]
2450    fn trust_file_is_host_global() {
2451        let storage = PathBuf::from("/tmp/cortexkit/aft");
2452        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2453
2454        assert_eq!(
2455            ctx.trust_file(),
2456            storage.join("trusted-filter-projects.json")
2457        );
2458    }
2459
2460    #[test]
2461    fn same_session_different_harness_resolve_different_paths() {
2462        let storage = PathBuf::from("/tmp/cortexkit/aft");
2463        let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2464        let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
2465
2466        assert_ne!(
2467            opencode.bash_tasks_dir("ses_same"),
2468            pi.bash_tasks_dir("ses_same")
2469        );
2470    }
2471}
2472
2473#[cfg(test)]
2474mod gitignore_tests {
2475    use super::*;
2476    use std::fs;
2477    use std::path::Path;
2478    use tempfile::TempDir;
2479
2480    fn make_ctx_with_root(root: &Path) -> AppContext {
2481        let provider = Box::new(crate::parser::TreeSitterProvider::new());
2482        let config = Config {
2483            project_root: Some(root.to_path_buf()),
2484            ..Config::default()
2485        };
2486        AppContext::new(provider, config)
2487    }
2488
2489    /// Helper: returns true when the matcher would skip `path` (as if it
2490    /// arrived via a watcher event for this project root). Canonicalizes
2491    /// the query path so symlink prefixes (e.g. macOS `/var` → `/private/var`)
2492    /// don't trip the `ignore` crate's "path is expected to be under the
2493    /// root" panic — production code does the same guard via
2494    /// `path.starts_with(matcher.path())` in `drain_watcher_events`.
2495    fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
2496        let Some(matcher) = ctx.gitignore() else {
2497            return false;
2498        };
2499        let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
2500        if !canonical.starts_with(matcher.path()) {
2501            return false;
2502        }
2503        let is_dir = canonical.is_dir();
2504        matcher
2505            .matched_path_or_any_parents(&canonical, is_dir)
2506            .is_ignore()
2507    }
2508
2509    /// Run `f` with global git-ignore discovery neutralized.
2510    ///
2511    /// `rebuild_gitignore` loads git's global excludes (the `ignore` crate
2512    /// resolves `$XDG_CONFIG_HOME/git/ignore`, falling back to
2513    /// `$HOME/.config/git/ignore`). A developer machine commonly has that file,
2514    /// so a "no project ignore → None" assertion is only deterministic when
2515    /// global discovery is pointed at an empty directory. Pointing
2516    /// `XDG_CONFIG_HOME` at a fresh tempdir does that without touching `HOME`
2517    /// (so it can't race the `HOME`-mutating configure tests). Serialized by a
2518    /// process-local mutex; env is restored before the closure result is used.
2519    fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
2520        use std::sync::{Mutex, OnceLock};
2521        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2522        let _guard = LOCK
2523            .get_or_init(|| Mutex::new(()))
2524            .lock()
2525            .unwrap_or_else(|e| e.into_inner());
2526        let tmp = TempDir::new().unwrap();
2527        let prev = std::env::var_os("XDG_CONFIG_HOME");
2528        // SAFETY: serialized by LOCK above; restored immediately after `f`.
2529        unsafe {
2530            std::env::set_var("XDG_CONFIG_HOME", tmp.path());
2531        }
2532        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
2533        unsafe {
2534            match prev {
2535                Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
2536                None => std::env::remove_var("XDG_CONFIG_HOME"),
2537            }
2538        }
2539        match result {
2540            Ok(r) => r,
2541            Err(p) => std::panic::resume_unwind(p),
2542        }
2543    }
2544
2545    #[test]
2546    fn rebuild_gitignore_returns_none_without_project_root() {
2547        let provider = Box::new(crate::parser::TreeSitterProvider::new());
2548        let ctx = AppContext::new(provider, Config::default());
2549        with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2550        assert!(ctx.gitignore().is_none());
2551    }
2552
2553    #[test]
2554    fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
2555        let tmp = TempDir::new().unwrap();
2556        let ctx = make_ctx_with_root(tmp.path());
2557        with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2558        assert!(ctx.gitignore().is_none());
2559    }
2560
2561    #[test]
2562    fn matcher_filters_files_in_ignored_dist_dir() {
2563        let tmp = TempDir::new().unwrap();
2564        fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
2565        fs::create_dir_all(tmp.path().join("dist")).unwrap();
2566        fs::create_dir_all(tmp.path().join("src")).unwrap();
2567        let dist_file = tmp.path().join("dist").join("bundle.js");
2568        let src_file = tmp.path().join("src").join("app.ts");
2569        fs::write(&dist_file, "x").unwrap();
2570        fs::write(&src_file, "y").unwrap();
2571
2572        let ctx = make_ctx_with_root(tmp.path());
2573        ctx.rebuild_gitignore();
2574
2575        assert!(ctx.gitignore().is_some());
2576        assert!(
2577            is_ignored(&ctx, &dist_file),
2578            "dist/bundle.js should be ignored"
2579        );
2580        assert!(
2581            !is_ignored(&ctx, &src_file),
2582            "src/app.ts should NOT be ignored"
2583        );
2584    }
2585
2586    #[test]
2587    fn matcher_handles_node_modules_and_target() {
2588        let tmp = TempDir::new().unwrap();
2589        fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
2590        fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
2591        fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
2592        let nm_file = tmp.path().join("node_modules/foo/index.js");
2593        let target_file = tmp.path().join("target/debug/aft");
2594        fs::write(&nm_file, "x").unwrap();
2595        fs::write(&target_file, "x").unwrap();
2596
2597        let ctx = make_ctx_with_root(tmp.path());
2598        ctx.rebuild_gitignore();
2599
2600        assert!(is_ignored(&ctx, &nm_file));
2601        assert!(is_ignored(&ctx, &target_file));
2602    }
2603
2604    #[test]
2605    fn matcher_honors_negation_pattern() {
2606        // .gitignore: ignore all *.log files EXCEPT important.log
2607        let tmp = TempDir::new().unwrap();
2608        fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
2609        let random_log = tmp.path().join("random.log");
2610        let important_log = tmp.path().join("important.log");
2611        fs::write(&random_log, "x").unwrap();
2612        fs::write(&important_log, "y").unwrap();
2613
2614        let ctx = make_ctx_with_root(tmp.path());
2615        ctx.rebuild_gitignore();
2616
2617        assert!(is_ignored(&ctx, &random_log));
2618        assert!(
2619            !is_ignored(&ctx, &important_log),
2620            "negation pattern should un-ignore important.log"
2621        );
2622    }
2623
2624    #[test]
2625    fn rebuild_picks_up_gitignore_changes() {
2626        let tmp = TempDir::new().unwrap();
2627        let ignore_path = tmp.path().join(".gitignore");
2628        fs::write(&ignore_path, "foo.txt\n").unwrap();
2629        let foo = tmp.path().join("foo.txt");
2630        let bar = tmp.path().join("bar.txt");
2631        fs::write(&foo, "").unwrap();
2632        fs::write(&bar, "").unwrap();
2633
2634        let ctx = make_ctx_with_root(tmp.path());
2635        ctx.rebuild_gitignore();
2636        assert!(is_ignored(&ctx, &foo));
2637        assert!(!is_ignored(&ctx, &bar));
2638
2639        // Now flip the rules: ignore bar.txt instead of foo.txt
2640        fs::write(&ignore_path, "bar.txt\n").unwrap();
2641        ctx.rebuild_gitignore();
2642        assert!(!is_ignored(&ctx, &foo));
2643        assert!(is_ignored(&ctx, &bar));
2644    }
2645
2646    #[test]
2647    fn gitignore_loads_info_exclude_when_present() {
2648        let tmp = TempDir::new().unwrap();
2649        let info_dir = tmp.path().join(".git/info");
2650        fs::create_dir_all(&info_dir).unwrap();
2651        fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
2652        let secrets = tmp.path().join("secrets.txt");
2653        let public = tmp.path().join("public.txt");
2654        fs::write(&secrets, "token").unwrap();
2655        fs::write(&public, "ok").unwrap();
2656
2657        let ctx = make_ctx_with_root(tmp.path());
2658        ctx.rebuild_gitignore();
2659
2660        assert!(is_ignored(&ctx, &secrets));
2661        assert!(!is_ignored(&ctx, &public));
2662    }
2663
2664    #[test]
2665    fn matcher_picks_up_nested_gitignore() {
2666        let tmp = TempDir::new().unwrap();
2667        // Root .gitignore is intentionally empty — only the nested one ignores
2668        fs::write(tmp.path().join(".gitignore"), "").unwrap();
2669        let sub = tmp.path().join("packages/foo");
2670        fs::create_dir_all(&sub).unwrap();
2671        fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
2672        let generated_file = sub.join("generated").join("out.js");
2673        fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
2674        fs::write(&generated_file, "x").unwrap();
2675
2676        let ctx = make_ctx_with_root(tmp.path());
2677        ctx.rebuild_gitignore();
2678
2679        assert!(
2680            is_ignored(&ctx, &generated_file),
2681            "nested gitignore in packages/foo/.gitignore should ignore generated/"
2682        );
2683    }
2684}