Skip to main content

aft/
context.rs

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