Skip to main content

aft/
context.rs

1use std::cell::{Ref, RefCell, RefMut};
2use std::collections::{BTreeMap, BTreeSet};
3use std::io::{self, BufWriter};
4use std::path::{Component, Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{mpsc, Arc, Mutex, OnceLock};
7use std::time::{Duration, Instant};
8
9use lsp_types::FileChangeType;
10use notify::RecommendedWatcher;
11use rusqlite::Connection;
12
13use crate::backup::hash_session;
14use crate::backup::BackupStore;
15use crate::bash_background::{BgCompletion, BgTaskRegistry};
16use crate::callgraph::CallGraph;
17use crate::checkpoint::CheckpointStore;
18use crate::config::Config;
19use crate::harness::Harness;
20use crate::inspect::{
21    InspectCategory, InspectManager, InspectSnapshot, Tier2RefreshScheduler, Tier2TriggerReason,
22};
23use crate::language::LanguageProvider;
24use crate::lsp::manager::LspManager;
25use crate::lsp::registry::is_config_file_path_with_custom;
26use crate::parser::{SharedSymbolCache, SymbolCache};
27use crate::protocol::{
28    ConfigureWarningsFrame, ProgressFrame, PushFrame, StatusChangedFrame, StatusPayload,
29};
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)]
131struct SemanticRefreshAccounting {
132    pending: usize,
133    in_flight: usize,
134}
135
136static SEMANTIC_REFRESH_ACCOUNTING: OnceLock<Mutex<BTreeMap<PathBuf, SemanticRefreshAccounting>>> =
137    OnceLock::new();
138
139fn semantic_refresh_accounting() -> &'static Mutex<BTreeMap<PathBuf, SemanticRefreshAccounting>> {
140    SEMANTIC_REFRESH_ACCOUNTING.get_or_init(|| Mutex::new(BTreeMap::new()))
141}
142
143fn clear_semantic_refresh_accounting() {
144    if let Some(accounting) = SEMANTIC_REFRESH_ACCOUNTING.get() {
145        if let Ok(mut accounting) = accounting.lock() {
146            accounting.clear();
147        }
148    }
149}
150
151fn ensure_refreshing_path(refreshing: &mut Vec<PathBuf>, path: PathBuf) {
152    if !refreshing.iter().any(|existing| existing == &path) {
153        refreshing.push(path);
154        refreshing.sort();
155    }
156}
157
158fn remove_refreshing_path(refreshing: &mut Vec<PathBuf>, path: &Path) {
159    refreshing.retain(|existing| existing != path);
160}
161
162#[derive(Debug, Clone)]
163pub enum SemanticIndexStatus {
164    Disabled,
165    Building {
166        /// Cold-build only — index is not queryable.
167        stage: String,
168        files: Option<usize>,
169        entries_done: Option<usize>,
170        entries_total: Option<usize>,
171    },
172    Ready {
173        /// Files currently being re-embedded after recent edits. The index is
174        /// still queryable; results for these files may be temporarily missing.
175        refreshing: Vec<PathBuf>,
176    },
177    Failed(String),
178}
179
180impl SemanticIndexStatus {
181    pub fn ready() -> Self {
182        clear_semantic_refresh_accounting();
183        Self::Ready {
184            refreshing: Vec::new(),
185        }
186    }
187
188    pub fn add_refreshing_file(&mut self, path: PathBuf) {
189        if let Self::Ready { refreshing } = self {
190            if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
191                let state = accounting.entry(path.clone()).or_default();
192                state.pending = state.pending.saturating_add(1);
193            }
194            ensure_refreshing_path(refreshing, path);
195        }
196    }
197
198    pub fn start_refreshing_file(&mut self, path: PathBuf) {
199        if let Self::Ready { refreshing } = self {
200            if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
201                let state = accounting.entry(path.clone()).or_default();
202                if state.pending == 0 {
203                    state.pending = 1;
204                }
205                if state.in_flight == 0 {
206                    state.in_flight = state.pending;
207                }
208            }
209            ensure_refreshing_path(refreshing, path);
210        }
211    }
212
213    pub fn cancel_refreshing_file(&mut self, path: &Path) {
214        self.finish_refreshing_file(path, false);
215    }
216
217    pub fn complete_refreshing_file(&mut self, path: &Path) {
218        self.finish_refreshing_file(path, true);
219    }
220
221    pub fn remove_refreshing_file(&mut self, path: &Path) {
222        self.complete_refreshing_file(path);
223    }
224
225    fn finish_refreshing_file(&mut self, path: &Path, complete_in_flight: bool) {
226        if let Self::Ready { refreshing } = self {
227            let mut keep_refreshing = false;
228            let mut accounting_checked = false;
229            if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
230                accounting_checked = true;
231                if let Some(state) = accounting.get_mut(path) {
232                    let finished = if complete_in_flight {
233                        state.in_flight.max(1)
234                    } else {
235                        1
236                    };
237                    state.pending = state.pending.saturating_sub(finished);
238                    if complete_in_flight {
239                        state.in_flight = 0;
240                    } else {
241                        state.in_flight = state.in_flight.min(state.pending);
242                    }
243                    keep_refreshing = state.pending > 0;
244                    if !keep_refreshing {
245                        accounting.remove(path);
246                    }
247                }
248            }
249
250            if !accounting_checked || !keep_refreshing {
251                remove_refreshing_path(refreshing, path);
252            }
253        }
254    }
255
256    pub fn refreshing_count(&self) -> usize {
257        match self {
258            Self::Ready { refreshing } => refreshing.len(),
259            _ => 0,
260        }
261    }
262}
263
264pub enum SemanticIndexEvent {
265    Progress {
266        stage: String,
267        files: Option<usize>,
268        entries_done: Option<usize>,
269        entries_total: Option<usize>,
270    },
271    Ready(SemanticIndex),
272    Failed(String),
273}
274
275#[derive(Debug, Clone)]
276pub enum SemanticRefreshRequest {
277    Files { paths: Vec<PathBuf> },
278    Corpus { current_files: Vec<PathBuf> },
279}
280
281#[derive(Debug)]
282pub enum SemanticRefreshEvent {
283    Started {
284        paths: Vec<PathBuf>,
285    },
286    Completed {
287        added_entries: Vec<EmbeddingEntry>,
288        updated_metadata: Vec<(PathBuf, FileFreshness)>,
289        completed_paths: Vec<PathBuf>,
290    },
291    CorpusCompleted {
292        index: SemanticIndex,
293        changed: usize,
294        added: usize,
295        deleted: usize,
296        total_processed: usize,
297    },
298    Failed {
299        paths: Vec<PathBuf>,
300        error: String,
301    },
302    CorpusFailed {
303        error: String,
304    },
305}
306
307pub type SemanticRefreshWorkerSlot = Arc<Mutex<Option<std::thread::JoinHandle<()>>>>;
308
309/// Normalize a path by resolving `.` and `..` components lexically,
310/// without touching the filesystem. This prevents path traversal
311/// attacks when `fs::canonicalize` fails (e.g. for non-existent paths).
312fn normalize_path(path: &Path) -> PathBuf {
313    let mut result = PathBuf::new();
314    for component in path.components() {
315        match component {
316            Component::ParentDir => {
317                // Pop the last component unless we're at root or have no components
318                if !result.pop() {
319                    result.push(component);
320                }
321            }
322            Component::CurDir => {} // Skip `.`
323            _ => result.push(component),
324        }
325    }
326    result
327}
328
329fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
330    let mut existing = path.to_path_buf();
331    let mut tail_segments = Vec::new();
332
333    while !existing.exists() {
334        if let Some(name) = existing.file_name() {
335            tail_segments.push(name.to_owned());
336        } else {
337            break;
338        }
339
340        existing = match existing.parent() {
341            Some(parent) => parent.to_path_buf(),
342            None => break,
343        };
344    }
345
346    let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
347    for segment in tail_segments.into_iter().rev() {
348        resolved.push(segment);
349    }
350
351    resolved
352}
353
354fn path_error_response(
355    req_id: &str,
356    path: &Path,
357    resolved_root: &Path,
358) -> crate::protocol::Response {
359    crate::protocol::Response::error(
360        req_id,
361        "path_outside_root",
362        format!(
363            "path '{}' is outside the project root '{}'",
364            path.display(),
365            resolved_root.display()
366        ),
367    )
368}
369
370/// Walk `candidate` component-by-component. For any component that is a
371/// symlink on disk, iteratively follow the full chain (up to 40 hops) and
372/// reject if any hop's resolved target lies outside `resolved_root`.
373///
374/// This is the fallback path used when `fs::canonicalize` fails (e.g. on
375/// Linux with broken symlink chains pointing to non-existent destinations).
376/// On macOS `canonicalize` also fails for broken symlinks but the returned
377/// `/var/...` tempdir paths diverge from `resolved_root`'s `/private/var/...`
378/// form, so we must accept either form when deciding which symlinks to check.
379fn reject_escaping_symlink(
380    req_id: &str,
381    original_path: &Path,
382    candidate: &Path,
383    resolved_root: &Path,
384    raw_root: &Path,
385) -> Result<(), crate::protocol::Response> {
386    let mut current = PathBuf::new();
387
388    for component in candidate.components() {
389        current.push(component);
390
391        let Ok(metadata) = std::fs::symlink_metadata(&current) else {
392            continue;
393        };
394
395        if !metadata.file_type().is_symlink() {
396            continue;
397        }
398
399        // Only check symlinks that live inside the project root. This skips
400        // OS-level prefix symlinks (macOS /var → /private/var) that are not
401        // inside our project directory and whose "escaping" is harmless.
402        //
403        // We compare against BOTH the canonicalized root (resolved_root, e.g.
404        // /private/var/.../project) AND the raw root (e.g. /var/.../project)
405        // because tempdir() returns raw paths while fs::canonicalize returns
406        // the resolved form — and our `current` may be in either form.
407        let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
408        if !inside_root {
409            continue;
410        }
411
412        iterative_follow_chain(req_id, original_path, &current, resolved_root)?;
413    }
414
415    Ok(())
416}
417
418/// Iteratively follow a symlink chain from `link` and reject if any hop's
419/// resolved target is outside `resolved_root`. Depth-capped at 40 hops.
420fn iterative_follow_chain(
421    req_id: &str,
422    original_path: &Path,
423    start: &Path,
424    resolved_root: &Path,
425) -> Result<(), crate::protocol::Response> {
426    let mut link = start.to_path_buf();
427    let mut depth = 0usize;
428
429    loop {
430        if depth > 40 {
431            return Err(path_error_response(req_id, original_path, resolved_root));
432        }
433
434        let target = match std::fs::read_link(&link) {
435            Ok(t) => t,
436            Err(_) => {
437                // Can't read the link — treat as escaping to be safe.
438                return Err(path_error_response(req_id, original_path, resolved_root));
439            }
440        };
441
442        let resolved_target = if target.is_absolute() {
443            normalize_path(&target)
444        } else {
445            let parent = link.parent().unwrap_or_else(|| Path::new(""));
446            normalize_path(&parent.join(&target))
447        };
448
449        // Check boundary: use canonicalized target when available (handles
450        // macOS /var → /private/var aliasing), fall back to the normalized
451        // path when canonicalize fails (e.g. broken symlink on Linux).
452        let canonical_target =
453            std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
454
455        if !canonical_target.starts_with(resolved_root)
456            && !resolved_target.starts_with(resolved_root)
457        {
458            return Err(path_error_response(req_id, original_path, resolved_root));
459        }
460
461        // If the target is itself a symlink, follow the next hop.
462        match std::fs::symlink_metadata(&resolved_target) {
463            Ok(meta) if meta.file_type().is_symlink() => {
464                link = resolved_target;
465                depth += 1;
466            }
467            _ => break, // Non-symlink or non-existent target — chain ends here.
468        }
469    }
470
471    Ok(())
472}
473
474/// Shared application context threaded through all command handlers.
475///
476/// Holds the language provider, backup/checkpoint stores, configuration,
477/// and call graph engine. Constructed once at startup and passed by
478/// reference to `dispatch`.
479///
480/// Stores use `RefCell` for interior mutability — the binary is single-threaded
481/// (one request at a time on the stdin read loop) so runtime borrow checking
482/// is safe and never contended.
483pub struct AppContext {
484    provider: Box<dyn LanguageProvider>,
485    backup: RefCell<BackupStore>,
486    checkpoint: RefCell<CheckpointStore>,
487    db: RefCell<Option<Arc<Mutex<Connection>>>>,
488    config: RefCell<Config>,
489    pub harness: RefCell<Option<Harness>>,
490    canonical_cache_root: RefCell<Option<PathBuf>>,
491    is_worktree_bridge: RefCell<bool>,
492    git_common_dir: RefCell<Option<PathBuf>>,
493    /// Reasons (if any) why heavy AFT subsystems were auto-disabled for the
494    /// current project root. Populated by `handle_configure` based on the
495    /// canonical project root and synchronous file count. Each reason is a
496    /// stable machine-readable string suffix (`"home_root"`,
497    /// `"search_too_many_files:N"`, etc.) so the plugin can render distinct
498    /// degraded-mode UI states without re-deriving the reason locally.
499    /// Empty when the project is healthy / full-featured.
500    degraded_reasons: RefCell<Vec<String>>,
501    callgraph: RefCell<Option<CallGraph>>,
502    search_index: RefCell<Option<SearchIndex>>,
503    search_index_rx: RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>>,
504    pending_search_index_paths: RefCell<BTreeSet<PathBuf>>,
505    symbol_cache: SharedSymbolCache,
506    inspect_manager: Arc<InspectManager>,
507    tier2_refresh_scheduler: RefCell<Tier2RefreshScheduler>,
508    semantic_index: RefCell<Option<SemanticIndex>>,
509    semantic_index_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
510    semantic_index_status: RefCell<SemanticIndexStatus>,
511    pending_semantic_index_paths: RefCell<BTreeSet<PathBuf>>,
512    pending_semantic_corpus_refresh: RefCell<bool>,
513    semantic_refresh_tx: RefCell<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>,
514    semantic_refresh_event_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
515    semantic_refresh_worker: RefCell<Option<SemanticRefreshWorkerSlot>>,
516    semantic_embedding_model: RefCell<Option<crate::semantic_index::EmbeddingModel>>,
517    watcher: RefCell<Option<RecommendedWatcher>>,
518    watcher_rx: RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>>,
519    lsp_manager: RefCell<LspManager>,
520    /// Shared registry of LSP child PIDs. Cloned and passed to the signal
521    /// handler so it can SIGKILL all children before aft exits, preventing
522    /// orphaned LSP processes when bridge.shutdown() SIGTERMs aft.
523    lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
524    stdout_writer: SharedStdoutWriter,
525    progress_sender: SharedProgressSender,
526    configure_generation: AtomicU64,
527    /// Last-seen value of `InspectManager::reuse_completion_count()`, so the
528    /// per-request inspect drain can detect watcher-driven Tier-2 scans that
529    /// finished since the previous tick and refresh the status bar (#3).
530    last_seen_reuse_completions: AtomicU64,
531    configure_warnings_tx: mpsc::Sender<(u64, ConfigureWarningsFrame)>,
532    configure_warnings_rx: mpsc::Receiver<(u64, ConfigureWarningsFrame)>,
533    status_emitter: StatusEmitter,
534    bash_background: BgTaskRegistry,
535    /// Thread-safe registry of TOML output filters. Lazy-built on first
536    /// access; populated atomically via `RwLock`. Shared between command
537    /// handlers (which use it through `filter_registry()` -> read guard) and
538    /// the `BgTaskRegistry` watchdog thread (which uses it through
539    /// `compress::compress_with_registry`). Reloaded when configure changes
540    /// the project root or storage_dir; see [`AppContext::reset_filter_registry`].
541    filter_registry: crate::compress::SharedFilterRegistry,
542    /// Set to true once the filter_registry has been populated. Avoids
543    /// double-loading on hot paths without holding a write lock.
544    filter_registry_loaded: std::sync::atomic::AtomicBool,
545    /// Live `experimental.bash.compress` flag, kept in sync with `config`
546    /// from the configure handler. Exposed via [`AppContext::bash_compress_flag`]
547    /// so the BgTaskRegistry's watchdog-thread compressor can read it without
548    /// holding the config refcell.
549    bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
550    /// Project gitignore matcher, rebuilt by [`AppContext::rebuild_gitignore`]
551    /// whenever `project_root` changes or a watcher event reports a
552    /// `.gitignore` write. Used by the watcher event filter to decide which
553    /// path-changes are interesting to AFT's caches. `None` when no project
554    /// root is configured or when the project has no gitignore files; in that
555    /// case the watcher falls back to a small hardcoded infra-directory skip.
556    gitignore: RefCell<Option<Arc<ignore::gitignore::Gitignore>>>,
557    /// Last-known Tier-2 + todos counts for the agent status bar, refreshed off
558    /// the hot path (on `aft_inspect` reads and background Tier-2 completions).
559    /// Errors/warnings are read live and not stored here.
560    status_bar_tier2: RefCell<StatusBarTier2>,
561}
562
563impl AppContext {
564    pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
565        let bash_compress_enabled = config.experimental_bash_compress;
566        let progress_sender = Arc::new(Mutex::new(None));
567        let stdout_writer = Arc::new(Mutex::new(BufWriter::new(io::stdout())));
568        let (configure_warnings_tx, configure_warnings_rx) = mpsc::channel();
569        let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
570        let symbol_cache = provider
571            .as_any()
572            .downcast_ref::<crate::parser::TreeSitterProvider>()
573            .map(|provider| provider.symbol_cache())
574            .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
575        let lsp_child_registry = crate::lsp::child_registry::LspChildRegistry::new();
576        let mut lsp_manager = LspManager::new();
577        lsp_manager.set_child_registry(lsp_child_registry.clone());
578        AppContext {
579            provider,
580            backup: RefCell::new(BackupStore::new()),
581            checkpoint: RefCell::new(CheckpointStore::new()),
582            db: RefCell::new(None),
583            config: RefCell::new(config),
584            harness: RefCell::new(None),
585            canonical_cache_root: RefCell::new(None),
586            is_worktree_bridge: RefCell::new(false),
587            git_common_dir: RefCell::new(None),
588            degraded_reasons: RefCell::new(Vec::new()),
589            callgraph: RefCell::new(None),
590            search_index: RefCell::new(None),
591            search_index_rx: RefCell::new(None),
592            pending_search_index_paths: RefCell::new(BTreeSet::new()),
593            symbol_cache,
594            inspect_manager: Arc::new(InspectManager::new()),
595            tier2_refresh_scheduler: RefCell::new(Tier2RefreshScheduler::new()),
596            semantic_index: RefCell::new(None),
597            semantic_index_rx: RefCell::new(None),
598            semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
599            pending_semantic_index_paths: RefCell::new(BTreeSet::new()),
600            pending_semantic_corpus_refresh: RefCell::new(false),
601            semantic_refresh_tx: RefCell::new(None),
602            semantic_refresh_event_rx: RefCell::new(None),
603            semantic_refresh_worker: RefCell::new(None),
604            semantic_embedding_model: RefCell::new(None),
605            watcher: RefCell::new(None),
606            watcher_rx: RefCell::new(None),
607            lsp_manager: RefCell::new(lsp_manager),
608            lsp_child_registry,
609            stdout_writer,
610            progress_sender: Arc::clone(&progress_sender),
611            configure_generation: AtomicU64::new(0),
612            last_seen_reuse_completions: AtomicU64::new(0),
613            configure_warnings_tx,
614            configure_warnings_rx,
615            status_emitter,
616            bash_background: BgTaskRegistry::new(progress_sender),
617            filter_registry: Arc::new(std::sync::RwLock::new(
618                crate::compress::toml_filter::FilterRegistry::default(),
619            )),
620            filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
621            bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
622            gitignore: RefCell::new(None),
623            status_bar_tier2: RefCell::new(StatusBarTier2::default()),
624        }
625    }
626
627    /// Current agent status-bar counts. `errors`/`warnings` are read LIVE from
628    /// the LSP diagnostics store (continuously drained, no round-trip); the
629    /// Tier-2 + todos counts are the last-known cached values. Returns `None`
630    /// until the Tier-2 cache has been populated at least once, so we never
631    /// surface a bar that misleadingly claims "0 dead code" before any scan.
632    pub fn status_bar_counts(&self) -> Option<StatusBarCounts> {
633        let tier2 = self.status_bar_tier2.borrow();
634        // All three Tier-2 categories must hold a real value before the bar is
635        // surfaced — otherwise a partially-scanned cold run would render a
636        // fabricated `0` for the not-yet-completed categories (#1).
637        let (Some(dead_code), Some(unused_exports), Some(duplicates)) =
638            (tier2.dead_code, tier2.unused_exports, tier2.duplicates)
639        else {
640            return None;
641        };
642        let (errors, warnings) = self.lsp_manager.borrow().warm_error_warning_counts();
643        Some(StatusBarCounts {
644            errors,
645            warnings,
646            dead_code,
647            unused_exports,
648            duplicates,
649            todos: tier2.todos.unwrap_or(0),
650            tier2_stale: tier2.stale,
651        })
652    }
653
654    /// Mark the status-bar Tier-2 counts stale (rendered with `~`) without
655    /// changing the numbers — called when the watcher sees a source-file change,
656    /// so the bar honestly signals the counts predate the latest edit until the
657    /// next background scan completes. No-op before the first populate.
658    pub fn mark_status_bar_tier2_stale(&self) {
659        let mut tier2 = self.status_bar_tier2.borrow_mut();
660        // No-op before the first full populate (nothing real to mark stale).
661        if tier2.dead_code.is_some() && tier2.unused_exports.is_some() && tier2.duplicates.is_some()
662        {
663            tier2.stale = true;
664        }
665    }
666
667    /// Refresh the cached Tier-2 + todos counts for the status bar. Each count
668    /// is `Option`: `None` preserves the last-known value (the category wasn't
669    /// recomputed or has no real aggregate yet) so we never overwrite a real
670    /// count with a fabricated `0`. `stale` marks the Tier-2 numbers as
671    /// not-yet-reconciled with the latest edits.
672    pub fn update_status_bar_tier2(
673        &self,
674        dead_code: Option<usize>,
675        unused_exports: Option<usize>,
676        duplicates: Option<usize>,
677        todos: Option<usize>,
678        stale: bool,
679    ) {
680        let mut tier2 = self.status_bar_tier2.borrow_mut();
681        if let Some(dead_code) = dead_code {
682            tier2.dead_code = Some(dead_code);
683        }
684        if let Some(unused_exports) = unused_exports {
685            tier2.unused_exports = Some(unused_exports);
686        }
687        if let Some(duplicates) = duplicates {
688            tier2.duplicates = Some(duplicates);
689        }
690        if let Some(todos) = todos {
691            tier2.todos = Some(todos);
692        }
693        tier2.stale = stale;
694    }
695
696    /// Borrow the cached project gitignore matcher. Returns `None` when no
697    /// project_root is configured or when the project has no gitignore files.
698    pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
699        self.gitignore.borrow().clone()
700    }
701
702    /// Rebuild the gitignore matcher from the current `project_root` and
703    /// cache it. Called by the configure handler whenever the project root
704    /// changes, and by the watcher event drain when a `.gitignore` file
705    /// itself is modified.
706    ///
707    /// The builder honors:
708    /// - `<project_root>/.gitignore`
709    /// - Git's global excludes file (the same source used by `ignore::WalkBuilder`)
710    /// - the repository's real `info/exclude` file, resolved through Git's
711    ///   common dir for linked worktrees
712    /// - nested `.gitignore` files (each `.gitignore` discovered during
713    ///   the recursive walk)
714    ///
715    /// Stores `None` if there's no project_root or no matchable gitignore
716    /// files. Logs build errors but never fails configure.
717    /// Clear any cached gitignore matcher without rebuilding.
718    ///
719    /// Used by `handle_configure` in degraded mode (e.g. `project_root == $HOME`)
720    /// where running the gitignore-discovery walk would exceed the configure
721    /// budget. The watcher event filter falls back to the hardcoded infra-dir
722    /// skip list when no matcher is present.
723    pub fn clear_gitignore(&self) {
724        *self.gitignore.borrow_mut() = None;
725    }
726
727    pub fn rebuild_gitignore(&self) {
728        use ignore::gitignore::GitignoreBuilder;
729        use std::path::Path;
730        let root_raw = match self.config().project_root.clone() {
731            Some(r) => r,
732            None => {
733                *self.gitignore.borrow_mut() = None;
734                return;
735            }
736        };
737        // Canonicalize the root so symlink-prefix mismatches don't cause
738        // `Gitignore::matched_path_or_any_parents` to panic on watcher event
739        // paths. macOS routinely surfaces `/private/var/...` while `project_root`
740        // arrives as `/var/...` (a symlink to `/private/var`); the `ignore`
741        // crate's matcher panics when a query path isn't lexically under the
742        // matcher's root. Canonicalizing both ends (here for root, naturally
743        // for watcher events on macOS) keeps them in the same prefix space.
744        let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
745        let mut builder = GitignoreBuilder::new(&root);
746        // Git's global excludes file — keep the live watcher matcher aligned
747        // with the project walkers (`WalkBuilder::git_global(true)`). The
748        // ignore crate exposes the same path discovery it uses internally, so
749        // this handles the default XDG location and configured excludesFile.
750        if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
751            if global_ignore.is_file() {
752                if let Some(err) = builder.add(&global_ignore) {
753                    crate::slog_warn!(
754                        "global gitignore parse error in {}: {}",
755                        global_ignore.display(),
756                        err
757                    );
758                }
759            }
760        }
761        // Add root .gitignore (the most common case)
762        let root_ignore = Path::new(&root).join(".gitignore");
763        if root_ignore.exists() {
764            if let Some(err) = builder.add(&root_ignore) {
765                crate::slog_warn!(
766                    "gitignore parse error in {}: {}",
767                    root_ignore.display(),
768                    err
769                );
770            }
771        }
772        // Root .aftignore — AFT-specific ignores layered on top of .gitignore.
773        // Lets users exclude paths git can't (e.g. submodules) from AFT's
774        // walks/indexes. Honored by the watcher matcher too, so edits under an
775        // aftignored path don't trigger reindexing.
776        let root_aftignore = Path::new(&root).join(".aftignore");
777        if root_aftignore.exists() {
778            if let Some(err) = builder.add(&root_aftignore) {
779                crate::slog_warn!(
780                    "aftignore parse error in {}: {}",
781                    root_aftignore.display(),
782                    err
783                );
784            }
785        }
786        // .git/info/exclude — manually added because GitignoreBuilder::new()
787        // does not auto-discover it (verified against ignore-0.4.25 source).
788        // In linked worktrees this lives under the repository common dir, not
789        // under `<worktree>/.git/info/exclude` (where `.git` is only a file).
790        let info_exclude = self
791            .git_common_dir
792            .borrow()
793            .clone()
794            .unwrap_or_else(|| Path::new(&root).join(".git"))
795            .join("info")
796            .join("exclude");
797        if info_exclude.exists() {
798            if let Some(err) = builder.add(&info_exclude) {
799                crate::slog_warn!(
800                    "gitignore parse error in {}: {}",
801                    info_exclude.display(),
802                    err
803                );
804            }
805        }
806        // Walk the project to pick up nested .gitignore/.aftignore files at
807        // arbitrary depth. The main project walkers honor deeply nested ignore
808        // files, so the watcher matcher must do the same or live invalidation
809        // can disagree with startup indexing. Skip obvious infra dirs so we
810        // don't accidentally load a vendored repo's ignore file as ours.
811        let walker = ignore::WalkBuilder::new(&root)
812            .standard_filters(true)
813            // Hidden files are filtered by default, but `.gitignore` starts with
814            // `.` so we need to traverse "hidden" entries to find nested ones.
815            // No `max_depth`: nested `.gitignore`/`.aftignore` files are honored
816            // at arbitrary depth (see configure_watcher_honors_deep_nested_aftignore).
817            // The walk is pruned by standard gitignore filters plus the infra
818            // skip below; configure never runs this against `$HOME` (guarded by
819            // `home_match`), and tests use bounded roots rather than `/`.
820            .hidden(false)
821            .filter_entry(|entry| {
822                let name = entry.file_name().to_string_lossy();
823                !matches!(
824                    name.as_ref(),
825                    "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
826                )
827            })
828            .build();
829        for entry in walker.flatten() {
830            let file_name = entry.file_name();
831            let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
832            let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
833            if is_nested_gitignore || is_nested_aftignore {
834                if let Some(err) = builder.add(entry.path()) {
835                    crate::slog_warn!(
836                        "nested ignore parse error in {}: {}",
837                        entry.path().display(),
838                        err
839                    );
840                }
841            }
842        }
843        match builder.build() {
844            Ok(gi) => {
845                let count = gi.num_ignores();
846                if count > 0 {
847                    crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
848                    *self.gitignore.borrow_mut() = Some(Arc::new(gi));
849                } else {
850                    *self.gitignore.borrow_mut() = None;
851                }
852            }
853            Err(err) => {
854                crate::slog_warn!("gitignore matcher build failed: {}", err);
855                *self.gitignore.borrow_mut() = None;
856            }
857        }
858    }
859
860    /// Shared atomic mirror of `experimental.bash.compress`. Updated by the
861    /// configure handler. Read by the BgTaskRegistry compressor closure.
862    pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
863        Arc::clone(&self.bash_compress_flag)
864    }
865
866    /// Update the shared `bash_compress_flag` mirror. Call this from the
867    /// configure handler whenever `experimental.bash.compress` changes so the
868    /// BgTaskRegistry watchdog sees the new value on the next completion.
869    pub fn sync_bash_compress_flag(&self) {
870        let value = self.config().experimental_bash_compress;
871        self.bash_compress_flag
872            .store(value, std::sync::atomic::Ordering::Relaxed);
873    }
874
875    pub fn set_bash_compress_enabled(&self, enabled: bool) {
876        self.config_mut().experimental_bash_compress = enabled;
877        self.bash_compress_flag
878            .store(enabled, std::sync::atomic::Ordering::Relaxed);
879    }
880
881    /// Read-only access to the TOML filter registry, building it lazily on
882    /// first use. Returns an `RwLockReadGuard` that callers can `lookup`
883    /// against directly.
884    pub fn filter_registry(
885        &self,
886    ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
887        self.ensure_filter_registry_loaded();
888        match self.filter_registry.read() {
889            Ok(g) => g,
890            Err(poisoned) => poisoned.into_inner(),
891        }
892    }
893
894    /// Returns the shared `Arc<RwLock<FilterRegistry>>` handle so threads
895    /// outside `AppContext` (notably the bash watchdog) can read it without
896    /// touching the rest of the context.
897    pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
898        self.ensure_filter_registry_loaded();
899        Arc::clone(&self.filter_registry)
900    }
901
902    /// Force a fresh load of the TOML filter registry. Called when configure
903    /// changes the project root, storage_dir, or trust state so subsequent
904    /// `compress::compress` calls pick up new filters.
905    pub fn reset_filter_registry(&self) {
906        let new_registry = crate::compress::build_registry_for_context(self);
907        match self.filter_registry.write() {
908            Ok(mut slot) => *slot = new_registry,
909            Err(poisoned) => *poisoned.into_inner() = new_registry,
910        }
911        self.filter_registry_loaded
912            .store(true, std::sync::atomic::Ordering::Release);
913    }
914
915    fn ensure_filter_registry_loaded(&self) {
916        use std::sync::atomic::Ordering;
917        if self.filter_registry_loaded.load(Ordering::Acquire) {
918            return;
919        }
920        // Build outside the lock to avoid blocking other readers during a
921        // multi-file TOML parse.
922        let new_registry = crate::compress::build_registry_for_context(self);
923        if let Ok(mut slot) = self.filter_registry.write() {
924            *slot = new_registry;
925            self.filter_registry_loaded.store(true, Ordering::Release);
926        }
927    }
928
929    /// Clone the LSP child registry handle. Used by main.rs to give the
930    /// signal handler thread a way to SIGKILL LSP children on shutdown.
931    pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
932        self.lsp_child_registry.clone()
933    }
934
935    pub fn stdout_writer(&self) -> SharedStdoutWriter {
936        Arc::clone(&self.stdout_writer)
937    }
938
939    pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
940        if let Ok(mut progress_sender) = self.progress_sender.lock() {
941            *progress_sender = sender;
942        }
943    }
944
945    pub fn emit_progress(&self, frame: ProgressFrame) {
946        let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
947            return;
948        };
949        if let Some(sender) = progress_sender.as_ref() {
950            sender(PushFrame::Progress(frame));
951        }
952    }
953
954    pub fn status_emitter(&self) -> &StatusEmitter {
955        &self.status_emitter
956    }
957
958    /// Get a clone of the current progress sender for use from background
959    /// threads. Returns `None` when the main loop hasn't installed one (tests,
960    /// CLI without push frames).
961    ///
962    /// Used by `configure`'s deferred file-walk thread to push warnings after
963    /// configure has already returned, so configure latency stays sub-100 ms
964    /// even on huge directories.
965    pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
966        self.progress_sender
967            .lock()
968            .ok()
969            .and_then(|sender| sender.clone())
970    }
971
972    pub fn advance_configure_generation(&self) -> u64 {
973        self.configure_generation
974            .fetch_add(1, Ordering::SeqCst)
975            .wrapping_add(1)
976    }
977
978    pub fn configure_generation(&self) -> u64 {
979        self.configure_generation.load(Ordering::SeqCst)
980    }
981
982    pub fn configure_warnings_sender(&self) -> mpsc::Sender<(u64, ConfigureWarningsFrame)> {
983        self.configure_warnings_tx.clone()
984    }
985
986    pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
987        let mut warnings = Vec::new();
988        while let Ok(warning) = self.configure_warnings_rx.try_recv() {
989            warnings.push(warning);
990        }
991        warnings
992    }
993
994    pub fn bash_background(&self) -> &BgTaskRegistry {
995        &self.bash_background
996    }
997
998    pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
999        self.bash_background.drain_completions()
1000    }
1001
1002    /// Access the language provider.
1003    pub fn provider(&self) -> &dyn LanguageProvider {
1004        self.provider.as_ref()
1005    }
1006
1007    /// Access the backup store.
1008    pub fn backup(&self) -> &RefCell<BackupStore> {
1009        &self.backup
1010    }
1011
1012    /// Access the checkpoint store.
1013    pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
1014        &self.checkpoint
1015    }
1016
1017    pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
1018        *self.db.borrow_mut() = Some(conn);
1019    }
1020
1021    pub fn clear_db(&self) {
1022        *self.db.borrow_mut() = None;
1023    }
1024
1025    pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
1026        self.db.borrow().clone()
1027    }
1028
1029    /// Access the configuration (shared borrow).
1030    pub fn config(&self) -> Ref<'_, Config> {
1031        self.config.borrow()
1032    }
1033
1034    /// Access the configuration (mutable borrow).
1035    pub fn config_mut(&self) -> RefMut<'_, Config> {
1036        self.config.borrow_mut()
1037    }
1038
1039    pub fn set_harness(&self, harness: Harness) {
1040        *self.harness.borrow_mut() = Some(harness);
1041        self.bash_background.set_harness(harness);
1042    }
1043
1044    pub fn harness_opt(&self) -> Option<Harness> {
1045        *self.harness.borrow()
1046    }
1047
1048    pub fn harness(&self) -> Harness {
1049        self.harness_opt()
1050            .expect("harness set by configure before any tool call")
1051    }
1052
1053    pub fn storage_dir(&self) -> PathBuf {
1054        crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
1055    }
1056
1057    pub fn harness_dir(&self) -> PathBuf {
1058        self.storage_dir().join(self.harness().as_str())
1059    }
1060
1061    pub fn inspect_dir(&self) -> PathBuf {
1062        self.harness_dir().join("inspect")
1063    }
1064
1065    pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
1066        self.harness_dir()
1067            .join("bash-tasks")
1068            .join(hash_session(session_id))
1069    }
1070
1071    pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
1072        self.harness_dir()
1073            .join("backups")
1074            .join(hash_session(session_id))
1075            .join(path_hash)
1076    }
1077
1078    pub fn filters_dir(&self) -> PathBuf {
1079        self.harness_dir().join("filters")
1080    }
1081
1082    /// HOST-GLOBAL — NOT under harness_dir. Read by trust.rs across both harnesses.
1083    pub fn trust_file(&self) -> PathBuf {
1084        self.storage_dir().join("trusted-filter-projects.json")
1085    }
1086
1087    pub fn set_canonical_cache_root(&self, root: PathBuf) {
1088        debug_assert!(root.is_absolute());
1089        *self.canonical_cache_root.borrow_mut() = Some(root);
1090    }
1091
1092    pub fn canonical_cache_root(&self) -> PathBuf {
1093        self.canonical_cache_root
1094            .borrow()
1095            .clone()
1096            .expect("canonical_cache_root accessed before handle_configure")
1097    }
1098
1099    pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
1100        self.canonical_cache_root.borrow().clone()
1101    }
1102
1103    pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
1104        *self.is_worktree_bridge.borrow_mut() = is_worktree_bridge;
1105        *self.git_common_dir.borrow_mut() = git_common_dir;
1106    }
1107
1108    pub fn is_worktree_bridge(&self) -> bool {
1109        *self.is_worktree_bridge.borrow()
1110    }
1111
1112    pub fn git_common_dir(&self) -> Option<PathBuf> {
1113        self.git_common_dir.borrow().clone()
1114    }
1115
1116    /// Replace the current degraded-mode reasons. Empty vec = full-featured
1117    /// mode (no degradation). Called by `handle_configure` after deciding
1118    /// which subsystems to disable for this project root.
1119    pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
1120        *self.degraded_reasons.borrow_mut() = reasons;
1121    }
1122
1123    /// Snapshot of current degraded-mode reasons. Order is stable
1124    /// (insertion order from `set_degraded_reasons`) so UI rendering and
1125    /// snapshot diffs are deterministic.
1126    pub fn degraded_reasons(&self) -> Vec<String> {
1127        self.degraded_reasons.borrow().clone()
1128    }
1129
1130    /// True iff at least one degraded reason is recorded.
1131    pub fn is_degraded(&self) -> bool {
1132        !self.degraded_reasons.borrow().is_empty()
1133    }
1134
1135    pub fn cache_role(&self) -> &'static str {
1136        if self.canonical_cache_root.borrow().is_none() {
1137            "not_initialized"
1138        } else if self.is_worktree_bridge() {
1139            "worktree"
1140        } else {
1141            "main"
1142        }
1143    }
1144
1145    /// Access the call graph engine.
1146    pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
1147        &self.callgraph
1148    }
1149
1150    /// Access the search index.
1151    pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
1152        &self.search_index
1153    }
1154
1155    /// Access the search-index build receiver.
1156    pub fn search_index_rx(&self) -> &RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>> {
1157        &self.search_index_rx
1158    }
1159
1160    pub fn add_pending_search_index_paths<I>(&self, paths: I)
1161    where
1162        I: IntoIterator<Item = PathBuf>,
1163    {
1164        self.pending_search_index_paths.borrow_mut().extend(paths);
1165    }
1166
1167    pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
1168        std::mem::take(&mut *self.pending_search_index_paths.borrow_mut())
1169            .into_iter()
1170            .collect()
1171    }
1172
1173    pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
1174    where
1175        I: IntoIterator<Item = PathBuf>,
1176    {
1177        self.pending_semantic_index_paths.borrow_mut().extend(paths);
1178    }
1179
1180    pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
1181        std::mem::take(&mut *self.pending_semantic_index_paths.borrow_mut())
1182            .into_iter()
1183            .collect()
1184    }
1185
1186    pub fn mark_pending_semantic_corpus_refresh(&self) {
1187        *self.pending_semantic_corpus_refresh.borrow_mut() = true;
1188    }
1189
1190    pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
1191        std::mem::take(&mut *self.pending_semantic_corpus_refresh.borrow_mut())
1192    }
1193
1194    pub fn clear_pending_index_updates(&self) {
1195        self.pending_search_index_paths.borrow_mut().clear();
1196        self.pending_semantic_index_paths.borrow_mut().clear();
1197        *self.pending_semantic_corpus_refresh.borrow_mut() = false;
1198    }
1199
1200    pub fn inspect_manager(&self) -> Arc<InspectManager> {
1201        Arc::clone(&self.inspect_manager)
1202    }
1203
1204    /// Returns true when one or more watcher-driven (reuse-path) Tier-2 scans
1205    /// have completed since the last call, advancing the last-seen marker. The
1206    /// per-request inspect drain uses this to refresh the status bar after a
1207    /// background scan — those completions bypass `drain_completions`.
1208    pub fn take_new_reuse_completions(&self) -> bool {
1209        let current = self.inspect_manager.reuse_completion_count();
1210        let previous = self
1211            .last_seen_reuse_completions
1212            .swap(current, Ordering::SeqCst);
1213        current != previous
1214    }
1215
1216    pub fn reset_tier2_refresh_scheduler(&self) {
1217        self.reset_tier2_refresh_scheduler_at(Instant::now());
1218    }
1219
1220    #[doc(hidden)]
1221    pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
1222        self.tier2_refresh_scheduler
1223            .borrow_mut()
1224            .reset_after_configure(now);
1225    }
1226
1227    pub fn request_tier2_refresh_pull(&self) -> bool {
1228        self.tier2_refresh_scheduler
1229            .borrow_mut()
1230            .request_pull(!self.is_worktree_bridge())
1231    }
1232
1233    pub fn tick_tier2_refresh_scheduler(
1234        &self,
1235        changed_path_count: usize,
1236    ) -> Option<Tier2TriggerReason> {
1237        self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
1238    }
1239
1240    #[doc(hidden)]
1241    pub fn tick_tier2_refresh_scheduler_at(
1242        &self,
1243        now: Instant,
1244        changed_path_count: usize,
1245    ) -> Option<Tier2TriggerReason> {
1246        let manager = self.inspect_manager();
1247        let can_write = !self.is_worktree_bridge();
1248        let in_flight = manager.tier2_any_in_flight();
1249        let decision = self.tier2_refresh_scheduler.borrow_mut().tick(
1250            now,
1251            changed_path_count,
1252            can_write,
1253            in_flight,
1254        );
1255
1256        if let Some(reason) = decision {
1257            self.start_tier2_refresh(reason, manager);
1258        }
1259
1260        decision
1261    }
1262
1263    pub fn note_tier2_refresh_started(&self) {
1264        self.note_tier2_refresh_started_at(Instant::now());
1265    }
1266
1267    #[doc(hidden)]
1268    pub fn note_tier2_refresh_started_at(&self, now: Instant) {
1269        self.tier2_refresh_scheduler
1270            .borrow_mut()
1271            .note_external_scan_started(now);
1272    }
1273
1274    pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
1275        self.tier2_refresh_scheduler
1276            .borrow()
1277            .last_trigger_reason()
1278            .map(Tier2TriggerReason::as_str)
1279    }
1280
1281    #[doc(hidden)]
1282    pub fn tier2_pull_demand_pending(&self) -> bool {
1283        self.tier2_refresh_scheduler.borrow().pull_demand_pending()
1284    }
1285
1286    fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
1287        if self.is_worktree_bridge() {
1288            return;
1289        }
1290        let Some(snapshot) = self.tier2_refresh_snapshot() else {
1291            return;
1292        };
1293        let categories = InspectCategory::active()
1294            .iter()
1295            .copied()
1296            .filter(|category| category.is_tier2())
1297            .collect::<Vec<_>>();
1298        let submission =
1299            manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
1300        if submission.has_new_work() {
1301            crate::slog_info!(
1302                "tier2 refresh scheduled: reason={}, categories={:?}",
1303                reason.as_str(),
1304                submission
1305                    .newly_queued_categories
1306                    .iter()
1307                    .map(|category| category.as_str())
1308                    .collect::<Vec<_>>()
1309            );
1310        }
1311        for error in submission.errors {
1312            crate::slog_warn!(
1313                "tier2 refresh schedule failed for {}: {}",
1314                error.category,
1315                error.message
1316            );
1317        }
1318    }
1319
1320    fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
1321        self.harness_opt()?;
1322        let config = self.config().clone();
1323        let project_root = config
1324            .project_root
1325            .clone()
1326            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1327        let project_root = std::fs::canonicalize(&project_root).unwrap_or(project_root);
1328        Some(InspectSnapshot::new(
1329            project_root,
1330            self.inspect_dir(),
1331            Arc::new(config),
1332            self.symbol_cache(),
1333        ))
1334    }
1335
1336    /// Access the shared symbol cache.
1337    pub fn symbol_cache(&self) -> SharedSymbolCache {
1338        Arc::clone(&self.symbol_cache)
1339    }
1340
1341    /// Clear the shared symbol cache and return the new active generation.
1342    pub fn reset_symbol_cache(&self) -> u64 {
1343        self.symbol_cache
1344            .write()
1345            .map(|mut cache| cache.reset())
1346            .unwrap_or(0)
1347    }
1348
1349    /// Access the semantic search index.
1350    pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
1351        &self.semantic_index
1352    }
1353
1354    /// Access the semantic-index build receiver.
1355    pub fn semantic_index_rx(
1356        &self,
1357    ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
1358        &self.semantic_index_rx
1359    }
1360
1361    pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
1362        &self.semantic_index_status
1363    }
1364
1365    pub fn install_semantic_refresh_worker(
1366        &self,
1367        sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
1368        event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
1369        worker_slot: SemanticRefreshWorkerSlot,
1370    ) {
1371        self.clear_semantic_refresh_worker();
1372        *self.semantic_refresh_tx.borrow_mut() = Some(sender);
1373        *self.semantic_refresh_event_rx.borrow_mut() = Some(event_rx);
1374        *self.semantic_refresh_worker.borrow_mut() = Some(worker_slot);
1375    }
1376
1377    pub fn clear_semantic_refresh_worker(&self) {
1378        *self.semantic_refresh_tx.borrow_mut() = None;
1379        *self.semantic_refresh_event_rx.borrow_mut() = None;
1380        if let Some(worker_slot) = self.semantic_refresh_worker.borrow_mut().take() {
1381            if let Ok(mut handle) = worker_slot.lock() {
1382                drop(handle.take());
1383            }
1384        }
1385    }
1386
1387    pub fn semantic_refresh_sender(
1388        &self,
1389    ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
1390        self.semantic_refresh_tx.borrow().clone()
1391    }
1392
1393    pub fn semantic_refresh_event_rx(
1394        &self,
1395    ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
1396        &self.semantic_refresh_event_rx
1397    }
1398
1399    /// Access the cached semantic embedding model.
1400    pub fn semantic_embedding_model(
1401        &self,
1402    ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
1403        &self.semantic_embedding_model
1404    }
1405
1406    /// Access the file watcher handle (kept alive to continue watching).
1407    pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
1408        &self.watcher
1409    }
1410
1411    /// Access the watcher event receiver.
1412    pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
1413        &self.watcher_rx
1414    }
1415
1416    /// Access the LSP manager.
1417    pub fn lsp(&self) -> RefMut<'_, LspManager> {
1418        self.lsp_manager.borrow_mut()
1419    }
1420
1421    /// Notify LSP servers that a file was written.
1422    /// Call this after write_format_validate in command handlers.
1423    pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
1424        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1425            let config = self.config();
1426            if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
1427                crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1428            }
1429        }
1430    }
1431
1432    /// Drop cached LSP diagnostics for a deleted/renamed-away file so its
1433    /// errors/warnings don't linger in the warm set (no server republishes for
1434    /// a vanished path), keeping the status bar and `aft_inspect` honest.
1435    /// Returns true if any entry was removed. Best-effort: a contended borrow is
1436    /// skipped silently (the watcher drain retries on subsequent events).
1437    pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
1438        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1439            lsp.clear_diagnostics_for_file(file_path)
1440        } else {
1441            false
1442        }
1443    }
1444
1445    /// Notify LSP and optionally wait for diagnostics.
1446    ///
1447    /// Call this after `write_format_validate` when the request has `"diagnostics": true`.
1448    /// Sends didChange to the server, waits briefly for publishDiagnostics, and returns
1449    /// any diagnostics for the file. If no server is running, returns empty immediately.
1450    ///
1451    /// v0.17.3: this is the version-aware path. Pre-edit cached diagnostics
1452    /// are NEVER returned — only entries whose `version` matches the
1453    /// post-edit document version (or, for unversioned servers, whose
1454    /// `epoch` advanced past the pre-edit snapshot).
1455    pub fn lsp_notify_and_collect_diagnostics(
1456        &self,
1457        file_path: &Path,
1458        content: &str,
1459        timeout: std::time::Duration,
1460    ) -> crate::lsp::manager::PostEditWaitOutcome {
1461        let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
1462            return crate::lsp::manager::PostEditWaitOutcome::default();
1463        };
1464
1465        // Clear any queued notifications before this write so the wait loop only
1466        // observes diagnostics triggered by the current change.
1467        lsp.drain_events();
1468
1469        // Snapshot per-server epochs and document versions BEFORE sending
1470        // didChange so the wait loop can prove freshness without accepting
1471        // stale pre-edit publishes that arrived late.
1472        let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
1473
1474        // Send didChange/didOpen and capture per-server target version.
1475        let config = self.config();
1476        let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
1477        {
1478            Ok(v) => v,
1479            Err(e) => {
1480                crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1481                return crate::lsp::manager::PostEditWaitOutcome::default();
1482            }
1483        };
1484
1485        // No server matched this file — return an empty outcome that's
1486        // honestly `complete: true` (nothing to wait for).
1487        if expected_versions.is_empty() {
1488            return crate::lsp::manager::PostEditWaitOutcome::default();
1489        }
1490
1491        lsp.wait_for_post_edit_diagnostics(
1492            file_path,
1493            &config,
1494            &expected_versions,
1495            &pre_snapshot,
1496            timeout,
1497        )
1498    }
1499
1500    /// Collect custom server root_markers from user config for use in
1501    /// `is_config_file_path_with_custom` checks (#25).
1502    fn custom_lsp_root_markers(&self) -> Vec<String> {
1503        self.config()
1504            .lsp_servers
1505            .iter()
1506            .flat_map(|s| s.root_markers.iter().cloned())
1507            .collect()
1508    }
1509
1510    fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
1511        let custom_markers = self.custom_lsp_root_markers();
1512        let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
1513            .iter()
1514            .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
1515            .cloned()
1516            .map(|path| {
1517                let change_type = if path.exists() {
1518                    FileChangeType::CHANGED
1519                } else {
1520                    FileChangeType::DELETED
1521                };
1522                (path, change_type)
1523            })
1524            .collect();
1525
1526        self.notify_watched_config_events(&config_paths);
1527    }
1528
1529    fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
1530        let paths = params
1531            .get("multi_file_write_paths")
1532            .and_then(|value| value.as_array())?
1533            .iter()
1534            .filter_map(|value| value.as_str())
1535            .map(PathBuf::from)
1536            .collect::<Vec<_>>();
1537
1538        (!paths.is_empty()).then_some(paths)
1539    }
1540
1541    /// Parse config-file watched events from `multi_file_write_paths` when the
1542    /// array contains object entries `{ "path": "...", "type": "created|changed|deleted" }`.
1543    ///
1544    /// This handles the OBJECT variant of `multi_file_write_paths`. The STRING
1545    /// variant (bare path strings) is handled by `multi_file_write_paths()` and
1546    /// `notify_watched_config_files()`. Both variants read the same JSON key but
1547    /// with different per-entry schemas — they are NOT redundant.
1548    ///
1549    /// #18 note: in older code this function also existed alongside `multi_file_write_paths()`
1550    /// and was reachable via the `else if` branch when all entries were objects.
1551    /// Restoring both is correct.
1552    fn watched_file_events_from_params(
1553        params: &serde_json::Value,
1554        extra_markers: &[String],
1555    ) -> Option<Vec<(PathBuf, FileChangeType)>> {
1556        let events = params
1557            .get("multi_file_write_paths")
1558            .and_then(|value| value.as_array())?
1559            .iter()
1560            .filter_map(|entry| {
1561                // Only handle object entries — string entries go through multi_file_write_paths()
1562                let path = entry
1563                    .get("path")
1564                    .and_then(|value| value.as_str())
1565                    .map(PathBuf::from)?;
1566
1567                if !is_config_file_path_with_custom(&path, extra_markers) {
1568                    return None;
1569                }
1570
1571                let change_type = entry
1572                    .get("type")
1573                    .and_then(|value| value.as_str())
1574                    .and_then(Self::parse_file_change_type)
1575                    .unwrap_or_else(|| Self::change_type_from_current_state(&path));
1576
1577                Some((path, change_type))
1578            })
1579            .collect::<Vec<_>>();
1580
1581        (!events.is_empty()).then_some(events)
1582    }
1583
1584    fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
1585        match value {
1586            "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
1587            "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
1588            "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
1589            _ => None,
1590        }
1591    }
1592
1593    fn change_type_from_current_state(path: &Path) -> FileChangeType {
1594        if path.exists() {
1595            FileChangeType::CHANGED
1596        } else {
1597            FileChangeType::DELETED
1598        }
1599    }
1600
1601    fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
1602        if config_paths.is_empty() {
1603            return;
1604        }
1605
1606        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1607            let config = self.config();
1608            if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
1609                crate::slog_warn!("watched-file sync error: {}", e);
1610            }
1611        }
1612    }
1613
1614    pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
1615        let custom_markers = self.custom_lsp_root_markers();
1616        if !is_config_file_path_with_custom(file_path, &custom_markers) {
1617            return;
1618        }
1619
1620        self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
1621    }
1622
1623    /// Post-write LSP hook for multi-file edits. When the patch includes
1624    /// config-file edits, notify active workspace servers via
1625    /// `workspace/didChangeWatchedFiles` before sending the per-document
1626    /// didOpen/didChange for the current file.
1627    pub fn lsp_post_multi_file_write(
1628        &self,
1629        file_path: &Path,
1630        content: &str,
1631        file_paths: &[PathBuf],
1632        params: &serde_json::Value,
1633    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
1634        self.notify_watched_config_files(file_paths);
1635
1636        let wants_diagnostics = params
1637            .get("diagnostics")
1638            .and_then(|v| v.as_bool())
1639            .unwrap_or(false);
1640
1641        if !wants_diagnostics {
1642            self.lsp_notify_file_changed(file_path, content);
1643            return None;
1644        }
1645
1646        let wait_ms = params
1647            .get("wait_ms")
1648            .and_then(|v| v.as_u64())
1649            .unwrap_or(3000)
1650            .min(10_000);
1651
1652        Some(self.lsp_notify_and_collect_diagnostics(
1653            file_path,
1654            content,
1655            std::time::Duration::from_millis(wait_ms),
1656        ))
1657    }
1658
1659    /// Post-write LSP hook: notify server and optionally collect diagnostics.
1660    ///
1661    /// This is the single call site for all command handlers after `write_format_validate`.
1662    /// Behavior:
1663    /// - When `diagnostics: true` is in `params`, notifies the server, waits
1664    ///   until matching diagnostics arrive or the timeout expires, and returns
1665    ///   `Some(outcome)` with the verified-fresh diagnostics + per-server
1666    ///   status.
1667    /// - When `diagnostics: false` (or absent), just notifies (fire-and-forget)
1668    ///   and returns `None`. Callers must NOT wrap this in `Some(...)`; the
1669    ///   `None` is what tells the response builder to omit the LSP fields
1670    ///   entirely (preserves the no-diagnostics-requested response shape).
1671    ///
1672    /// v0.17.3: default `wait_ms` raised from 1500 to 3000 because real-world
1673    /// tsserver re-analysis on monorepo files routinely takes 2-5s. Still
1674    /// capped at 10000ms.
1675    pub fn lsp_post_write(
1676        &self,
1677        file_path: &Path,
1678        content: &str,
1679        params: &serde_json::Value,
1680    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
1681        let wants_diagnostics = params
1682            .get("diagnostics")
1683            .and_then(|v| v.as_bool())
1684            .unwrap_or(false);
1685
1686        let custom_markers = self.custom_lsp_root_markers();
1687
1688        if !wants_diagnostics {
1689            if let Some(file_paths) = Self::multi_file_write_paths(params) {
1690                self.notify_watched_config_files(&file_paths);
1691            } else if let Some(config_events) =
1692                Self::watched_file_events_from_params(params, &custom_markers)
1693            {
1694                self.notify_watched_config_events(&config_events);
1695            }
1696            self.lsp_notify_file_changed(file_path, content);
1697            return None;
1698        }
1699
1700        let wait_ms = params
1701            .get("wait_ms")
1702            .and_then(|v| v.as_u64())
1703            .unwrap_or(3000)
1704            .min(10_000); // Cap at 10 seconds to prevent hangs from adversarial input
1705
1706        if let Some(file_paths) = Self::multi_file_write_paths(params) {
1707            return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
1708        }
1709
1710        if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
1711        {
1712            self.notify_watched_config_events(&config_events);
1713        }
1714
1715        Some(self.lsp_notify_and_collect_diagnostics(
1716            file_path,
1717            content,
1718            std::time::Duration::from_millis(wait_ms),
1719        ))
1720    }
1721
1722    /// Validate that a file path falls within the configured project root.
1723    ///
1724    /// When `project_root` is configured (normal plugin usage), this resolves the
1725    /// path and checks it starts with the root. Returns the canonicalized path on
1726    /// success, or an error response on violation.
1727    ///
1728    /// When no `project_root` is configured (direct CLI usage), all paths pass
1729    /// through unrestricted for backward compatibility.
1730    pub fn validate_path(
1731        &self,
1732        req_id: &str,
1733        path: &Path,
1734    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
1735        let config = self.config();
1736        // When restrict_to_project_root is false (default), allow all paths
1737        if !config.restrict_to_project_root {
1738            return Ok(path.to_path_buf());
1739        }
1740        let root = match &config.project_root {
1741            Some(r) => r.clone(),
1742            None => return Ok(path.to_path_buf()), // No root configured, allow all
1743        };
1744        drop(config);
1745
1746        // Keep the raw root for symlink-guard comparisons. On macOS, tempdir()
1747        // returns /var/... paths while canonicalize gives /private/var/...; we
1748        // need both forms so reject_escaping_symlink can recognise in-root
1749        // symlinks regardless of which prefix form `current` happens to have.
1750        let raw_root = root.clone();
1751        let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
1752
1753        // Resolve the path (follow symlinks, normalize ..). If canonicalization
1754        // fails (e.g. path does not exist or traverses a broken symlink), inspect
1755        // every existing component with lstat before falling back lexically so a
1756        // broken in-root symlink cannot be used to write outside project_root.
1757        let path_for_resolution = if path.is_relative() {
1758            raw_root.join(path)
1759        } else {
1760            path.to_path_buf()
1761        };
1762        let resolved = match std::fs::canonicalize(&path_for_resolution) {
1763            Ok(resolved) => resolved,
1764            Err(_) => {
1765                let normalized = normalize_path(&path_for_resolution);
1766                reject_escaping_symlink(
1767                    req_id,
1768                    &path_for_resolution,
1769                    &normalized,
1770                    &resolved_root,
1771                    &raw_root,
1772                )?;
1773                resolve_with_existing_ancestors(&normalized)
1774            }
1775        };
1776
1777        if !resolved.starts_with(&resolved_root) {
1778            return Err(path_error_response(req_id, path, &resolved_root));
1779        }
1780
1781        Ok(resolved)
1782    }
1783
1784    /// Count active LSP server instances.
1785    pub fn lsp_server_count(&self) -> usize {
1786        self.lsp_manager
1787            .try_borrow()
1788            .map(|lsp| lsp.server_count())
1789            .unwrap_or(0)
1790    }
1791
1792    /// Symbol cache statistics from the language provider.
1793    pub fn symbol_cache_stats(&self) -> serde_json::Value {
1794        let entries = self
1795            .symbol_cache
1796            .read()
1797            .map(|cache| cache.len())
1798            .unwrap_or(0);
1799        serde_json::json!({
1800            "local_entries": entries,
1801            "warm_entries": 0,
1802        })
1803    }
1804}
1805
1806#[cfg(test)]
1807mod status_emitter_tests {
1808    use super::*;
1809    use crate::parser::TreeSitterProvider;
1810
1811    fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
1812        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
1813        let (tx, rx) = mpsc::channel();
1814        ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
1815            let _ = tx.send(frame);
1816        }))));
1817        (ctx, rx)
1818    }
1819
1820    #[test]
1821    fn status_emitter_signal_triggers_push() {
1822        let (ctx, rx) = ctx_with_frame_rx();
1823        ctx.status_emitter().signal(ctx.build_status_snapshot());
1824        let frame = rx
1825            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1826            .expect("status_changed push");
1827        assert!(matches!(frame, PushFrame::StatusChanged(_)));
1828    }
1829
1830    #[test]
1831    fn status_emitter_debounces_burst() {
1832        let (ctx, rx) = ctx_with_frame_rx();
1833        for _ in 0..10 {
1834            ctx.status_emitter().signal(ctx.build_status_snapshot());
1835        }
1836        let frame = rx
1837            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1838            .expect("status_changed push");
1839        assert!(matches!(frame, PushFrame::StatusChanged(_)));
1840        assert!(rx.try_recv().is_err());
1841    }
1842
1843    #[test]
1844    fn status_emitter_separate_windows_separate_pushes() {
1845        let (ctx, rx) = ctx_with_frame_rx();
1846        ctx.status_emitter().signal(ctx.build_status_snapshot());
1847        rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1848            .expect("first push");
1849        ctx.status_emitter().signal(ctx.build_status_snapshot());
1850        rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1851            .expect("second push");
1852    }
1853
1854    #[test]
1855    fn status_emitter_no_signal_no_push() {
1856        let (_ctx, rx) = ctx_with_frame_rx();
1857        assert!(rx
1858            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
1859            .is_err());
1860    }
1861
1862    #[test]
1863    fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
1864        let (ctx, rx) = ctx_with_frame_rx();
1865        drop(ctx);
1866        assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
1867    }
1868}
1869
1870#[cfg(test)]
1871mod status_bar_tests {
1872    use super::*;
1873    use crate::parser::TreeSitterProvider;
1874
1875    fn ctx() -> AppContext {
1876        AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
1877    }
1878
1879    #[test]
1880    fn status_bar_counts_none_until_tier2_populated() {
1881        let ctx = ctx();
1882        // No scan has run yet — never surface a bar claiming "0 dead code".
1883        assert!(ctx.status_bar_counts().is_none());
1884
1885        ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
1886        let counts = ctx.status_bar_counts().expect("populated");
1887        assert_eq!(counts.dead_code, 5);
1888        assert_eq!(counts.unused_exports, 3);
1889        assert_eq!(counts.duplicates, 7);
1890        assert_eq!(counts.todos, 2);
1891        assert!(!counts.tier2_stale);
1892        // Errors/warnings are read live from an empty LSP store → 0.
1893        assert_eq!(counts.errors, 0);
1894        assert_eq!(counts.warnings, 0);
1895    }
1896
1897    #[test]
1898    fn partial_tier2_does_not_fabricate_zeros() {
1899        let ctx = ctx();
1900        // Only dead_code has completed (the slow first serial category); the
1901        // other two are still in flight. The bar must stay suppressed rather
1902        // than render `D5 U0 C0` with fabricated zeros (#1).
1903        ctx.update_status_bar_tier2(Some(5), None, None, None, true);
1904        assert!(
1905            ctx.status_bar_counts().is_none(),
1906            "bar must not surface until all three Tier-2 categories are real"
1907        );
1908
1909        // Second category completes — still incomplete, still suppressed.
1910        ctx.update_status_bar_tier2(None, Some(3), None, None, true);
1911        assert!(ctx.status_bar_counts().is_none());
1912
1913        // Final category completes → bar surfaces with all real counts, and
1914        // none of them were ever fabricated.
1915        ctx.update_status_bar_tier2(None, None, Some(7), None, false);
1916        let counts = ctx.status_bar_counts().expect("all three real now");
1917        assert_eq!(counts.dead_code, 5);
1918        assert_eq!(counts.unused_exports, 3);
1919        assert_eq!(counts.duplicates, 7);
1920    }
1921
1922    #[test]
1923    fn update_with_none_todos_preserves_last_known_todos() {
1924        let ctx = ctx();
1925        ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
1926        // A background-scan refresh passes todos=None → todo count preserved.
1927        ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
1928        let counts = ctx.status_bar_counts().expect("populated");
1929        assert_eq!(counts.todos, 9);
1930        assert_eq!(counts.dead_code, 2);
1931    }
1932
1933    #[test]
1934    fn update_with_none_count_preserves_last_known_count() {
1935        let ctx = ctx();
1936        ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
1937        // A refresh that only recomputed dead_code preserves the other two
1938        // real counts rather than overwriting them with a fabricated 0.
1939        ctx.update_status_bar_tier2(Some(11), None, None, None, false);
1940        let counts = ctx.status_bar_counts().expect("populated");
1941        assert_eq!(counts.dead_code, 11);
1942        assert_eq!(counts.unused_exports, 20);
1943        assert_eq!(counts.duplicates, 30);
1944    }
1945
1946    #[test]
1947    fn mark_stale_sets_flag_only_after_populate() {
1948        let ctx = ctx();
1949        // No-op before first populate.
1950        ctx.mark_status_bar_tier2_stale();
1951        assert!(ctx.status_bar_counts().is_none());
1952
1953        ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), Some(0), false);
1954        ctx.mark_status_bar_tier2_stale();
1955        assert!(ctx.status_bar_counts().expect("populated").tier2_stale);
1956
1957        // A completed scan clears stale.
1958        ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), None, false);
1959        assert!(!ctx.status_bar_counts().expect("populated").tier2_stale);
1960    }
1961
1962    // End-to-end wiring: a diagnostic for a file inflates the status-bar `E`
1963    // count (read live from the warm LSP set); clearing that file's diagnostics
1964    // (the deleted-file path) drops it back. This is the AppContext glue between
1965    // the watcher-drain clear and the agent-visible bar.
1966    #[test]
1967    fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
1968        use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
1969        use crate::lsp::registry::ServerKind;
1970        use crate::lsp::roots::ServerKey;
1971
1972        let ctx = ctx();
1973        ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); // populate so the bar surfaces
1974
1975        let file = std::path::PathBuf::from("/proj/gone.ts");
1976        {
1977            let mut lsp = ctx.lsp();
1978            lsp.diagnostics_store_mut_for_test().publish(
1979                ServerKey {
1980                    kind: ServerKind::TypeScript,
1981                    root: std::path::PathBuf::from("/proj"),
1982                },
1983                file.clone(),
1984                vec![StoredDiagnostic {
1985                    file: file.clone(),
1986                    line: 1,
1987                    column: 1,
1988                    end_line: 1,
1989                    end_column: 2,
1990                    severity: DiagnosticSeverity::Error,
1991                    message: "boom".into(),
1992                    code: None,
1993                    source: None,
1994                }],
1995            );
1996        }
1997
1998        // Bar reflects the live warm-set error.
1999        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
2000
2001        // Clearing the (now-deleted) file's diagnostics drops the count.
2002        let removed = ctx.lsp_clear_diagnostics_for_file(&file);
2003        assert!(removed);
2004        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
2005    }
2006}
2007
2008#[cfg(test)]
2009mod harness_path_tests {
2010    use super::*;
2011    use crate::harness::Harness;
2012    use crate::parser::TreeSitterProvider;
2013
2014    fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
2015        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2016        ctx.config_mut().storage_dir = Some(storage_dir);
2017        ctx.set_harness(harness);
2018        ctx
2019    }
2020
2021    #[test]
2022    fn harness_dir_resolves_correctly() {
2023        let storage = PathBuf::from("/tmp/cortexkit/aft");
2024        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2025
2026        assert_eq!(ctx.harness_dir(), storage.join("pi"));
2027    }
2028
2029    #[test]
2030    fn bash_tasks_dir_uses_hash_session() {
2031        let storage = PathBuf::from("/tmp/cortexkit/aft");
2032        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2033
2034        assert_eq!(
2035            ctx.bash_tasks_dir("ses_abc"),
2036            storage
2037                .join("opencode")
2038                .join("bash-tasks")
2039                .join(hash_session("ses_abc"))
2040        );
2041    }
2042
2043    #[test]
2044    fn backups_dir_includes_path_hash() {
2045        let storage = PathBuf::from("/tmp/cortexkit/aft");
2046        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2047
2048        assert_eq!(
2049            ctx.backups_dir("ses_abc", "pathhash"),
2050            storage
2051                .join("pi")
2052                .join("backups")
2053                .join(hash_session("ses_abc"))
2054                .join("pathhash")
2055        );
2056    }
2057
2058    #[test]
2059    fn filters_dir_under_harness() {
2060        let storage = PathBuf::from("/tmp/cortexkit/aft");
2061        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2062
2063        assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
2064    }
2065
2066    #[test]
2067    fn trust_file_is_host_global() {
2068        let storage = PathBuf::from("/tmp/cortexkit/aft");
2069        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2070
2071        assert_eq!(
2072            ctx.trust_file(),
2073            storage.join("trusted-filter-projects.json")
2074        );
2075    }
2076
2077    #[test]
2078    fn same_session_different_harness_resolve_different_paths() {
2079        let storage = PathBuf::from("/tmp/cortexkit/aft");
2080        let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2081        let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
2082
2083        assert_ne!(
2084            opencode.bash_tasks_dir("ses_same"),
2085            pi.bash_tasks_dir("ses_same")
2086        );
2087    }
2088}
2089
2090#[cfg(test)]
2091mod gitignore_tests {
2092    use super::*;
2093    use std::fs;
2094    use std::path::Path;
2095    use tempfile::TempDir;
2096
2097    fn make_ctx_with_root(root: &Path) -> AppContext {
2098        let provider = Box::new(crate::parser::TreeSitterProvider::new());
2099        let config = Config {
2100            project_root: Some(root.to_path_buf()),
2101            ..Config::default()
2102        };
2103        AppContext::new(provider, config)
2104    }
2105
2106    /// Helper: returns true when the matcher would skip `path` (as if it
2107    /// arrived via a watcher event for this project root). Canonicalizes
2108    /// the query path so symlink prefixes (e.g. macOS `/var` → `/private/var`)
2109    /// don't trip the `ignore` crate's "path is expected to be under the
2110    /// root" panic — production code does the same guard via
2111    /// `path.starts_with(matcher.path())` in `drain_watcher_events`.
2112    fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
2113        let Some(matcher) = ctx.gitignore() else {
2114            return false;
2115        };
2116        let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
2117        if !canonical.starts_with(matcher.path()) {
2118            return false;
2119        }
2120        let is_dir = canonical.is_dir();
2121        matcher
2122            .matched_path_or_any_parents(&canonical, is_dir)
2123            .is_ignore()
2124    }
2125
2126    /// Run `f` with global git-ignore discovery neutralized.
2127    ///
2128    /// `rebuild_gitignore` loads git's global excludes (the `ignore` crate
2129    /// resolves `$XDG_CONFIG_HOME/git/ignore`, falling back to
2130    /// `$HOME/.config/git/ignore`). A developer machine commonly has that file,
2131    /// so a "no project ignore → None" assertion is only deterministic when
2132    /// global discovery is pointed at an empty directory. Pointing
2133    /// `XDG_CONFIG_HOME` at a fresh tempdir does that without touching `HOME`
2134    /// (so it can't race the `HOME`-mutating configure tests). Serialized by a
2135    /// process-local mutex; env is restored before the closure result is used.
2136    fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
2137        use std::sync::{Mutex, OnceLock};
2138        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2139        let _guard = LOCK
2140            .get_or_init(|| Mutex::new(()))
2141            .lock()
2142            .unwrap_or_else(|e| e.into_inner());
2143        let tmp = TempDir::new().unwrap();
2144        let prev = std::env::var_os("XDG_CONFIG_HOME");
2145        // SAFETY: serialized by LOCK above; restored immediately after `f`.
2146        unsafe {
2147            std::env::set_var("XDG_CONFIG_HOME", tmp.path());
2148        }
2149        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
2150        unsafe {
2151            match prev {
2152                Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
2153                None => std::env::remove_var("XDG_CONFIG_HOME"),
2154            }
2155        }
2156        match result {
2157            Ok(r) => r,
2158            Err(p) => std::panic::resume_unwind(p),
2159        }
2160    }
2161
2162    #[test]
2163    fn rebuild_gitignore_returns_none_without_project_root() {
2164        let provider = Box::new(crate::parser::TreeSitterProvider::new());
2165        let ctx = AppContext::new(provider, Config::default());
2166        with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2167        assert!(ctx.gitignore().is_none());
2168    }
2169
2170    #[test]
2171    fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
2172        let tmp = TempDir::new().unwrap();
2173        let ctx = make_ctx_with_root(tmp.path());
2174        with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2175        assert!(ctx.gitignore().is_none());
2176    }
2177
2178    #[test]
2179    fn matcher_filters_files_in_ignored_dist_dir() {
2180        let tmp = TempDir::new().unwrap();
2181        fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
2182        fs::create_dir_all(tmp.path().join("dist")).unwrap();
2183        fs::create_dir_all(tmp.path().join("src")).unwrap();
2184        let dist_file = tmp.path().join("dist").join("bundle.js");
2185        let src_file = tmp.path().join("src").join("app.ts");
2186        fs::write(&dist_file, "x").unwrap();
2187        fs::write(&src_file, "y").unwrap();
2188
2189        let ctx = make_ctx_with_root(tmp.path());
2190        ctx.rebuild_gitignore();
2191
2192        assert!(ctx.gitignore().is_some());
2193        assert!(
2194            is_ignored(&ctx, &dist_file),
2195            "dist/bundle.js should be ignored"
2196        );
2197        assert!(
2198            !is_ignored(&ctx, &src_file),
2199            "src/app.ts should NOT be ignored"
2200        );
2201    }
2202
2203    #[test]
2204    fn matcher_handles_node_modules_and_target() {
2205        let tmp = TempDir::new().unwrap();
2206        fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
2207        fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
2208        fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
2209        let nm_file = tmp.path().join("node_modules/foo/index.js");
2210        let target_file = tmp.path().join("target/debug/aft");
2211        fs::write(&nm_file, "x").unwrap();
2212        fs::write(&target_file, "x").unwrap();
2213
2214        let ctx = make_ctx_with_root(tmp.path());
2215        ctx.rebuild_gitignore();
2216
2217        assert!(is_ignored(&ctx, &nm_file));
2218        assert!(is_ignored(&ctx, &target_file));
2219    }
2220
2221    #[test]
2222    fn matcher_honors_negation_pattern() {
2223        // .gitignore: ignore all *.log files EXCEPT important.log
2224        let tmp = TempDir::new().unwrap();
2225        fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
2226        let random_log = tmp.path().join("random.log");
2227        let important_log = tmp.path().join("important.log");
2228        fs::write(&random_log, "x").unwrap();
2229        fs::write(&important_log, "y").unwrap();
2230
2231        let ctx = make_ctx_with_root(tmp.path());
2232        ctx.rebuild_gitignore();
2233
2234        assert!(is_ignored(&ctx, &random_log));
2235        assert!(
2236            !is_ignored(&ctx, &important_log),
2237            "negation pattern should un-ignore important.log"
2238        );
2239    }
2240
2241    #[test]
2242    fn rebuild_picks_up_gitignore_changes() {
2243        let tmp = TempDir::new().unwrap();
2244        let ignore_path = tmp.path().join(".gitignore");
2245        fs::write(&ignore_path, "foo.txt\n").unwrap();
2246        let foo = tmp.path().join("foo.txt");
2247        let bar = tmp.path().join("bar.txt");
2248        fs::write(&foo, "").unwrap();
2249        fs::write(&bar, "").unwrap();
2250
2251        let ctx = make_ctx_with_root(tmp.path());
2252        ctx.rebuild_gitignore();
2253        assert!(is_ignored(&ctx, &foo));
2254        assert!(!is_ignored(&ctx, &bar));
2255
2256        // Now flip the rules: ignore bar.txt instead of foo.txt
2257        fs::write(&ignore_path, "bar.txt\n").unwrap();
2258        ctx.rebuild_gitignore();
2259        assert!(!is_ignored(&ctx, &foo));
2260        assert!(is_ignored(&ctx, &bar));
2261    }
2262
2263    #[test]
2264    fn gitignore_loads_info_exclude_when_present() {
2265        let tmp = TempDir::new().unwrap();
2266        let info_dir = tmp.path().join(".git/info");
2267        fs::create_dir_all(&info_dir).unwrap();
2268        fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
2269        let secrets = tmp.path().join("secrets.txt");
2270        let public = tmp.path().join("public.txt");
2271        fs::write(&secrets, "token").unwrap();
2272        fs::write(&public, "ok").unwrap();
2273
2274        let ctx = make_ctx_with_root(tmp.path());
2275        ctx.rebuild_gitignore();
2276
2277        assert!(is_ignored(&ctx, &secrets));
2278        assert!(!is_ignored(&ctx, &public));
2279    }
2280
2281    #[test]
2282    fn matcher_picks_up_nested_gitignore() {
2283        let tmp = TempDir::new().unwrap();
2284        // Root .gitignore is intentionally empty — only the nested one ignores
2285        fs::write(tmp.path().join(".gitignore"), "").unwrap();
2286        let sub = tmp.path().join("packages/foo");
2287        fs::create_dir_all(&sub).unwrap();
2288        fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
2289        let generated_file = sub.join("generated").join("out.js");
2290        fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
2291        fs::write(&generated_file, "x").unwrap();
2292
2293        let ctx = make_ctx_with_root(tmp.path());
2294        ctx.rebuild_gitignore();
2295
2296        assert!(
2297            is_ignored(&ctx, &generated_file),
2298            "nested gitignore in packages/foo/.gitignore should ignore generated/"
2299        );
2300    }
2301}