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