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