1use std::collections::{BTreeMap, BTreeSet};
2use std::io::{self, BufWriter};
3use std::path::{Component, Path, PathBuf};
4use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
5use std::sync::{mpsc, Arc, Mutex, RwLock};
6use std::time::{Duration, Instant};
7
8use lsp_types::FileChangeType;
9use notify::RecommendedWatcher;
10use rusqlite::Connection;
11
12use crate::backup::hash_session;
13use crate::backup::BackupStore;
14use crate::bash_background::{BgCompletion, BgTaskRegistry};
15use crate::callgraph::CallGraph;
16use crate::callgraph_store::{CallGraphStore, CallGraphStoreError};
17use crate::checkpoint::CheckpointStore;
18use crate::config::Config;
19use crate::harness::Harness;
20use crate::inspect::{
21 InspectCategory, InspectManager, InspectSnapshot, Tier2RefreshScheduler, Tier2TriggerReason,
22};
23use crate::language::LanguageProvider;
24use crate::lsp::manager::LspManager;
25use crate::lsp::registry::is_config_file_path_with_custom;
26use crate::parser::{SharedSymbolCache, SymbolCache, TreeSitterProvider};
27use crate::protocol::{
28 ConfigureWarningsFrame, ProgressFrame, PushFrame, StatusChangedFrame, StatusPayload,
29};
30use crate::watcher_filter::{SharedGitignore, WatcherDispatchEvent, WatcherThreadHandle};
31
32pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
33pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
34pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
35const STATUS_DEBOUNCE_MS: u64 = 1_000;
36
37#[derive(Debug, Clone, Default, PartialEq, Eq)]
45pub struct StatusBarCounts {
46 pub errors: usize,
47 pub warnings: usize,
48 pub dead_code: usize,
49 pub unused_exports: usize,
50 pub duplicates: usize,
51 pub todos: usize,
52 pub tier2_stale: bool,
53}
54
55#[derive(Debug, Clone, Default)]
64struct StatusBarTier2 {
65 dead_code: Option<usize>,
66 unused_exports: Option<usize>,
67 duplicates: Option<usize>,
68 todos: Option<usize>,
69 stale: bool,
70}
71
72pub struct StatusEmitter {
73 latest: Arc<Mutex<Option<StatusPayload>>>,
74 notify: mpsc::Sender<()>,
75}
76
77impl StatusEmitter {
78 fn new(progress_sender: SharedProgressSender) -> Self {
79 let (notify, rx) = mpsc::channel();
80 let latest = Arc::new(Mutex::new(None));
81 let latest_for_thread = Arc::clone(&latest);
82 std::thread::spawn(move || {
83 status_debounce_loop(rx, latest_for_thread, progress_sender);
84 });
85 Self { latest, notify }
86 }
87
88 pub fn signal(&self, snapshot: StatusPayload) {
89 if let Ok(mut latest) = self.latest.lock() {
90 *latest = Some(snapshot);
91 }
92 let _ = self.notify.send(());
93 }
94}
95
96fn status_debounce_loop(
97 rx: mpsc::Receiver<()>,
98 latest: Arc<Mutex<Option<StatusPayload>>>,
99 progress_sender: SharedProgressSender,
100) {
101 while rx.recv().is_ok() {
102 let deadline = Instant::now() + Duration::from_millis(STATUS_DEBOUNCE_MS);
103 while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
104 match rx.recv_timeout(remaining) {
105 Ok(()) => continue,
106 Err(mpsc::RecvTimeoutError::Timeout) => break,
107 Err(mpsc::RecvTimeoutError::Disconnected) => return,
108 }
109 }
110
111 let snapshot = latest.lock().ok().and_then(|mut latest| latest.take());
112 let Some(snapshot) = snapshot else { continue };
113 let sender = progress_sender
114 .lock()
115 .ok()
116 .and_then(|sender| sender.clone());
117 if let Some(sender) = sender {
118 sender(PushFrame::StatusChanged(StatusChangedFrame::new(
119 None, snapshot,
120 )));
121 }
122 }
123}
124use crate::cache_freshness::FileFreshness;
125use crate::search_index::SearchIndex;
126use crate::semantic_index::{EmbeddingEntry, SemanticIndex};
127
128#[derive(Debug, Default, Clone)]
132#[doc(hidden)]
133pub struct SemanticRefreshAccounting {
134 #[doc(hidden)]
135 pub pending: usize,
136 #[doc(hidden)]
137 pub in_flight: usize,
138}
139
140#[derive(Debug, Default)]
141struct SemanticRefreshCircuit {
142 consecutive_transient_failures: AtomicUsize,
143 open: AtomicBool,
144 probe_in_flight: AtomicBool,
145 probe_ready: AtomicBool,
146}
147
148fn ensure_refreshing_path(refreshing: &mut Vec<PathBuf>, path: PathBuf) {
149 if !refreshing.iter().any(|existing| existing == &path) {
150 refreshing.push(path);
151 refreshing.sort();
152 }
153}
154
155fn remove_refreshing_path(refreshing: &mut Vec<PathBuf>, path: &Path) {
156 refreshing.retain(|existing| existing != path);
157}
158
159#[derive(Debug, Clone)]
160pub enum SemanticIndexStatus {
161 Disabled,
162 Building {
163 stage: String,
165 files: Option<usize>,
166 entries_done: Option<usize>,
167 entries_total: Option<usize>,
168 },
169 Ready {
170 refreshing: Vec<PathBuf>,
173 #[doc(hidden)]
177 accounting: BTreeMap<PathBuf, SemanticRefreshAccounting>,
178 },
179 Failed(String),
180}
181
182impl SemanticIndexStatus {
183 pub fn ready() -> Self {
184 Self::Ready {
185 refreshing: Vec::new(),
186 accounting: BTreeMap::new(),
187 }
188 }
189
190 pub fn add_refreshing_file(&mut self, path: PathBuf) {
191 if let Self::Ready {
192 refreshing,
193 accounting,
194 } = self
195 {
196 let state = accounting.entry(path.clone()).or_default();
197 state.pending = state.pending.saturating_add(1);
198 ensure_refreshing_path(refreshing, path);
199 }
200 }
201
202 pub fn start_refreshing_file(&mut self, path: PathBuf) {
203 if let Self::Ready {
204 refreshing,
205 accounting,
206 } = self
207 {
208 let state = accounting.entry(path.clone()).or_default();
209 if state.pending == 0 {
210 state.pending = 1;
211 }
212 if state.in_flight == 0 {
213 state.in_flight = state.pending;
214 }
215 ensure_refreshing_path(refreshing, path);
216 }
217 }
218
219 pub fn cancel_refreshing_file(&mut self, path: &Path) {
220 self.finish_refreshing_file(path, false);
221 }
222
223 pub fn complete_refreshing_file(&mut self, path: &Path) {
224 self.finish_refreshing_file(path, true);
225 }
226
227 pub fn remove_refreshing_file(&mut self, path: &Path) {
228 self.complete_refreshing_file(path);
229 }
230
231 fn finish_refreshing_file(&mut self, path: &Path, complete_in_flight: bool) {
232 if let Self::Ready {
233 refreshing,
234 accounting,
235 } = self
236 {
237 let mut keep_refreshing = false;
238 if let Some(state) = accounting.get_mut(path) {
239 let finished = if complete_in_flight {
240 state.in_flight.max(1)
241 } else {
242 1
243 };
244 state.pending = state.pending.saturating_sub(finished);
245 if complete_in_flight {
246 state.in_flight = 0;
247 } else {
248 state.in_flight = state.in_flight.min(state.pending);
249 }
250 keep_refreshing = state.pending > 0;
251 if !keep_refreshing {
252 accounting.remove(path);
253 }
254 }
255
256 if !keep_refreshing {
257 remove_refreshing_path(refreshing, path);
258 }
259 }
260 }
261
262 pub fn refreshing_count(&self) -> usize {
263 match self {
264 Self::Ready { refreshing, .. } => refreshing.len(),
265 _ => 0,
266 }
267 }
268}
269
270pub enum SemanticIndexEvent {
271 Progress {
272 stage: String,
273 files: Option<usize>,
274 entries_done: Option<usize>,
275 entries_total: Option<usize>,
276 },
277 Ready(SemanticIndex),
278 Failed(String),
279}
280
281#[derive(Debug, Clone)]
282pub enum SemanticRefreshRequest {
283 Files {
284 paths: Vec<PathBuf>,
285 },
286 Corpus,
290}
291
292#[derive(Debug)]
293pub enum SemanticRefreshEvent {
294 Started {
295 paths: Vec<PathBuf>,
296 },
297 CorpusStarted {
298 files: usize,
299 },
300 Completed {
301 added_entries: Vec<EmbeddingEntry>,
302 updated_metadata: Vec<(PathBuf, FileFreshness)>,
303 completed_paths: Vec<PathBuf>,
304 },
305 CorpusCompleted {
306 index: SemanticIndex,
307 changed: usize,
308 added: usize,
309 deleted: usize,
310 total_processed: usize,
311 },
312 Failed {
313 paths: Vec<PathBuf>,
314 error: String,
315 },
316 CorpusFailed {
317 error: String,
318 },
319}
320
321pub type SemanticRefreshWorkerSlot = Arc<Mutex<Option<std::thread::JoinHandle<()>>>>;
322
323fn normalize_path(path: &Path) -> PathBuf {
327 let mut result = PathBuf::new();
328 for component in path.components() {
329 match component {
330 Component::ParentDir => {
331 if !result.pop() {
333 result.push(component);
334 }
335 }
336 Component::CurDir => {} _ => result.push(component),
338 }
339 }
340 result
341}
342
343fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
344 let mut existing = path.to_path_buf();
345 let mut tail_segments = Vec::new();
346
347 while !existing.exists() {
348 if let Some(name) = existing.file_name() {
349 tail_segments.push(name.to_owned());
350 } else {
351 break;
352 }
353
354 existing = match existing.parent() {
355 Some(parent) => parent.to_path_buf(),
356 None => break,
357 };
358 }
359
360 let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
361 for segment in tail_segments.into_iter().rev() {
362 resolved.push(segment);
363 }
364
365 resolved
366}
367
368fn path_error_response(
369 req_id: &str,
370 path: &Path,
371 resolved_root: &Path,
372) -> crate::protocol::Response {
373 crate::protocol::Response::error(
374 req_id,
375 "path_outside_root",
376 format!(
377 "path '{}' is outside the project root '{}'",
378 path.display(),
379 resolved_root.display()
380 ),
381 )
382}
383
384fn reject_escaping_symlink(
394 req_id: &str,
395 original_path: &Path,
396 candidate: &Path,
397 resolved_root: &Path,
398 raw_root: &Path,
399) -> Result<(), crate::protocol::Response> {
400 let mut current = PathBuf::new();
401
402 for component in candidate.components() {
403 current.push(component);
404
405 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
406 continue;
407 };
408
409 if !metadata.file_type().is_symlink() {
410 continue;
411 }
412
413 let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
422 if !inside_root {
423 continue;
424 }
425
426 iterative_follow_chain(req_id, original_path, ¤t, resolved_root)?;
427 }
428
429 Ok(())
430}
431
432fn iterative_follow_chain(
435 req_id: &str,
436 original_path: &Path,
437 start: &Path,
438 resolved_root: &Path,
439) -> Result<(), crate::protocol::Response> {
440 let mut link = start.to_path_buf();
441 let mut depth = 0usize;
442
443 loop {
444 if depth > 40 {
445 return Err(path_error_response(req_id, original_path, resolved_root));
446 }
447
448 let target = match std::fs::read_link(&link) {
449 Ok(t) => t,
450 Err(_) => {
451 return Err(path_error_response(req_id, original_path, resolved_root));
453 }
454 };
455
456 let resolved_target = if target.is_absolute() {
457 normalize_path(&target)
458 } else {
459 let parent = link.parent().unwrap_or_else(|| Path::new(""));
460 normalize_path(&parent.join(&target))
461 };
462
463 let canonical_target =
467 std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
468
469 if !canonical_target.starts_with(resolved_root)
470 && !resolved_target.starts_with(resolved_root)
471 {
472 return Err(path_error_response(req_id, original_path, resolved_root));
473 }
474
475 match std::fs::symlink_metadata(&resolved_target) {
477 Ok(meta) if meta.file_type().is_symlink() => {
478 link = resolved_target;
479 depth += 1;
480 }
481 _ => break, }
483 }
484
485 Ok(())
486}
487
488pub type LanguageProviderFactory = fn() -> Box<dyn LanguageProvider>;
489
490pub fn default_language_provider_factory() -> Box<dyn LanguageProvider> {
491 Box::new(TreeSitterProvider::new())
492}
493
494pub struct App {
499 db: parking_lot::Mutex<Option<Arc<Mutex<Connection>>>>,
500 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
501 stdout_writer: SharedStdoutWriter,
502 provider_factory: LanguageProviderFactory,
503}
504
505impl App {
506 pub fn new(provider_factory: LanguageProviderFactory) -> Self {
507 Self {
508 db: parking_lot::Mutex::new(None),
509 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry::new(),
510 stdout_writer: Arc::new(Mutex::new(BufWriter::new(io::stdout()))),
511 provider_factory,
512 }
513 }
514
515 pub fn shared(provider_factory: LanguageProviderFactory) -> Arc<Self> {
517 Arc::new(Self::new(provider_factory))
518 }
519
520 pub fn default_shared() -> Arc<Self> {
521 Self::shared(default_language_provider_factory)
522 }
523
524 pub fn create_provider(&self) -> Box<dyn LanguageProvider> {
525 (self.provider_factory)()
526 }
527
528 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
529 self.lsp_child_registry.clone()
530 }
531
532 pub fn stdout_writer(&self) -> SharedStdoutWriter {
533 Arc::clone(&self.stdout_writer)
534 }
535
536 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
537 *self.db.lock() = Some(conn);
538 }
539
540 pub fn clear_db(&self) {
541 *self.db.lock() = None;
542 }
543
544 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
545 self.db.lock().clone()
546 }
547}
548
549impl Default for App {
550 fn default() -> Self {
551 Self::new(default_language_provider_factory)
552 }
553}
554
555const _: fn() = || {
556 fn assert_send_sync<T: Send + Sync>() {}
557 fn assert_send<T: Send>() {}
558
559 assert_send_sync::<App>();
560 assert_send_sync::<AppContext>();
561 assert_send::<crate::lsp::manager::LspManager>();
562 assert_send::<crate::semantic_index::EmbeddingModel>();
563};
564
565pub struct AppContext {
577 app: Arc<App>,
578 provider: Box<dyn LanguageProvider>,
579 backup: parking_lot::Mutex<BackupStore>,
580 checkpoint: parking_lot::Mutex<CheckpointStore>,
581 config: RwLock<Arc<Config>>,
582 pub harness: parking_lot::Mutex<Option<Harness>>,
583 canonical_cache_root: parking_lot::Mutex<Option<PathBuf>>,
584 is_worktree_bridge: parking_lot::Mutex<bool>,
585 git_common_dir: parking_lot::Mutex<Option<PathBuf>>,
586 degraded_reasons: parking_lot::Mutex<Vec<String>>,
594 callgraph: parking_lot::Mutex<Option<CallGraph>>,
595 callgraph_store: RwLock<Option<Arc<CallGraphStore>>>,
596 callgraph_store_force_rebuild: parking_lot::Mutex<bool>,
597 callgraph_store_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStore>>>,
598 pending_callgraph_store_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
599 search_index: RwLock<Option<SearchIndex>>,
600 search_index_rx: RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>>,
601 pending_search_index_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
602 symbol_cache: SharedSymbolCache,
603 inspect_manager: Arc<InspectManager>,
604 tier2_refresh_scheduler: parking_lot::Mutex<Tier2RefreshScheduler>,
605 pending_tier2_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
606 semantic_index: RwLock<Option<SemanticIndex>>,
607 semantic_index_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
608 semantic_index_status: RwLock<SemanticIndexStatus>,
609 pending_semantic_index_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
610 pending_semantic_corpus_refresh: parking_lot::Mutex<bool>,
611 semantic_refresh_tx:
612 parking_lot::Mutex<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>,
613 semantic_refresh_event_rx:
614 parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
615 semantic_refresh_worker: parking_lot::Mutex<Option<SemanticRefreshWorkerSlot>>,
616 semantic_refresh_retry_attempts: parking_lot::Mutex<BTreeMap<PathBuf, usize>>,
617 semantic_refresh_circuit: Arc<SemanticRefreshCircuit>,
618 semantic_embedding_model: parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>>,
619 watcher: parking_lot::Mutex<Option<RecommendedWatcher>>,
620 watcher_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>>,
621 watcher_thread: parking_lot::Mutex<Option<WatcherThreadHandle>>,
622 lsp_manager: parking_lot::Mutex<LspManager>,
623 configure_generation: AtomicU64,
624 last_seen_reuse_completions: AtomicU64,
628 configure_warnings_tx: crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)>,
629 configure_warnings_rx: crossbeam_channel::Receiver<(u64, ConfigureWarningsFrame)>,
630 progress_sender: SharedProgressSender,
633 status_emitter: StatusEmitter,
634 status_bar_last_emitted: RwLock<Option<StatusBarCounts>>,
638 bash_background: BgTaskRegistry,
639 filter_registry: crate::compress::SharedFilterRegistry,
646 filter_registry_loaded: std::sync::atomic::AtomicBool,
649 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
654 gitignore: SharedGitignore,
661 gitignore_generation: Arc<AtomicU64>,
662 status_bar_tier2: RwLock<StatusBarTier2>,
666 tsconfig_membership:
673 parking_lot::Mutex<crate::lsp::tsconfig_membership::TsconfigMembershipCache>,
674}
675
676impl Drop for AppContext {
677 fn drop(&mut self) {
678 if let Some(runtime) = self.watcher_thread.get_mut().take() {
679 runtime.shutdown_and_join();
680 }
681 }
682}
683
684pub enum CallgraphStoreAccess {
692 Ready(Arc<CallGraphStore>),
694 Building,
696 Unavailable,
698 Error(CallGraphStoreError),
700}
701
702fn callgraph_build_wait_window() -> Duration {
707 std::env::var("AFT_CALLGRAPH_BUILD_WAIT_MS")
708 .ok()
709 .and_then(|raw| raw.parse::<u64>().ok())
710 .map(Duration::from_millis)
711 .unwrap_or(Duration::ZERO)
712}
713
714static CALLGRAPH_COLD_BUILD_SPAWN_COUNT: AtomicUsize = AtomicUsize::new(0);
715
716#[doc(hidden)]
717pub fn reset_callgraph_cold_build_spawn_count_for_test() {
718 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
719}
720
721#[doc(hidden)]
722pub fn callgraph_cold_build_spawn_count_for_test() -> usize {
723 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst)
724}
725
726impl AppContext {
727 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
728 Self::with_app_and_provider(App::default_shared(), provider, config)
729 }
730
731 pub fn from_app(app: Arc<App>, config: Config) -> Self {
732 let provider = app.create_provider();
733 Self::with_app_and_provider(app, provider, config)
734 }
735
736 pub fn with_app_and_provider(
737 app: Arc<App>,
738 provider: Box<dyn LanguageProvider>,
739 config: Config,
740 ) -> Self {
741 let bash_compress_enabled = config.experimental_bash_compress;
742 let (configure_warnings_tx, configure_warnings_rx) = crossbeam_channel::unbounded();
743 let progress_sender: SharedProgressSender = Arc::new(Mutex::new(None));
744 let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
745 let symbol_cache = provider
746 .as_any()
747 .downcast_ref::<TreeSitterProvider>()
748 .map(|provider| provider.symbol_cache())
749 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
750 let mut lsp_manager = LspManager::new();
751 lsp_manager.set_child_registry(app.lsp_child_registry());
752 lsp_manager.set_diagnostic_capacity(config.diagnostic_cache_size);
755 AppContext {
756 app: Arc::clone(&app),
757 provider,
758 backup: parking_lot::Mutex::new(BackupStore::new()),
759 checkpoint: parking_lot::Mutex::new(CheckpointStore::new()),
760 config: RwLock::new(Arc::new(config)),
761 harness: parking_lot::Mutex::new(None),
762 canonical_cache_root: parking_lot::Mutex::new(None),
763 is_worktree_bridge: parking_lot::Mutex::new(false),
764 git_common_dir: parking_lot::Mutex::new(None),
765 degraded_reasons: parking_lot::Mutex::new(Vec::new()),
766 callgraph: parking_lot::Mutex::new(None),
767 callgraph_store: RwLock::new(None),
768 callgraph_store_force_rebuild: parking_lot::Mutex::new(false),
769 callgraph_store_rx: parking_lot::Mutex::new(None),
770 pending_callgraph_store_paths: parking_lot::Mutex::new(BTreeSet::new()),
771 search_index: RwLock::new(None),
772 search_index_rx: RwLock::new(None),
773 pending_search_index_paths: parking_lot::Mutex::new(BTreeSet::new()),
774 symbol_cache,
775 inspect_manager: Arc::new(InspectManager::new()),
776 tier2_refresh_scheduler: parking_lot::Mutex::new(Tier2RefreshScheduler::new()),
777 pending_tier2_paths: parking_lot::Mutex::new(BTreeSet::new()),
778 semantic_index: RwLock::new(None),
779 semantic_index_rx: parking_lot::Mutex::new(None),
780 semantic_index_status: RwLock::new(SemanticIndexStatus::Disabled),
781 pending_semantic_index_paths: parking_lot::Mutex::new(BTreeSet::new()),
782 pending_semantic_corpus_refresh: parking_lot::Mutex::new(false),
783 semantic_refresh_tx: parking_lot::Mutex::new(None),
784 semantic_refresh_event_rx: parking_lot::Mutex::new(None),
785 semantic_refresh_worker: parking_lot::Mutex::new(None),
786 semantic_refresh_retry_attempts: parking_lot::Mutex::new(BTreeMap::new()),
787 semantic_refresh_circuit: Arc::new(SemanticRefreshCircuit::default()),
788 semantic_embedding_model: parking_lot::Mutex::new(None),
789 watcher: parking_lot::Mutex::new(None),
790 watcher_rx: parking_lot::Mutex::new(None),
791 watcher_thread: parking_lot::Mutex::new(None),
792 lsp_manager: parking_lot::Mutex::new(lsp_manager),
793 configure_generation: AtomicU64::new(0),
794 last_seen_reuse_completions: AtomicU64::new(0),
795 configure_warnings_tx,
796 configure_warnings_rx,
797 progress_sender: Arc::clone(&progress_sender),
798 status_emitter,
799 status_bar_last_emitted: RwLock::new(None),
800 bash_background: BgTaskRegistry::new(Arc::clone(&progress_sender)),
801 filter_registry: Arc::new(std::sync::RwLock::new(
802 crate::compress::toml_filter::FilterRegistry::default(),
803 )),
804 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
805 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
806 gitignore: Arc::new(std::sync::RwLock::new(None)),
807 gitignore_generation: Arc::new(AtomicU64::new(0)),
808 status_bar_tier2: RwLock::new(StatusBarTier2::default()),
809 tsconfig_membership: parking_lot::Mutex::new(
810 crate::lsp::tsconfig_membership::TsconfigMembershipCache::new(),
811 ),
812 }
813 }
814
815 pub fn status_bar_counts(&self) -> Option<StatusBarCounts> {
821 let (dead_code, unused_exports, duplicates, todos, tier2_stale) = {
827 let tier2 = self
828 .status_bar_tier2
829 .read()
830 .unwrap_or_else(std::sync::PoisonError::into_inner);
831 let (Some(dead_code), Some(unused_exports), Some(duplicates)) =
832 (tier2.dead_code, tier2.unused_exports, tier2.duplicates)
833 else {
834 return None;
835 };
836 (
837 dead_code,
838 unused_exports,
839 duplicates,
840 tier2.todos.unwrap_or(0),
841 tier2.stale,
842 )
843 };
844 let (errors, warnings) = self.status_bar_error_warning_counts();
845 Some(StatusBarCounts {
846 errors,
847 warnings,
848 dead_code,
849 unused_exports,
850 duplicates,
851 todos,
852 tier2_stale,
853 })
854 }
855
856 pub fn should_emit_status_bar(&self, counts: &StatusBarCounts) -> bool {
857 let mut last = self
858 .status_bar_last_emitted
859 .write()
860 .unwrap_or_else(std::sync::PoisonError::into_inner);
861 if last.as_ref() == Some(counts) {
862 return false;
863 }
864 *last = Some(counts.clone());
865 true
866 }
867
868 fn status_bar_error_warning_counts(&self) -> (usize, usize) {
874 let Some(root) = self.canonical_cache_root_opt() else {
875 return self.lsp_manager.lock().warm_error_warning_counts();
878 };
879 let lsp = self.lsp_manager.lock();
880 let mut membership = self.tsconfig_membership.lock();
881 lsp.filtered_error_warning_counts(|file| {
882 file.starts_with(&root) && !membership.should_skip_diagnostics(file)
883 })
884 }
885
886 pub fn clear_tsconfig_membership_cache(&self) {
890 self.tsconfig_membership.lock().clear();
891 }
892
893 pub fn mark_status_bar_tier2_stale(&self) -> bool {
899 let mut tier2 = self
900 .status_bar_tier2
901 .write()
902 .unwrap_or_else(std::sync::PoisonError::into_inner);
903 if tier2.dead_code.is_some() && tier2.unused_exports.is_some() && tier2.duplicates.is_some()
905 {
906 let changed = !tier2.stale;
907 tier2.stale = true;
908 return changed;
909 }
910 false
911 }
912
913 pub fn update_status_bar_tier2(
919 &self,
920 dead_code: Option<usize>,
921 unused_exports: Option<usize>,
922 duplicates: Option<usize>,
923 todos: Option<usize>,
924 stale: bool,
925 ) {
926 let mut tier2 = self
927 .status_bar_tier2
928 .write()
929 .unwrap_or_else(std::sync::PoisonError::into_inner);
930 if let Some(dead_code) = dead_code {
931 tier2.dead_code = Some(dead_code);
932 }
933 if let Some(unused_exports) = unused_exports {
934 tier2.unused_exports = Some(unused_exports);
935 }
936 if let Some(duplicates) = duplicates {
937 tier2.duplicates = Some(duplicates);
938 }
939 if let Some(todos) = todos {
940 tier2.todos = Some(todos);
941 }
942 tier2.stale = stale;
943 }
944
945 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
948 self.gitignore
949 .read()
950 .unwrap_or_else(|poisoned| poisoned.into_inner())
951 .clone()
952 }
953
954 pub fn shared_gitignore(&self) -> SharedGitignore {
956 Arc::clone(&self.gitignore)
957 }
958
959 pub fn gitignore_generation(&self) -> Arc<AtomicU64> {
963 Arc::clone(&self.gitignore_generation)
964 }
965
966 fn set_gitignore(&self, matcher: Option<Arc<ignore::gitignore::Gitignore>>) {
967 *self
968 .gitignore
969 .write()
970 .unwrap_or_else(|poisoned| poisoned.into_inner()) = matcher;
971 self.gitignore_generation.fetch_add(1, Ordering::SeqCst);
972 }
973
974 pub fn clear_gitignore(&self) {
996 self.set_gitignore(None);
997 }
998
999 pub fn rebuild_gitignore(&self) {
1000 use ignore::gitignore::GitignoreBuilder;
1001 use std::path::Path;
1002 let root_raw = match self.config().project_root.clone() {
1003 Some(r) => r,
1004 None => {
1005 self.set_gitignore(None);
1006 return;
1007 }
1008 };
1009 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
1017 let mut builder = GitignoreBuilder::new(&root);
1018 if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
1023 if global_ignore.is_file() {
1024 if let Some(err) = builder.add(&global_ignore) {
1025 crate::slog_warn!(
1026 "global gitignore parse error in {}: {}",
1027 global_ignore.display(),
1028 err
1029 );
1030 }
1031 }
1032 }
1033 let root_ignore = Path::new(&root).join(".gitignore");
1035 if root_ignore.exists() {
1036 if let Some(err) = builder.add(&root_ignore) {
1037 crate::slog_warn!(
1038 "gitignore parse error in {}: {}",
1039 root_ignore.display(),
1040 err
1041 );
1042 }
1043 }
1044 let root_aftignore = Path::new(&root).join(".aftignore");
1049 if root_aftignore.exists() {
1050 if let Some(err) = builder.add(&root_aftignore) {
1051 crate::slog_warn!(
1052 "aftignore parse error in {}: {}",
1053 root_aftignore.display(),
1054 err
1055 );
1056 }
1057 }
1058 let info_exclude = self
1063 .git_common_dir
1064 .lock()
1065 .clone()
1066 .unwrap_or_else(|| Path::new(&root).join(".git"))
1067 .join("info")
1068 .join("exclude");
1069 if info_exclude.exists() {
1070 if let Some(err) = builder.add(&info_exclude) {
1071 crate::slog_warn!(
1072 "gitignore parse error in {}: {}",
1073 info_exclude.display(),
1074 err
1075 );
1076 }
1077 }
1078 let walker = ignore::WalkBuilder::new(&root)
1084 .standard_filters(true)
1085 .hidden(false)
1093 .filter_entry(|entry| {
1094 let name = entry.file_name().to_string_lossy();
1095 !matches!(
1096 name.as_ref(),
1097 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
1098 )
1099 })
1100 .build();
1101 for entry in walker.flatten() {
1102 let file_name = entry.file_name();
1103 let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
1104 let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
1105 if is_nested_gitignore || is_nested_aftignore {
1106 if let Some(err) = builder.add(entry.path()) {
1107 crate::slog_warn!(
1108 "nested ignore parse error in {}: {}",
1109 entry.path().display(),
1110 err
1111 );
1112 }
1113 }
1114 }
1115 match builder.build() {
1116 Ok(gi) => {
1117 let count = gi.num_ignores();
1118 if count > 0 {
1119 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
1120 self.set_gitignore(Some(Arc::new(gi)));
1121 } else {
1122 self.set_gitignore(None);
1123 }
1124 }
1125 Err(err) => {
1126 crate::slog_warn!("gitignore matcher build failed: {}", err);
1127 self.set_gitignore(None);
1128 }
1129 }
1130 }
1131
1132 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
1135 Arc::clone(&self.bash_compress_flag)
1136 }
1137
1138 pub fn sync_bash_compress_flag(&self) {
1142 let value = self.config().experimental_bash_compress;
1143 self.bash_compress_flag
1144 .store(value, std::sync::atomic::Ordering::Relaxed);
1145 }
1146
1147 pub fn set_bash_compress_enabled(&self, enabled: bool) {
1148 self.update_config(|config| {
1149 config.experimental_bash_compress = enabled;
1150 });
1151 self.bash_compress_flag
1152 .store(enabled, std::sync::atomic::Ordering::Relaxed);
1153 }
1154
1155 pub fn filter_registry(
1159 &self,
1160 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
1161 self.ensure_filter_registry_loaded();
1162 match self.filter_registry.read() {
1163 Ok(g) => g,
1164 Err(poisoned) => poisoned.into_inner(),
1165 }
1166 }
1167
1168 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
1172 self.ensure_filter_registry_loaded();
1173 Arc::clone(&self.filter_registry)
1174 }
1175
1176 pub fn reset_filter_registry(&self) {
1180 let new_registry = crate::compress::build_registry_for_context(self);
1181 match self.filter_registry.write() {
1182 Ok(mut slot) => *slot = new_registry,
1183 Err(poisoned) => *poisoned.into_inner() = new_registry,
1184 }
1185 self.filter_registry_loaded
1186 .store(true, std::sync::atomic::Ordering::Release);
1187 }
1188
1189 fn ensure_filter_registry_loaded(&self) {
1190 use std::sync::atomic::Ordering;
1191 if self.filter_registry_loaded.load(Ordering::Acquire) {
1192 return;
1193 }
1194 let new_registry = crate::compress::build_registry_for_context(self);
1197 if let Ok(mut slot) = self.filter_registry.write() {
1198 *slot = new_registry;
1199 self.filter_registry_loaded.store(true, Ordering::Release);
1200 }
1201 }
1202
1203 pub fn app(&self) -> Arc<App> {
1204 Arc::clone(&self.app)
1205 }
1206
1207 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
1210 self.app.lsp_child_registry()
1211 }
1212
1213 pub fn stdout_writer(&self) -> SharedStdoutWriter {
1214 self.app.stdout_writer()
1215 }
1216
1217 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
1218 if let Ok(mut progress_sender) = self.progress_sender.lock() {
1219 *progress_sender = sender;
1220 }
1221 }
1222
1223 pub fn emit_progress(&self, frame: ProgressFrame) {
1224 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
1225 return;
1226 };
1227 if let Some(sender) = progress_sender.as_ref() {
1228 sender(PushFrame::Progress(frame));
1229 }
1230 }
1231
1232 pub fn status_emitter(&self) -> &StatusEmitter {
1233 &self.status_emitter
1234 }
1235
1236 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
1244 self.progress_sender
1245 .lock()
1246 .ok()
1247 .and_then(|sender| sender.clone())
1248 }
1249
1250 pub fn advance_configure_generation(&self) -> u64 {
1251 self.configure_generation
1252 .fetch_add(1, Ordering::SeqCst)
1253 .wrapping_add(1)
1254 }
1255
1256 pub fn configure_generation(&self) -> u64 {
1257 self.configure_generation.load(Ordering::SeqCst)
1258 }
1259
1260 pub fn configure_warnings_sender(
1261 &self,
1262 ) -> crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)> {
1263 self.configure_warnings_tx.clone()
1264 }
1265
1266 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
1267 let mut warnings = Vec::new();
1268 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
1269 warnings.push(warning);
1270 }
1271 warnings
1272 }
1273
1274 pub fn bash_background(&self) -> &BgTaskRegistry {
1275 &self.bash_background
1276 }
1277
1278 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
1279 self.bash_background.drain_completions()
1280 }
1281
1282 pub fn provider(&self) -> &dyn LanguageProvider {
1284 self.provider.as_ref()
1285 }
1286
1287 pub fn backup(&self) -> &parking_lot::Mutex<BackupStore> {
1289 &self.backup
1290 }
1291
1292 pub fn checkpoint(&self) -> &parking_lot::Mutex<CheckpointStore> {
1294 &self.checkpoint
1295 }
1296
1297 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
1298 self.app.set_db(conn);
1299 }
1300
1301 pub fn clear_db(&self) {
1302 self.app.clear_db();
1303 }
1304
1305 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
1306 self.app.db()
1307 }
1308
1309 pub fn config(&self) -> Arc<Config> {
1311 let guard = match self.config.read() {
1312 Ok(guard) => guard,
1313 Err(poisoned) => poisoned.into_inner(),
1314 };
1315 Arc::clone(&*guard)
1316 }
1317
1318 pub fn set_config(&self, config: Config) {
1320 let next = Arc::new(config);
1321 match self.config.write() {
1322 Ok(mut guard) => *guard = next,
1323 Err(poisoned) => *poisoned.into_inner() = next,
1324 }
1325 }
1326
1327 pub fn update_config(&self, update: impl FnOnce(&mut Config)) {
1329 let mut next = self.config().as_ref().clone();
1330 update(&mut next);
1331 self.set_config(next);
1332 }
1333
1334 pub fn set_harness(&self, harness: Harness) {
1335 self.bash_background.set_harness(harness.clone());
1336 *self.harness.lock() = Some(harness);
1337 }
1338
1339 pub fn harness_opt(&self) -> Option<Harness> {
1340 self.harness.lock().clone()
1341 }
1342
1343 pub fn harness(&self) -> Harness {
1344 self.harness_opt()
1345 .expect("harness set by configure before any tool call")
1346 }
1347
1348 pub fn storage_dir(&self) -> PathBuf {
1349 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
1350 }
1351
1352 pub fn harness_dir(&self) -> PathBuf {
1353 self.storage_dir().join(self.harness().storage_segment())
1354 }
1355
1356 pub fn inspect_dir(&self) -> PathBuf {
1357 self.harness_dir().join("inspect")
1358 }
1359
1360 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
1361 self.harness_dir()
1362 .join("bash-tasks")
1363 .join(hash_session(session_id))
1364 }
1365
1366 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
1367 self.harness_dir()
1368 .join("backups")
1369 .join(hash_session(session_id))
1370 .join(path_hash)
1371 }
1372
1373 pub fn filters_dir(&self) -> PathBuf {
1374 self.harness_dir().join("filters")
1375 }
1376
1377 pub fn trust_file(&self) -> PathBuf {
1379 self.storage_dir().join("trusted-filter-projects.json")
1380 }
1381
1382 pub fn set_canonical_cache_root(&self, root: PathBuf) {
1383 debug_assert!(root.is_absolute());
1384 *self.canonical_cache_root.lock() = Some(root);
1385 }
1386
1387 pub fn canonical_cache_root(&self) -> PathBuf {
1388 self.canonical_cache_root
1389 .lock()
1390 .clone()
1391 .expect("canonical_cache_root accessed before handle_configure")
1392 }
1393
1394 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
1395 self.canonical_cache_root.lock().clone()
1396 }
1397
1398 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
1399 *self.is_worktree_bridge.lock() = is_worktree_bridge;
1400 *self.git_common_dir.lock() = git_common_dir;
1401 }
1402
1403 pub fn is_worktree_bridge(&self) -> bool {
1404 *self.is_worktree_bridge.lock()
1405 }
1406
1407 pub fn git_common_dir(&self) -> Option<PathBuf> {
1408 self.git_common_dir.lock().clone()
1409 }
1410
1411 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
1415 *self.degraded_reasons.lock() = reasons;
1416 }
1417
1418 pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
1419 let reason = reason.into();
1420 let mut reasons = self.degraded_reasons.lock();
1421 if reasons.iter().any(|existing| existing == &reason) {
1422 return false;
1423 }
1424 reasons.push(reason);
1425 true
1426 }
1427
1428 pub fn degraded_reasons(&self) -> Vec<String> {
1432 self.degraded_reasons.lock().clone()
1433 }
1434
1435 pub fn is_degraded(&self) -> bool {
1437 !self.degraded_reasons.lock().is_empty()
1438 }
1439
1440 pub fn cache_role(&self) -> &'static str {
1441 if self.canonical_cache_root.lock().is_none() {
1442 "not_initialized"
1443 } else if self.is_worktree_bridge() {
1444 "worktree"
1445 } else {
1446 "main"
1447 }
1448 }
1449
1450 pub fn callgraph(&self) -> &parking_lot::Mutex<Option<CallGraph>> {
1452 &self.callgraph
1453 }
1454
1455 pub fn callgraph_store(&self) -> &RwLock<Option<Arc<CallGraphStore>>> {
1457 &self.callgraph_store
1458 }
1459
1460 pub fn mark_callgraph_store_force_rebuild(&self) {
1461 *self.callgraph_store_force_rebuild.lock() = true;
1462 }
1463
1464 fn take_callgraph_store_force_rebuild(&self) -> bool {
1465 let mut force = self.callgraph_store_force_rebuild.lock();
1466 let was_forced = *force;
1467 *force = false;
1468 was_forced
1469 }
1470
1471 pub fn callgraph_store_dir(&self) -> PathBuf {
1472 match self.harness_opt() {
1473 Some(harness) => self
1474 .storage_dir()
1475 .join(harness.storage_segment())
1476 .join("callgraph"),
1477 None => self.storage_dir().join("callgraph"),
1478 }
1479 }
1480
1481 pub fn ensure_callgraph_store(
1482 &self,
1483 ) -> Result<Option<Arc<CallGraphStore>>, CallGraphStoreError> {
1484 self.ensure_callgraph_store_with_flag(true)
1485 }
1486
1487 fn ensure_callgraph_store_with_flag(
1488 &self,
1489 respect_config_flag: bool,
1490 ) -> Result<Option<Arc<CallGraphStore>>, CallGraphStoreError> {
1491 if respect_config_flag && !self.config().callgraph_store {
1492 return Ok(None);
1493 }
1494 if let Some(store) = {
1495 let guard = self
1496 .callgraph_store
1497 .read()
1498 .unwrap_or_else(std::sync::PoisonError::into_inner);
1499 guard.as_ref().map(Arc::clone)
1500 } {
1501 return Ok(Some(store));
1502 }
1503
1504 let Some(project_root) = self.callgraph_project_root() else {
1505 return Ok(None);
1506 };
1507 let callgraph_dir = self.callgraph_store_dir();
1508 let force_rebuild = self.take_callgraph_store_force_rebuild();
1509 let store = if self.is_worktree_bridge() {
1510 CallGraphStore::open_readonly(callgraph_dir, project_root)?
1511 } else if force_rebuild {
1512 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1513 let (store, _stats) = CallGraphStore::cold_build_with_lease_chunked(
1514 callgraph_dir,
1515 project_root,
1516 &files,
1517 self.config().callgraph_chunk_size,
1518 )?;
1519 Some(store)
1520 } else if CallGraphStore::needs_cold_build(&callgraph_dir, &project_root)? {
1521 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1522 let (store, _stats) = CallGraphStore::ensure_built_with_lease_chunked(
1523 callgraph_dir,
1524 project_root,
1525 &files,
1526 self.config().callgraph_chunk_size,
1527 )?;
1528 Some(store)
1529 } else {
1530 Some(CallGraphStore::open(callgraph_dir, project_root)?)
1531 };
1532
1533 let Some(store) = store else {
1534 return Ok(None);
1535 };
1536 let store = Arc::new(store);
1537 {
1538 let mut guard = self
1539 .callgraph_store
1540 .write()
1541 .unwrap_or_else(std::sync::PoisonError::into_inner);
1542 *guard = Some(Arc::clone(&store));
1543 }
1544 Ok(Some(store))
1545 }
1546
1547 fn callgraph_project_root(&self) -> Option<PathBuf> {
1550 self.canonical_cache_root_opt().or_else(|| {
1551 self.config()
1552 .project_root
1553 .clone()
1554 .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
1555 })
1556 }
1557
1558 pub fn revalidate_callgraph_store_generation(&self) {
1576 if self.callgraph_store_rx.lock().is_some() {
1579 return;
1580 }
1581 let superseded = {
1582 let guard = self
1583 .callgraph_store
1584 .read()
1585 .unwrap_or_else(std::sync::PoisonError::into_inner);
1586 guard.as_ref().is_some_and(|store| !store.is_current())
1587 };
1588 if superseded {
1589 let mut guard = self
1590 .callgraph_store
1591 .write()
1592 .unwrap_or_else(std::sync::PoisonError::into_inner);
1593 *guard = None;
1594 }
1595 }
1596
1597 pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess {
1598 self.revalidate_callgraph_store_generation();
1602 if let Some(store) = {
1603 let guard = self
1604 .callgraph_store
1605 .read()
1606 .unwrap_or_else(std::sync::PoisonError::into_inner);
1607 guard.as_ref().map(Arc::clone)
1608 } {
1609 return CallgraphStoreAccess::Ready(store);
1610 }
1611
1612 if self.callgraph_store_rx.lock().is_some() {
1614 return CallgraphStoreAccess::Building;
1615 }
1616
1617 let Some(project_root) = self.callgraph_project_root() else {
1618 return CallgraphStoreAccess::Unavailable;
1619 };
1620 let callgraph_dir = self.callgraph_store_dir();
1621
1622 if self.is_worktree_bridge() {
1625 match CallGraphStore::open_readonly(callgraph_dir, project_root) {
1626 Ok(Some(store)) => {
1627 let store = Arc::new(store);
1628 {
1629 let mut guard = self
1630 .callgraph_store
1631 .write()
1632 .unwrap_or_else(std::sync::PoisonError::into_inner);
1633 *guard = Some(Arc::clone(&store));
1634 }
1635 return CallgraphStoreAccess::Ready(store);
1636 }
1637 Ok(None) | Err(_) => return CallgraphStoreAccess::Unavailable,
1638 }
1639 }
1640
1641 let force_rebuild = *self.callgraph_store_force_rebuild.lock();
1642 if !force_rebuild {
1645 match CallGraphStore::needs_cold_build(&callgraph_dir, &project_root) {
1646 Ok(false) => match CallGraphStore::open(callgraph_dir, project_root) {
1647 Ok(store) => {
1648 let store = Arc::new(store);
1649 {
1650 let mut guard = self
1651 .callgraph_store
1652 .write()
1653 .unwrap_or_else(std::sync::PoisonError::into_inner);
1654 *guard = Some(Arc::clone(&store));
1655 }
1656 return CallgraphStoreAccess::Ready(store);
1657 }
1658 Err(error) => return CallgraphStoreAccess::Error(error),
1659 },
1660 Ok(true) => {}
1661 Err(error) => return CallgraphStoreAccess::Error(error),
1662 }
1663 }
1664
1665 if !self.spawn_callgraph_store_cold_build(project_root, callgraph_dir, force_rebuild) {
1673 return CallgraphStoreAccess::Building;
1674 }
1675
1676 let wait = callgraph_build_wait_window();
1677 if !wait.is_zero() {
1678 let received = {
1679 let rx_ref = self.callgraph_store_rx.lock();
1680 let Some(rx) = rx_ref.as_ref() else {
1681 return CallgraphStoreAccess::Building;
1682 };
1683 rx.recv_timeout(wait)
1684 };
1685 match received {
1686 Ok(store) => {
1687 let pending = self.take_pending_callgraph_store_paths();
1691 if !pending.is_empty() {
1692 if let Err(error) = store.refresh_files(&pending) {
1693 crate::slog_warn!(
1694 "callgraph store inline post-build refresh failed: {}",
1695 error
1696 );
1697 let _ = store.mark_files_stale(&pending);
1698 }
1699 }
1700 let store = Arc::new(store);
1701 {
1702 let mut guard = self
1703 .callgraph_store
1704 .write()
1705 .unwrap_or_else(std::sync::PoisonError::into_inner);
1706 *guard = Some(Arc::clone(&store));
1707 }
1708 *self.callgraph_store_rx.lock() = None;
1709 return CallgraphStoreAccess::Ready(store);
1710 }
1711 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1712 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
1713 *self.callgraph_store_rx.lock() = None;
1716 }
1717 }
1718 }
1719 CallgraphStoreAccess::Building
1720 }
1721
1722 fn spawn_callgraph_store_cold_build(
1729 &self,
1730 project_root: PathBuf,
1731 callgraph_dir: PathBuf,
1732 force_rebuild: bool,
1733 ) -> bool {
1734 let session_id = crate::log_ctx::current_session();
1735 let chunk_size = self.config().callgraph_chunk_size;
1736
1737 let mut rx_guard = self.callgraph_store_rx.lock();
1738 if rx_guard.is_some() {
1739 return false;
1740 }
1741
1742 if force_rebuild {
1743 self.take_callgraph_store_force_rebuild();
1746 }
1747 let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStore>();
1748 *rx_guard = Some(rx);
1749
1750 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.fetch_add(1, Ordering::SeqCst);
1751
1752 std::thread::spawn(move || {
1753 crate::log_ctx::with_session(session_id, || {
1754 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1755 let built = if force_rebuild {
1756 CallGraphStore::cold_build_with_lease_chunked(
1757 callgraph_dir,
1758 project_root,
1759 &files,
1760 chunk_size,
1761 )
1762 .map(|(store, _)| store)
1763 } else {
1764 CallGraphStore::ensure_built_with_lease_chunked(
1765 callgraph_dir,
1766 project_root,
1767 &files,
1768 chunk_size,
1769 )
1770 .map(|(store, _)| store)
1771 };
1772 match built {
1773 Ok(store) => {
1774 let _ = tx.send(store);
1775 }
1776 Err(error) => {
1777 crate::slog_warn!("callgraph store cold build failed: {}", error);
1778 }
1781 }
1782 });
1783 });
1784 true
1785 }
1786
1787 pub fn callgraph_store_rx(
1790 &self,
1791 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStore>>> {
1792 &self.callgraph_store_rx
1793 }
1794
1795 pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
1798 where
1799 I: IntoIterator<Item = PathBuf>,
1800 {
1801 self.pending_callgraph_store_paths.lock().extend(paths);
1802 }
1803
1804 pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
1806 std::mem::take(&mut *self.pending_callgraph_store_paths.lock())
1807 .into_iter()
1808 .collect()
1809 }
1810
1811 pub fn search_index(&self) -> &RwLock<Option<SearchIndex>> {
1813 &self.search_index
1814 }
1815
1816 pub fn search_index_rx(&self) -> &RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>> {
1818 &self.search_index_rx
1819 }
1820
1821 pub fn add_pending_search_index_paths<I>(&self, paths: I)
1822 where
1823 I: IntoIterator<Item = PathBuf>,
1824 {
1825 self.pending_search_index_paths.lock().extend(paths);
1826 }
1827
1828 pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
1829 std::mem::take(&mut *self.pending_search_index_paths.lock())
1830 .into_iter()
1831 .collect()
1832 }
1833
1834 pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
1835 where
1836 I: IntoIterator<Item = PathBuf>,
1837 {
1838 self.pending_semantic_index_paths.lock().extend(paths);
1839 }
1840
1841 pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
1842 std::mem::take(&mut *self.pending_semantic_index_paths.lock())
1843 .into_iter()
1844 .collect()
1845 }
1846
1847 pub fn mark_pending_semantic_corpus_refresh(&self) {
1848 *self.pending_semantic_corpus_refresh.lock() = true;
1849 }
1850
1851 pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
1852 std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock())
1853 }
1854
1855 pub fn clear_pending_index_updates(&self) {
1856 self.pending_search_index_paths.lock().clear();
1857 self.pending_callgraph_store_paths.lock().clear();
1858 self.pending_tier2_paths.lock().clear();
1859 self.pending_semantic_index_paths.lock().clear();
1860 *self.pending_semantic_corpus_refresh.lock() = false;
1861 }
1862
1863 pub fn inspect_manager(&self) -> Arc<InspectManager> {
1864 Arc::clone(&self.inspect_manager)
1865 }
1866
1867 pub fn add_pending_tier2_paths<I>(&self, paths: I)
1868 where
1869 I: IntoIterator<Item = PathBuf>,
1870 {
1871 self.pending_tier2_paths.lock().extend(paths);
1872 }
1873
1874 pub fn pending_tier2_paths(&self) -> Vec<PathBuf> {
1875 self.pending_tier2_paths.lock().iter().cloned().collect()
1876 }
1877
1878 pub fn remove_pending_tier2_paths<I>(&self, paths: I)
1879 where
1880 I: IntoIterator<Item = PathBuf>,
1881 {
1882 let mut pending = self.pending_tier2_paths.lock();
1883 for path in paths {
1884 pending.remove(&path);
1885 }
1886 }
1887
1888 pub fn take_new_reuse_completions(&self) -> bool {
1893 let current = self.inspect_manager.reuse_completion_count();
1894 let previous = self
1895 .last_seen_reuse_completions
1896 .swap(current, Ordering::SeqCst);
1897 current != previous
1898 }
1899
1900 pub fn reset_tier2_refresh_scheduler(&self) {
1901 self.reset_tier2_refresh_scheduler_at(Instant::now());
1902 }
1903
1904 #[doc(hidden)]
1905 pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
1906 self.tier2_refresh_scheduler
1907 .lock()
1908 .reset_after_configure(now);
1909 }
1910
1911 pub fn request_tier2_refresh_pull(&self) -> bool {
1912 self.tier2_refresh_scheduler
1913 .lock()
1914 .request_pull(!self.is_worktree_bridge())
1915 }
1916
1917 pub fn tick_tier2_refresh_scheduler(
1918 &self,
1919 changed_path_count: usize,
1920 ) -> Option<Tier2TriggerReason> {
1921 self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
1922 }
1923
1924 #[doc(hidden)]
1925 pub fn tick_tier2_refresh_scheduler_at(
1926 &self,
1927 now: Instant,
1928 changed_path_count: usize,
1929 ) -> Option<Tier2TriggerReason> {
1930 let manager = self.inspect_manager();
1931 let can_write = !self.is_worktree_bridge();
1932 let in_flight = manager.tier2_any_in_flight();
1933 let decision =
1934 self.tier2_refresh_scheduler
1935 .lock()
1936 .tick(now, changed_path_count, can_write, in_flight);
1937
1938 if let Some(reason) = decision {
1939 self.start_tier2_refresh(reason, manager);
1940 }
1941
1942 decision
1943 }
1944
1945 pub fn note_tier2_refresh_started(&self) {
1946 self.note_tier2_refresh_started_at(Instant::now());
1947 }
1948
1949 #[doc(hidden)]
1950 pub fn note_tier2_refresh_started_at(&self, now: Instant) {
1951 self.tier2_refresh_scheduler
1952 .lock()
1953 .note_external_scan_started(now);
1954 }
1955
1956 pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
1957 self.tier2_refresh_scheduler
1958 .lock()
1959 .last_trigger_reason()
1960 .map(Tier2TriggerReason::as_str)
1961 }
1962
1963 #[doc(hidden)]
1964 pub fn tier2_pull_demand_pending(&self) -> bool {
1965 self.tier2_refresh_scheduler.lock().pull_demand_pending()
1966 }
1967
1968 fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
1969 if self.is_worktree_bridge()
1970 || self
1971 .degraded_reasons
1972 .lock()
1973 .iter()
1974 .any(|r| r == "home_root")
1975 || !self.config().inspect.enabled
1976 {
1977 return;
1978 }
1979 let Some(snapshot) = self.tier2_refresh_snapshot() else {
1980 return;
1981 };
1982 let categories = InspectCategory::active()
1983 .iter()
1984 .copied()
1985 .filter(|category| category.is_tier2())
1986 .collect::<Vec<_>>();
1987 let submission =
1988 manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
1989 if submission.has_new_work() {
1990 crate::slog_info!(
1991 "tier2 refresh scheduled: reason={}, categories={:?}",
1992 reason.as_str(),
1993 submission
1994 .newly_queued_categories
1995 .iter()
1996 .map(|category| category.as_str())
1997 .collect::<Vec<_>>()
1998 );
1999 }
2000 for error in submission.errors {
2001 crate::slog_warn!(
2002 "tier2 refresh schedule failed for {}: {}",
2003 error.category,
2004 error.message
2005 );
2006 }
2007 }
2008
2009 fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
2010 self.harness_opt()?;
2011 let config = self.config();
2012 let project_root = config
2013 .project_root
2014 .clone()
2015 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
2016 let project_root = std::fs::canonicalize(&project_root).unwrap_or(project_root);
2017 Some(InspectSnapshot::new(
2018 project_root,
2019 self.inspect_dir(),
2020 config,
2021 self.symbol_cache(),
2022 ))
2023 }
2024
2025 pub fn symbol_cache(&self) -> SharedSymbolCache {
2027 Arc::clone(&self.symbol_cache)
2028 }
2029
2030 pub fn reset_symbol_cache(&self) -> u64 {
2032 self.symbol_cache
2033 .write()
2034 .map(|mut cache| cache.reset())
2035 .unwrap_or(0)
2036 }
2037
2038 pub fn semantic_index(&self) -> &RwLock<Option<SemanticIndex>> {
2040 &self.semantic_index
2041 }
2042
2043 pub fn semantic_index_rx(
2045 &self,
2046 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
2047 &self.semantic_index_rx
2048 }
2049
2050 pub fn semantic_index_status(&self) -> &RwLock<SemanticIndexStatus> {
2051 &self.semantic_index_status
2052 }
2053
2054 pub fn install_semantic_refresh_worker(
2055 &self,
2056 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
2057 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
2058 worker_slot: SemanticRefreshWorkerSlot,
2059 ) {
2060 self.clear_semantic_refresh_worker();
2061 *self.semantic_refresh_tx.lock() = Some(sender);
2062 *self.semantic_refresh_event_rx.lock() = Some(event_rx);
2063 *self.semantic_refresh_worker.lock() = Some(worker_slot);
2064 }
2065
2066 pub fn clear_semantic_refresh_worker(&self) {
2067 *self.semantic_refresh_tx.lock() = None;
2068 *self.semantic_refresh_event_rx.lock() = None;
2069 if let Some(worker_slot) = self.semantic_refresh_worker.lock().take() {
2070 if let Ok(mut handle) = worker_slot.lock() {
2071 drop(handle.take());
2072 }
2073 }
2074 }
2075
2076 pub fn semantic_refresh_sender(
2077 &self,
2078 ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
2079 self.semantic_refresh_tx.lock().clone()
2080 }
2081
2082 pub fn semantic_refresh_event_rx(
2083 &self,
2084 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
2085 &self.semantic_refresh_event_rx
2086 }
2087
2088 pub fn with_semantic_refresh_retry_attempts_mut<R>(
2089 &self,
2090 f: impl FnOnce(&mut BTreeMap<PathBuf, usize>) -> R,
2091 ) -> R {
2092 let mut attempts = self.semantic_refresh_retry_attempts.lock();
2093 f(&mut attempts)
2094 }
2095
2096 pub fn clear_semantic_refresh_retry_attempts(&self, paths: &[PathBuf]) {
2097 let mut attempts = self.semantic_refresh_retry_attempts.lock();
2098 for path in paths {
2099 attempts.remove(path);
2100 }
2101 }
2102
2103 pub fn clear_all_semantic_refresh_retry_attempts(&self) {
2104 self.semantic_refresh_retry_attempts.lock().clear();
2105 }
2106
2107 pub fn semantic_refresh_circuit_is_open(&self) -> bool {
2108 self.semantic_refresh_circuit.open.load(Ordering::SeqCst)
2109 }
2110
2111 pub fn record_semantic_refresh_transient_failure(&self, trip_threshold: usize) -> bool {
2112 let failures = self
2113 .semantic_refresh_circuit
2114 .consecutive_transient_failures
2115 .fetch_add(1, Ordering::SeqCst)
2116 .saturating_add(1);
2117 if failures >= trip_threshold
2118 && !self
2119 .semantic_refresh_circuit
2120 .open
2121 .swap(true, Ordering::SeqCst)
2122 {
2123 crate::slog_warn!(
2124 "embedding backend appears down; suspending active retries, will resume on next change or successful probe"
2125 );
2126 }
2127 self.semantic_refresh_circuit_is_open()
2128 }
2129
2130 pub fn reset_semantic_refresh_transient_failure_count(&self) {
2131 self.semantic_refresh_circuit
2132 .consecutive_transient_failures
2133 .store(0, Ordering::SeqCst);
2134 }
2135
2136 pub fn reset_semantic_refresh_circuit_after_success(&self) {
2137 self.reset_semantic_refresh_transient_failure_count();
2138 self.semantic_refresh_circuit
2139 .probe_ready
2140 .store(false, Ordering::SeqCst);
2141 if self
2142 .semantic_refresh_circuit
2143 .open
2144 .swap(false, Ordering::SeqCst)
2145 {
2146 crate::slog_info!("embedding backend recovered; resuming normal refresh retries");
2147 }
2148 }
2149
2150 pub fn semantic_refresh_transient_failure_count(&self) -> usize {
2151 self.semantic_refresh_circuit
2152 .consecutive_transient_failures
2153 .load(Ordering::SeqCst)
2154 }
2155
2156 pub fn semantic_refresh_probe_is_scheduled(&self) -> bool {
2157 self.semantic_refresh_circuit
2158 .probe_in_flight
2159 .load(Ordering::SeqCst)
2160 || self
2161 .semantic_refresh_circuit
2162 .probe_ready
2163 .load(Ordering::SeqCst)
2164 }
2165
2166 pub fn take_semantic_refresh_probe_ready(&self) -> bool {
2167 self.semantic_refresh_circuit
2168 .probe_ready
2169 .swap(false, Ordering::SeqCst)
2170 }
2171
2172 pub fn ensure_semantic_refresh_probe_scheduled(&self, delay: Duration) {
2173 if self
2174 .semantic_refresh_circuit
2175 .probe_ready
2176 .load(Ordering::SeqCst)
2177 {
2178 return;
2179 }
2180 if self
2181 .semantic_refresh_circuit
2182 .probe_in_flight
2183 .swap(true, Ordering::SeqCst)
2184 {
2185 return;
2186 }
2187 if self
2188 .semantic_refresh_circuit
2189 .probe_ready
2190 .load(Ordering::SeqCst)
2191 {
2192 self.semantic_refresh_circuit
2193 .probe_in_flight
2194 .store(false, Ordering::SeqCst);
2195 return;
2196 }
2197
2198 let circuit = Arc::clone(&self.semantic_refresh_circuit);
2199 let session_id = crate::log_ctx::current_session();
2200 std::thread::spawn(move || {
2201 crate::log_ctx::with_session(session_id, || {
2202 std::thread::sleep(delay);
2203 circuit.probe_ready.store(true, Ordering::SeqCst);
2204 circuit.probe_in_flight.store(false, Ordering::SeqCst);
2205 });
2206 });
2207 }
2208
2209 pub fn semantic_embedding_model(
2211 &self,
2212 ) -> &parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>> {
2213 &self.semantic_embedding_model
2214 }
2215
2216 pub fn watcher(&self) -> &parking_lot::Mutex<Option<RecommendedWatcher>> {
2218 &self.watcher
2219 }
2220
2221 pub fn watcher_rx(
2223 &self,
2224 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>> {
2225 &self.watcher_rx
2226 }
2227
2228 pub fn install_watcher_runtime(
2231 &self,
2232 rx: crossbeam_channel::Receiver<WatcherDispatchEvent>,
2233 runtime: WatcherThreadHandle,
2234 ) {
2235 *self.watcher_rx.lock() = Some(rx);
2236 *self.watcher_thread.lock() = Some(runtime);
2237 }
2238
2239 pub fn stop_watcher_runtime(&self) {
2242 if let Some(runtime) = self.watcher_thread.lock().take() {
2243 runtime.shutdown_and_join();
2244 }
2245 *self.watcher_rx.lock() = None;
2246 *self.watcher.lock() = None;
2247 }
2248
2249 pub fn lsp(&self) -> parking_lot::MutexGuard<'_, LspManager> {
2251 self.lsp_manager.lock()
2252 }
2253
2254 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
2257 let config = self.config();
2258 if let Some(mut lsp) = self.lsp_manager.try_lock() {
2259 if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
2260 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
2261 }
2262 }
2263 }
2264
2265 pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
2271 if let Some(mut lsp) = self.lsp_manager.try_lock() {
2272 lsp.clear_diagnostics_for_file(file_path)
2273 } else {
2274 false
2275 }
2276 }
2277
2278 pub fn lsp_notify_and_collect_diagnostics(
2289 &self,
2290 file_path: &Path,
2291 content: &str,
2292 timeout: std::time::Duration,
2293 ) -> crate::lsp::manager::PostEditWaitOutcome {
2294 let config = self.config();
2295 let Some(mut lsp) = self.lsp_manager.try_lock() else {
2296 return crate::lsp::manager::PostEditWaitOutcome::default();
2297 };
2298
2299 lsp.drain_events();
2302
2303 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
2307
2308 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
2310 {
2311 Ok(v) => v,
2312 Err(e) => {
2313 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
2314 return crate::lsp::manager::PostEditWaitOutcome::default();
2315 }
2316 };
2317
2318 if expected_versions.is_empty() {
2321 return crate::lsp::manager::PostEditWaitOutcome::default();
2322 }
2323
2324 lsp.wait_for_post_edit_diagnostics(
2325 file_path,
2326 &config,
2327 &expected_versions,
2328 &pre_snapshot,
2329 timeout,
2330 )
2331 }
2332
2333 fn custom_lsp_root_markers(&self) -> Vec<String> {
2336 self.config()
2337 .lsp_servers
2338 .iter()
2339 .flat_map(|s| s.root_markers.iter().cloned())
2340 .collect()
2341 }
2342
2343 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
2344 let custom_markers = self.custom_lsp_root_markers();
2345 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
2346 .iter()
2347 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
2348 .cloned()
2349 .map(|path| {
2350 let change_type = if path.exists() {
2351 FileChangeType::CHANGED
2352 } else {
2353 FileChangeType::DELETED
2354 };
2355 (path, change_type)
2356 })
2357 .collect();
2358
2359 self.notify_watched_config_events(&config_paths);
2360 }
2361
2362 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
2363 let paths = params
2364 .get("multi_file_write_paths")
2365 .and_then(|value| value.as_array())?
2366 .iter()
2367 .filter_map(|value| value.as_str())
2368 .map(PathBuf::from)
2369 .collect::<Vec<_>>();
2370
2371 (!paths.is_empty()).then_some(paths)
2372 }
2373
2374 fn watched_file_events_from_params(
2386 params: &serde_json::Value,
2387 extra_markers: &[String],
2388 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
2389 let events = params
2390 .get("multi_file_write_paths")
2391 .and_then(|value| value.as_array())?
2392 .iter()
2393 .filter_map(|entry| {
2394 let path = entry
2396 .get("path")
2397 .and_then(|value| value.as_str())
2398 .map(PathBuf::from)?;
2399
2400 if !is_config_file_path_with_custom(&path, extra_markers) {
2401 return None;
2402 }
2403
2404 let change_type = entry
2405 .get("type")
2406 .and_then(|value| value.as_str())
2407 .and_then(Self::parse_file_change_type)
2408 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
2409
2410 Some((path, change_type))
2411 })
2412 .collect::<Vec<_>>();
2413
2414 (!events.is_empty()).then_some(events)
2415 }
2416
2417 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
2418 match value {
2419 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
2420 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
2421 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
2422 _ => None,
2423 }
2424 }
2425
2426 fn change_type_from_current_state(path: &Path) -> FileChangeType {
2427 if path.exists() {
2428 FileChangeType::CHANGED
2429 } else {
2430 FileChangeType::DELETED
2431 }
2432 }
2433
2434 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
2435 if config_paths.is_empty() {
2436 return;
2437 }
2438
2439 let config = self.config();
2440 if let Some(mut lsp) = self.lsp_manager.try_lock() {
2441 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
2442 crate::slog_warn!("watched-file sync error: {}", e);
2443 }
2444 }
2445 }
2446
2447 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
2448 let custom_markers = self.custom_lsp_root_markers();
2449 if !is_config_file_path_with_custom(file_path, &custom_markers) {
2450 return;
2451 }
2452
2453 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
2454 }
2455
2456 pub fn lsp_post_multi_file_write(
2461 &self,
2462 file_path: &Path,
2463 content: &str,
2464 file_paths: &[PathBuf],
2465 params: &serde_json::Value,
2466 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2467 self.notify_watched_config_files(file_paths);
2468 self.add_pending_tier2_paths(file_paths.iter().cloned());
2469 let _ = self.mark_status_bar_tier2_stale();
2470
2471 let wants_diagnostics = params
2472 .get("diagnostics")
2473 .and_then(|v| v.as_bool())
2474 .unwrap_or(false);
2475
2476 if !wants_diagnostics {
2477 self.lsp_notify_file_changed(file_path, content);
2478 return None;
2479 }
2480
2481 let wait_ms = params
2482 .get("wait_ms")
2483 .and_then(|v| v.as_u64())
2484 .unwrap_or(3000)
2485 .min(10_000);
2486
2487 Some(self.lsp_notify_and_collect_diagnostics(
2488 file_path,
2489 content,
2490 std::time::Duration::from_millis(wait_ms),
2491 ))
2492 }
2493
2494 pub fn lsp_post_write(
2511 &self,
2512 file_path: &Path,
2513 content: &str,
2514 params: &serde_json::Value,
2515 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2516 let wants_diagnostics = params
2517 .get("diagnostics")
2518 .and_then(|v| v.as_bool())
2519 .unwrap_or(false);
2520
2521 let custom_markers = self.custom_lsp_root_markers();
2522 if let Some(file_paths) = Self::multi_file_write_paths(params) {
2523 self.add_pending_tier2_paths(file_paths);
2524 } else {
2525 self.add_pending_tier2_paths([file_path.to_path_buf()]);
2526 }
2527 let _ = self.mark_status_bar_tier2_stale();
2528
2529 if !wants_diagnostics {
2530 if let Some(file_paths) = Self::multi_file_write_paths(params) {
2531 self.notify_watched_config_files(&file_paths);
2532 } else if let Some(config_events) =
2533 Self::watched_file_events_from_params(params, &custom_markers)
2534 {
2535 self.notify_watched_config_events(&config_events);
2536 }
2537 self.lsp_notify_file_changed(file_path, content);
2538 return None;
2539 }
2540
2541 let wait_ms = params
2542 .get("wait_ms")
2543 .and_then(|v| v.as_u64())
2544 .unwrap_or(3000)
2545 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
2548 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
2549 }
2550
2551 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
2552 {
2553 self.notify_watched_config_events(&config_events);
2554 }
2555
2556 Some(self.lsp_notify_and_collect_diagnostics(
2557 file_path,
2558 content,
2559 std::time::Duration::from_millis(wait_ms),
2560 ))
2561 }
2562
2563 pub fn validate_path(
2572 &self,
2573 req_id: &str,
2574 path: &Path,
2575 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
2576 let config = self.config();
2577 if !config.restrict_to_project_root {
2579 return Ok(path.to_path_buf());
2580 }
2581 let root = match &config.project_root {
2582 Some(r) => r.clone(),
2583 None => return Ok(path.to_path_buf()), };
2585 drop(config);
2586
2587 let raw_root = root.clone();
2592 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
2593
2594 let path_for_resolution = if path.is_relative() {
2599 raw_root.join(path)
2600 } else {
2601 path.to_path_buf()
2602 };
2603 let resolved = match std::fs::canonicalize(&path_for_resolution) {
2604 Ok(resolved) => resolved,
2605 Err(_) => {
2606 let normalized = normalize_path(&path_for_resolution);
2607 reject_escaping_symlink(
2608 req_id,
2609 &path_for_resolution,
2610 &normalized,
2611 &resolved_root,
2612 &raw_root,
2613 )?;
2614 resolve_with_existing_ancestors(&normalized)
2615 }
2616 };
2617
2618 if !resolved.starts_with(&resolved_root) {
2619 return Err(path_error_response(req_id, path, &resolved_root));
2620 }
2621
2622 Ok(resolved)
2623 }
2624
2625 pub fn lsp_server_count(&self) -> usize {
2627 self.lsp_manager
2628 .try_lock()
2629 .map(|lsp| lsp.server_count())
2630 .unwrap_or(0)
2631 }
2632
2633 pub fn symbol_cache_stats(&self) -> serde_json::Value {
2635 let entries = self
2636 .symbol_cache
2637 .read()
2638 .map(|cache| cache.len())
2639 .unwrap_or(0);
2640 serde_json::json!({
2641 "local_entries": entries,
2642 "warm_entries": 0,
2643 })
2644 }
2645}
2646
2647#[cfg(test)]
2648mod callgraph_store_for_ops_tests {
2649 use super::*;
2650 use crate::parser::TreeSitterProvider;
2651 use std::ffi::OsString;
2652 use std::sync::{Barrier, Mutex as StdMutex, MutexGuard, OnceLock};
2653 use tempfile::TempDir;
2654
2655 struct CallgraphWaitWindowEnvGuard {
2656 _guard: MutexGuard<'static, ()>,
2657 previous: Option<OsString>,
2658 }
2659
2660 impl Drop for CallgraphWaitWindowEnvGuard {
2661 fn drop(&mut self) {
2662 unsafe {
2665 match &self.previous {
2666 Some(value) => std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", value),
2667 None => std::env::remove_var("AFT_CALLGRAPH_BUILD_WAIT_MS"),
2668 }
2669 }
2670 }
2671 }
2672
2673 fn force_async_callgraph_builds() -> CallgraphWaitWindowEnvGuard {
2674 static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
2675 let guard = LOCK
2676 .get_or_init(|| StdMutex::new(()))
2677 .lock()
2678 .unwrap_or_else(|error| error.into_inner());
2679 let previous = std::env::var_os("AFT_CALLGRAPH_BUILD_WAIT_MS");
2680 unsafe {
2682 std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", "0");
2683 }
2684 CallgraphWaitWindowEnvGuard {
2685 _guard: guard,
2686 previous,
2687 }
2688 }
2689
2690 #[test]
2691 fn concurrent_cold_callgraph_store_for_ops_spawns_one_build() {
2692 let _env_guard = force_async_callgraph_builds();
2693 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
2694
2695 let project = TempDir::new().expect("project tempdir");
2696 let storage = TempDir::new().expect("storage tempdir");
2697 let source_dir = project.path().join("src");
2698 std::fs::create_dir_all(&source_dir).expect("source dir");
2699 std::fs::write(
2700 source_dir.join("lib.rs"),
2701 "pub fn caller() { callee(); }\npub fn callee() {}\n",
2702 )
2703 .expect("source file");
2704
2705 let ctx = Arc::new(AppContext::new(
2706 Box::new(TreeSitterProvider::new()),
2707 Config {
2708 project_root: Some(project.path().to_path_buf()),
2709 storage_dir: Some(storage.path().to_path_buf()),
2710 callgraph_chunk_size: 1,
2711 ..Config::default()
2712 },
2713 ));
2714
2715 let barrier = Arc::new(Barrier::new(3));
2716 let handles = (0..2)
2717 .map(|_| {
2718 let ctx = Arc::clone(&ctx);
2719 let barrier = Arc::clone(&barrier);
2720 std::thread::spawn(move || {
2721 barrier.wait();
2722 matches!(
2723 ctx.callgraph_store_for_ops(),
2724 CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
2725 )
2726 })
2727 })
2728 .collect::<Vec<_>>();
2729
2730 barrier.wait();
2731 for handle in handles {
2732 assert!(
2733 handle.join().expect("callgraph caller thread"),
2734 "cold callgraph ops should report Building or observe the installed store"
2735 );
2736 }
2737
2738 assert_eq!(
2739 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
2740 1,
2741 "concurrent cold callers must share one background build"
2742 );
2743
2744 let rx = ctx
2745 .callgraph_store_rx
2746 .lock()
2747 .as_ref()
2748 .cloned()
2749 .expect("in-flight receiver installed before spawn");
2750 rx.recv_timeout(Duration::from_secs(30))
2751 .expect("background cold build should complete");
2752 *ctx.callgraph_store_rx.lock() = None;
2753 }
2754}
2755
2756#[cfg(test)]
2757mod status_emitter_tests {
2758 use super::*;
2759 use crate::parser::TreeSitterProvider;
2760
2761 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
2762 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2763 let (tx, rx) = mpsc::channel();
2764 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
2765 let _ = tx.send(frame);
2766 }))));
2767 (ctx, rx)
2768 }
2769
2770 #[test]
2771 fn status_emitter_signal_triggers_push() {
2772 let (ctx, rx) = ctx_with_frame_rx();
2773 ctx.status_emitter().signal(ctx.build_status_snapshot());
2774 let frame = rx
2775 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2776 .expect("status_changed push");
2777 assert!(matches!(frame, PushFrame::StatusChanged(_)));
2778 }
2779
2780 #[test]
2781 fn status_emitter_debounces_burst() {
2782 let (ctx, rx) = ctx_with_frame_rx();
2783 for _ in 0..10 {
2784 ctx.status_emitter().signal(ctx.build_status_snapshot());
2785 }
2786 let frame = rx
2787 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2788 .expect("status_changed push");
2789 assert!(matches!(frame, PushFrame::StatusChanged(_)));
2790 assert!(rx.try_recv().is_err());
2791 }
2792
2793 #[test]
2794 fn status_emitter_separate_windows_separate_pushes() {
2795 let (ctx, rx) = ctx_with_frame_rx();
2796 ctx.status_emitter().signal(ctx.build_status_snapshot());
2797 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2798 .expect("first push");
2799 ctx.status_emitter().signal(ctx.build_status_snapshot());
2800 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2801 .expect("second push");
2802 }
2803
2804 #[test]
2805 fn status_emitter_no_signal_no_push() {
2806 let (_ctx, rx) = ctx_with_frame_rx();
2807 assert!(rx
2808 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
2809 .is_err());
2810 }
2811
2812 #[test]
2813 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
2814 let (ctx, rx) = ctx_with_frame_rx();
2815 drop(ctx);
2816 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
2817 }
2818
2819 #[test]
2820 fn progress_sender_slot_is_per_context_for_shared_app() {
2821 let app = App::default_shared();
2822 let ctx_a = AppContext::from_app(Arc::clone(&app), Config::default());
2823 let ctx_b = AppContext::from_app(app, Config::default());
2824 let (tx_a, rx_a) = mpsc::channel();
2825 let (tx_b, rx_b) = mpsc::channel();
2826
2827 ctx_a.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
2828 let _ = tx_a.send(frame);
2829 }))));
2830 ctx_b.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
2831 let _ = tx_b.send(frame);
2832 }))));
2833
2834 ctx_a.emit_progress(ProgressFrame {
2835 frame_type: "progress",
2836 request_id: "ctx-a".to_string(),
2837 kind: crate::protocol::ProgressKind::Stdout,
2838 chunk: "a".to_string(),
2839 });
2840 ctx_b.emit_progress(ProgressFrame {
2841 frame_type: "progress",
2842 request_id: "ctx-b".to_string(),
2843 kind: crate::protocol::ProgressKind::Stdout,
2844 chunk: "b".to_string(),
2845 });
2846
2847 match rx_a
2848 .recv_timeout(Duration::from_millis(50))
2849 .expect("ctx A progress frame")
2850 {
2851 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-a"),
2852 other => panic!("unexpected frame for ctx A: {other:?}"),
2853 }
2854 assert!(rx_a.try_recv().is_err());
2855
2856 match rx_b
2857 .recv_timeout(Duration::from_millis(50))
2858 .expect("ctx B progress frame")
2859 {
2860 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-b"),
2861 other => panic!("unexpected frame for ctx B: {other:?}"),
2862 }
2863 assert!(rx_b.try_recv().is_err());
2864 }
2865}
2866
2867#[cfg(test)]
2868mod status_bar_tests {
2869 use super::*;
2870 use crate::parser::TreeSitterProvider;
2871
2872 fn ctx() -> AppContext {
2873 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
2874 }
2875
2876 #[test]
2877 fn status_bar_counts_none_until_tier2_populated() {
2878 let ctx = ctx();
2879 assert!(ctx.status_bar_counts().is_none());
2881
2882 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
2883 let counts = ctx.status_bar_counts().expect("populated");
2884 assert_eq!(counts.dead_code, 5);
2885 assert_eq!(counts.unused_exports, 3);
2886 assert_eq!(counts.duplicates, 7);
2887 assert_eq!(counts.todos, 2);
2888 assert!(!counts.tier2_stale);
2889 assert_eq!(counts.errors, 0);
2891 assert_eq!(counts.warnings, 0);
2892 }
2893
2894 #[test]
2895 fn partial_tier2_does_not_fabricate_zeros() {
2896 let ctx = ctx();
2897 ctx.update_status_bar_tier2(Some(5), None, None, None, true);
2901 assert!(
2902 ctx.status_bar_counts().is_none(),
2903 "bar must not surface until all three Tier-2 categories are real"
2904 );
2905
2906 ctx.update_status_bar_tier2(None, Some(3), None, None, true);
2908 assert!(ctx.status_bar_counts().is_none());
2909
2910 ctx.update_status_bar_tier2(None, None, Some(7), None, false);
2913 let counts = ctx.status_bar_counts().expect("all three real now");
2914 assert_eq!(counts.dead_code, 5);
2915 assert_eq!(counts.unused_exports, 3);
2916 assert_eq!(counts.duplicates, 7);
2917 }
2918
2919 #[test]
2920 fn update_with_none_todos_preserves_last_known_todos() {
2921 let ctx = ctx();
2922 ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
2923 ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
2925 let counts = ctx.status_bar_counts().expect("populated");
2926 assert_eq!(counts.todos, 9);
2927 assert_eq!(counts.dead_code, 2);
2928 }
2929
2930 #[test]
2931 fn update_with_none_count_preserves_last_known_count() {
2932 let ctx = ctx();
2933 ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
2934 ctx.update_status_bar_tier2(Some(11), None, None, None, false);
2937 let counts = ctx.status_bar_counts().expect("populated");
2938 assert_eq!(counts.dead_code, 11);
2939 assert_eq!(counts.unused_exports, 20);
2940 assert_eq!(counts.duplicates, 30);
2941 }
2942
2943 #[test]
2944 fn mark_stale_sets_flag_only_after_populate() {
2945 let ctx = ctx();
2946 ctx.mark_status_bar_tier2_stale();
2948 assert!(ctx.status_bar_counts().is_none());
2949
2950 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), Some(0), false);
2951 ctx.mark_status_bar_tier2_stale();
2952 assert!(ctx.status_bar_counts().expect("populated").tier2_stale);
2953
2954 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), None, false);
2956 assert!(!ctx.status_bar_counts().expect("populated").tier2_stale);
2957 }
2958
2959 #[test]
2964 fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
2965 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
2966 use crate::lsp::registry::ServerKind;
2967 use crate::lsp::roots::ServerKey;
2968
2969 let ctx = ctx();
2970 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); let file = std::path::PathBuf::from("/proj/gone.ts");
2973 {
2974 let mut lsp = ctx.lsp();
2975 lsp.diagnostics_store_mut_for_test().publish(
2976 ServerKey {
2977 kind: ServerKind::TypeScript,
2978 root: std::path::PathBuf::from("/proj"),
2979 },
2980 file.clone(),
2981 vec![StoredDiagnostic {
2982 file: file.clone(),
2983 line: 1,
2984 column: 1,
2985 end_line: 1,
2986 end_column: 2,
2987 severity: DiagnosticSeverity::Error,
2988 message: "boom".into(),
2989 code: None,
2990 source: None,
2991 }],
2992 );
2993 }
2994
2995 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
2997
2998 let removed = ctx.lsp_clear_diagnostics_for_file(&file);
3000 assert!(removed);
3001 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
3002 }
3003
3004 #[test]
3005 fn status_bar_filtered_counts_ignore_environmental_flap() {
3006 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
3007 use crate::lsp::registry::ServerKind;
3008 use crate::lsp::roots::ServerKey;
3009
3010 let ctx = ctx();
3011 let root = if cfg!(windows) {
3012 std::path::PathBuf::from(r"C:\proj")
3013 } else {
3014 std::path::PathBuf::from("/proj")
3015 };
3016 ctx.set_canonical_cache_root(root.clone());
3017 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
3018
3019 let file = root.join("aft.jsonc");
3020 let key = ServerKey {
3021 kind: ServerKind::TypeScript,
3022 root: root.clone(),
3023 };
3024 let env = StoredDiagnostic {
3025 file: file.clone(),
3026 line: 1,
3027 column: 1,
3028 end_line: 1,
3029 end_column: 2,
3030 severity: DiagnosticSeverity::Error,
3031 message: "Failed to load schema from https://example.com/schema.json".into(),
3032 code: None,
3033 source: Some("json".into()),
3034 };
3035
3036 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
3037
3038 {
3039 let mut lsp = ctx.lsp();
3040 lsp.diagnostics_store_mut_for_test()
3041 .publish(key.clone(), file.clone(), vec![env]);
3042 }
3043 assert_eq!(
3044 ctx.status_bar_counts().expect("populated").errors,
3045 0,
3046 "environmental publish must not change status-bar E"
3047 );
3048
3049 {
3050 let mut lsp = ctx.lsp();
3051 lsp.diagnostics_store_mut_for_test()
3052 .publish(key, file, vec![]);
3053 }
3054 assert_eq!(
3055 ctx.status_bar_counts().expect("populated").errors,
3056 0,
3057 "environmental clear must not change status-bar E"
3058 );
3059 }
3060}
3061
3062#[cfg(test)]
3063mod harness_path_tests {
3064 use super::*;
3065 use crate::harness::Harness;
3066 use crate::parser::TreeSitterProvider;
3067
3068 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
3069 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3070 ctx.update_config(|config| {
3071 config.storage_dir = Some(storage_dir);
3072 });
3073 ctx.set_harness(harness);
3074 ctx
3075 }
3076
3077 #[test]
3078 fn harness_dir_resolves_correctly() {
3079 let storage = PathBuf::from("/tmp/cortexkit/aft");
3080 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
3081
3082 assert_eq!(ctx.harness_dir(), storage.join("pi"));
3083 }
3084
3085 #[test]
3086 fn bash_tasks_dir_uses_hash_session() {
3087 let storage = PathBuf::from("/tmp/cortexkit/aft");
3088 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
3089
3090 assert_eq!(
3091 ctx.bash_tasks_dir("ses_abc"),
3092 storage
3093 .join("opencode")
3094 .join("bash-tasks")
3095 .join(hash_session("ses_abc"))
3096 );
3097 }
3098
3099 #[test]
3100 fn backups_dir_includes_path_hash() {
3101 let storage = PathBuf::from("/tmp/cortexkit/aft");
3102 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
3103
3104 assert_eq!(
3105 ctx.backups_dir("ses_abc", "pathhash"),
3106 storage
3107 .join("pi")
3108 .join("backups")
3109 .join(hash_session("ses_abc"))
3110 .join("pathhash")
3111 );
3112 }
3113
3114 #[test]
3115 fn filters_dir_under_harness() {
3116 let storage = PathBuf::from("/tmp/cortexkit/aft");
3117 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
3118
3119 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
3120 }
3121
3122 #[test]
3123 fn trust_file_is_host_global() {
3124 let storage = PathBuf::from("/tmp/cortexkit/aft");
3125 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
3126
3127 assert_eq!(
3128 ctx.trust_file(),
3129 storage.join("trusted-filter-projects.json")
3130 );
3131 }
3132
3133 #[test]
3134 fn same_session_different_harness_resolve_different_paths() {
3135 let storage = PathBuf::from("/tmp/cortexkit/aft");
3136 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
3137 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
3138
3139 assert_ne!(
3140 opencode.bash_tasks_dir("ses_same"),
3141 pi.bash_tasks_dir("ses_same")
3142 );
3143 }
3144}
3145
3146#[cfg(test)]
3147mod gitignore_tests {
3148 use super::*;
3149 use std::fs;
3150 use std::path::Path;
3151 use tempfile::TempDir;
3152
3153 fn make_ctx_with_root(root: &Path) -> AppContext {
3154 let provider = Box::new(crate::parser::TreeSitterProvider::new());
3155 let config = Config {
3156 project_root: Some(root.to_path_buf()),
3157 ..Config::default()
3158 };
3159 AppContext::new(provider, config)
3160 }
3161
3162 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
3169 let Some(matcher) = ctx.gitignore() else {
3170 return false;
3171 };
3172 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
3173 if !canonical.starts_with(matcher.path()) {
3174 return false;
3175 }
3176 let is_dir = canonical.is_dir();
3177 matcher
3178 .matched_path_or_any_parents(&canonical, is_dir)
3179 .is_ignore()
3180 }
3181
3182 fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
3193 use std::sync::{Mutex, OnceLock};
3194 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
3195 let _guard = LOCK
3196 .get_or_init(|| Mutex::new(()))
3197 .lock()
3198 .unwrap_or_else(|e| e.into_inner());
3199 let tmp = TempDir::new().unwrap();
3200 let prev = std::env::var_os("XDG_CONFIG_HOME");
3201 unsafe {
3203 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
3204 }
3205 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
3206 unsafe {
3207 match prev {
3208 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
3209 None => std::env::remove_var("XDG_CONFIG_HOME"),
3210 }
3211 }
3212 match result {
3213 Ok(r) => r,
3214 Err(p) => std::panic::resume_unwind(p),
3215 }
3216 }
3217
3218 #[test]
3219 fn rebuild_gitignore_returns_none_without_project_root() {
3220 let provider = Box::new(crate::parser::TreeSitterProvider::new());
3221 let ctx = AppContext::new(provider, Config::default());
3222 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
3223 assert!(ctx.gitignore().is_none());
3224 }
3225
3226 #[test]
3227 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
3228 let tmp = TempDir::new().unwrap();
3229 let ctx = make_ctx_with_root(tmp.path());
3230 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
3231 assert!(ctx.gitignore().is_none());
3232 }
3233
3234 #[test]
3235 fn matcher_filters_files_in_ignored_dist_dir() {
3236 let tmp = TempDir::new().unwrap();
3237 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
3238 fs::create_dir_all(tmp.path().join("dist")).unwrap();
3239 fs::create_dir_all(tmp.path().join("src")).unwrap();
3240 let dist_file = tmp.path().join("dist").join("bundle.js");
3241 let src_file = tmp.path().join("src").join("app.ts");
3242 fs::write(&dist_file, "x").unwrap();
3243 fs::write(&src_file, "y").unwrap();
3244
3245 let ctx = make_ctx_with_root(tmp.path());
3246 ctx.rebuild_gitignore();
3247
3248 assert!(ctx.gitignore().is_some());
3249 assert!(
3250 is_ignored(&ctx, &dist_file),
3251 "dist/bundle.js should be ignored"
3252 );
3253 assert!(
3254 !is_ignored(&ctx, &src_file),
3255 "src/app.ts should NOT be ignored"
3256 );
3257 }
3258
3259 #[test]
3260 fn matcher_handles_node_modules_and_target() {
3261 let tmp = TempDir::new().unwrap();
3262 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
3263 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
3264 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
3265 let nm_file = tmp.path().join("node_modules/foo/index.js");
3266 let target_file = tmp.path().join("target/debug/aft");
3267 fs::write(&nm_file, "x").unwrap();
3268 fs::write(&target_file, "x").unwrap();
3269
3270 let ctx = make_ctx_with_root(tmp.path());
3271 ctx.rebuild_gitignore();
3272
3273 assert!(is_ignored(&ctx, &nm_file));
3274 assert!(is_ignored(&ctx, &target_file));
3275 }
3276
3277 #[test]
3278 fn matcher_honors_negation_pattern() {
3279 let tmp = TempDir::new().unwrap();
3281 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
3282 let random_log = tmp.path().join("random.log");
3283 let important_log = tmp.path().join("important.log");
3284 fs::write(&random_log, "x").unwrap();
3285 fs::write(&important_log, "y").unwrap();
3286
3287 let ctx = make_ctx_with_root(tmp.path());
3288 ctx.rebuild_gitignore();
3289
3290 assert!(is_ignored(&ctx, &random_log));
3291 assert!(
3292 !is_ignored(&ctx, &important_log),
3293 "negation pattern should un-ignore important.log"
3294 );
3295 }
3296
3297 #[test]
3298 fn rebuild_picks_up_gitignore_changes() {
3299 let tmp = TempDir::new().unwrap();
3300 let ignore_path = tmp.path().join(".gitignore");
3301 fs::write(&ignore_path, "foo.txt\n").unwrap();
3302 let foo = tmp.path().join("foo.txt");
3303 let bar = tmp.path().join("bar.txt");
3304 fs::write(&foo, "").unwrap();
3305 fs::write(&bar, "").unwrap();
3306
3307 let ctx = make_ctx_with_root(tmp.path());
3308 ctx.rebuild_gitignore();
3309 assert!(is_ignored(&ctx, &foo));
3310 assert!(!is_ignored(&ctx, &bar));
3311
3312 fs::write(&ignore_path, "bar.txt\n").unwrap();
3314 ctx.rebuild_gitignore();
3315 assert!(!is_ignored(&ctx, &foo));
3316 assert!(is_ignored(&ctx, &bar));
3317 }
3318
3319 #[test]
3320 fn gitignore_loads_info_exclude_when_present() {
3321 let tmp = TempDir::new().unwrap();
3322 let info_dir = tmp.path().join(".git/info");
3323 fs::create_dir_all(&info_dir).unwrap();
3324 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
3325 let secrets = tmp.path().join("secrets.txt");
3326 let public = tmp.path().join("public.txt");
3327 fs::write(&secrets, "token").unwrap();
3328 fs::write(&public, "ok").unwrap();
3329
3330 let ctx = make_ctx_with_root(tmp.path());
3331 ctx.rebuild_gitignore();
3332
3333 assert!(is_ignored(&ctx, &secrets));
3334 assert!(!is_ignored(&ctx, &public));
3335 }
3336
3337 #[test]
3338 fn matcher_picks_up_nested_gitignore() {
3339 let tmp = TempDir::new().unwrap();
3340 fs::write(tmp.path().join(".gitignore"), "").unwrap();
3342 let sub = tmp.path().join("packages/foo");
3343 fs::create_dir_all(&sub).unwrap();
3344 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
3345 let generated_file = sub.join("generated").join("out.js");
3346 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
3347 fs::write(&generated_file, "x").unwrap();
3348
3349 let ctx = make_ctx_with_root(tmp.path());
3350 ctx.rebuild_gitignore();
3351
3352 assert!(
3353 is_ignored(&ctx, &generated_file),
3354 "nested gitignore in packages/foo/.gitignore should ignore generated/"
3355 );
3356 }
3357}