Skip to main content

aft/
context.rs

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