1use std::cell::{Ref, RefCell, RefMut};
2use std::collections::{BTreeMap, BTreeSet};
3use std::io::{self, BufWriter};
4use std::path::{Component, Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{mpsc, Arc, Mutex, OnceLock};
7use std::time::{Duration, Instant};
8
9use lsp_types::FileChangeType;
10use notify::RecommendedWatcher;
11use rusqlite::Connection;
12
13use crate::backup::hash_session;
14use crate::backup::BackupStore;
15use crate::bash_background::{BgCompletion, BgTaskRegistry};
16use crate::callgraph::CallGraph;
17use crate::callgraph_store::{CallGraphStore, CallGraphStoreError};
18use crate::checkpoint::CheckpointStore;
19use crate::config::Config;
20use crate::harness::Harness;
21use crate::inspect::{
22 InspectCategory, InspectManager, InspectSnapshot, Tier2RefreshScheduler, Tier2TriggerReason,
23};
24use crate::language::LanguageProvider;
25use crate::lsp::manager::LspManager;
26use crate::lsp::registry::is_config_file_path_with_custom;
27use crate::parser::{SharedSymbolCache, SymbolCache};
28use crate::protocol::{
29 ConfigureWarningsFrame, ProgressFrame, PushFrame, StatusChangedFrame, StatusPayload,
30};
31use crate::watcher_filter::{SharedGitignore, WatcherDispatchEvent, WatcherThreadHandle};
32
33pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
34pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
35pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
36const STATUS_DEBOUNCE_MS: u64 = 1_000;
37
38#[derive(Debug, Clone, Default, PartialEq, Eq)]
46pub struct StatusBarCounts {
47 pub errors: usize,
48 pub warnings: usize,
49 pub dead_code: usize,
50 pub unused_exports: usize,
51 pub duplicates: usize,
52 pub todos: usize,
53 pub tier2_stale: bool,
54}
55
56#[derive(Debug, Clone, Default)]
65struct StatusBarTier2 {
66 dead_code: Option<usize>,
67 unused_exports: Option<usize>,
68 duplicates: Option<usize>,
69 todos: Option<usize>,
70 stale: bool,
71}
72
73pub struct StatusEmitter {
74 latest: Arc<Mutex<Option<StatusPayload>>>,
75 notify: mpsc::Sender<()>,
76}
77
78impl StatusEmitter {
79 fn new(progress_sender: SharedProgressSender) -> Self {
80 let (notify, rx) = mpsc::channel();
81 let latest = Arc::new(Mutex::new(None));
82 let latest_for_thread = Arc::clone(&latest);
83 std::thread::spawn(move || {
84 status_debounce_loop(rx, latest_for_thread, progress_sender);
85 });
86 Self { latest, notify }
87 }
88
89 pub fn signal(&self, snapshot: StatusPayload) {
90 if let Ok(mut latest) = self.latest.lock() {
91 *latest = Some(snapshot);
92 }
93 let _ = self.notify.send(());
94 }
95}
96
97fn status_debounce_loop(
98 rx: mpsc::Receiver<()>,
99 latest: Arc<Mutex<Option<StatusPayload>>>,
100 progress_sender: SharedProgressSender,
101) {
102 while rx.recv().is_ok() {
103 let deadline = Instant::now() + Duration::from_millis(STATUS_DEBOUNCE_MS);
104 while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
105 match rx.recv_timeout(remaining) {
106 Ok(()) => continue,
107 Err(mpsc::RecvTimeoutError::Timeout) => break,
108 Err(mpsc::RecvTimeoutError::Disconnected) => return,
109 }
110 }
111
112 let snapshot = latest.lock().ok().and_then(|mut latest| latest.take());
113 let Some(snapshot) = snapshot else { continue };
114 let sender = progress_sender
115 .lock()
116 .ok()
117 .and_then(|sender| sender.clone());
118 if let Some(sender) = sender {
119 sender(PushFrame::StatusChanged(StatusChangedFrame::new(
120 None, snapshot,
121 )));
122 }
123 }
124}
125use crate::cache_freshness::FileFreshness;
126use crate::search_index::SearchIndex;
127use crate::semantic_index::{EmbeddingEntry, SemanticIndex};
128
129#[derive(Debug, Default)]
133struct SemanticRefreshAccounting {
134 pending: usize,
135 in_flight: usize,
136}
137
138static SEMANTIC_REFRESH_ACCOUNTING: OnceLock<Mutex<BTreeMap<PathBuf, SemanticRefreshAccounting>>> =
139 OnceLock::new();
140
141fn semantic_refresh_accounting() -> &'static Mutex<BTreeMap<PathBuf, SemanticRefreshAccounting>> {
142 SEMANTIC_REFRESH_ACCOUNTING.get_or_init(|| Mutex::new(BTreeMap::new()))
143}
144
145fn clear_semantic_refresh_accounting() {
146 if let Some(accounting) = SEMANTIC_REFRESH_ACCOUNTING.get() {
147 if let Ok(mut accounting) = accounting.lock() {
148 accounting.clear();
149 }
150 }
151}
152
153fn ensure_refreshing_path(refreshing: &mut Vec<PathBuf>, path: PathBuf) {
154 if !refreshing.iter().any(|existing| existing == &path) {
155 refreshing.push(path);
156 refreshing.sort();
157 }
158}
159
160fn remove_refreshing_path(refreshing: &mut Vec<PathBuf>, path: &Path) {
161 refreshing.retain(|existing| existing != path);
162}
163
164#[derive(Debug, Clone)]
165pub enum SemanticIndexStatus {
166 Disabled,
167 Building {
168 stage: String,
170 files: Option<usize>,
171 entries_done: Option<usize>,
172 entries_total: Option<usize>,
173 },
174 Ready {
175 refreshing: Vec<PathBuf>,
178 },
179 Failed(String),
180}
181
182impl SemanticIndexStatus {
183 pub fn ready() -> Self {
184 clear_semantic_refresh_accounting();
185 Self::Ready {
186 refreshing: Vec::new(),
187 }
188 }
189
190 pub fn add_refreshing_file(&mut self, path: PathBuf) {
191 if let Self::Ready { refreshing } = self {
192 if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
193 let state = accounting.entry(path.clone()).or_default();
194 state.pending = state.pending.saturating_add(1);
195 }
196 ensure_refreshing_path(refreshing, path);
197 }
198 }
199
200 pub fn start_refreshing_file(&mut self, path: PathBuf) {
201 if let Self::Ready { refreshing } = self {
202 if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
203 let state = accounting.entry(path.clone()).or_default();
204 if state.pending == 0 {
205 state.pending = 1;
206 }
207 if state.in_flight == 0 {
208 state.in_flight = state.pending;
209 }
210 }
211 ensure_refreshing_path(refreshing, path);
212 }
213 }
214
215 pub fn cancel_refreshing_file(&mut self, path: &Path) {
216 self.finish_refreshing_file(path, false);
217 }
218
219 pub fn complete_refreshing_file(&mut self, path: &Path) {
220 self.finish_refreshing_file(path, true);
221 }
222
223 pub fn remove_refreshing_file(&mut self, path: &Path) {
224 self.complete_refreshing_file(path);
225 }
226
227 fn finish_refreshing_file(&mut self, path: &Path, complete_in_flight: bool) {
228 if let Self::Ready { refreshing } = self {
229 let mut keep_refreshing = false;
230 let mut accounting_checked = false;
231 if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
232 accounting_checked = true;
233 if let Some(state) = accounting.get_mut(path) {
234 let finished = if complete_in_flight {
235 state.in_flight.max(1)
236 } else {
237 1
238 };
239 state.pending = state.pending.saturating_sub(finished);
240 if complete_in_flight {
241 state.in_flight = 0;
242 } else {
243 state.in_flight = state.in_flight.min(state.pending);
244 }
245 keep_refreshing = state.pending > 0;
246 if !keep_refreshing {
247 accounting.remove(path);
248 }
249 }
250 }
251
252 if !accounting_checked || !keep_refreshing {
253 remove_refreshing_path(refreshing, path);
254 }
255 }
256 }
257
258 pub fn refreshing_count(&self) -> usize {
259 match self {
260 Self::Ready { refreshing } => refreshing.len(),
261 _ => 0,
262 }
263 }
264}
265
266pub enum SemanticIndexEvent {
267 Progress {
268 stage: String,
269 files: Option<usize>,
270 entries_done: Option<usize>,
271 entries_total: Option<usize>,
272 },
273 Ready(SemanticIndex),
274 Failed(String),
275}
276
277#[derive(Debug, Clone)]
278pub enum SemanticRefreshRequest {
279 Files {
280 paths: Vec<PathBuf>,
281 },
282 Corpus,
286}
287
288#[derive(Debug)]
289pub enum SemanticRefreshEvent {
290 Started {
291 paths: Vec<PathBuf>,
292 },
293 CorpusStarted {
294 files: usize,
295 },
296 Completed {
297 added_entries: Vec<EmbeddingEntry>,
298 updated_metadata: Vec<(PathBuf, FileFreshness)>,
299 completed_paths: Vec<PathBuf>,
300 },
301 CorpusCompleted {
302 index: SemanticIndex,
303 changed: usize,
304 added: usize,
305 deleted: usize,
306 total_processed: usize,
307 },
308 Failed {
309 paths: Vec<PathBuf>,
310 error: String,
311 },
312 CorpusFailed {
313 error: String,
314 },
315}
316
317pub type SemanticRefreshWorkerSlot = Arc<Mutex<Option<std::thread::JoinHandle<()>>>>;
318
319fn normalize_path(path: &Path) -> PathBuf {
323 let mut result = PathBuf::new();
324 for component in path.components() {
325 match component {
326 Component::ParentDir => {
327 if !result.pop() {
329 result.push(component);
330 }
331 }
332 Component::CurDir => {} _ => result.push(component),
334 }
335 }
336 result
337}
338
339fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
340 let mut existing = path.to_path_buf();
341 let mut tail_segments = Vec::new();
342
343 while !existing.exists() {
344 if let Some(name) = existing.file_name() {
345 tail_segments.push(name.to_owned());
346 } else {
347 break;
348 }
349
350 existing = match existing.parent() {
351 Some(parent) => parent.to_path_buf(),
352 None => break,
353 };
354 }
355
356 let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
357 for segment in tail_segments.into_iter().rev() {
358 resolved.push(segment);
359 }
360
361 resolved
362}
363
364fn path_error_response(
365 req_id: &str,
366 path: &Path,
367 resolved_root: &Path,
368) -> crate::protocol::Response {
369 crate::protocol::Response::error(
370 req_id,
371 "path_outside_root",
372 format!(
373 "path '{}' is outside the project root '{}'",
374 path.display(),
375 resolved_root.display()
376 ),
377 )
378}
379
380fn reject_escaping_symlink(
390 req_id: &str,
391 original_path: &Path,
392 candidate: &Path,
393 resolved_root: &Path,
394 raw_root: &Path,
395) -> Result<(), crate::protocol::Response> {
396 let mut current = PathBuf::new();
397
398 for component in candidate.components() {
399 current.push(component);
400
401 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
402 continue;
403 };
404
405 if !metadata.file_type().is_symlink() {
406 continue;
407 }
408
409 let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
418 if !inside_root {
419 continue;
420 }
421
422 iterative_follow_chain(req_id, original_path, ¤t, resolved_root)?;
423 }
424
425 Ok(())
426}
427
428fn iterative_follow_chain(
431 req_id: &str,
432 original_path: &Path,
433 start: &Path,
434 resolved_root: &Path,
435) -> Result<(), crate::protocol::Response> {
436 let mut link = start.to_path_buf();
437 let mut depth = 0usize;
438
439 loop {
440 if depth > 40 {
441 return Err(path_error_response(req_id, original_path, resolved_root));
442 }
443
444 let target = match std::fs::read_link(&link) {
445 Ok(t) => t,
446 Err(_) => {
447 return Err(path_error_response(req_id, original_path, resolved_root));
449 }
450 };
451
452 let resolved_target = if target.is_absolute() {
453 normalize_path(&target)
454 } else {
455 let parent = link.parent().unwrap_or_else(|| Path::new(""));
456 normalize_path(&parent.join(&target))
457 };
458
459 let canonical_target =
463 std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
464
465 if !canonical_target.starts_with(resolved_root)
466 && !resolved_target.starts_with(resolved_root)
467 {
468 return Err(path_error_response(req_id, original_path, resolved_root));
469 }
470
471 match std::fs::symlink_metadata(&resolved_target) {
473 Ok(meta) if meta.file_type().is_symlink() => {
474 link = resolved_target;
475 depth += 1;
476 }
477 _ => break, }
479 }
480
481 Ok(())
482}
483
484pub struct AppContext {
494 provider: Box<dyn LanguageProvider>,
495 backup: RefCell<BackupStore>,
496 checkpoint: RefCell<CheckpointStore>,
497 db: RefCell<Option<Arc<Mutex<Connection>>>>,
498 config: RefCell<Config>,
499 pub harness: RefCell<Option<Harness>>,
500 canonical_cache_root: RefCell<Option<PathBuf>>,
501 is_worktree_bridge: RefCell<bool>,
502 git_common_dir: RefCell<Option<PathBuf>>,
503 degraded_reasons: RefCell<Vec<String>>,
511 callgraph: RefCell<Option<CallGraph>>,
512 callgraph_store: RefCell<Option<CallGraphStore>>,
513 callgraph_store_force_rebuild: RefCell<bool>,
514 callgraph_store_rx: RefCell<Option<crossbeam_channel::Receiver<CallGraphStore>>>,
515 pending_callgraph_store_paths: RefCell<BTreeSet<PathBuf>>,
516 search_index: RefCell<Option<SearchIndex>>,
517 search_index_rx: RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>>,
518 pending_search_index_paths: RefCell<BTreeSet<PathBuf>>,
519 symbol_cache: SharedSymbolCache,
520 inspect_manager: Arc<InspectManager>,
521 tier2_refresh_scheduler: RefCell<Tier2RefreshScheduler>,
522 semantic_index: RefCell<Option<SemanticIndex>>,
523 semantic_index_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
524 semantic_index_status: RefCell<SemanticIndexStatus>,
525 pending_semantic_index_paths: RefCell<BTreeSet<PathBuf>>,
526 pending_semantic_corpus_refresh: RefCell<bool>,
527 semantic_refresh_tx: RefCell<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>,
528 semantic_refresh_event_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
529 semantic_refresh_worker: RefCell<Option<SemanticRefreshWorkerSlot>>,
530 semantic_embedding_model: RefCell<Option<crate::semantic_index::EmbeddingModel>>,
531 watcher: RefCell<Option<RecommendedWatcher>>,
532 watcher_rx: RefCell<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>>,
533 watcher_thread: RefCell<Option<WatcherThreadHandle>>,
534 lsp_manager: RefCell<LspManager>,
535 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
539 stdout_writer: SharedStdoutWriter,
540 progress_sender: SharedProgressSender,
541 configure_generation: AtomicU64,
542 last_seen_reuse_completions: AtomicU64,
546 configure_warnings_tx: mpsc::Sender<(u64, ConfigureWarningsFrame)>,
547 configure_warnings_rx: mpsc::Receiver<(u64, ConfigureWarningsFrame)>,
548 status_emitter: StatusEmitter,
549 bash_background: BgTaskRegistry,
550 filter_registry: crate::compress::SharedFilterRegistry,
557 filter_registry_loaded: std::sync::atomic::AtomicBool,
560 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
565 gitignore: SharedGitignore,
572 gitignore_generation: Arc<AtomicU64>,
573 status_bar_tier2: RefCell<StatusBarTier2>,
577 tsconfig_membership: RefCell<crate::lsp::tsconfig_membership::TsconfigMembershipCache>,
584}
585
586impl Drop for AppContext {
587 fn drop(&mut self) {
588 if let Some(runtime) = self.watcher_thread.get_mut().take() {
589 runtime.shutdown_and_join();
590 }
591 }
592}
593
594pub enum CallgraphStoreAccess<'a> {
602 Ready(RefMut<'a, CallGraphStore>),
604 Building,
606 Unavailable,
608 Error(CallGraphStoreError),
610}
611
612fn callgraph_build_wait_window() -> Duration {
617 std::env::var("AFT_CALLGRAPH_BUILD_WAIT_MS")
618 .ok()
619 .and_then(|raw| raw.parse::<u64>().ok())
620 .map(Duration::from_millis)
621 .unwrap_or(Duration::ZERO)
622}
623
624impl AppContext {
625 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
626 let bash_compress_enabled = config.experimental_bash_compress;
627 let progress_sender = Arc::new(Mutex::new(None));
628 let stdout_writer = Arc::new(Mutex::new(BufWriter::new(io::stdout())));
629 let (configure_warnings_tx, configure_warnings_rx) = mpsc::channel();
630 let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
631 let symbol_cache = provider
632 .as_any()
633 .downcast_ref::<crate::parser::TreeSitterProvider>()
634 .map(|provider| provider.symbol_cache())
635 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
636 let lsp_child_registry = crate::lsp::child_registry::LspChildRegistry::new();
637 let mut lsp_manager = LspManager::new();
638 lsp_manager.set_child_registry(lsp_child_registry.clone());
639 lsp_manager.set_diagnostic_capacity(config.diagnostic_cache_size);
642 AppContext {
643 provider,
644 backup: RefCell::new(BackupStore::new()),
645 checkpoint: RefCell::new(CheckpointStore::new()),
646 db: RefCell::new(None),
647 config: RefCell::new(config),
648 harness: RefCell::new(None),
649 canonical_cache_root: RefCell::new(None),
650 is_worktree_bridge: RefCell::new(false),
651 git_common_dir: RefCell::new(None),
652 degraded_reasons: RefCell::new(Vec::new()),
653 callgraph: RefCell::new(None),
654 callgraph_store: RefCell::new(None),
655 callgraph_store_force_rebuild: RefCell::new(false),
656 callgraph_store_rx: RefCell::new(None),
657 pending_callgraph_store_paths: RefCell::new(BTreeSet::new()),
658 search_index: RefCell::new(None),
659 search_index_rx: RefCell::new(None),
660 pending_search_index_paths: RefCell::new(BTreeSet::new()),
661 symbol_cache,
662 inspect_manager: Arc::new(InspectManager::new()),
663 tier2_refresh_scheduler: RefCell::new(Tier2RefreshScheduler::new()),
664 semantic_index: RefCell::new(None),
665 semantic_index_rx: RefCell::new(None),
666 semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
667 pending_semantic_index_paths: RefCell::new(BTreeSet::new()),
668 pending_semantic_corpus_refresh: RefCell::new(false),
669 semantic_refresh_tx: RefCell::new(None),
670 semantic_refresh_event_rx: RefCell::new(None),
671 semantic_refresh_worker: RefCell::new(None),
672 semantic_embedding_model: RefCell::new(None),
673 watcher: RefCell::new(None),
674 watcher_rx: RefCell::new(None),
675 watcher_thread: RefCell::new(None),
676 lsp_manager: RefCell::new(lsp_manager),
677 lsp_child_registry,
678 stdout_writer,
679 progress_sender: Arc::clone(&progress_sender),
680 configure_generation: AtomicU64::new(0),
681 last_seen_reuse_completions: AtomicU64::new(0),
682 configure_warnings_tx,
683 configure_warnings_rx,
684 status_emitter,
685 bash_background: BgTaskRegistry::new(progress_sender),
686 filter_registry: Arc::new(std::sync::RwLock::new(
687 crate::compress::toml_filter::FilterRegistry::default(),
688 )),
689 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
690 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
691 gitignore: Arc::new(std::sync::RwLock::new(None)),
692 gitignore_generation: Arc::new(AtomicU64::new(0)),
693 status_bar_tier2: RefCell::new(StatusBarTier2::default()),
694 tsconfig_membership: RefCell::new(
695 crate::lsp::tsconfig_membership::TsconfigMembershipCache::new(),
696 ),
697 }
698 }
699
700 pub fn status_bar_counts(&self) -> Option<StatusBarCounts> {
706 let tier2 = self.status_bar_tier2.borrow();
707 let (Some(dead_code), Some(unused_exports), Some(duplicates)) =
711 (tier2.dead_code, tier2.unused_exports, tier2.duplicates)
712 else {
713 return None;
714 };
715 let (errors, warnings) = self.status_bar_error_warning_counts();
716 Some(StatusBarCounts {
717 errors,
718 warnings,
719 dead_code,
720 unused_exports,
721 duplicates,
722 todos: tier2.todos.unwrap_or(0),
723 tier2_stale: tier2.stale,
724 })
725 }
726
727 fn status_bar_error_warning_counts(&self) -> (usize, usize) {
733 let Some(root) = self.canonical_cache_root_opt() else {
734 return self.lsp_manager.borrow().warm_error_warning_counts();
737 };
738 let mut membership = self.tsconfig_membership.borrow_mut();
739 self.lsp_manager
740 .borrow()
741 .filtered_error_warning_counts(|file| {
742 file.starts_with(&root) && !membership.should_skip_diagnostics(file)
743 })
744 }
745
746 pub fn clear_tsconfig_membership_cache(&self) {
750 self.tsconfig_membership.borrow_mut().clear();
751 }
752
753 pub fn mark_status_bar_tier2_stale(&self) -> bool {
759 let mut tier2 = self.status_bar_tier2.borrow_mut();
760 if tier2.dead_code.is_some() && tier2.unused_exports.is_some() && tier2.duplicates.is_some()
762 {
763 let changed = !tier2.stale;
764 tier2.stale = true;
765 return changed;
766 }
767 false
768 }
769
770 pub fn update_status_bar_tier2(
776 &self,
777 dead_code: Option<usize>,
778 unused_exports: Option<usize>,
779 duplicates: Option<usize>,
780 todos: Option<usize>,
781 stale: bool,
782 ) {
783 let mut tier2 = self.status_bar_tier2.borrow_mut();
784 if let Some(dead_code) = dead_code {
785 tier2.dead_code = Some(dead_code);
786 }
787 if let Some(unused_exports) = unused_exports {
788 tier2.unused_exports = Some(unused_exports);
789 }
790 if let Some(duplicates) = duplicates {
791 tier2.duplicates = Some(duplicates);
792 }
793 if let Some(todos) = todos {
794 tier2.todos = Some(todos);
795 }
796 tier2.stale = stale;
797 }
798
799 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
802 self.gitignore
803 .read()
804 .unwrap_or_else(|poisoned| poisoned.into_inner())
805 .clone()
806 }
807
808 pub fn shared_gitignore(&self) -> SharedGitignore {
810 Arc::clone(&self.gitignore)
811 }
812
813 pub fn gitignore_generation(&self) -> Arc<AtomicU64> {
817 Arc::clone(&self.gitignore_generation)
818 }
819
820 fn set_gitignore(&self, matcher: Option<Arc<ignore::gitignore::Gitignore>>) {
821 *self
822 .gitignore
823 .write()
824 .unwrap_or_else(|poisoned| poisoned.into_inner()) = matcher;
825 self.gitignore_generation.fetch_add(1, Ordering::SeqCst);
826 }
827
828 pub fn clear_gitignore(&self) {
850 self.set_gitignore(None);
851 }
852
853 pub fn rebuild_gitignore(&self) {
854 use ignore::gitignore::GitignoreBuilder;
855 use std::path::Path;
856 let root_raw = match self.config().project_root.clone() {
857 Some(r) => r,
858 None => {
859 self.set_gitignore(None);
860 return;
861 }
862 };
863 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
871 let mut builder = GitignoreBuilder::new(&root);
872 if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
877 if global_ignore.is_file() {
878 if let Some(err) = builder.add(&global_ignore) {
879 crate::slog_warn!(
880 "global gitignore parse error in {}: {}",
881 global_ignore.display(),
882 err
883 );
884 }
885 }
886 }
887 let root_ignore = Path::new(&root).join(".gitignore");
889 if root_ignore.exists() {
890 if let Some(err) = builder.add(&root_ignore) {
891 crate::slog_warn!(
892 "gitignore parse error in {}: {}",
893 root_ignore.display(),
894 err
895 );
896 }
897 }
898 let root_aftignore = Path::new(&root).join(".aftignore");
903 if root_aftignore.exists() {
904 if let Some(err) = builder.add(&root_aftignore) {
905 crate::slog_warn!(
906 "aftignore parse error in {}: {}",
907 root_aftignore.display(),
908 err
909 );
910 }
911 }
912 let info_exclude = self
917 .git_common_dir
918 .borrow()
919 .clone()
920 .unwrap_or_else(|| Path::new(&root).join(".git"))
921 .join("info")
922 .join("exclude");
923 if info_exclude.exists() {
924 if let Some(err) = builder.add(&info_exclude) {
925 crate::slog_warn!(
926 "gitignore parse error in {}: {}",
927 info_exclude.display(),
928 err
929 );
930 }
931 }
932 let walker = ignore::WalkBuilder::new(&root)
938 .standard_filters(true)
939 .hidden(false)
947 .filter_entry(|entry| {
948 let name = entry.file_name().to_string_lossy();
949 !matches!(
950 name.as_ref(),
951 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
952 )
953 })
954 .build();
955 for entry in walker.flatten() {
956 let file_name = entry.file_name();
957 let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
958 let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
959 if is_nested_gitignore || is_nested_aftignore {
960 if let Some(err) = builder.add(entry.path()) {
961 crate::slog_warn!(
962 "nested ignore parse error in {}: {}",
963 entry.path().display(),
964 err
965 );
966 }
967 }
968 }
969 match builder.build() {
970 Ok(gi) => {
971 let count = gi.num_ignores();
972 if count > 0 {
973 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
974 self.set_gitignore(Some(Arc::new(gi)));
975 } else {
976 self.set_gitignore(None);
977 }
978 }
979 Err(err) => {
980 crate::slog_warn!("gitignore matcher build failed: {}", err);
981 self.set_gitignore(None);
982 }
983 }
984 }
985
986 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
989 Arc::clone(&self.bash_compress_flag)
990 }
991
992 pub fn sync_bash_compress_flag(&self) {
996 let value = self.config().experimental_bash_compress;
997 self.bash_compress_flag
998 .store(value, std::sync::atomic::Ordering::Relaxed);
999 }
1000
1001 pub fn set_bash_compress_enabled(&self, enabled: bool) {
1002 self.config_mut().experimental_bash_compress = enabled;
1003 self.bash_compress_flag
1004 .store(enabled, std::sync::atomic::Ordering::Relaxed);
1005 }
1006
1007 pub fn filter_registry(
1011 &self,
1012 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
1013 self.ensure_filter_registry_loaded();
1014 match self.filter_registry.read() {
1015 Ok(g) => g,
1016 Err(poisoned) => poisoned.into_inner(),
1017 }
1018 }
1019
1020 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
1024 self.ensure_filter_registry_loaded();
1025 Arc::clone(&self.filter_registry)
1026 }
1027
1028 pub fn reset_filter_registry(&self) {
1032 let new_registry = crate::compress::build_registry_for_context(self);
1033 match self.filter_registry.write() {
1034 Ok(mut slot) => *slot = new_registry,
1035 Err(poisoned) => *poisoned.into_inner() = new_registry,
1036 }
1037 self.filter_registry_loaded
1038 .store(true, std::sync::atomic::Ordering::Release);
1039 }
1040
1041 fn ensure_filter_registry_loaded(&self) {
1042 use std::sync::atomic::Ordering;
1043 if self.filter_registry_loaded.load(Ordering::Acquire) {
1044 return;
1045 }
1046 let new_registry = crate::compress::build_registry_for_context(self);
1049 if let Ok(mut slot) = self.filter_registry.write() {
1050 *slot = new_registry;
1051 self.filter_registry_loaded.store(true, Ordering::Release);
1052 }
1053 }
1054
1055 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
1058 self.lsp_child_registry.clone()
1059 }
1060
1061 pub fn stdout_writer(&self) -> SharedStdoutWriter {
1062 Arc::clone(&self.stdout_writer)
1063 }
1064
1065 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
1066 if let Ok(mut progress_sender) = self.progress_sender.lock() {
1067 *progress_sender = sender;
1068 }
1069 }
1070
1071 pub fn emit_progress(&self, frame: ProgressFrame) {
1072 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
1073 return;
1074 };
1075 if let Some(sender) = progress_sender.as_ref() {
1076 sender(PushFrame::Progress(frame));
1077 }
1078 }
1079
1080 pub fn status_emitter(&self) -> &StatusEmitter {
1081 &self.status_emitter
1082 }
1083
1084 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
1092 self.progress_sender
1093 .lock()
1094 .ok()
1095 .and_then(|sender| sender.clone())
1096 }
1097
1098 pub fn advance_configure_generation(&self) -> u64 {
1099 self.configure_generation
1100 .fetch_add(1, Ordering::SeqCst)
1101 .wrapping_add(1)
1102 }
1103
1104 pub fn configure_generation(&self) -> u64 {
1105 self.configure_generation.load(Ordering::SeqCst)
1106 }
1107
1108 pub fn configure_warnings_sender(&self) -> mpsc::Sender<(u64, ConfigureWarningsFrame)> {
1109 self.configure_warnings_tx.clone()
1110 }
1111
1112 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
1113 let mut warnings = Vec::new();
1114 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
1115 warnings.push(warning);
1116 }
1117 warnings
1118 }
1119
1120 pub fn bash_background(&self) -> &BgTaskRegistry {
1121 &self.bash_background
1122 }
1123
1124 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
1125 self.bash_background.drain_completions()
1126 }
1127
1128 pub fn provider(&self) -> &dyn LanguageProvider {
1130 self.provider.as_ref()
1131 }
1132
1133 pub fn backup(&self) -> &RefCell<BackupStore> {
1135 &self.backup
1136 }
1137
1138 pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
1140 &self.checkpoint
1141 }
1142
1143 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
1144 *self.db.borrow_mut() = Some(conn);
1145 }
1146
1147 pub fn clear_db(&self) {
1148 *self.db.borrow_mut() = None;
1149 }
1150
1151 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
1152 self.db.borrow().clone()
1153 }
1154
1155 pub fn config(&self) -> Ref<'_, Config> {
1157 self.config.borrow()
1158 }
1159
1160 pub fn config_mut(&self) -> RefMut<'_, Config> {
1162 self.config.borrow_mut()
1163 }
1164
1165 pub fn set_harness(&self, harness: Harness) {
1166 *self.harness.borrow_mut() = Some(harness);
1167 self.bash_background.set_harness(harness);
1168 }
1169
1170 pub fn harness_opt(&self) -> Option<Harness> {
1171 *self.harness.borrow()
1172 }
1173
1174 pub fn harness(&self) -> Harness {
1175 self.harness_opt()
1176 .expect("harness set by configure before any tool call")
1177 }
1178
1179 pub fn storage_dir(&self) -> PathBuf {
1180 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
1181 }
1182
1183 pub fn harness_dir(&self) -> PathBuf {
1184 self.storage_dir().join(self.harness().as_str())
1185 }
1186
1187 pub fn inspect_dir(&self) -> PathBuf {
1188 self.harness_dir().join("inspect")
1189 }
1190
1191 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
1192 self.harness_dir()
1193 .join("bash-tasks")
1194 .join(hash_session(session_id))
1195 }
1196
1197 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
1198 self.harness_dir()
1199 .join("backups")
1200 .join(hash_session(session_id))
1201 .join(path_hash)
1202 }
1203
1204 pub fn filters_dir(&self) -> PathBuf {
1205 self.harness_dir().join("filters")
1206 }
1207
1208 pub fn trust_file(&self) -> PathBuf {
1210 self.storage_dir().join("trusted-filter-projects.json")
1211 }
1212
1213 pub fn set_canonical_cache_root(&self, root: PathBuf) {
1214 debug_assert!(root.is_absolute());
1215 *self.canonical_cache_root.borrow_mut() = Some(root);
1216 }
1217
1218 pub fn canonical_cache_root(&self) -> PathBuf {
1219 self.canonical_cache_root
1220 .borrow()
1221 .clone()
1222 .expect("canonical_cache_root accessed before handle_configure")
1223 }
1224
1225 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
1226 self.canonical_cache_root.borrow().clone()
1227 }
1228
1229 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
1230 *self.is_worktree_bridge.borrow_mut() = is_worktree_bridge;
1231 *self.git_common_dir.borrow_mut() = git_common_dir;
1232 }
1233
1234 pub fn is_worktree_bridge(&self) -> bool {
1235 *self.is_worktree_bridge.borrow()
1236 }
1237
1238 pub fn git_common_dir(&self) -> Option<PathBuf> {
1239 self.git_common_dir.borrow().clone()
1240 }
1241
1242 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
1246 *self.degraded_reasons.borrow_mut() = reasons;
1247 }
1248
1249 pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
1250 let reason = reason.into();
1251 let mut reasons = self.degraded_reasons.borrow_mut();
1252 if reasons.iter().any(|existing| existing == &reason) {
1253 return false;
1254 }
1255 reasons.push(reason);
1256 true
1257 }
1258
1259 pub fn degraded_reasons(&self) -> Vec<String> {
1263 self.degraded_reasons.borrow().clone()
1264 }
1265
1266 pub fn is_degraded(&self) -> bool {
1268 !self.degraded_reasons.borrow().is_empty()
1269 }
1270
1271 pub fn cache_role(&self) -> &'static str {
1272 if self.canonical_cache_root.borrow().is_none() {
1273 "not_initialized"
1274 } else if self.is_worktree_bridge() {
1275 "worktree"
1276 } else {
1277 "main"
1278 }
1279 }
1280
1281 pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
1283 &self.callgraph
1284 }
1285
1286 pub fn callgraph_store(&self) -> &RefCell<Option<CallGraphStore>> {
1288 &self.callgraph_store
1289 }
1290
1291 pub fn mark_callgraph_store_force_rebuild(&self) {
1292 *self.callgraph_store_force_rebuild.borrow_mut() = true;
1293 }
1294
1295 fn take_callgraph_store_force_rebuild(&self) -> bool {
1296 let force = *self.callgraph_store_force_rebuild.borrow();
1297 *self.callgraph_store_force_rebuild.borrow_mut() = false;
1298 force
1299 }
1300
1301 pub fn callgraph_store_dir(&self) -> PathBuf {
1302 match self.harness_opt() {
1303 Some(harness) => self.storage_dir().join(harness.as_str()).join("callgraph"),
1304 None => self.storage_dir().join("callgraph"),
1305 }
1306 }
1307
1308 pub fn ensure_callgraph_store(
1309 &self,
1310 ) -> Result<Option<RefMut<'_, CallGraphStore>>, CallGraphStoreError> {
1311 self.ensure_callgraph_store_with_flag(true)
1312 }
1313
1314 fn ensure_callgraph_store_with_flag(
1315 &self,
1316 respect_config_flag: bool,
1317 ) -> Result<Option<RefMut<'_, CallGraphStore>>, CallGraphStoreError> {
1318 if respect_config_flag && !self.config().callgraph_store {
1319 return Ok(None);
1320 }
1321 if self.callgraph_store.borrow().is_none() {
1322 let Some(project_root) = self.callgraph_project_root() else {
1323 return Ok(None);
1324 };
1325 let callgraph_dir = self.callgraph_store_dir();
1326 let force_rebuild = self.take_callgraph_store_force_rebuild();
1327 let store = if self.is_worktree_bridge() {
1328 CallGraphStore::open_readonly(callgraph_dir, project_root)?
1329 } else if force_rebuild {
1330 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1331 let (store, _stats) = CallGraphStore::cold_build_with_lease_chunked(
1332 callgraph_dir,
1333 project_root,
1334 &files,
1335 self.config().callgraph_chunk_size,
1336 )?;
1337 Some(store)
1338 } else if CallGraphStore::needs_cold_build(&callgraph_dir, &project_root)? {
1339 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1340 let (store, _stats) = CallGraphStore::ensure_built_with_lease_chunked(
1341 callgraph_dir,
1342 project_root,
1343 &files,
1344 self.config().callgraph_chunk_size,
1345 )?;
1346 Some(store)
1347 } else {
1348 Some(CallGraphStore::open(callgraph_dir, project_root)?)
1349 };
1350 *self.callgraph_store.borrow_mut() = store;
1351 }
1352 let borrow = self.callgraph_store.borrow_mut();
1353 Ok(RefMut::filter_map(borrow, Option::as_mut).ok())
1354 }
1355
1356 fn callgraph_project_root(&self) -> Option<PathBuf> {
1359 self.canonical_cache_root_opt().or_else(|| {
1360 self.config()
1361 .project_root
1362 .clone()
1363 .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
1364 })
1365 }
1366
1367 pub fn revalidate_callgraph_store_generation(&self) {
1385 if self.callgraph_store_rx.borrow().is_some() {
1388 return;
1389 }
1390 let superseded = self
1391 .callgraph_store
1392 .borrow()
1393 .as_ref()
1394 .is_some_and(|store| !store.is_current());
1395 if superseded {
1396 *self.callgraph_store.borrow_mut() = None;
1397 }
1398 }
1399
1400 pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess<'_> {
1401 self.revalidate_callgraph_store_generation();
1405 if self.callgraph_store.borrow().is_some() {
1406 let borrow = self.callgraph_store.borrow_mut();
1407 return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1408 Some(store) => CallgraphStoreAccess::Ready(store),
1409 None => CallgraphStoreAccess::Unavailable,
1410 };
1411 }
1412
1413 if self.callgraph_store_rx.borrow().is_some() {
1415 return CallgraphStoreAccess::Building;
1416 }
1417
1418 let Some(project_root) = self.callgraph_project_root() else {
1419 return CallgraphStoreAccess::Unavailable;
1420 };
1421 let callgraph_dir = self.callgraph_store_dir();
1422
1423 if self.is_worktree_bridge() {
1426 match CallGraphStore::open_readonly(callgraph_dir, project_root) {
1427 Ok(Some(store)) => {
1428 *self.callgraph_store.borrow_mut() = Some(store);
1429 let borrow = self.callgraph_store.borrow_mut();
1430 return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1431 Some(store) => CallgraphStoreAccess::Ready(store),
1432 None => CallgraphStoreAccess::Unavailable,
1433 };
1434 }
1435 Ok(None) | Err(_) => return CallgraphStoreAccess::Unavailable,
1436 }
1437 }
1438
1439 let force_rebuild = *self.callgraph_store_force_rebuild.borrow();
1440 if !force_rebuild {
1443 match CallGraphStore::needs_cold_build(&callgraph_dir, &project_root) {
1444 Ok(false) => match CallGraphStore::open(callgraph_dir, project_root) {
1445 Ok(store) => {
1446 *self.callgraph_store.borrow_mut() = Some(store);
1447 let borrow = self.callgraph_store.borrow_mut();
1448 return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1449 Some(store) => CallgraphStoreAccess::Ready(store),
1450 None => CallgraphStoreAccess::Unavailable,
1451 };
1452 }
1453 Err(error) => return CallgraphStoreAccess::Error(error),
1454 },
1455 Ok(true) => {}
1456 Err(error) => return CallgraphStoreAccess::Error(error),
1457 }
1458 }
1459
1460 self.spawn_callgraph_store_cold_build(project_root, callgraph_dir, force_rebuild);
1468
1469 let wait = callgraph_build_wait_window();
1470 if !wait.is_zero() {
1471 let received = {
1472 let rx_ref = self.callgraph_store_rx.borrow();
1473 let Some(rx) = rx_ref.as_ref() else {
1474 return CallgraphStoreAccess::Building;
1475 };
1476 rx.recv_timeout(wait)
1477 };
1478 match received {
1479 Ok(store) => {
1480 let pending = self.take_pending_callgraph_store_paths();
1484 if !pending.is_empty() {
1485 if let Err(error) = store.refresh_files(&pending) {
1486 crate::slog_warn!(
1487 "callgraph store inline post-build refresh failed: {}",
1488 error
1489 );
1490 let _ = store.mark_files_stale(&pending);
1491 }
1492 }
1493 *self.callgraph_store.borrow_mut() = Some(store);
1494 *self.callgraph_store_rx.borrow_mut() = None;
1495 let borrow = self.callgraph_store.borrow_mut();
1496 return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1497 Some(store) => CallgraphStoreAccess::Ready(store),
1498 None => CallgraphStoreAccess::Unavailable,
1499 };
1500 }
1501 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1502 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
1503 *self.callgraph_store_rx.borrow_mut() = None;
1506 }
1507 }
1508 }
1509 CallgraphStoreAccess::Building
1510 }
1511
1512 fn spawn_callgraph_store_cold_build(
1517 &self,
1518 project_root: PathBuf,
1519 callgraph_dir: PathBuf,
1520 force_rebuild: bool,
1521 ) {
1522 if force_rebuild {
1523 self.take_callgraph_store_force_rebuild();
1526 }
1527 let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStore>();
1528 *self.callgraph_store_rx.borrow_mut() = Some(rx);
1529 let session_id = crate::log_ctx::current_session();
1530 let chunk_size = self.config().callgraph_chunk_size;
1531 std::thread::spawn(move || {
1532 crate::log_ctx::with_session(session_id, || {
1533 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1534 let built = if force_rebuild {
1535 CallGraphStore::cold_build_with_lease_chunked(
1536 callgraph_dir,
1537 project_root,
1538 &files,
1539 chunk_size,
1540 )
1541 .map(|(store, _)| store)
1542 } else {
1543 CallGraphStore::ensure_built_with_lease_chunked(
1544 callgraph_dir,
1545 project_root,
1546 &files,
1547 chunk_size,
1548 )
1549 .map(|(store, _)| store)
1550 };
1551 match built {
1552 Ok(store) => {
1553 let _ = tx.send(store);
1554 }
1555 Err(error) => {
1556 crate::slog_warn!("callgraph store cold build failed: {}", error);
1557 }
1560 }
1561 });
1562 });
1563 }
1564
1565 pub fn callgraph_store_rx(
1568 &self,
1569 ) -> &RefCell<Option<crossbeam_channel::Receiver<CallGraphStore>>> {
1570 &self.callgraph_store_rx
1571 }
1572
1573 pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
1576 where
1577 I: IntoIterator<Item = PathBuf>,
1578 {
1579 self.pending_callgraph_store_paths
1580 .borrow_mut()
1581 .extend(paths);
1582 }
1583
1584 pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
1586 std::mem::take(&mut *self.pending_callgraph_store_paths.borrow_mut())
1587 .into_iter()
1588 .collect()
1589 }
1590
1591 pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
1593 &self.search_index
1594 }
1595
1596 pub fn search_index_rx(&self) -> &RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>> {
1598 &self.search_index_rx
1599 }
1600
1601 pub fn add_pending_search_index_paths<I>(&self, paths: I)
1602 where
1603 I: IntoIterator<Item = PathBuf>,
1604 {
1605 self.pending_search_index_paths.borrow_mut().extend(paths);
1606 }
1607
1608 pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
1609 std::mem::take(&mut *self.pending_search_index_paths.borrow_mut())
1610 .into_iter()
1611 .collect()
1612 }
1613
1614 pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
1615 where
1616 I: IntoIterator<Item = PathBuf>,
1617 {
1618 self.pending_semantic_index_paths.borrow_mut().extend(paths);
1619 }
1620
1621 pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
1622 std::mem::take(&mut *self.pending_semantic_index_paths.borrow_mut())
1623 .into_iter()
1624 .collect()
1625 }
1626
1627 pub fn mark_pending_semantic_corpus_refresh(&self) {
1628 *self.pending_semantic_corpus_refresh.borrow_mut() = true;
1629 }
1630
1631 pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
1632 std::mem::take(&mut *self.pending_semantic_corpus_refresh.borrow_mut())
1633 }
1634
1635 pub fn clear_pending_index_updates(&self) {
1636 self.pending_search_index_paths.borrow_mut().clear();
1637 self.pending_callgraph_store_paths.borrow_mut().clear();
1638 self.pending_semantic_index_paths.borrow_mut().clear();
1639 *self.pending_semantic_corpus_refresh.borrow_mut() = false;
1640 }
1641
1642 pub fn inspect_manager(&self) -> Arc<InspectManager> {
1643 Arc::clone(&self.inspect_manager)
1644 }
1645
1646 pub fn take_new_reuse_completions(&self) -> bool {
1651 let current = self.inspect_manager.reuse_completion_count();
1652 let previous = self
1653 .last_seen_reuse_completions
1654 .swap(current, Ordering::SeqCst);
1655 current != previous
1656 }
1657
1658 pub fn reset_tier2_refresh_scheduler(&self) {
1659 self.reset_tier2_refresh_scheduler_at(Instant::now());
1660 }
1661
1662 #[doc(hidden)]
1663 pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
1664 self.tier2_refresh_scheduler
1665 .borrow_mut()
1666 .reset_after_configure(now);
1667 }
1668
1669 pub fn request_tier2_refresh_pull(&self) -> bool {
1670 self.tier2_refresh_scheduler
1671 .borrow_mut()
1672 .request_pull(!self.is_worktree_bridge())
1673 }
1674
1675 pub fn tick_tier2_refresh_scheduler(
1676 &self,
1677 changed_path_count: usize,
1678 ) -> Option<Tier2TriggerReason> {
1679 self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
1680 }
1681
1682 #[doc(hidden)]
1683 pub fn tick_tier2_refresh_scheduler_at(
1684 &self,
1685 now: Instant,
1686 changed_path_count: usize,
1687 ) -> Option<Tier2TriggerReason> {
1688 let manager = self.inspect_manager();
1689 let can_write = !self.is_worktree_bridge();
1690 let in_flight = manager.tier2_any_in_flight();
1691 let decision = self.tier2_refresh_scheduler.borrow_mut().tick(
1692 now,
1693 changed_path_count,
1694 can_write,
1695 in_flight,
1696 );
1697
1698 if let Some(reason) = decision {
1699 self.start_tier2_refresh(reason, manager);
1700 }
1701
1702 decision
1703 }
1704
1705 pub fn note_tier2_refresh_started(&self) {
1706 self.note_tier2_refresh_started_at(Instant::now());
1707 }
1708
1709 #[doc(hidden)]
1710 pub fn note_tier2_refresh_started_at(&self, now: Instant) {
1711 self.tier2_refresh_scheduler
1712 .borrow_mut()
1713 .note_external_scan_started(now);
1714 }
1715
1716 pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
1717 self.tier2_refresh_scheduler
1718 .borrow()
1719 .last_trigger_reason()
1720 .map(Tier2TriggerReason::as_str)
1721 }
1722
1723 #[doc(hidden)]
1724 pub fn tier2_pull_demand_pending(&self) -> bool {
1725 self.tier2_refresh_scheduler.borrow().pull_demand_pending()
1726 }
1727
1728 fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
1729 if self.is_worktree_bridge()
1730 || self
1731 .degraded_reasons
1732 .borrow()
1733 .iter()
1734 .any(|r| r == "home_root")
1735 || !self.config().inspect.enabled
1736 {
1737 return;
1738 }
1739 let Some(snapshot) = self.tier2_refresh_snapshot() else {
1740 return;
1741 };
1742 let categories = InspectCategory::active()
1743 .iter()
1744 .copied()
1745 .filter(|category| category.is_tier2())
1746 .collect::<Vec<_>>();
1747 let submission =
1748 manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
1749 if submission.has_new_work() {
1750 crate::slog_info!(
1751 "tier2 refresh scheduled: reason={}, categories={:?}",
1752 reason.as_str(),
1753 submission
1754 .newly_queued_categories
1755 .iter()
1756 .map(|category| category.as_str())
1757 .collect::<Vec<_>>()
1758 );
1759 }
1760 for error in submission.errors {
1761 crate::slog_warn!(
1762 "tier2 refresh schedule failed for {}: {}",
1763 error.category,
1764 error.message
1765 );
1766 }
1767 }
1768
1769 fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
1770 self.harness_opt()?;
1771 let config = self.config().clone();
1772 let project_root = config
1773 .project_root
1774 .clone()
1775 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1776 let project_root = std::fs::canonicalize(&project_root).unwrap_or(project_root);
1777 Some(InspectSnapshot::new(
1778 project_root,
1779 self.inspect_dir(),
1780 Arc::new(config),
1781 self.symbol_cache(),
1782 ))
1783 }
1784
1785 pub fn symbol_cache(&self) -> SharedSymbolCache {
1787 Arc::clone(&self.symbol_cache)
1788 }
1789
1790 pub fn reset_symbol_cache(&self) -> u64 {
1792 self.symbol_cache
1793 .write()
1794 .map(|mut cache| cache.reset())
1795 .unwrap_or(0)
1796 }
1797
1798 pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
1800 &self.semantic_index
1801 }
1802
1803 pub fn semantic_index_rx(
1805 &self,
1806 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
1807 &self.semantic_index_rx
1808 }
1809
1810 pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
1811 &self.semantic_index_status
1812 }
1813
1814 pub fn install_semantic_refresh_worker(
1815 &self,
1816 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
1817 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
1818 worker_slot: SemanticRefreshWorkerSlot,
1819 ) {
1820 self.clear_semantic_refresh_worker();
1821 *self.semantic_refresh_tx.borrow_mut() = Some(sender);
1822 *self.semantic_refresh_event_rx.borrow_mut() = Some(event_rx);
1823 *self.semantic_refresh_worker.borrow_mut() = Some(worker_slot);
1824 }
1825
1826 pub fn clear_semantic_refresh_worker(&self) {
1827 *self.semantic_refresh_tx.borrow_mut() = None;
1828 *self.semantic_refresh_event_rx.borrow_mut() = None;
1829 if let Some(worker_slot) = self.semantic_refresh_worker.borrow_mut().take() {
1830 if let Ok(mut handle) = worker_slot.lock() {
1831 drop(handle.take());
1832 }
1833 }
1834 }
1835
1836 pub fn semantic_refresh_sender(
1837 &self,
1838 ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
1839 self.semantic_refresh_tx.borrow().clone()
1840 }
1841
1842 pub fn semantic_refresh_event_rx(
1843 &self,
1844 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
1845 &self.semantic_refresh_event_rx
1846 }
1847
1848 pub fn semantic_embedding_model(
1850 &self,
1851 ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
1852 &self.semantic_embedding_model
1853 }
1854
1855 pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
1857 &self.watcher
1858 }
1859
1860 pub fn watcher_rx(
1862 &self,
1863 ) -> &RefCell<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>> {
1864 &self.watcher_rx
1865 }
1866
1867 pub fn install_watcher_runtime(
1870 &self,
1871 rx: crossbeam_channel::Receiver<WatcherDispatchEvent>,
1872 runtime: WatcherThreadHandle,
1873 ) {
1874 *self.watcher_rx.borrow_mut() = Some(rx);
1875 *self.watcher_thread.borrow_mut() = Some(runtime);
1876 }
1877
1878 pub fn stop_watcher_runtime(&self) {
1881 if let Some(runtime) = self.watcher_thread.borrow_mut().take() {
1882 runtime.shutdown_and_join();
1883 }
1884 *self.watcher_rx.borrow_mut() = None;
1885 *self.watcher.borrow_mut() = None;
1886 }
1887
1888 pub fn lsp(&self) -> RefMut<'_, LspManager> {
1890 self.lsp_manager.borrow_mut()
1891 }
1892
1893 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
1896 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1897 let config = self.config();
1898 if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
1899 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1900 }
1901 }
1902 }
1903
1904 pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
1910 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1911 lsp.clear_diagnostics_for_file(file_path)
1912 } else {
1913 false
1914 }
1915 }
1916
1917 pub fn lsp_notify_and_collect_diagnostics(
1928 &self,
1929 file_path: &Path,
1930 content: &str,
1931 timeout: std::time::Duration,
1932 ) -> crate::lsp::manager::PostEditWaitOutcome {
1933 let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
1934 return crate::lsp::manager::PostEditWaitOutcome::default();
1935 };
1936
1937 lsp.drain_events();
1940
1941 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
1945
1946 let config = self.config();
1948 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
1949 {
1950 Ok(v) => v,
1951 Err(e) => {
1952 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1953 return crate::lsp::manager::PostEditWaitOutcome::default();
1954 }
1955 };
1956
1957 if expected_versions.is_empty() {
1960 return crate::lsp::manager::PostEditWaitOutcome::default();
1961 }
1962
1963 lsp.wait_for_post_edit_diagnostics(
1964 file_path,
1965 &config,
1966 &expected_versions,
1967 &pre_snapshot,
1968 timeout,
1969 )
1970 }
1971
1972 fn custom_lsp_root_markers(&self) -> Vec<String> {
1975 self.config()
1976 .lsp_servers
1977 .iter()
1978 .flat_map(|s| s.root_markers.iter().cloned())
1979 .collect()
1980 }
1981
1982 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
1983 let custom_markers = self.custom_lsp_root_markers();
1984 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
1985 .iter()
1986 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
1987 .cloned()
1988 .map(|path| {
1989 let change_type = if path.exists() {
1990 FileChangeType::CHANGED
1991 } else {
1992 FileChangeType::DELETED
1993 };
1994 (path, change_type)
1995 })
1996 .collect();
1997
1998 self.notify_watched_config_events(&config_paths);
1999 }
2000
2001 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
2002 let paths = params
2003 .get("multi_file_write_paths")
2004 .and_then(|value| value.as_array())?
2005 .iter()
2006 .filter_map(|value| value.as_str())
2007 .map(PathBuf::from)
2008 .collect::<Vec<_>>();
2009
2010 (!paths.is_empty()).then_some(paths)
2011 }
2012
2013 fn watched_file_events_from_params(
2025 params: &serde_json::Value,
2026 extra_markers: &[String],
2027 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
2028 let events = params
2029 .get("multi_file_write_paths")
2030 .and_then(|value| value.as_array())?
2031 .iter()
2032 .filter_map(|entry| {
2033 let path = entry
2035 .get("path")
2036 .and_then(|value| value.as_str())
2037 .map(PathBuf::from)?;
2038
2039 if !is_config_file_path_with_custom(&path, extra_markers) {
2040 return None;
2041 }
2042
2043 let change_type = entry
2044 .get("type")
2045 .and_then(|value| value.as_str())
2046 .and_then(Self::parse_file_change_type)
2047 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
2048
2049 Some((path, change_type))
2050 })
2051 .collect::<Vec<_>>();
2052
2053 (!events.is_empty()).then_some(events)
2054 }
2055
2056 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
2057 match value {
2058 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
2059 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
2060 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
2061 _ => None,
2062 }
2063 }
2064
2065 fn change_type_from_current_state(path: &Path) -> FileChangeType {
2066 if path.exists() {
2067 FileChangeType::CHANGED
2068 } else {
2069 FileChangeType::DELETED
2070 }
2071 }
2072
2073 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
2074 if config_paths.is_empty() {
2075 return;
2076 }
2077
2078 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
2079 let config = self.config();
2080 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
2081 crate::slog_warn!("watched-file sync error: {}", e);
2082 }
2083 }
2084 }
2085
2086 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
2087 let custom_markers = self.custom_lsp_root_markers();
2088 if !is_config_file_path_with_custom(file_path, &custom_markers) {
2089 return;
2090 }
2091
2092 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
2093 }
2094
2095 pub fn lsp_post_multi_file_write(
2100 &self,
2101 file_path: &Path,
2102 content: &str,
2103 file_paths: &[PathBuf],
2104 params: &serde_json::Value,
2105 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2106 self.notify_watched_config_files(file_paths);
2107
2108 let wants_diagnostics = params
2109 .get("diagnostics")
2110 .and_then(|v| v.as_bool())
2111 .unwrap_or(false);
2112
2113 if !wants_diagnostics {
2114 self.lsp_notify_file_changed(file_path, content);
2115 return None;
2116 }
2117
2118 let wait_ms = params
2119 .get("wait_ms")
2120 .and_then(|v| v.as_u64())
2121 .unwrap_or(3000)
2122 .min(10_000);
2123
2124 Some(self.lsp_notify_and_collect_diagnostics(
2125 file_path,
2126 content,
2127 std::time::Duration::from_millis(wait_ms),
2128 ))
2129 }
2130
2131 pub fn lsp_post_write(
2148 &self,
2149 file_path: &Path,
2150 content: &str,
2151 params: &serde_json::Value,
2152 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2153 let wants_diagnostics = params
2154 .get("diagnostics")
2155 .and_then(|v| v.as_bool())
2156 .unwrap_or(false);
2157
2158 let custom_markers = self.custom_lsp_root_markers();
2159
2160 if !wants_diagnostics {
2161 if let Some(file_paths) = Self::multi_file_write_paths(params) {
2162 self.notify_watched_config_files(&file_paths);
2163 } else if let Some(config_events) =
2164 Self::watched_file_events_from_params(params, &custom_markers)
2165 {
2166 self.notify_watched_config_events(&config_events);
2167 }
2168 self.lsp_notify_file_changed(file_path, content);
2169 return None;
2170 }
2171
2172 let wait_ms = params
2173 .get("wait_ms")
2174 .and_then(|v| v.as_u64())
2175 .unwrap_or(3000)
2176 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
2179 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
2180 }
2181
2182 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
2183 {
2184 self.notify_watched_config_events(&config_events);
2185 }
2186
2187 Some(self.lsp_notify_and_collect_diagnostics(
2188 file_path,
2189 content,
2190 std::time::Duration::from_millis(wait_ms),
2191 ))
2192 }
2193
2194 pub fn validate_path(
2203 &self,
2204 req_id: &str,
2205 path: &Path,
2206 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
2207 let config = self.config();
2208 if !config.restrict_to_project_root {
2210 return Ok(path.to_path_buf());
2211 }
2212 let root = match &config.project_root {
2213 Some(r) => r.clone(),
2214 None => return Ok(path.to_path_buf()), };
2216 drop(config);
2217
2218 let raw_root = root.clone();
2223 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
2224
2225 let path_for_resolution = if path.is_relative() {
2230 raw_root.join(path)
2231 } else {
2232 path.to_path_buf()
2233 };
2234 let resolved = match std::fs::canonicalize(&path_for_resolution) {
2235 Ok(resolved) => resolved,
2236 Err(_) => {
2237 let normalized = normalize_path(&path_for_resolution);
2238 reject_escaping_symlink(
2239 req_id,
2240 &path_for_resolution,
2241 &normalized,
2242 &resolved_root,
2243 &raw_root,
2244 )?;
2245 resolve_with_existing_ancestors(&normalized)
2246 }
2247 };
2248
2249 if !resolved.starts_with(&resolved_root) {
2250 return Err(path_error_response(req_id, path, &resolved_root));
2251 }
2252
2253 Ok(resolved)
2254 }
2255
2256 pub fn lsp_server_count(&self) -> usize {
2258 self.lsp_manager
2259 .try_borrow()
2260 .map(|lsp| lsp.server_count())
2261 .unwrap_or(0)
2262 }
2263
2264 pub fn symbol_cache_stats(&self) -> serde_json::Value {
2266 let entries = self
2267 .symbol_cache
2268 .read()
2269 .map(|cache| cache.len())
2270 .unwrap_or(0);
2271 serde_json::json!({
2272 "local_entries": entries,
2273 "warm_entries": 0,
2274 })
2275 }
2276}
2277
2278#[cfg(test)]
2279mod status_emitter_tests {
2280 use super::*;
2281 use crate::parser::TreeSitterProvider;
2282
2283 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
2284 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2285 let (tx, rx) = mpsc::channel();
2286 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
2287 let _ = tx.send(frame);
2288 }))));
2289 (ctx, rx)
2290 }
2291
2292 #[test]
2293 fn status_emitter_signal_triggers_push() {
2294 let (ctx, rx) = ctx_with_frame_rx();
2295 ctx.status_emitter().signal(ctx.build_status_snapshot());
2296 let frame = rx
2297 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2298 .expect("status_changed push");
2299 assert!(matches!(frame, PushFrame::StatusChanged(_)));
2300 }
2301
2302 #[test]
2303 fn status_emitter_debounces_burst() {
2304 let (ctx, rx) = ctx_with_frame_rx();
2305 for _ in 0..10 {
2306 ctx.status_emitter().signal(ctx.build_status_snapshot());
2307 }
2308 let frame = rx
2309 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2310 .expect("status_changed push");
2311 assert!(matches!(frame, PushFrame::StatusChanged(_)));
2312 assert!(rx.try_recv().is_err());
2313 }
2314
2315 #[test]
2316 fn status_emitter_separate_windows_separate_pushes() {
2317 let (ctx, rx) = ctx_with_frame_rx();
2318 ctx.status_emitter().signal(ctx.build_status_snapshot());
2319 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2320 .expect("first push");
2321 ctx.status_emitter().signal(ctx.build_status_snapshot());
2322 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2323 .expect("second push");
2324 }
2325
2326 #[test]
2327 fn status_emitter_no_signal_no_push() {
2328 let (_ctx, rx) = ctx_with_frame_rx();
2329 assert!(rx
2330 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
2331 .is_err());
2332 }
2333
2334 #[test]
2335 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
2336 let (ctx, rx) = ctx_with_frame_rx();
2337 drop(ctx);
2338 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
2339 }
2340}
2341
2342#[cfg(test)]
2343mod status_bar_tests {
2344 use super::*;
2345 use crate::parser::TreeSitterProvider;
2346
2347 fn ctx() -> AppContext {
2348 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
2349 }
2350
2351 #[test]
2352 fn status_bar_counts_none_until_tier2_populated() {
2353 let ctx = ctx();
2354 assert!(ctx.status_bar_counts().is_none());
2356
2357 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
2358 let counts = ctx.status_bar_counts().expect("populated");
2359 assert_eq!(counts.dead_code, 5);
2360 assert_eq!(counts.unused_exports, 3);
2361 assert_eq!(counts.duplicates, 7);
2362 assert_eq!(counts.todos, 2);
2363 assert!(!counts.tier2_stale);
2364 assert_eq!(counts.errors, 0);
2366 assert_eq!(counts.warnings, 0);
2367 }
2368
2369 #[test]
2370 fn partial_tier2_does_not_fabricate_zeros() {
2371 let ctx = ctx();
2372 ctx.update_status_bar_tier2(Some(5), None, None, None, true);
2376 assert!(
2377 ctx.status_bar_counts().is_none(),
2378 "bar must not surface until all three Tier-2 categories are real"
2379 );
2380
2381 ctx.update_status_bar_tier2(None, Some(3), None, None, true);
2383 assert!(ctx.status_bar_counts().is_none());
2384
2385 ctx.update_status_bar_tier2(None, None, Some(7), None, false);
2388 let counts = ctx.status_bar_counts().expect("all three real now");
2389 assert_eq!(counts.dead_code, 5);
2390 assert_eq!(counts.unused_exports, 3);
2391 assert_eq!(counts.duplicates, 7);
2392 }
2393
2394 #[test]
2395 fn update_with_none_todos_preserves_last_known_todos() {
2396 let ctx = ctx();
2397 ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
2398 ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
2400 let counts = ctx.status_bar_counts().expect("populated");
2401 assert_eq!(counts.todos, 9);
2402 assert_eq!(counts.dead_code, 2);
2403 }
2404
2405 #[test]
2406 fn update_with_none_count_preserves_last_known_count() {
2407 let ctx = ctx();
2408 ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
2409 ctx.update_status_bar_tier2(Some(11), None, None, None, false);
2412 let counts = ctx.status_bar_counts().expect("populated");
2413 assert_eq!(counts.dead_code, 11);
2414 assert_eq!(counts.unused_exports, 20);
2415 assert_eq!(counts.duplicates, 30);
2416 }
2417
2418 #[test]
2419 fn mark_stale_sets_flag_only_after_populate() {
2420 let ctx = ctx();
2421 ctx.mark_status_bar_tier2_stale();
2423 assert!(ctx.status_bar_counts().is_none());
2424
2425 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), Some(0), false);
2426 ctx.mark_status_bar_tier2_stale();
2427 assert!(ctx.status_bar_counts().expect("populated").tier2_stale);
2428
2429 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), None, false);
2431 assert!(!ctx.status_bar_counts().expect("populated").tier2_stale);
2432 }
2433
2434 #[test]
2439 fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
2440 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
2441 use crate::lsp::registry::ServerKind;
2442 use crate::lsp::roots::ServerKey;
2443
2444 let ctx = ctx();
2445 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); let file = std::path::PathBuf::from("/proj/gone.ts");
2448 {
2449 let mut lsp = ctx.lsp();
2450 lsp.diagnostics_store_mut_for_test().publish(
2451 ServerKey {
2452 kind: ServerKind::TypeScript,
2453 root: std::path::PathBuf::from("/proj"),
2454 },
2455 file.clone(),
2456 vec![StoredDiagnostic {
2457 file: file.clone(),
2458 line: 1,
2459 column: 1,
2460 end_line: 1,
2461 end_column: 2,
2462 severity: DiagnosticSeverity::Error,
2463 message: "boom".into(),
2464 code: None,
2465 source: None,
2466 }],
2467 );
2468 }
2469
2470 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
2472
2473 let removed = ctx.lsp_clear_diagnostics_for_file(&file);
2475 assert!(removed);
2476 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
2477 }
2478
2479 #[test]
2480 fn status_bar_filtered_counts_ignore_environmental_flap() {
2481 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
2482 use crate::lsp::registry::ServerKind;
2483 use crate::lsp::roots::ServerKey;
2484
2485 let ctx = ctx();
2486 let root = if cfg!(windows) {
2487 std::path::PathBuf::from(r"C:\proj")
2488 } else {
2489 std::path::PathBuf::from("/proj")
2490 };
2491 ctx.set_canonical_cache_root(root.clone());
2492 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
2493
2494 let file = root.join("aft.jsonc");
2495 let key = ServerKey {
2496 kind: ServerKind::TypeScript,
2497 root: root.clone(),
2498 };
2499 let env = StoredDiagnostic {
2500 file: file.clone(),
2501 line: 1,
2502 column: 1,
2503 end_line: 1,
2504 end_column: 2,
2505 severity: DiagnosticSeverity::Error,
2506 message: "Failed to load schema from https://example.com/schema.json".into(),
2507 code: None,
2508 source: Some("json".into()),
2509 };
2510
2511 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
2512
2513 {
2514 let mut lsp = ctx.lsp();
2515 lsp.diagnostics_store_mut_for_test()
2516 .publish(key.clone(), file.clone(), vec![env]);
2517 }
2518 assert_eq!(
2519 ctx.status_bar_counts().expect("populated").errors,
2520 0,
2521 "environmental publish must not change status-bar E"
2522 );
2523
2524 {
2525 let mut lsp = ctx.lsp();
2526 lsp.diagnostics_store_mut_for_test()
2527 .publish(key, file, vec![]);
2528 }
2529 assert_eq!(
2530 ctx.status_bar_counts().expect("populated").errors,
2531 0,
2532 "environmental clear must not change status-bar E"
2533 );
2534 }
2535}
2536
2537#[cfg(test)]
2538mod harness_path_tests {
2539 use super::*;
2540 use crate::harness::Harness;
2541 use crate::parser::TreeSitterProvider;
2542
2543 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
2544 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2545 ctx.config_mut().storage_dir = Some(storage_dir);
2546 ctx.set_harness(harness);
2547 ctx
2548 }
2549
2550 #[test]
2551 fn harness_dir_resolves_correctly() {
2552 let storage = PathBuf::from("/tmp/cortexkit/aft");
2553 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2554
2555 assert_eq!(ctx.harness_dir(), storage.join("pi"));
2556 }
2557
2558 #[test]
2559 fn bash_tasks_dir_uses_hash_session() {
2560 let storage = PathBuf::from("/tmp/cortexkit/aft");
2561 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2562
2563 assert_eq!(
2564 ctx.bash_tasks_dir("ses_abc"),
2565 storage
2566 .join("opencode")
2567 .join("bash-tasks")
2568 .join(hash_session("ses_abc"))
2569 );
2570 }
2571
2572 #[test]
2573 fn backups_dir_includes_path_hash() {
2574 let storage = PathBuf::from("/tmp/cortexkit/aft");
2575 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2576
2577 assert_eq!(
2578 ctx.backups_dir("ses_abc", "pathhash"),
2579 storage
2580 .join("pi")
2581 .join("backups")
2582 .join(hash_session("ses_abc"))
2583 .join("pathhash")
2584 );
2585 }
2586
2587 #[test]
2588 fn filters_dir_under_harness() {
2589 let storage = PathBuf::from("/tmp/cortexkit/aft");
2590 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2591
2592 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
2593 }
2594
2595 #[test]
2596 fn trust_file_is_host_global() {
2597 let storage = PathBuf::from("/tmp/cortexkit/aft");
2598 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2599
2600 assert_eq!(
2601 ctx.trust_file(),
2602 storage.join("trusted-filter-projects.json")
2603 );
2604 }
2605
2606 #[test]
2607 fn same_session_different_harness_resolve_different_paths() {
2608 let storage = PathBuf::from("/tmp/cortexkit/aft");
2609 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2610 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
2611
2612 assert_ne!(
2613 opencode.bash_tasks_dir("ses_same"),
2614 pi.bash_tasks_dir("ses_same")
2615 );
2616 }
2617}
2618
2619#[cfg(test)]
2620mod gitignore_tests {
2621 use super::*;
2622 use std::fs;
2623 use std::path::Path;
2624 use tempfile::TempDir;
2625
2626 fn make_ctx_with_root(root: &Path) -> AppContext {
2627 let provider = Box::new(crate::parser::TreeSitterProvider::new());
2628 let config = Config {
2629 project_root: Some(root.to_path_buf()),
2630 ..Config::default()
2631 };
2632 AppContext::new(provider, config)
2633 }
2634
2635 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
2642 let Some(matcher) = ctx.gitignore() else {
2643 return false;
2644 };
2645 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
2646 if !canonical.starts_with(matcher.path()) {
2647 return false;
2648 }
2649 let is_dir = canonical.is_dir();
2650 matcher
2651 .matched_path_or_any_parents(&canonical, is_dir)
2652 .is_ignore()
2653 }
2654
2655 fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
2666 use std::sync::{Mutex, OnceLock};
2667 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2668 let _guard = LOCK
2669 .get_or_init(|| Mutex::new(()))
2670 .lock()
2671 .unwrap_or_else(|e| e.into_inner());
2672 let tmp = TempDir::new().unwrap();
2673 let prev = std::env::var_os("XDG_CONFIG_HOME");
2674 unsafe {
2676 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
2677 }
2678 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
2679 unsafe {
2680 match prev {
2681 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
2682 None => std::env::remove_var("XDG_CONFIG_HOME"),
2683 }
2684 }
2685 match result {
2686 Ok(r) => r,
2687 Err(p) => std::panic::resume_unwind(p),
2688 }
2689 }
2690
2691 #[test]
2692 fn rebuild_gitignore_returns_none_without_project_root() {
2693 let provider = Box::new(crate::parser::TreeSitterProvider::new());
2694 let ctx = AppContext::new(provider, Config::default());
2695 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2696 assert!(ctx.gitignore().is_none());
2697 }
2698
2699 #[test]
2700 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
2701 let tmp = TempDir::new().unwrap();
2702 let ctx = make_ctx_with_root(tmp.path());
2703 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2704 assert!(ctx.gitignore().is_none());
2705 }
2706
2707 #[test]
2708 fn matcher_filters_files_in_ignored_dist_dir() {
2709 let tmp = TempDir::new().unwrap();
2710 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
2711 fs::create_dir_all(tmp.path().join("dist")).unwrap();
2712 fs::create_dir_all(tmp.path().join("src")).unwrap();
2713 let dist_file = tmp.path().join("dist").join("bundle.js");
2714 let src_file = tmp.path().join("src").join("app.ts");
2715 fs::write(&dist_file, "x").unwrap();
2716 fs::write(&src_file, "y").unwrap();
2717
2718 let ctx = make_ctx_with_root(tmp.path());
2719 ctx.rebuild_gitignore();
2720
2721 assert!(ctx.gitignore().is_some());
2722 assert!(
2723 is_ignored(&ctx, &dist_file),
2724 "dist/bundle.js should be ignored"
2725 );
2726 assert!(
2727 !is_ignored(&ctx, &src_file),
2728 "src/app.ts should NOT be ignored"
2729 );
2730 }
2731
2732 #[test]
2733 fn matcher_handles_node_modules_and_target() {
2734 let tmp = TempDir::new().unwrap();
2735 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
2736 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
2737 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
2738 let nm_file = tmp.path().join("node_modules/foo/index.js");
2739 let target_file = tmp.path().join("target/debug/aft");
2740 fs::write(&nm_file, "x").unwrap();
2741 fs::write(&target_file, "x").unwrap();
2742
2743 let ctx = make_ctx_with_root(tmp.path());
2744 ctx.rebuild_gitignore();
2745
2746 assert!(is_ignored(&ctx, &nm_file));
2747 assert!(is_ignored(&ctx, &target_file));
2748 }
2749
2750 #[test]
2751 fn matcher_honors_negation_pattern() {
2752 let tmp = TempDir::new().unwrap();
2754 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
2755 let random_log = tmp.path().join("random.log");
2756 let important_log = tmp.path().join("important.log");
2757 fs::write(&random_log, "x").unwrap();
2758 fs::write(&important_log, "y").unwrap();
2759
2760 let ctx = make_ctx_with_root(tmp.path());
2761 ctx.rebuild_gitignore();
2762
2763 assert!(is_ignored(&ctx, &random_log));
2764 assert!(
2765 !is_ignored(&ctx, &important_log),
2766 "negation pattern should un-ignore important.log"
2767 );
2768 }
2769
2770 #[test]
2771 fn rebuild_picks_up_gitignore_changes() {
2772 let tmp = TempDir::new().unwrap();
2773 let ignore_path = tmp.path().join(".gitignore");
2774 fs::write(&ignore_path, "foo.txt\n").unwrap();
2775 let foo = tmp.path().join("foo.txt");
2776 let bar = tmp.path().join("bar.txt");
2777 fs::write(&foo, "").unwrap();
2778 fs::write(&bar, "").unwrap();
2779
2780 let ctx = make_ctx_with_root(tmp.path());
2781 ctx.rebuild_gitignore();
2782 assert!(is_ignored(&ctx, &foo));
2783 assert!(!is_ignored(&ctx, &bar));
2784
2785 fs::write(&ignore_path, "bar.txt\n").unwrap();
2787 ctx.rebuild_gitignore();
2788 assert!(!is_ignored(&ctx, &foo));
2789 assert!(is_ignored(&ctx, &bar));
2790 }
2791
2792 #[test]
2793 fn gitignore_loads_info_exclude_when_present() {
2794 let tmp = TempDir::new().unwrap();
2795 let info_dir = tmp.path().join(".git/info");
2796 fs::create_dir_all(&info_dir).unwrap();
2797 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
2798 let secrets = tmp.path().join("secrets.txt");
2799 let public = tmp.path().join("public.txt");
2800 fs::write(&secrets, "token").unwrap();
2801 fs::write(&public, "ok").unwrap();
2802
2803 let ctx = make_ctx_with_root(tmp.path());
2804 ctx.rebuild_gitignore();
2805
2806 assert!(is_ignored(&ctx, &secrets));
2807 assert!(!is_ignored(&ctx, &public));
2808 }
2809
2810 #[test]
2811 fn matcher_picks_up_nested_gitignore() {
2812 let tmp = TempDir::new().unwrap();
2813 fs::write(tmp.path().join(".gitignore"), "").unwrap();
2815 let sub = tmp.path().join("packages/foo");
2816 fs::create_dir_all(&sub).unwrap();
2817 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
2818 let generated_file = sub.join("generated").join("out.js");
2819 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
2820 fs::write(&generated_file, "x").unwrap();
2821
2822 let ctx = make_ctx_with_root(tmp.path());
2823 ctx.rebuild_gitignore();
2824
2825 assert!(
2826 is_ignored(&ctx, &generated_file),
2827 "nested gitignore in packages/foo/.gitignore should ignore generated/"
2828 );
2829 }
2830}