Skip to main content

aft/
context.rs

1use std::cell::{Ref, RefCell, RefMut};
2use std::io::{self, BufWriter};
3use std::path::{Component, Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::{mpsc, Arc, Mutex};
6use std::time::{Duration, Instant};
7
8use lsp_types::FileChangeType;
9use notify::RecommendedWatcher;
10use rusqlite::Connection;
11
12use crate::backup::hash_session;
13use crate::backup::BackupStore;
14use crate::bash_background::{BgCompletion, BgTaskRegistry};
15use crate::callgraph::CallGraph;
16use crate::checkpoint::CheckpointStore;
17use crate::config::Config;
18use crate::harness::Harness;
19use crate::language::LanguageProvider;
20use crate::lsp::manager::LspManager;
21use crate::lsp::registry::is_config_file_path_with_custom;
22use crate::parser::{SharedSymbolCache, SymbolCache};
23use crate::protocol::{
24    ConfigureWarningsFrame, ProgressFrame, PushFrame, StatusChangedFrame, StatusPayload,
25};
26
27pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
28pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
29pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
30const STATUS_DEBOUNCE_MS: u64 = 1_000;
31
32pub struct StatusEmitter {
33    latest: Arc<Mutex<Option<StatusPayload>>>,
34    notify: mpsc::Sender<()>,
35}
36
37impl StatusEmitter {
38    fn new(progress_sender: SharedProgressSender) -> Self {
39        let (notify, rx) = mpsc::channel();
40        let latest = Arc::new(Mutex::new(None));
41        let latest_for_thread = Arc::clone(&latest);
42        std::thread::spawn(move || {
43            status_debounce_loop(rx, latest_for_thread, progress_sender);
44        });
45        Self { latest, notify }
46    }
47
48    pub fn signal(&self, snapshot: StatusPayload) {
49        if let Ok(mut latest) = self.latest.lock() {
50            *latest = Some(snapshot);
51        }
52        let _ = self.notify.send(());
53    }
54}
55
56fn status_debounce_loop(
57    rx: mpsc::Receiver<()>,
58    latest: Arc<Mutex<Option<StatusPayload>>>,
59    progress_sender: SharedProgressSender,
60) {
61    while rx.recv().is_ok() {
62        let deadline = Instant::now() + Duration::from_millis(STATUS_DEBOUNCE_MS);
63        while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
64            match rx.recv_timeout(remaining) {
65                Ok(()) => continue,
66                Err(mpsc::RecvTimeoutError::Timeout) => break,
67                Err(mpsc::RecvTimeoutError::Disconnected) => return,
68            }
69        }
70
71        let snapshot = latest.lock().ok().and_then(|mut latest| latest.take());
72        let Some(snapshot) = snapshot else { continue };
73        let sender = progress_sender
74            .lock()
75            .ok()
76            .and_then(|sender| sender.clone());
77        if let Some(sender) = sender {
78            sender(PushFrame::StatusChanged(StatusChangedFrame::new(
79                None, snapshot,
80            )));
81        }
82    }
83}
84use crate::search_index::SearchIndex;
85use crate::semantic_index::SemanticIndex;
86
87#[derive(Debug, Clone)]
88pub enum SemanticIndexStatus {
89    Disabled,
90    Building {
91        /// Cold-build only — index is not queryable.
92        stage: String,
93        files: Option<usize>,
94        entries_done: Option<usize>,
95        entries_total: Option<usize>,
96    },
97    Ready {
98        /// Files currently being re-embedded after recent edits. The index is
99        /// still queryable; results for these files may be temporarily missing.
100        refreshing: Vec<PathBuf>,
101    },
102    Failed(String),
103}
104
105impl SemanticIndexStatus {
106    pub fn ready() -> Self {
107        Self::Ready {
108            refreshing: Vec::new(),
109        }
110    }
111
112    pub fn add_refreshing_file(&mut self, path: PathBuf) {
113        if let Self::Ready { refreshing } = self {
114            if !refreshing.iter().any(|existing| existing == &path) {
115                refreshing.push(path);
116                refreshing.sort();
117            }
118        }
119    }
120
121    pub fn remove_refreshing_file(&mut self, path: &Path) {
122        if let Self::Ready { refreshing } = self {
123            refreshing.retain(|existing| existing != path);
124        }
125    }
126
127    pub fn refreshing_count(&self) -> usize {
128        match self {
129            Self::Ready { refreshing } => refreshing.len(),
130            _ => 0,
131        }
132    }
133}
134
135pub enum SemanticIndexEvent {
136    Progress {
137        stage: String,
138        files: Option<usize>,
139        entries_done: Option<usize>,
140        entries_total: Option<usize>,
141    },
142    Ready(SemanticIndex),
143    Failed(String),
144}
145
146/// Normalize a path by resolving `.` and `..` components lexically,
147/// without touching the filesystem. This prevents path traversal
148/// attacks when `fs::canonicalize` fails (e.g. for non-existent paths).
149fn normalize_path(path: &Path) -> PathBuf {
150    let mut result = PathBuf::new();
151    for component in path.components() {
152        match component {
153            Component::ParentDir => {
154                // Pop the last component unless we're at root or have no components
155                if !result.pop() {
156                    result.push(component);
157                }
158            }
159            Component::CurDir => {} // Skip `.`
160            _ => result.push(component),
161        }
162    }
163    result
164}
165
166fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
167    let mut existing = path.to_path_buf();
168    let mut tail_segments = Vec::new();
169
170    while !existing.exists() {
171        if let Some(name) = existing.file_name() {
172            tail_segments.push(name.to_owned());
173        } else {
174            break;
175        }
176
177        existing = match existing.parent() {
178            Some(parent) => parent.to_path_buf(),
179            None => break,
180        };
181    }
182
183    let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
184    for segment in tail_segments.into_iter().rev() {
185        resolved.push(segment);
186    }
187
188    resolved
189}
190
191fn path_error_response(
192    req_id: &str,
193    path: &Path,
194    resolved_root: &Path,
195) -> crate::protocol::Response {
196    crate::protocol::Response::error(
197        req_id,
198        "path_outside_root",
199        format!(
200            "path '{}' is outside the project root '{}'",
201            path.display(),
202            resolved_root.display()
203        ),
204    )
205}
206
207/// Walk `candidate` component-by-component. For any component that is a
208/// symlink on disk, iteratively follow the full chain (up to 40 hops) and
209/// reject if any hop's resolved target lies outside `resolved_root`.
210///
211/// This is the fallback path used when `fs::canonicalize` fails (e.g. on
212/// Linux with broken symlink chains pointing to non-existent destinations).
213/// On macOS `canonicalize` also fails for broken symlinks but the returned
214/// `/var/...` tempdir paths diverge from `resolved_root`'s `/private/var/...`
215/// form, so we must accept either form when deciding which symlinks to check.
216fn reject_escaping_symlink(
217    req_id: &str,
218    original_path: &Path,
219    candidate: &Path,
220    resolved_root: &Path,
221    raw_root: &Path,
222) -> Result<(), crate::protocol::Response> {
223    let mut current = PathBuf::new();
224
225    for component in candidate.components() {
226        current.push(component);
227
228        let Ok(metadata) = std::fs::symlink_metadata(&current) else {
229            continue;
230        };
231
232        if !metadata.file_type().is_symlink() {
233            continue;
234        }
235
236        // Only check symlinks that live inside the project root. This skips
237        // OS-level prefix symlinks (macOS /var → /private/var) that are not
238        // inside our project directory and whose "escaping" is harmless.
239        //
240        // We compare against BOTH the canonicalized root (resolved_root, e.g.
241        // /private/var/.../project) AND the raw root (e.g. /var/.../project)
242        // because tempdir() returns raw paths while fs::canonicalize returns
243        // the resolved form — and our `current` may be in either form.
244        let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
245        if !inside_root {
246            continue;
247        }
248
249        iterative_follow_chain(req_id, original_path, &current, resolved_root)?;
250    }
251
252    Ok(())
253}
254
255/// Iteratively follow a symlink chain from `link` and reject if any hop's
256/// resolved target is outside `resolved_root`. Depth-capped at 40 hops.
257fn iterative_follow_chain(
258    req_id: &str,
259    original_path: &Path,
260    start: &Path,
261    resolved_root: &Path,
262) -> Result<(), crate::protocol::Response> {
263    let mut link = start.to_path_buf();
264    let mut depth = 0usize;
265
266    loop {
267        if depth > 40 {
268            return Err(path_error_response(req_id, original_path, resolved_root));
269        }
270
271        let target = match std::fs::read_link(&link) {
272            Ok(t) => t,
273            Err(_) => {
274                // Can't read the link — treat as escaping to be safe.
275                return Err(path_error_response(req_id, original_path, resolved_root));
276            }
277        };
278
279        let resolved_target = if target.is_absolute() {
280            normalize_path(&target)
281        } else {
282            let parent = link.parent().unwrap_or_else(|| Path::new(""));
283            normalize_path(&parent.join(&target))
284        };
285
286        // Check boundary: use canonicalized target when available (handles
287        // macOS /var → /private/var aliasing), fall back to the normalized
288        // path when canonicalize fails (e.g. broken symlink on Linux).
289        let canonical_target =
290            std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
291
292        if !canonical_target.starts_with(resolved_root)
293            && !resolved_target.starts_with(resolved_root)
294        {
295            return Err(path_error_response(req_id, original_path, resolved_root));
296        }
297
298        // If the target is itself a symlink, follow the next hop.
299        match std::fs::symlink_metadata(&resolved_target) {
300            Ok(meta) if meta.file_type().is_symlink() => {
301                link = resolved_target;
302                depth += 1;
303            }
304            _ => break, // Non-symlink or non-existent target — chain ends here.
305        }
306    }
307
308    Ok(())
309}
310
311/// Shared application context threaded through all command handlers.
312///
313/// Holds the language provider, backup/checkpoint stores, configuration,
314/// and call graph engine. Constructed once at startup and passed by
315/// reference to `dispatch`.
316///
317/// Stores use `RefCell` for interior mutability — the binary is single-threaded
318/// (one request at a time on the stdin read loop) so runtime borrow checking
319/// is safe and never contended.
320pub struct AppContext {
321    provider: Box<dyn LanguageProvider>,
322    backup: RefCell<BackupStore>,
323    checkpoint: RefCell<CheckpointStore>,
324    db: RefCell<Option<Arc<Mutex<Connection>>>>,
325    config: RefCell<Config>,
326    pub harness: RefCell<Option<Harness>>,
327    canonical_cache_root: RefCell<Option<PathBuf>>,
328    is_worktree_bridge: RefCell<bool>,
329    git_common_dir: RefCell<Option<PathBuf>>,
330    /// Reasons (if any) why heavy AFT subsystems were auto-disabled for the
331    /// current project root. Populated by `handle_configure` based on the
332    /// canonical project root and synchronous file count. Each reason is a
333    /// stable machine-readable string suffix (`"home_root"`,
334    /// `"search_too_many_files:N"`, etc.) so the plugin can render distinct
335    /// degraded-mode UI states without re-deriving the reason locally.
336    /// Empty when the project is healthy / full-featured.
337    degraded_reasons: RefCell<Vec<String>>,
338    callgraph: RefCell<Option<CallGraph>>,
339    search_index: RefCell<Option<SearchIndex>>,
340    search_index_rx: RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>>,
341    symbol_cache: SharedSymbolCache,
342    semantic_index: RefCell<Option<SemanticIndex>>,
343    semantic_index_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
344    semantic_index_status: RefCell<SemanticIndexStatus>,
345    semantic_embedding_model: RefCell<Option<crate::semantic_index::EmbeddingModel>>,
346    watcher: RefCell<Option<RecommendedWatcher>>,
347    watcher_rx: RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>>,
348    lsp_manager: RefCell<LspManager>,
349    /// Shared registry of LSP child PIDs. Cloned and passed to the signal
350    /// handler so it can SIGKILL all children before aft exits, preventing
351    /// orphaned LSP processes when bridge.shutdown() SIGTERMs aft.
352    lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
353    stdout_writer: SharedStdoutWriter,
354    progress_sender: SharedProgressSender,
355    configure_generation: AtomicU64,
356    configure_warnings_tx: mpsc::Sender<(u64, ConfigureWarningsFrame)>,
357    configure_warnings_rx: mpsc::Receiver<(u64, ConfigureWarningsFrame)>,
358    status_emitter: StatusEmitter,
359    bash_background: BgTaskRegistry,
360    /// Thread-safe registry of TOML output filters. Lazy-built on first
361    /// access; populated atomically via `RwLock`. Shared between command
362    /// handlers (which use it through `filter_registry()` -> read guard) and
363    /// the `BgTaskRegistry` watchdog thread (which uses it through
364    /// `compress::compress_with_registry`). Reloaded when configure changes
365    /// the project root or storage_dir; see [`AppContext::reset_filter_registry`].
366    filter_registry: crate::compress::SharedFilterRegistry,
367    /// Set to true once the filter_registry has been populated. Avoids
368    /// double-loading on hot paths without holding a write lock.
369    filter_registry_loaded: std::sync::atomic::AtomicBool,
370    /// Live `experimental.bash.compress` flag, kept in sync with `config`
371    /// from the configure handler. Exposed via [`AppContext::bash_compress_flag`]
372    /// so the BgTaskRegistry's watchdog-thread compressor can read it without
373    /// holding the config refcell.
374    bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
375    /// Project gitignore matcher, rebuilt by [`AppContext::rebuild_gitignore`]
376    /// whenever `project_root` changes or a watcher event reports a
377    /// `.gitignore` write. Used by the watcher event filter to decide which
378    /// path-changes are interesting to AFT's caches. `None` when no project
379    /// root is configured or when the project has no gitignore files; in that
380    /// case the watcher falls back to a small hardcoded infra-directory skip.
381    gitignore: RefCell<Option<Arc<ignore::gitignore::Gitignore>>>,
382}
383
384impl AppContext {
385    pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
386        let bash_compress_enabled = config.experimental_bash_compress;
387        let progress_sender = Arc::new(Mutex::new(None));
388        let stdout_writer = Arc::new(Mutex::new(BufWriter::new(io::stdout())));
389        let (configure_warnings_tx, configure_warnings_rx) = mpsc::channel();
390        let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
391        let symbol_cache = provider
392            .as_any()
393            .downcast_ref::<crate::parser::TreeSitterProvider>()
394            .map(|provider| provider.symbol_cache())
395            .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
396        let lsp_child_registry = crate::lsp::child_registry::LspChildRegistry::new();
397        let mut lsp_manager = LspManager::new();
398        lsp_manager.set_child_registry(lsp_child_registry.clone());
399        AppContext {
400            provider,
401            backup: RefCell::new(BackupStore::new()),
402            checkpoint: RefCell::new(CheckpointStore::new()),
403            db: RefCell::new(None),
404            config: RefCell::new(config),
405            harness: RefCell::new(None),
406            canonical_cache_root: RefCell::new(None),
407            is_worktree_bridge: RefCell::new(false),
408            git_common_dir: RefCell::new(None),
409            degraded_reasons: RefCell::new(Vec::new()),
410            callgraph: RefCell::new(None),
411            search_index: RefCell::new(None),
412            search_index_rx: RefCell::new(None),
413            symbol_cache,
414            semantic_index: RefCell::new(None),
415            semantic_index_rx: RefCell::new(None),
416            semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
417            semantic_embedding_model: RefCell::new(None),
418            watcher: RefCell::new(None),
419            watcher_rx: RefCell::new(None),
420            lsp_manager: RefCell::new(lsp_manager),
421            lsp_child_registry,
422            stdout_writer,
423            progress_sender: Arc::clone(&progress_sender),
424            configure_generation: AtomicU64::new(0),
425            configure_warnings_tx,
426            configure_warnings_rx,
427            status_emitter,
428            bash_background: BgTaskRegistry::new(progress_sender),
429            filter_registry: Arc::new(std::sync::RwLock::new(
430                crate::compress::toml_filter::FilterRegistry::default(),
431            )),
432            filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
433            bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
434            gitignore: RefCell::new(None),
435        }
436    }
437
438    /// Borrow the cached project gitignore matcher. Returns `None` when no
439    /// project_root is configured or when the project has no gitignore files.
440    pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
441        self.gitignore.borrow().clone()
442    }
443
444    /// Rebuild the gitignore matcher from the current `project_root` and
445    /// cache it. Called by the configure handler whenever the project root
446    /// changes, and by the watcher event drain when a `.gitignore` file
447    /// itself is modified.
448    ///
449    /// The builder honors:
450    /// - `<project_root>/.gitignore`
451    /// - `<project_root>/.git/info/exclude` (loaded explicitly because
452    ///   `GitignoreBuilder::new` does not auto-discover it)
453    /// - nested `.gitignore` files (each `.gitignore` discovered during
454    ///   the recursive walk)
455    ///
456    /// Stores `None` if there's no project_root or no matchable gitignore
457    /// files. Logs build errors but never fails configure.
458    /// Clear any cached gitignore matcher without rebuilding.
459    ///
460    /// Used by `handle_configure` in degraded mode (e.g. `project_root == $HOME`)
461    /// where running the gitignore-discovery walk would exceed the configure
462    /// budget. The watcher event filter falls back to the hardcoded infra-dir
463    /// skip list when no matcher is present.
464    pub fn clear_gitignore(&self) {
465        *self.gitignore.borrow_mut() = None;
466    }
467
468    pub fn rebuild_gitignore(&self) {
469        use ignore::gitignore::GitignoreBuilder;
470        use std::path::Path;
471        let root_raw = match self.config().project_root.clone() {
472            Some(r) => r,
473            None => {
474                *self.gitignore.borrow_mut() = None;
475                return;
476            }
477        };
478        // Canonicalize the root so symlink-prefix mismatches don't cause
479        // `Gitignore::matched_path_or_any_parents` to panic on watcher event
480        // paths. macOS routinely surfaces `/private/var/...` while `project_root`
481        // arrives as `/var/...` (a symlink to `/private/var`); the `ignore`
482        // crate's matcher panics when a query path isn't lexically under the
483        // matcher's root. Canonicalizing both ends (here for root, naturally
484        // for watcher events on macOS) keeps them in the same prefix space.
485        let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
486        let mut builder = GitignoreBuilder::new(&root);
487        // Add root .gitignore (the most common case)
488        let root_ignore = Path::new(&root).join(".gitignore");
489        if root_ignore.exists() {
490            if let Some(err) = builder.add(&root_ignore) {
491                crate::slog_warn!(
492                    "gitignore parse error in {}: {}",
493                    root_ignore.display(),
494                    err
495                );
496            }
497        }
498        // .git/info/exclude — manually added because GitignoreBuilder::new()
499        // does not auto-discover it (verified against ignore-0.4.25 source).
500        let info_exclude = Path::new(&root).join(".git").join("info").join("exclude");
501        if info_exclude.exists() {
502            if let Some(err) = builder.add(&info_exclude) {
503                crate::slog_warn!(
504                    "gitignore parse error in {}: {}",
505                    info_exclude.display(),
506                    err
507                );
508            }
509        }
510        // Walk the project to pick up nested .gitignore files. Cap the walk
511        // at the same SOURCE_WALK_LIMIT used by other configure-time walks
512        // (currently 20000 files); gitignore lookup-cost stays bounded for
513        // huge monorepos. Skip the obvious infra dirs so we don't accidentally
514        // load a vendored repo's .gitignore that doesn't apply to ours.
515        let walker = ignore::WalkBuilder::new(&root)
516            .standard_filters(true)
517            // Hidden files are filtered by default, but `.gitignore` starts with
518            // `.` so we need to traverse "hidden" entries to find nested ones.
519            .hidden(false)
520            .max_depth(Some(8))
521            .filter_entry(|entry| {
522                let name = entry.file_name().to_string_lossy();
523                !matches!(
524                    name.as_ref(),
525                    "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
526                )
527            })
528            .build();
529        for entry in walker.flatten() {
530            if entry.file_name() == ".gitignore" && entry.path() != root_ignore {
531                if let Some(err) = builder.add(entry.path()) {
532                    crate::slog_warn!(
533                        "nested gitignore parse error in {}: {}",
534                        entry.path().display(),
535                        err
536                    );
537                }
538            }
539        }
540        match builder.build() {
541            Ok(gi) => {
542                let count = gi.num_ignores();
543                if count > 0 {
544                    crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
545                    *self.gitignore.borrow_mut() = Some(Arc::new(gi));
546                } else {
547                    *self.gitignore.borrow_mut() = None;
548                }
549            }
550            Err(err) => {
551                crate::slog_warn!("gitignore matcher build failed: {}", err);
552                *self.gitignore.borrow_mut() = None;
553            }
554        }
555    }
556
557    /// Shared atomic mirror of `experimental.bash.compress`. Updated by the
558    /// configure handler. Read by the BgTaskRegistry compressor closure.
559    pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
560        Arc::clone(&self.bash_compress_flag)
561    }
562
563    /// Update the shared `bash_compress_flag` mirror. Call this from the
564    /// configure handler whenever `experimental.bash.compress` changes so the
565    /// BgTaskRegistry watchdog sees the new value on the next completion.
566    pub fn sync_bash_compress_flag(&self) {
567        let value = self.config().experimental_bash_compress;
568        self.bash_compress_flag
569            .store(value, std::sync::atomic::Ordering::Relaxed);
570    }
571
572    pub fn set_bash_compress_enabled(&self, enabled: bool) {
573        self.config_mut().experimental_bash_compress = enabled;
574        self.bash_compress_flag
575            .store(enabled, std::sync::atomic::Ordering::Relaxed);
576    }
577
578    /// Read-only access to the TOML filter registry, building it lazily on
579    /// first use. Returns an `RwLockReadGuard` that callers can `lookup`
580    /// against directly.
581    pub fn filter_registry(
582        &self,
583    ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
584        self.ensure_filter_registry_loaded();
585        match self.filter_registry.read() {
586            Ok(g) => g,
587            Err(poisoned) => poisoned.into_inner(),
588        }
589    }
590
591    /// Returns the shared `Arc<RwLock<FilterRegistry>>` handle so threads
592    /// outside `AppContext` (notably the bash watchdog) can read it without
593    /// touching the rest of the context.
594    pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
595        self.ensure_filter_registry_loaded();
596        Arc::clone(&self.filter_registry)
597    }
598
599    /// Force a fresh load of the TOML filter registry. Called when configure
600    /// changes the project root, storage_dir, or trust state so subsequent
601    /// `compress::compress` calls pick up new filters.
602    pub fn reset_filter_registry(&self) {
603        let new_registry = crate::compress::build_registry_for_context(self);
604        match self.filter_registry.write() {
605            Ok(mut slot) => *slot = new_registry,
606            Err(poisoned) => *poisoned.into_inner() = new_registry,
607        }
608        self.filter_registry_loaded
609            .store(true, std::sync::atomic::Ordering::Release);
610    }
611
612    fn ensure_filter_registry_loaded(&self) {
613        use std::sync::atomic::Ordering;
614        if self.filter_registry_loaded.load(Ordering::Acquire) {
615            return;
616        }
617        // Build outside the lock to avoid blocking other readers during a
618        // multi-file TOML parse.
619        let new_registry = crate::compress::build_registry_for_context(self);
620        if let Ok(mut slot) = self.filter_registry.write() {
621            *slot = new_registry;
622            self.filter_registry_loaded.store(true, Ordering::Release);
623        }
624    }
625
626    /// Clone the LSP child registry handle. Used by main.rs to give the
627    /// signal handler thread a way to SIGKILL LSP children on shutdown.
628    pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
629        self.lsp_child_registry.clone()
630    }
631
632    pub fn stdout_writer(&self) -> SharedStdoutWriter {
633        Arc::clone(&self.stdout_writer)
634    }
635
636    pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
637        if let Ok(mut progress_sender) = self.progress_sender.lock() {
638            *progress_sender = sender;
639        }
640    }
641
642    pub fn emit_progress(&self, frame: ProgressFrame) {
643        let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
644            return;
645        };
646        if let Some(sender) = progress_sender.as_ref() {
647            sender(PushFrame::Progress(frame));
648        }
649    }
650
651    pub fn status_emitter(&self) -> &StatusEmitter {
652        &self.status_emitter
653    }
654
655    /// Get a clone of the current progress sender for use from background
656    /// threads. Returns `None` when the main loop hasn't installed one (tests,
657    /// CLI without push frames).
658    ///
659    /// Used by `configure`'s deferred file-walk thread to push warnings after
660    /// configure has already returned, so configure latency stays sub-100 ms
661    /// even on huge directories.
662    pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
663        self.progress_sender
664            .lock()
665            .ok()
666            .and_then(|sender| sender.clone())
667    }
668
669    pub fn advance_configure_generation(&self) -> u64 {
670        self.configure_generation
671            .fetch_add(1, Ordering::SeqCst)
672            .wrapping_add(1)
673    }
674
675    pub fn configure_generation(&self) -> u64 {
676        self.configure_generation.load(Ordering::SeqCst)
677    }
678
679    pub fn configure_warnings_sender(&self) -> mpsc::Sender<(u64, ConfigureWarningsFrame)> {
680        self.configure_warnings_tx.clone()
681    }
682
683    pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
684        let mut warnings = Vec::new();
685        while let Ok(warning) = self.configure_warnings_rx.try_recv() {
686            warnings.push(warning);
687        }
688        warnings
689    }
690
691    pub fn bash_background(&self) -> &BgTaskRegistry {
692        &self.bash_background
693    }
694
695    pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
696        self.bash_background.drain_completions()
697    }
698
699    /// Access the language provider.
700    pub fn provider(&self) -> &dyn LanguageProvider {
701        self.provider.as_ref()
702    }
703
704    /// Access the backup store.
705    pub fn backup(&self) -> &RefCell<BackupStore> {
706        &self.backup
707    }
708
709    /// Access the checkpoint store.
710    pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
711        &self.checkpoint
712    }
713
714    pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
715        *self.db.borrow_mut() = Some(conn);
716    }
717
718    pub fn clear_db(&self) {
719        *self.db.borrow_mut() = None;
720    }
721
722    pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
723        self.db.borrow().clone()
724    }
725
726    /// Access the configuration (shared borrow).
727    pub fn config(&self) -> Ref<'_, Config> {
728        self.config.borrow()
729    }
730
731    /// Access the configuration (mutable borrow).
732    pub fn config_mut(&self) -> RefMut<'_, Config> {
733        self.config.borrow_mut()
734    }
735
736    pub fn set_harness(&self, harness: Harness) {
737        *self.harness.borrow_mut() = Some(harness);
738        self.bash_background.set_harness(harness);
739    }
740
741    pub fn harness_opt(&self) -> Option<Harness> {
742        *self.harness.borrow()
743    }
744
745    pub fn harness(&self) -> Harness {
746        self.harness_opt()
747            .expect("harness set by configure before any tool call")
748    }
749
750    pub fn storage_dir(&self) -> PathBuf {
751        crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
752    }
753
754    pub fn harness_dir(&self) -> PathBuf {
755        self.storage_dir().join(self.harness().as_str())
756    }
757
758    pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
759        self.harness_dir()
760            .join("bash-tasks")
761            .join(hash_session(session_id))
762    }
763
764    pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
765        self.harness_dir()
766            .join("backups")
767            .join(hash_session(session_id))
768            .join(path_hash)
769    }
770
771    pub fn filters_dir(&self) -> PathBuf {
772        self.harness_dir().join("filters")
773    }
774
775    /// HOST-GLOBAL — NOT under harness_dir. Read by trust.rs across both harnesses.
776    pub fn trust_file(&self) -> PathBuf {
777        self.storage_dir().join("trusted-filter-projects.json")
778    }
779
780    pub fn set_canonical_cache_root(&self, root: PathBuf) {
781        debug_assert!(root.is_absolute());
782        *self.canonical_cache_root.borrow_mut() = Some(root);
783    }
784
785    pub fn canonical_cache_root(&self) -> PathBuf {
786        self.canonical_cache_root
787            .borrow()
788            .clone()
789            .expect("canonical_cache_root accessed before handle_configure")
790    }
791
792    pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
793        self.canonical_cache_root.borrow().clone()
794    }
795
796    pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
797        *self.is_worktree_bridge.borrow_mut() = is_worktree_bridge;
798        *self.git_common_dir.borrow_mut() = git_common_dir;
799    }
800
801    pub fn is_worktree_bridge(&self) -> bool {
802        *self.is_worktree_bridge.borrow()
803    }
804
805    /// Replace the current degraded-mode reasons. Empty vec = full-featured
806    /// mode (no degradation). Called by `handle_configure` after deciding
807    /// which subsystems to disable for this project root.
808    pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
809        *self.degraded_reasons.borrow_mut() = reasons;
810    }
811
812    /// Snapshot of current degraded-mode reasons. Order is stable
813    /// (insertion order from `set_degraded_reasons`) so UI rendering and
814    /// snapshot diffs are deterministic.
815    pub fn degraded_reasons(&self) -> Vec<String> {
816        self.degraded_reasons.borrow().clone()
817    }
818
819    /// True iff at least one degraded reason is recorded.
820    pub fn is_degraded(&self) -> bool {
821        !self.degraded_reasons.borrow().is_empty()
822    }
823
824    pub fn cache_role(&self) -> &'static str {
825        if self.canonical_cache_root.borrow().is_none() {
826            "not_initialized"
827        } else if self.is_worktree_bridge() {
828            "worktree"
829        } else {
830            "main"
831        }
832    }
833
834    /// Access the call graph engine.
835    pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
836        &self.callgraph
837    }
838
839    /// Access the search index.
840    pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
841        &self.search_index
842    }
843
844    /// Access the search-index build receiver.
845    pub fn search_index_rx(&self) -> &RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>> {
846        &self.search_index_rx
847    }
848
849    /// Access the shared symbol cache.
850    pub fn symbol_cache(&self) -> SharedSymbolCache {
851        Arc::clone(&self.symbol_cache)
852    }
853
854    /// Clear the shared symbol cache and return the new active generation.
855    pub fn reset_symbol_cache(&self) -> u64 {
856        self.symbol_cache
857            .write()
858            .map(|mut cache| cache.reset())
859            .unwrap_or(0)
860    }
861
862    /// Access the semantic search index.
863    pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
864        &self.semantic_index
865    }
866
867    /// Access the semantic-index build receiver.
868    pub fn semantic_index_rx(
869        &self,
870    ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
871        &self.semantic_index_rx
872    }
873
874    pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
875        &self.semantic_index_status
876    }
877
878    /// Access the cached semantic embedding model.
879    pub fn semantic_embedding_model(
880        &self,
881    ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
882        &self.semantic_embedding_model
883    }
884
885    /// Access the file watcher handle (kept alive to continue watching).
886    pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
887        &self.watcher
888    }
889
890    /// Access the watcher event receiver.
891    pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
892        &self.watcher_rx
893    }
894
895    /// Access the LSP manager.
896    pub fn lsp(&self) -> RefMut<'_, LspManager> {
897        self.lsp_manager.borrow_mut()
898    }
899
900    /// Notify LSP servers that a file was written.
901    /// Call this after write_format_validate in command handlers.
902    pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
903        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
904            let config = self.config();
905            if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
906                crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
907            }
908        }
909    }
910
911    /// Notify LSP and optionally wait for diagnostics.
912    ///
913    /// Call this after `write_format_validate` when the request has `"diagnostics": true`.
914    /// Sends didChange to the server, waits briefly for publishDiagnostics, and returns
915    /// any diagnostics for the file. If no server is running, returns empty immediately.
916    ///
917    /// v0.17.3: this is the version-aware path. Pre-edit cached diagnostics
918    /// are NEVER returned — only entries whose `version` matches the
919    /// post-edit document version (or, for unversioned servers, whose
920    /// `epoch` advanced past the pre-edit snapshot).
921    pub fn lsp_notify_and_collect_diagnostics(
922        &self,
923        file_path: &Path,
924        content: &str,
925        timeout: std::time::Duration,
926    ) -> crate::lsp::manager::PostEditWaitOutcome {
927        let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
928            return crate::lsp::manager::PostEditWaitOutcome::default();
929        };
930
931        // Clear any queued notifications before this write so the wait loop only
932        // observes diagnostics triggered by the current change.
933        lsp.drain_events();
934
935        // Snapshot per-server epochs and document versions BEFORE sending
936        // didChange so the wait loop can prove freshness without accepting
937        // stale pre-edit publishes that arrived late.
938        let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
939
940        // Send didChange/didOpen and capture per-server target version.
941        let config = self.config();
942        let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
943        {
944            Ok(v) => v,
945            Err(e) => {
946                crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
947                return crate::lsp::manager::PostEditWaitOutcome::default();
948            }
949        };
950
951        // No server matched this file — return an empty outcome that's
952        // honestly `complete: true` (nothing to wait for).
953        if expected_versions.is_empty() {
954            return crate::lsp::manager::PostEditWaitOutcome::default();
955        }
956
957        lsp.wait_for_post_edit_diagnostics(
958            file_path,
959            &config,
960            &expected_versions,
961            &pre_snapshot,
962            timeout,
963        )
964    }
965
966    /// Collect custom server root_markers from user config for use in
967    /// `is_config_file_path_with_custom` checks (#25).
968    fn custom_lsp_root_markers(&self) -> Vec<String> {
969        self.config()
970            .lsp_servers
971            .iter()
972            .flat_map(|s| s.root_markers.iter().cloned())
973            .collect()
974    }
975
976    fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
977        let custom_markers = self.custom_lsp_root_markers();
978        let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
979            .iter()
980            .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
981            .cloned()
982            .map(|path| {
983                let change_type = if path.exists() {
984                    FileChangeType::CHANGED
985                } else {
986                    FileChangeType::DELETED
987                };
988                (path, change_type)
989            })
990            .collect();
991
992        self.notify_watched_config_events(&config_paths);
993    }
994
995    fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
996        let paths = params
997            .get("multi_file_write_paths")
998            .and_then(|value| value.as_array())?
999            .iter()
1000            .filter_map(|value| value.as_str())
1001            .map(PathBuf::from)
1002            .collect::<Vec<_>>();
1003
1004        (!paths.is_empty()).then_some(paths)
1005    }
1006
1007    /// Parse config-file watched events from `multi_file_write_paths` when the
1008    /// array contains object entries `{ "path": "...", "type": "created|changed|deleted" }`.
1009    ///
1010    /// This handles the OBJECT variant of `multi_file_write_paths`. The STRING
1011    /// variant (bare path strings) is handled by `multi_file_write_paths()` and
1012    /// `notify_watched_config_files()`. Both variants read the same JSON key but
1013    /// with different per-entry schemas — they are NOT redundant.
1014    ///
1015    /// #18 note: in older code this function also existed alongside `multi_file_write_paths()`
1016    /// and was reachable via the `else if` branch when all entries were objects.
1017    /// Restoring both is correct.
1018    fn watched_file_events_from_params(
1019        params: &serde_json::Value,
1020        extra_markers: &[String],
1021    ) -> Option<Vec<(PathBuf, FileChangeType)>> {
1022        let events = params
1023            .get("multi_file_write_paths")
1024            .and_then(|value| value.as_array())?
1025            .iter()
1026            .filter_map(|entry| {
1027                // Only handle object entries — string entries go through multi_file_write_paths()
1028                let path = entry
1029                    .get("path")
1030                    .and_then(|value| value.as_str())
1031                    .map(PathBuf::from)?;
1032
1033                if !is_config_file_path_with_custom(&path, extra_markers) {
1034                    return None;
1035                }
1036
1037                let change_type = entry
1038                    .get("type")
1039                    .and_then(|value| value.as_str())
1040                    .and_then(Self::parse_file_change_type)
1041                    .unwrap_or_else(|| Self::change_type_from_current_state(&path));
1042
1043                Some((path, change_type))
1044            })
1045            .collect::<Vec<_>>();
1046
1047        (!events.is_empty()).then_some(events)
1048    }
1049
1050    fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
1051        match value {
1052            "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
1053            "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
1054            "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
1055            _ => None,
1056        }
1057    }
1058
1059    fn change_type_from_current_state(path: &Path) -> FileChangeType {
1060        if path.exists() {
1061            FileChangeType::CHANGED
1062        } else {
1063            FileChangeType::DELETED
1064        }
1065    }
1066
1067    fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
1068        if config_paths.is_empty() {
1069            return;
1070        }
1071
1072        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1073            let config = self.config();
1074            if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
1075                crate::slog_warn!("watched-file sync error: {}", e);
1076            }
1077        }
1078    }
1079
1080    pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
1081        let custom_markers = self.custom_lsp_root_markers();
1082        if !is_config_file_path_with_custom(file_path, &custom_markers) {
1083            return;
1084        }
1085
1086        self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
1087    }
1088
1089    /// Post-write LSP hook for multi-file edits. When the patch includes
1090    /// config-file edits, notify active workspace servers via
1091    /// `workspace/didChangeWatchedFiles` before sending the per-document
1092    /// didOpen/didChange for the current file.
1093    pub fn lsp_post_multi_file_write(
1094        &self,
1095        file_path: &Path,
1096        content: &str,
1097        file_paths: &[PathBuf],
1098        params: &serde_json::Value,
1099    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
1100        self.notify_watched_config_files(file_paths);
1101
1102        let wants_diagnostics = params
1103            .get("diagnostics")
1104            .and_then(|v| v.as_bool())
1105            .unwrap_or(false);
1106
1107        if !wants_diagnostics {
1108            self.lsp_notify_file_changed(file_path, content);
1109            return None;
1110        }
1111
1112        let wait_ms = params
1113            .get("wait_ms")
1114            .and_then(|v| v.as_u64())
1115            .unwrap_or(3000)
1116            .min(10_000);
1117
1118        Some(self.lsp_notify_and_collect_diagnostics(
1119            file_path,
1120            content,
1121            std::time::Duration::from_millis(wait_ms),
1122        ))
1123    }
1124
1125    /// Post-write LSP hook: notify server and optionally collect diagnostics.
1126    ///
1127    /// This is the single call site for all command handlers after `write_format_validate`.
1128    /// Behavior:
1129    /// - When `diagnostics: true` is in `params`, notifies the server, waits
1130    ///   until matching diagnostics arrive or the timeout expires, and returns
1131    ///   `Some(outcome)` with the verified-fresh diagnostics + per-server
1132    ///   status.
1133    /// - When `diagnostics: false` (or absent), just notifies (fire-and-forget)
1134    ///   and returns `None`. Callers must NOT wrap this in `Some(...)`; the
1135    ///   `None` is what tells the response builder to omit the LSP fields
1136    ///   entirely (preserves the no-diagnostics-requested response shape).
1137    ///
1138    /// v0.17.3: default `wait_ms` raised from 1500 to 3000 because real-world
1139    /// tsserver re-analysis on monorepo files routinely takes 2-5s. Still
1140    /// capped at 10000ms.
1141    pub fn lsp_post_write(
1142        &self,
1143        file_path: &Path,
1144        content: &str,
1145        params: &serde_json::Value,
1146    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
1147        let wants_diagnostics = params
1148            .get("diagnostics")
1149            .and_then(|v| v.as_bool())
1150            .unwrap_or(false);
1151
1152        let custom_markers = self.custom_lsp_root_markers();
1153
1154        if !wants_diagnostics {
1155            if let Some(file_paths) = Self::multi_file_write_paths(params) {
1156                self.notify_watched_config_files(&file_paths);
1157            } else if let Some(config_events) =
1158                Self::watched_file_events_from_params(params, &custom_markers)
1159            {
1160                self.notify_watched_config_events(&config_events);
1161            }
1162            self.lsp_notify_file_changed(file_path, content);
1163            return None;
1164        }
1165
1166        let wait_ms = params
1167            .get("wait_ms")
1168            .and_then(|v| v.as_u64())
1169            .unwrap_or(3000)
1170            .min(10_000); // Cap at 10 seconds to prevent hangs from adversarial input
1171
1172        if let Some(file_paths) = Self::multi_file_write_paths(params) {
1173            return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
1174        }
1175
1176        if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
1177        {
1178            self.notify_watched_config_events(&config_events);
1179        }
1180
1181        Some(self.lsp_notify_and_collect_diagnostics(
1182            file_path,
1183            content,
1184            std::time::Duration::from_millis(wait_ms),
1185        ))
1186    }
1187
1188    /// Validate that a file path falls within the configured project root.
1189    ///
1190    /// When `project_root` is configured (normal plugin usage), this resolves the
1191    /// path and checks it starts with the root. Returns the canonicalized path on
1192    /// success, or an error response on violation.
1193    ///
1194    /// When no `project_root` is configured (direct CLI usage), all paths pass
1195    /// through unrestricted for backward compatibility.
1196    pub fn validate_path(
1197        &self,
1198        req_id: &str,
1199        path: &Path,
1200    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
1201        let config = self.config();
1202        // When restrict_to_project_root is false (default), allow all paths
1203        if !config.restrict_to_project_root {
1204            return Ok(path.to_path_buf());
1205        }
1206        let root = match &config.project_root {
1207            Some(r) => r.clone(),
1208            None => return Ok(path.to_path_buf()), // No root configured, allow all
1209        };
1210        drop(config);
1211
1212        // Keep the raw root for symlink-guard comparisons. On macOS, tempdir()
1213        // returns /var/... paths while canonicalize gives /private/var/...; we
1214        // need both forms so reject_escaping_symlink can recognise in-root
1215        // symlinks regardless of which prefix form `current` happens to have.
1216        let raw_root = root.clone();
1217        let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
1218
1219        // Resolve the path (follow symlinks, normalize ..). If canonicalization
1220        // fails (e.g. path does not exist or traverses a broken symlink), inspect
1221        // every existing component with lstat before falling back lexically so a
1222        // broken in-root symlink cannot be used to write outside project_root.
1223        let path_for_resolution = if path.is_relative() {
1224            raw_root.join(path)
1225        } else {
1226            path.to_path_buf()
1227        };
1228        let resolved = match std::fs::canonicalize(&path_for_resolution) {
1229            Ok(resolved) => resolved,
1230            Err(_) => {
1231                let normalized = normalize_path(&path_for_resolution);
1232                reject_escaping_symlink(
1233                    req_id,
1234                    &path_for_resolution,
1235                    &normalized,
1236                    &resolved_root,
1237                    &raw_root,
1238                )?;
1239                resolve_with_existing_ancestors(&normalized)
1240            }
1241        };
1242
1243        if !resolved.starts_with(&resolved_root) {
1244            return Err(path_error_response(req_id, path, &resolved_root));
1245        }
1246
1247        Ok(resolved)
1248    }
1249
1250    /// Count active LSP server instances.
1251    pub fn lsp_server_count(&self) -> usize {
1252        self.lsp_manager
1253            .try_borrow()
1254            .map(|lsp| lsp.server_count())
1255            .unwrap_or(0)
1256    }
1257
1258    /// Symbol cache statistics from the language provider.
1259    pub fn symbol_cache_stats(&self) -> serde_json::Value {
1260        let entries = self
1261            .symbol_cache
1262            .read()
1263            .map(|cache| cache.len())
1264            .unwrap_or(0);
1265        serde_json::json!({
1266            "local_entries": entries,
1267            "warm_entries": 0,
1268        })
1269    }
1270}
1271
1272#[cfg(test)]
1273mod status_emitter_tests {
1274    use super::*;
1275    use crate::parser::TreeSitterProvider;
1276
1277    fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
1278        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
1279        let (tx, rx) = mpsc::channel();
1280        ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
1281            let _ = tx.send(frame);
1282        }))));
1283        (ctx, rx)
1284    }
1285
1286    #[test]
1287    fn status_emitter_signal_triggers_push() {
1288        let (ctx, rx) = ctx_with_frame_rx();
1289        ctx.status_emitter().signal(ctx.build_status_snapshot());
1290        let frame = rx
1291            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1292            .expect("status_changed push");
1293        assert!(matches!(frame, PushFrame::StatusChanged(_)));
1294    }
1295
1296    #[test]
1297    fn status_emitter_debounces_burst() {
1298        let (ctx, rx) = ctx_with_frame_rx();
1299        for _ in 0..10 {
1300            ctx.status_emitter().signal(ctx.build_status_snapshot());
1301        }
1302        let frame = rx
1303            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1304            .expect("status_changed push");
1305        assert!(matches!(frame, PushFrame::StatusChanged(_)));
1306        assert!(rx.try_recv().is_err());
1307    }
1308
1309    #[test]
1310    fn status_emitter_separate_windows_separate_pushes() {
1311        let (ctx, rx) = ctx_with_frame_rx();
1312        ctx.status_emitter().signal(ctx.build_status_snapshot());
1313        rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1314            .expect("first push");
1315        ctx.status_emitter().signal(ctx.build_status_snapshot());
1316        rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1317            .expect("second push");
1318    }
1319
1320    #[test]
1321    fn status_emitter_no_signal_no_push() {
1322        let (_ctx, rx) = ctx_with_frame_rx();
1323        assert!(rx
1324            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
1325            .is_err());
1326    }
1327
1328    #[test]
1329    fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
1330        let (ctx, rx) = ctx_with_frame_rx();
1331        drop(ctx);
1332        assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
1333    }
1334}
1335
1336#[cfg(test)]
1337mod harness_path_tests {
1338    use super::*;
1339    use crate::harness::Harness;
1340    use crate::parser::TreeSitterProvider;
1341
1342    fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
1343        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
1344        ctx.config_mut().storage_dir = Some(storage_dir);
1345        ctx.set_harness(harness);
1346        ctx
1347    }
1348
1349    #[test]
1350    fn harness_dir_resolves_correctly() {
1351        let storage = PathBuf::from("/tmp/cortexkit/aft");
1352        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
1353
1354        assert_eq!(ctx.harness_dir(), storage.join("pi"));
1355    }
1356
1357    #[test]
1358    fn bash_tasks_dir_uses_hash_session() {
1359        let storage = PathBuf::from("/tmp/cortexkit/aft");
1360        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
1361
1362        assert_eq!(
1363            ctx.bash_tasks_dir("ses_abc"),
1364            storage
1365                .join("opencode")
1366                .join("bash-tasks")
1367                .join(hash_session("ses_abc"))
1368        );
1369    }
1370
1371    #[test]
1372    fn backups_dir_includes_path_hash() {
1373        let storage = PathBuf::from("/tmp/cortexkit/aft");
1374        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
1375
1376        assert_eq!(
1377            ctx.backups_dir("ses_abc", "pathhash"),
1378            storage
1379                .join("pi")
1380                .join("backups")
1381                .join(hash_session("ses_abc"))
1382                .join("pathhash")
1383        );
1384    }
1385
1386    #[test]
1387    fn filters_dir_under_harness() {
1388        let storage = PathBuf::from("/tmp/cortexkit/aft");
1389        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
1390
1391        assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
1392    }
1393
1394    #[test]
1395    fn trust_file_is_host_global() {
1396        let storage = PathBuf::from("/tmp/cortexkit/aft");
1397        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
1398
1399        assert_eq!(
1400            ctx.trust_file(),
1401            storage.join("trusted-filter-projects.json")
1402        );
1403    }
1404
1405    #[test]
1406    fn same_session_different_harness_resolve_different_paths() {
1407        let storage = PathBuf::from("/tmp/cortexkit/aft");
1408        let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
1409        let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
1410
1411        assert_ne!(
1412            opencode.bash_tasks_dir("ses_same"),
1413            pi.bash_tasks_dir("ses_same")
1414        );
1415    }
1416}
1417
1418#[cfg(test)]
1419mod gitignore_tests {
1420    use super::*;
1421    use std::fs;
1422    use std::path::Path;
1423    use tempfile::TempDir;
1424
1425    fn make_ctx_with_root(root: &Path) -> AppContext {
1426        let provider = Box::new(crate::parser::TreeSitterProvider::new());
1427        let config = Config {
1428            project_root: Some(root.to_path_buf()),
1429            ..Config::default()
1430        };
1431        AppContext::new(provider, config)
1432    }
1433
1434    /// Helper: returns true when the matcher would skip `path` (as if it
1435    /// arrived via a watcher event for this project root). Canonicalizes
1436    /// the query path so symlink prefixes (e.g. macOS `/var` → `/private/var`)
1437    /// don't trip the `ignore` crate's "path is expected to be under the
1438    /// root" panic — production code does the same guard via
1439    /// `path.starts_with(matcher.path())` in `drain_watcher_events`.
1440    fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
1441        let Some(matcher) = ctx.gitignore() else {
1442            return false;
1443        };
1444        let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1445        if !canonical.starts_with(matcher.path()) {
1446            return false;
1447        }
1448        let is_dir = canonical.is_dir();
1449        matcher
1450            .matched_path_or_any_parents(&canonical, is_dir)
1451            .is_ignore()
1452    }
1453
1454    #[test]
1455    fn rebuild_gitignore_returns_none_without_project_root() {
1456        let provider = Box::new(crate::parser::TreeSitterProvider::new());
1457        let ctx = AppContext::new(provider, Config::default());
1458        ctx.rebuild_gitignore();
1459        assert!(ctx.gitignore().is_none());
1460    }
1461
1462    #[test]
1463    fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
1464        let tmp = TempDir::new().unwrap();
1465        let ctx = make_ctx_with_root(tmp.path());
1466        ctx.rebuild_gitignore();
1467        assert!(ctx.gitignore().is_none());
1468    }
1469
1470    #[test]
1471    fn matcher_filters_files_in_ignored_dist_dir() {
1472        let tmp = TempDir::new().unwrap();
1473        fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
1474        fs::create_dir_all(tmp.path().join("dist")).unwrap();
1475        fs::create_dir_all(tmp.path().join("src")).unwrap();
1476        let dist_file = tmp.path().join("dist").join("bundle.js");
1477        let src_file = tmp.path().join("src").join("app.ts");
1478        fs::write(&dist_file, "x").unwrap();
1479        fs::write(&src_file, "y").unwrap();
1480
1481        let ctx = make_ctx_with_root(tmp.path());
1482        ctx.rebuild_gitignore();
1483
1484        assert!(ctx.gitignore().is_some());
1485        assert!(
1486            is_ignored(&ctx, &dist_file),
1487            "dist/bundle.js should be ignored"
1488        );
1489        assert!(
1490            !is_ignored(&ctx, &src_file),
1491            "src/app.ts should NOT be ignored"
1492        );
1493    }
1494
1495    #[test]
1496    fn matcher_handles_node_modules_and_target() {
1497        let tmp = TempDir::new().unwrap();
1498        fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
1499        fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
1500        fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
1501        let nm_file = tmp.path().join("node_modules/foo/index.js");
1502        let target_file = tmp.path().join("target/debug/aft");
1503        fs::write(&nm_file, "x").unwrap();
1504        fs::write(&target_file, "x").unwrap();
1505
1506        let ctx = make_ctx_with_root(tmp.path());
1507        ctx.rebuild_gitignore();
1508
1509        assert!(is_ignored(&ctx, &nm_file));
1510        assert!(is_ignored(&ctx, &target_file));
1511    }
1512
1513    #[test]
1514    fn matcher_honors_negation_pattern() {
1515        // .gitignore: ignore all *.log files EXCEPT important.log
1516        let tmp = TempDir::new().unwrap();
1517        fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
1518        let random_log = tmp.path().join("random.log");
1519        let important_log = tmp.path().join("important.log");
1520        fs::write(&random_log, "x").unwrap();
1521        fs::write(&important_log, "y").unwrap();
1522
1523        let ctx = make_ctx_with_root(tmp.path());
1524        ctx.rebuild_gitignore();
1525
1526        assert!(is_ignored(&ctx, &random_log));
1527        assert!(
1528            !is_ignored(&ctx, &important_log),
1529            "negation pattern should un-ignore important.log"
1530        );
1531    }
1532
1533    #[test]
1534    fn rebuild_picks_up_gitignore_changes() {
1535        let tmp = TempDir::new().unwrap();
1536        let ignore_path = tmp.path().join(".gitignore");
1537        fs::write(&ignore_path, "foo.txt\n").unwrap();
1538        let foo = tmp.path().join("foo.txt");
1539        let bar = tmp.path().join("bar.txt");
1540        fs::write(&foo, "").unwrap();
1541        fs::write(&bar, "").unwrap();
1542
1543        let ctx = make_ctx_with_root(tmp.path());
1544        ctx.rebuild_gitignore();
1545        assert!(is_ignored(&ctx, &foo));
1546        assert!(!is_ignored(&ctx, &bar));
1547
1548        // Now flip the rules: ignore bar.txt instead of foo.txt
1549        fs::write(&ignore_path, "bar.txt\n").unwrap();
1550        ctx.rebuild_gitignore();
1551        assert!(!is_ignored(&ctx, &foo));
1552        assert!(is_ignored(&ctx, &bar));
1553    }
1554
1555    #[test]
1556    fn gitignore_loads_info_exclude_when_present() {
1557        let tmp = TempDir::new().unwrap();
1558        let info_dir = tmp.path().join(".git/info");
1559        fs::create_dir_all(&info_dir).unwrap();
1560        fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
1561        let secrets = tmp.path().join("secrets.txt");
1562        let public = tmp.path().join("public.txt");
1563        fs::write(&secrets, "token").unwrap();
1564        fs::write(&public, "ok").unwrap();
1565
1566        let ctx = make_ctx_with_root(tmp.path());
1567        ctx.rebuild_gitignore();
1568
1569        assert!(is_ignored(&ctx, &secrets));
1570        assert!(!is_ignored(&ctx, &public));
1571    }
1572
1573    #[test]
1574    fn matcher_picks_up_nested_gitignore() {
1575        let tmp = TempDir::new().unwrap();
1576        // Root .gitignore is intentionally empty — only the nested one ignores
1577        fs::write(tmp.path().join(".gitignore"), "").unwrap();
1578        let sub = tmp.path().join("packages/foo");
1579        fs::create_dir_all(&sub).unwrap();
1580        fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
1581        let generated_file = sub.join("generated").join("out.js");
1582        fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
1583        fs::write(&generated_file, "x").unwrap();
1584
1585        let ctx = make_ctx_with_root(tmp.path());
1586        ctx.rebuild_gitignore();
1587
1588        assert!(
1589            is_ignored(&ctx, &generated_file),
1590            "nested gitignore in packages/foo/.gitignore should ignore generated/"
1591        );
1592    }
1593}