Skip to main content

aft/
context.rs

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