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