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