Skip to main content

aft/
context.rs

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