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