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