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