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, StaleDiagnosticsMark};
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 ColdSeedGateCleared,
281 Ready(SemanticIndex),
282 Failed(String),
283}
284
285#[derive(Debug, Clone)]
286pub enum SemanticRefreshRequest {
287 Files {
288 paths: Vec<PathBuf>,
289 },
290 Corpus,
294}
295
296#[derive(Debug)]
297pub enum SemanticRefreshEvent {
298 Started {
299 paths: Vec<PathBuf>,
300 },
301 CorpusStarted {
302 files: usize,
303 },
304 Completed {
305 added_entries: Vec<EmbeddingEntry>,
306 updated_metadata: Vec<(PathBuf, FileFreshness)>,
307 completed_paths: Vec<PathBuf>,
308 },
309 CorpusCompleted {
310 index: SemanticIndex,
311 changed: usize,
312 added: usize,
313 deleted: usize,
314 total_processed: usize,
315 },
316 Failed {
317 paths: Vec<PathBuf>,
318 error: String,
319 },
320 CorpusFailed {
321 error: String,
322 },
323}
324
325pub type SemanticRefreshWorkerSlot = Arc<Mutex<Option<std::thread::JoinHandle<()>>>>;
326
327fn normalize_path(path: &Path) -> PathBuf {
331 let mut result = PathBuf::new();
332 for component in path.components() {
333 match component {
334 Component::ParentDir => {
335 if !result.pop() {
337 result.push(component);
338 }
339 }
340 Component::CurDir => {} _ => result.push(component),
342 }
343 }
344 result
345}
346
347fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
348 let mut existing = path.to_path_buf();
349 let mut tail_segments = Vec::new();
350
351 while !existing.exists() {
352 if let Some(name) = existing.file_name() {
353 tail_segments.push(name.to_owned());
354 } else {
355 break;
356 }
357
358 existing = match existing.parent() {
359 Some(parent) => parent.to_path_buf(),
360 None => break,
361 };
362 }
363
364 let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
365 for segment in tail_segments.into_iter().rev() {
366 resolved.push(segment);
367 }
368
369 resolved
370}
371
372fn path_error_response(
373 req_id: &str,
374 path: &Path,
375 resolved_root: &Path,
376) -> crate::protocol::Response {
377 crate::protocol::Response::error(
378 req_id,
379 "path_outside_root",
380 format!(
381 "path '{}' is outside the project root '{}'",
382 path.display(),
383 resolved_root.display()
384 ),
385 )
386}
387
388fn reject_escaping_symlink(
398 req_id: &str,
399 original_path: &Path,
400 candidate: &Path,
401 resolved_root: &Path,
402 raw_root: &Path,
403) -> Result<(), crate::protocol::Response> {
404 let mut current = PathBuf::new();
405
406 for component in candidate.components() {
407 current.push(component);
408
409 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
410 continue;
411 };
412
413 if !metadata.file_type().is_symlink() {
414 continue;
415 }
416
417 let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
426 if !inside_root {
427 continue;
428 }
429
430 iterative_follow_chain(req_id, original_path, ¤t, resolved_root)?;
431 }
432
433 Ok(())
434}
435
436fn iterative_follow_chain(
439 req_id: &str,
440 original_path: &Path,
441 start: &Path,
442 resolved_root: &Path,
443) -> Result<(), crate::protocol::Response> {
444 let mut link = start.to_path_buf();
445 let mut depth = 0usize;
446
447 loop {
448 if depth > 40 {
449 return Err(path_error_response(req_id, original_path, resolved_root));
450 }
451
452 let target = match std::fs::read_link(&link) {
453 Ok(t) => t,
454 Err(_) => {
455 return Err(path_error_response(req_id, original_path, resolved_root));
457 }
458 };
459
460 let resolved_target = if target.is_absolute() {
461 normalize_path(&target)
462 } else {
463 let parent = link.parent().unwrap_or_else(|| Path::new(""));
464 normalize_path(&parent.join(&target))
465 };
466
467 let canonical_target =
471 std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
472
473 if !canonical_target.starts_with(resolved_root)
474 && !resolved_target.starts_with(resolved_root)
475 {
476 return Err(path_error_response(req_id, original_path, resolved_root));
477 }
478
479 match std::fs::symlink_metadata(&resolved_target) {
481 Ok(meta) if meta.file_type().is_symlink() => {
482 link = resolved_target;
483 depth += 1;
484 }
485 _ => break, }
487 }
488
489 Ok(())
490}
491
492pub type LanguageProviderFactory = fn() -> Box<dyn LanguageProvider>;
493
494pub fn default_language_provider_factory() -> Box<dyn LanguageProvider> {
495 Box::new(TreeSitterProvider::new())
496}
497
498pub struct App {
503 db: parking_lot::Mutex<Option<Arc<Mutex<Connection>>>>,
504 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
505 stdout_writer: SharedStdoutWriter,
506 provider_factory: LanguageProviderFactory,
507}
508
509impl App {
510 pub fn new(provider_factory: LanguageProviderFactory) -> Self {
511 Self {
512 db: parking_lot::Mutex::new(None),
513 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry::new(),
514 stdout_writer: Arc::new(Mutex::new(BufWriter::new(io::stdout()))),
515 provider_factory,
516 }
517 }
518
519 pub fn shared(provider_factory: LanguageProviderFactory) -> Arc<Self> {
521 Arc::new(Self::new(provider_factory))
522 }
523
524 pub fn default_shared() -> Arc<Self> {
525 Self::shared(default_language_provider_factory)
526 }
527
528 pub fn create_provider(&self) -> Box<dyn LanguageProvider> {
529 (self.provider_factory)()
530 }
531
532 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
533 self.lsp_child_registry.clone()
534 }
535
536 pub fn stdout_writer(&self) -> SharedStdoutWriter {
537 Arc::clone(&self.stdout_writer)
538 }
539
540 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
541 *self.db.lock() = Some(conn);
542 }
543
544 pub fn clear_db(&self) {
545 *self.db.lock() = None;
546 }
547
548 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
549 self.db.lock().clone()
550 }
551}
552
553impl Default for App {
554 fn default() -> Self {
555 Self::new(default_language_provider_factory)
556 }
557}
558
559const _: fn() = || {
560 fn assert_send_sync<T: Send + Sync>() {}
561 fn assert_send<T: Send>() {}
562
563 assert_send_sync::<App>();
564 assert_send_sync::<AppContext>();
565 assert_send::<crate::lsp::manager::LspManager>();
566 assert_send::<crate::semantic_index::EmbeddingModel>();
567};
568
569pub struct AppContext {
581 app: Arc<App>,
582 provider: Box<dyn LanguageProvider>,
583 backup: parking_lot::Mutex<BackupStore>,
584 checkpoint: parking_lot::Mutex<CheckpointStore>,
585 config: RwLock<Arc<Config>>,
586 force_restrict_requests: parking_lot::Mutex<BTreeMap<String, usize>>,
587 pub harness: parking_lot::Mutex<Option<Harness>>,
588 canonical_cache_root: parking_lot::Mutex<Option<PathBuf>>,
589 is_worktree_bridge: parking_lot::Mutex<bool>,
590 git_common_dir: parking_lot::Mutex<Option<PathBuf>>,
591 degraded_reasons: parking_lot::Mutex<Vec<String>>,
598 callgraph_store: RwLock<Option<Arc<CallGraphStore>>>,
599 callgraph_store_force_rebuild: parking_lot::Mutex<bool>,
600 callgraph_store_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStore>>>,
601 pending_callgraph_store_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
602 search_index: RwLock<Option<SearchIndex>>,
603 search_index_rx: RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>>,
604 pending_search_index_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
605 symbol_cache: SharedSymbolCache,
606 inspect_manager: Arc<InspectManager>,
607 tier2_refresh_scheduler: parking_lot::Mutex<Tier2RefreshScheduler>,
608 pending_tier2_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
609 semantic_index: RwLock<Option<SemanticIndex>>,
610 semantic_index_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
611 semantic_index_status: RwLock<SemanticIndexStatus>,
612 semantic_cold_seed_active: Arc<AtomicBool>,
616 semantic_cold_seed_generation: Arc<AtomicU64>,
619 semantic_callgraph_warm_deferred: AtomicBool,
620 pending_semantic_index_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
621 pending_semantic_corpus_refresh: parking_lot::Mutex<bool>,
622 semantic_refresh_tx:
623 parking_lot::Mutex<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>,
624 semantic_refresh_event_rx:
625 parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
626 semantic_refresh_worker: parking_lot::Mutex<Option<SemanticRefreshWorkerSlot>>,
627 semantic_refresh_retry_attempts: parking_lot::Mutex<BTreeMap<PathBuf, usize>>,
628 semantic_refresh_circuit: Arc<SemanticRefreshCircuit>,
629 semantic_embedding_model: parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>>,
630 watcher: parking_lot::Mutex<Option<RecommendedWatcher>>,
631 watcher_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>>,
632 watcher_thread: parking_lot::Mutex<Option<WatcherThreadHandle>>,
633 lsp_manager: parking_lot::Mutex<LspManager>,
634 configure_generation: AtomicU64,
635 last_seen_reuse_completions: AtomicU64,
639 configure_warnings_tx: crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)>,
640 configure_warnings_rx: crossbeam_channel::Receiver<(u64, ConfigureWarningsFrame)>,
641 progress_sender: SharedProgressSender,
644 status_emitter: StatusEmitter,
645 status_bar_last_emitted: RwLock<Option<StatusBarCounts>>,
649 bash_background: BgTaskRegistry,
650 filter_registry: crate::compress::SharedFilterRegistry,
657 filter_registry_loaded: std::sync::atomic::AtomicBool,
660 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
665 gitignore: SharedGitignore,
672 gitignore_generation: Arc<AtomicU64>,
673 status_bar_tier2: RwLock<StatusBarTier2>,
677 tsconfig_membership:
684 parking_lot::Mutex<crate::lsp::tsconfig_membership::TsconfigMembershipCache>,
685}
686
687pub struct ForceRestrictGuard<'a> {
693 ctx: &'a AppContext,
694 req_id: String,
695}
696
697impl Drop for ForceRestrictGuard<'_> {
698 fn drop(&mut self) {
699 self.ctx.release_force_restrict(&self.req_id);
700 }
701}
702
703impl Drop for AppContext {
704 fn drop(&mut self) {
705 if let Some(runtime) = self.watcher_thread.get_mut().take() {
706 runtime.shutdown_and_join();
707 }
708 }
709}
710
711pub enum CallgraphStoreAccess {
719 Ready(Arc<CallGraphStore>),
721 Building,
723 Unavailable,
725 Error(CallGraphStoreError),
727}
728
729fn callgraph_build_wait_window() -> Duration {
734 std::env::var("AFT_CALLGRAPH_BUILD_WAIT_MS")
735 .ok()
736 .and_then(|raw| raw.parse::<u64>().ok())
737 .map(Duration::from_millis)
738 .unwrap_or(Duration::ZERO)
739}
740
741static CALLGRAPH_COLD_BUILD_SPAWN_COUNT: AtomicUsize = AtomicUsize::new(0);
742
743#[doc(hidden)]
744pub fn reset_callgraph_cold_build_spawn_count_for_test() {
745 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
746}
747
748#[doc(hidden)]
749pub fn callgraph_cold_build_spawn_count_for_test() -> usize {
750 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst)
751}
752
753impl AppContext {
754 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
755 Self::with_app_and_provider(App::default_shared(), provider, config)
756 }
757
758 pub fn from_app(app: Arc<App>, config: Config) -> Self {
759 let provider = app.create_provider();
760 Self::with_app_and_provider(app, provider, config)
761 }
762
763 pub fn with_app_and_provider(
764 app: Arc<App>,
765 provider: Box<dyn LanguageProvider>,
766 config: Config,
767 ) -> Self {
768 let bash_compress_enabled = config.experimental_bash_compress;
769 let (configure_warnings_tx, configure_warnings_rx) = crossbeam_channel::unbounded();
770 let progress_sender: SharedProgressSender = Arc::new(Mutex::new(None));
771 let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
772 let symbol_cache = provider
773 .as_any()
774 .downcast_ref::<TreeSitterProvider>()
775 .map(|provider| provider.symbol_cache())
776 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
777 let mut lsp_manager = LspManager::new();
778 lsp_manager.set_child_registry(app.lsp_child_registry());
779 lsp_manager.set_diagnostic_capacity(config.diagnostic_cache_size);
782 AppContext {
783 app: Arc::clone(&app),
784 provider,
785 backup: parking_lot::Mutex::new(BackupStore::new()),
786 checkpoint: parking_lot::Mutex::new(CheckpointStore::new()),
787 config: RwLock::new(Arc::new(config)),
788 force_restrict_requests: parking_lot::Mutex::new(BTreeMap::new()),
789 harness: parking_lot::Mutex::new(None),
790 canonical_cache_root: parking_lot::Mutex::new(None),
791 is_worktree_bridge: parking_lot::Mutex::new(false),
792 git_common_dir: parking_lot::Mutex::new(None),
793 degraded_reasons: parking_lot::Mutex::new(Vec::new()),
794 callgraph_store: RwLock::new(None),
795 callgraph_store_force_rebuild: parking_lot::Mutex::new(false),
796 callgraph_store_rx: parking_lot::Mutex::new(None),
797 pending_callgraph_store_paths: parking_lot::Mutex::new(BTreeSet::new()),
798 search_index: RwLock::new(None),
799 search_index_rx: RwLock::new(None),
800 pending_search_index_paths: parking_lot::Mutex::new(BTreeSet::new()),
801 symbol_cache,
802 inspect_manager: Arc::new(InspectManager::new()),
803 tier2_refresh_scheduler: parking_lot::Mutex::new(Tier2RefreshScheduler::new()),
804 pending_tier2_paths: parking_lot::Mutex::new(BTreeSet::new()),
805 semantic_index: RwLock::new(None),
806 semantic_index_rx: parking_lot::Mutex::new(None),
807 semantic_index_status: RwLock::new(SemanticIndexStatus::Disabled),
808 semantic_cold_seed_active: Arc::new(AtomicBool::new(false)),
809 semantic_cold_seed_generation: Arc::new(AtomicU64::new(0)),
810 semantic_callgraph_warm_deferred: AtomicBool::new(false),
811 pending_semantic_index_paths: parking_lot::Mutex::new(BTreeSet::new()),
812 pending_semantic_corpus_refresh: parking_lot::Mutex::new(false),
813 semantic_refresh_tx: parking_lot::Mutex::new(None),
814 semantic_refresh_event_rx: parking_lot::Mutex::new(None),
815 semantic_refresh_worker: parking_lot::Mutex::new(None),
816 semantic_refresh_retry_attempts: parking_lot::Mutex::new(BTreeMap::new()),
817 semantic_refresh_circuit: Arc::new(SemanticRefreshCircuit::default()),
818 semantic_embedding_model: parking_lot::Mutex::new(None),
819 watcher: parking_lot::Mutex::new(None),
820 watcher_rx: parking_lot::Mutex::new(None),
821 watcher_thread: parking_lot::Mutex::new(None),
822 lsp_manager: parking_lot::Mutex::new(lsp_manager),
823 configure_generation: AtomicU64::new(0),
824 last_seen_reuse_completions: AtomicU64::new(0),
825 configure_warnings_tx,
826 configure_warnings_rx,
827 progress_sender: Arc::clone(&progress_sender),
828 status_emitter,
829 status_bar_last_emitted: RwLock::new(None),
830 bash_background: BgTaskRegistry::new(Arc::clone(&progress_sender)),
831 filter_registry: Arc::new(std::sync::RwLock::new(
832 crate::compress::toml_filter::FilterRegistry::default(),
833 )),
834 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
835 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
836 gitignore: Arc::new(std::sync::RwLock::new(None)),
837 gitignore_generation: Arc::new(AtomicU64::new(0)),
838 status_bar_tier2: RwLock::new(StatusBarTier2::default()),
839 tsconfig_membership: parking_lot::Mutex::new(
840 crate::lsp::tsconfig_membership::TsconfigMembershipCache::new(),
841 ),
842 }
843 }
844
845 pub fn status_bar_counts(&self) -> Option<StatusBarCounts> {
851 let (dead_code, unused_exports, duplicates, todos, tier2_stale) = {
857 let tier2 = self
858 .status_bar_tier2
859 .read()
860 .unwrap_or_else(std::sync::PoisonError::into_inner);
861 let (Some(dead_code), Some(unused_exports), Some(duplicates)) =
862 (tier2.dead_code, tier2.unused_exports, tier2.duplicates)
863 else {
864 return None;
865 };
866 (
867 dead_code,
868 unused_exports,
869 duplicates,
870 tier2.todos.unwrap_or(0),
871 tier2.stale,
872 )
873 };
874 let (errors, warnings) = self.status_bar_error_warning_counts();
875 Some(StatusBarCounts {
876 errors,
877 warnings,
878 dead_code,
879 unused_exports,
880 duplicates,
881 todos,
882 tier2_stale,
883 })
884 }
885
886 pub fn should_emit_status_bar(&self, counts: &StatusBarCounts) -> bool {
887 let mut last = self
888 .status_bar_last_emitted
889 .write()
890 .unwrap_or_else(std::sync::PoisonError::into_inner);
891 if last.as_ref() == Some(counts) {
892 return false;
893 }
894 *last = Some(counts.clone());
895 true
896 }
897
898 fn status_bar_error_warning_counts(&self) -> (usize, usize) {
904 let Some(root) = self.canonical_cache_root_opt() else {
905 return self.lsp_manager.lock().warm_error_warning_counts();
908 };
909 let lsp = self.lsp_manager.lock();
910 let mut membership = self.tsconfig_membership.lock();
911 lsp.filtered_error_warning_counts(|file| {
912 file.starts_with(&root) && !membership.should_skip_diagnostics(file)
913 })
914 }
915
916 pub fn clear_tsconfig_membership_cache(&self) {
920 self.tsconfig_membership.lock().clear();
921 }
922
923 pub fn mark_status_bar_tier2_stale(&self) -> bool {
929 let mut tier2 = self
930 .status_bar_tier2
931 .write()
932 .unwrap_or_else(std::sync::PoisonError::into_inner);
933 if tier2.dead_code.is_some() && tier2.unused_exports.is_some() && tier2.duplicates.is_some()
935 {
936 let changed = !tier2.stale;
937 tier2.stale = true;
938 return changed;
939 }
940 false
941 }
942
943 pub fn update_status_bar_tier2(
949 &self,
950 dead_code: Option<usize>,
951 unused_exports: Option<usize>,
952 duplicates: Option<usize>,
953 todos: Option<usize>,
954 stale: bool,
955 ) {
956 let mut tier2 = self
957 .status_bar_tier2
958 .write()
959 .unwrap_or_else(std::sync::PoisonError::into_inner);
960 if let Some(dead_code) = dead_code {
961 tier2.dead_code = Some(dead_code);
962 }
963 if let Some(unused_exports) = unused_exports {
964 tier2.unused_exports = Some(unused_exports);
965 }
966 if let Some(duplicates) = duplicates {
967 tier2.duplicates = Some(duplicates);
968 }
969 if let Some(todos) = todos {
970 tier2.todos = Some(todos);
971 }
972 tier2.stale = stale;
973 }
974
975 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
978 self.gitignore
979 .read()
980 .unwrap_or_else(|poisoned| poisoned.into_inner())
981 .clone()
982 }
983
984 pub fn shared_gitignore(&self) -> SharedGitignore {
986 Arc::clone(&self.gitignore)
987 }
988
989 pub fn gitignore_generation(&self) -> Arc<AtomicU64> {
993 Arc::clone(&self.gitignore_generation)
994 }
995
996 fn set_gitignore(&self, matcher: Option<Arc<ignore::gitignore::Gitignore>>) {
997 *self
998 .gitignore
999 .write()
1000 .unwrap_or_else(|poisoned| poisoned.into_inner()) = matcher;
1001 self.gitignore_generation.fetch_add(1, Ordering::SeqCst);
1002 }
1003
1004 pub fn clear_gitignore(&self) {
1026 self.set_gitignore(None);
1027 }
1028
1029 pub fn rebuild_gitignore(&self) {
1030 use ignore::gitignore::GitignoreBuilder;
1031 use std::path::Path;
1032 let root_raw = match self.config().project_root.clone() {
1033 Some(r) => r,
1034 None => {
1035 self.set_gitignore(None);
1036 return;
1037 }
1038 };
1039 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
1047 let mut builder = GitignoreBuilder::new(&root);
1048 if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
1053 if global_ignore.is_file() {
1054 if let Some(err) = builder.add(&global_ignore) {
1055 crate::slog_warn!(
1056 "global gitignore parse error in {}: {}",
1057 global_ignore.display(),
1058 err
1059 );
1060 }
1061 }
1062 }
1063 let root_ignore = Path::new(&root).join(".gitignore");
1065 if root_ignore.exists() {
1066 if let Some(err) = builder.add(&root_ignore) {
1067 crate::slog_warn!(
1068 "gitignore parse error in {}: {}",
1069 root_ignore.display(),
1070 err
1071 );
1072 }
1073 }
1074 let root_aftignore = Path::new(&root).join(".aftignore");
1079 if root_aftignore.exists() {
1080 if let Some(err) = builder.add(&root_aftignore) {
1081 crate::slog_warn!(
1082 "aftignore parse error in {}: {}",
1083 root_aftignore.display(),
1084 err
1085 );
1086 }
1087 }
1088 let info_exclude = self
1093 .git_common_dir
1094 .lock()
1095 .clone()
1096 .unwrap_or_else(|| Path::new(&root).join(".git"))
1097 .join("info")
1098 .join("exclude");
1099 if info_exclude.exists() {
1100 if let Some(err) = builder.add(&info_exclude) {
1101 crate::slog_warn!(
1102 "gitignore parse error in {}: {}",
1103 info_exclude.display(),
1104 err
1105 );
1106 }
1107 }
1108 let walker = ignore::WalkBuilder::new(&root)
1114 .standard_filters(true)
1115 .hidden(false)
1123 .filter_entry(|entry| {
1124 let name = entry.file_name().to_string_lossy();
1125 !matches!(
1126 name.as_ref(),
1127 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
1128 )
1129 })
1130 .build();
1131 for entry in walker.flatten() {
1132 let file_name = entry.file_name();
1133 let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
1134 let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
1135 if is_nested_gitignore || is_nested_aftignore {
1136 if let Some(err) = builder.add(entry.path()) {
1137 crate::slog_warn!(
1138 "nested ignore parse error in {}: {}",
1139 entry.path().display(),
1140 err
1141 );
1142 }
1143 }
1144 }
1145 match builder.build() {
1146 Ok(gi) => {
1147 let count = gi.num_ignores();
1148 if count > 0 {
1149 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
1150 self.set_gitignore(Some(Arc::new(gi)));
1151 } else {
1152 self.set_gitignore(None);
1153 }
1154 }
1155 Err(err) => {
1156 crate::slog_warn!("gitignore matcher build failed: {}", err);
1157 self.set_gitignore(None);
1158 }
1159 }
1160 }
1161
1162 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
1165 Arc::clone(&self.bash_compress_flag)
1166 }
1167
1168 pub fn sync_bash_compress_flag(&self) {
1172 let value = self.config().experimental_bash_compress;
1173 self.bash_compress_flag
1174 .store(value, std::sync::atomic::Ordering::Relaxed);
1175 }
1176
1177 pub fn set_bash_compress_enabled(&self, enabled: bool) {
1178 self.update_config(|config| {
1179 config.experimental_bash_compress = enabled;
1180 });
1181 self.bash_compress_flag
1182 .store(enabled, std::sync::atomic::Ordering::Relaxed);
1183 }
1184
1185 pub fn filter_registry(
1189 &self,
1190 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
1191 self.ensure_filter_registry_loaded();
1192 match self.filter_registry.read() {
1193 Ok(g) => g,
1194 Err(poisoned) => poisoned.into_inner(),
1195 }
1196 }
1197
1198 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
1202 self.ensure_filter_registry_loaded();
1203 Arc::clone(&self.filter_registry)
1204 }
1205
1206 pub fn reset_filter_registry(&self) {
1210 let new_registry = crate::compress::build_registry_for_context(self);
1211 match self.filter_registry.write() {
1212 Ok(mut slot) => *slot = new_registry,
1213 Err(poisoned) => *poisoned.into_inner() = new_registry,
1214 }
1215 self.filter_registry_loaded
1216 .store(true, std::sync::atomic::Ordering::Release);
1217 }
1218
1219 fn ensure_filter_registry_loaded(&self) {
1220 use std::sync::atomic::Ordering;
1221 if self.filter_registry_loaded.load(Ordering::Acquire) {
1222 return;
1223 }
1224 let new_registry = crate::compress::build_registry_for_context(self);
1227 if let Ok(mut slot) = self.filter_registry.write() {
1228 *slot = new_registry;
1229 self.filter_registry_loaded.store(true, Ordering::Release);
1230 }
1231 }
1232
1233 pub fn app(&self) -> Arc<App> {
1234 Arc::clone(&self.app)
1235 }
1236
1237 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
1240 self.app.lsp_child_registry()
1241 }
1242
1243 pub fn stdout_writer(&self) -> SharedStdoutWriter {
1244 self.app.stdout_writer()
1245 }
1246
1247 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
1248 if let Ok(mut progress_sender) = self.progress_sender.lock() {
1249 *progress_sender = sender;
1250 }
1251 }
1252
1253 pub fn emit_progress(&self, frame: ProgressFrame) {
1254 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
1255 return;
1256 };
1257 if let Some(sender) = progress_sender.as_ref() {
1258 sender(PushFrame::Progress(frame));
1259 }
1260 }
1261
1262 pub fn status_emitter(&self) -> &StatusEmitter {
1263 &self.status_emitter
1264 }
1265
1266 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
1274 self.progress_sender
1275 .lock()
1276 .ok()
1277 .and_then(|sender| sender.clone())
1278 }
1279
1280 pub fn advance_configure_generation(&self) -> u64 {
1281 self.configure_generation
1282 .fetch_add(1, Ordering::SeqCst)
1283 .wrapping_add(1)
1284 }
1285
1286 pub fn configure_generation(&self) -> u64 {
1287 self.configure_generation.load(Ordering::SeqCst)
1288 }
1289
1290 pub fn configure_warnings_sender(
1291 &self,
1292 ) -> crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)> {
1293 self.configure_warnings_tx.clone()
1294 }
1295
1296 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
1297 let mut warnings = Vec::new();
1298 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
1299 warnings.push(warning);
1300 }
1301 warnings
1302 }
1303
1304 pub fn bash_background(&self) -> &BgTaskRegistry {
1305 &self.bash_background
1306 }
1307
1308 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
1309 self.bash_background.drain_completions()
1310 }
1311
1312 pub fn provider(&self) -> &dyn LanguageProvider {
1314 self.provider.as_ref()
1315 }
1316
1317 pub fn backup(&self) -> &parking_lot::Mutex<BackupStore> {
1319 &self.backup
1320 }
1321
1322 pub fn checkpoint(&self) -> &parking_lot::Mutex<CheckpointStore> {
1324 &self.checkpoint
1325 }
1326
1327 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
1328 self.app.set_db(conn);
1329 }
1330
1331 pub fn clear_db(&self) {
1332 self.app.clear_db();
1333 }
1334
1335 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
1336 self.app.db()
1337 }
1338
1339 pub fn config(&self) -> Arc<Config> {
1341 let guard = match self.config.read() {
1342 Ok(guard) => guard,
1343 Err(poisoned) => poisoned.into_inner(),
1344 };
1345 Arc::clone(&*guard)
1346 }
1347
1348 pub fn set_config(&self, config: Config) {
1350 let next = Arc::new(config);
1351 match self.config.write() {
1352 Ok(mut guard) => *guard = next,
1353 Err(poisoned) => *poisoned.into_inner() = next,
1354 }
1355 }
1356
1357 pub fn update_config(&self, update: impl FnOnce(&mut Config)) {
1359 let mut next = self.config().as_ref().clone();
1360 update(&mut next);
1361 self.set_config(next);
1362 }
1363
1364 pub fn force_restrict_guard(&self, req_id: &str) -> ForceRestrictGuard<'_> {
1365 let mut requests = self.force_restrict_requests.lock();
1366 *requests.entry(req_id.to_string()).or_insert(0) += 1;
1367 ForceRestrictGuard {
1368 ctx: self,
1369 req_id: req_id.to_string(),
1370 }
1371 }
1372
1373 pub fn with_force_restrict<R>(&self, req_id: &str, f: impl FnOnce() -> R) -> R {
1374 let _guard = self.force_restrict_guard(req_id);
1375 f()
1376 }
1377
1378 pub fn request_force_restrict(&self, req_id: &str) -> bool {
1379 self.force_restrict_requests.lock().contains_key(req_id)
1380 }
1381
1382 fn release_force_restrict(&self, req_id: &str) {
1383 let mut requests = self.force_restrict_requests.lock();
1384 match requests.get_mut(req_id) {
1385 Some(count) if *count > 1 => *count -= 1,
1386 Some(_) => {
1387 requests.remove(req_id);
1388 }
1389 None => {}
1390 }
1391 }
1392
1393 pub fn set_harness(&self, harness: Harness) {
1394 self.bash_background.set_harness(harness.clone());
1395 *self.harness.lock() = Some(harness);
1396 }
1397
1398 pub fn harness_opt(&self) -> Option<Harness> {
1399 self.harness.lock().clone()
1400 }
1401
1402 pub fn harness(&self) -> Harness {
1403 self.harness_opt()
1404 .expect("harness set by configure before any tool call")
1405 }
1406
1407 pub fn storage_dir(&self) -> PathBuf {
1408 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
1409 }
1410
1411 pub fn harness_dir(&self) -> PathBuf {
1412 self.storage_dir().join(self.harness().storage_segment())
1413 }
1414
1415 pub fn inspect_dir(&self) -> PathBuf {
1416 self.harness_dir().join("inspect")
1417 }
1418
1419 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
1420 self.harness_dir()
1421 .join("bash-tasks")
1422 .join(hash_session(session_id))
1423 }
1424
1425 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
1426 self.harness_dir()
1427 .join("backups")
1428 .join(hash_session(session_id))
1429 .join(path_hash)
1430 }
1431
1432 pub fn filters_dir(&self) -> PathBuf {
1433 self.harness_dir().join("filters")
1434 }
1435
1436 pub fn trust_file(&self) -> PathBuf {
1438 self.storage_dir().join("trusted-filter-projects.json")
1439 }
1440
1441 pub fn set_canonical_cache_root(&self, root: PathBuf) {
1442 debug_assert!(root.is_absolute());
1443 *self.canonical_cache_root.lock() = Some(root);
1444 }
1445
1446 pub fn canonical_cache_root(&self) -> PathBuf {
1447 self.canonical_cache_root
1448 .lock()
1449 .clone()
1450 .expect("canonical_cache_root accessed before handle_configure")
1451 }
1452
1453 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
1454 self.canonical_cache_root.lock().clone()
1455 }
1456
1457 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
1458 *self.is_worktree_bridge.lock() = is_worktree_bridge;
1459 *self.git_common_dir.lock() = git_common_dir;
1460 }
1461
1462 pub fn is_worktree_bridge(&self) -> bool {
1463 *self.is_worktree_bridge.lock()
1464 }
1465
1466 pub fn git_common_dir(&self) -> Option<PathBuf> {
1467 self.git_common_dir.lock().clone()
1468 }
1469
1470 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
1474 *self.degraded_reasons.lock() = reasons;
1475 }
1476
1477 pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
1478 let reason = reason.into();
1479 let mut reasons = self.degraded_reasons.lock();
1480 if reasons.iter().any(|existing| existing == &reason) {
1481 return false;
1482 }
1483 reasons.push(reason);
1484 true
1485 }
1486
1487 pub fn degraded_reasons(&self) -> Vec<String> {
1491 self.degraded_reasons.lock().clone()
1492 }
1493
1494 pub fn is_degraded(&self) -> bool {
1496 !self.degraded_reasons.lock().is_empty()
1497 }
1498
1499 pub fn cache_role(&self) -> &'static str {
1500 if self.canonical_cache_root.lock().is_none() {
1501 "not_initialized"
1502 } else if self.is_worktree_bridge() {
1503 "worktree"
1504 } else {
1505 "main"
1506 }
1507 }
1508
1509 pub fn callgraph_store(&self) -> &RwLock<Option<Arc<CallGraphStore>>> {
1511 &self.callgraph_store
1512 }
1513
1514 pub fn mark_callgraph_store_force_rebuild(&self) {
1515 *self.callgraph_store_force_rebuild.lock() = true;
1516 }
1517
1518 fn take_callgraph_store_force_rebuild(&self) -> bool {
1519 let mut force = self.callgraph_store_force_rebuild.lock();
1520 let was_forced = *force;
1521 *force = false;
1522 was_forced
1523 }
1524
1525 pub fn callgraph_store_dir(&self) -> PathBuf {
1526 match self.harness_opt() {
1527 Some(harness) => self
1528 .storage_dir()
1529 .join(harness.storage_segment())
1530 .join("callgraph"),
1531 None => self.storage_dir().join("callgraph"),
1532 }
1533 }
1534
1535 pub fn ensure_callgraph_store(
1536 &self,
1537 ) -> Result<Option<Arc<CallGraphStore>>, CallGraphStoreError> {
1538 self.ensure_callgraph_store_with_flag(true)
1539 }
1540
1541 fn ensure_callgraph_store_with_flag(
1542 &self,
1543 respect_config_flag: bool,
1544 ) -> Result<Option<Arc<CallGraphStore>>, CallGraphStoreError> {
1545 if respect_config_flag && !self.config().callgraph_store {
1546 return Ok(None);
1547 }
1548 if let Some(store) = {
1549 let guard = self
1550 .callgraph_store
1551 .read()
1552 .unwrap_or_else(std::sync::PoisonError::into_inner);
1553 guard.as_ref().map(Arc::clone)
1554 } {
1555 return Ok(Some(store));
1556 }
1557
1558 let Some(project_root) = self.callgraph_project_root() else {
1559 return Ok(None);
1560 };
1561 let callgraph_dir = self.callgraph_store_dir();
1562 let force_rebuild = self.take_callgraph_store_force_rebuild();
1563 let store = if self.is_worktree_bridge() {
1564 CallGraphStore::open_readonly(callgraph_dir, project_root)?
1565 } else if force_rebuild {
1566 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1567 let (store, _stats) = CallGraphStore::cold_build_with_lease_chunked(
1568 callgraph_dir,
1569 project_root,
1570 &files,
1571 self.config().callgraph_chunk_size,
1572 )?;
1573 Some(store)
1574 } else if CallGraphStore::needs_cold_build(&callgraph_dir, &project_root)? {
1575 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1576 let (store, _stats) = CallGraphStore::ensure_built_with_lease_chunked(
1577 callgraph_dir,
1578 project_root,
1579 &files,
1580 self.config().callgraph_chunk_size,
1581 )?;
1582 Some(store)
1583 } else {
1584 Some(CallGraphStore::open(callgraph_dir, project_root)?)
1585 };
1586
1587 let Some(store) = store else {
1588 return Ok(None);
1589 };
1590 let store = Arc::new(store);
1591 {
1592 let mut guard = self
1593 .callgraph_store
1594 .write()
1595 .unwrap_or_else(std::sync::PoisonError::into_inner);
1596 *guard = Some(Arc::clone(&store));
1597 }
1598 Ok(Some(store))
1599 }
1600
1601 fn callgraph_project_root(&self) -> Option<PathBuf> {
1604 self.canonical_cache_root_opt().or_else(|| {
1605 self.config()
1606 .project_root
1607 .clone()
1608 .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
1609 })
1610 }
1611
1612 pub fn revalidate_callgraph_store_generation(&self) {
1630 if self.callgraph_store_rx.lock().is_some() {
1633 return;
1634 }
1635 let superseded = {
1636 let guard = self
1637 .callgraph_store
1638 .read()
1639 .unwrap_or_else(std::sync::PoisonError::into_inner);
1640 guard.as_ref().is_some_and(|store| !store.is_current())
1641 };
1642 if superseded {
1643 let mut guard = self
1644 .callgraph_store
1645 .write()
1646 .unwrap_or_else(std::sync::PoisonError::into_inner);
1647 *guard = None;
1648 }
1649 }
1650
1651 pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess {
1652 self.revalidate_callgraph_store_generation();
1656 if let Some(store) = {
1657 let guard = self
1658 .callgraph_store
1659 .read()
1660 .unwrap_or_else(std::sync::PoisonError::into_inner);
1661 guard.as_ref().map(Arc::clone)
1662 } {
1663 return CallgraphStoreAccess::Ready(store);
1664 }
1665
1666 if self.callgraph_store_rx.lock().is_some() {
1668 return CallgraphStoreAccess::Building;
1669 }
1670
1671 let Some(project_root) = self.callgraph_project_root() else {
1672 return CallgraphStoreAccess::Unavailable;
1673 };
1674 let callgraph_dir = self.callgraph_store_dir();
1675
1676 if self.is_worktree_bridge() {
1679 match CallGraphStore::open_readonly(callgraph_dir, project_root) {
1680 Ok(Some(store)) => {
1681 let store = Arc::new(store);
1682 {
1683 let mut guard = self
1684 .callgraph_store
1685 .write()
1686 .unwrap_or_else(std::sync::PoisonError::into_inner);
1687 *guard = Some(Arc::clone(&store));
1688 }
1689 return CallgraphStoreAccess::Ready(store);
1690 }
1691 Ok(None) | Err(_) => return CallgraphStoreAccess::Unavailable,
1692 }
1693 }
1694
1695 let force_rebuild = *self.callgraph_store_force_rebuild.lock();
1696 if !force_rebuild {
1699 match CallGraphStore::needs_cold_build(&callgraph_dir, &project_root) {
1700 Ok(false) => match CallGraphStore::open(callgraph_dir, project_root) {
1701 Ok(store) => {
1702 let store = Arc::new(store);
1703 {
1704 let mut guard = self
1705 .callgraph_store
1706 .write()
1707 .unwrap_or_else(std::sync::PoisonError::into_inner);
1708 *guard = Some(Arc::clone(&store));
1709 }
1710 return CallgraphStoreAccess::Ready(store);
1711 }
1712 Err(error) => return CallgraphStoreAccess::Error(error),
1713 },
1714 Ok(true) => {}
1715 Err(error) => return CallgraphStoreAccess::Error(error),
1716 }
1717 }
1718
1719 if self.semantic_cold_seed_active() {
1720 self.defer_callgraph_store_warm_for_semantic_cold_seed();
1721 return CallgraphStoreAccess::Building;
1722 }
1723
1724 if !self.spawn_callgraph_store_cold_build(project_root, callgraph_dir, force_rebuild) {
1732 return CallgraphStoreAccess::Building;
1733 }
1734
1735 let wait = callgraph_build_wait_window();
1736 if !wait.is_zero() {
1737 let received = {
1738 let rx_ref = self.callgraph_store_rx.lock();
1739 let Some(rx) = rx_ref.as_ref() else {
1740 return CallgraphStoreAccess::Building;
1741 };
1742 rx.recv_timeout(wait)
1743 };
1744 match received {
1745 Ok(store) => {
1746 let pending = self.take_pending_callgraph_store_paths();
1750 if !pending.is_empty() {
1751 if let Err(error) = store.refresh_files(&pending) {
1752 crate::slog_warn!(
1753 "callgraph store inline post-build refresh failed: {}",
1754 error
1755 );
1756 let _ = store.mark_files_stale(&pending);
1757 }
1758 }
1759 let store = Arc::new(store);
1760 {
1761 let mut guard = self
1762 .callgraph_store
1763 .write()
1764 .unwrap_or_else(std::sync::PoisonError::into_inner);
1765 *guard = Some(Arc::clone(&store));
1766 }
1767 *self.callgraph_store_rx.lock() = None;
1768 return CallgraphStoreAccess::Ready(store);
1769 }
1770 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1771 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
1772 *self.callgraph_store_rx.lock() = None;
1775 }
1776 }
1777 }
1778 CallgraphStoreAccess::Building
1779 }
1780
1781 fn spawn_callgraph_store_cold_build(
1788 &self,
1789 project_root: PathBuf,
1790 callgraph_dir: PathBuf,
1791 force_rebuild: bool,
1792 ) -> bool {
1793 let session_id = crate::log_ctx::current_session();
1794 let chunk_size = self.config().callgraph_chunk_size;
1795
1796 let mut rx_guard = self.callgraph_store_rx.lock();
1797 if rx_guard.is_some() {
1798 return false;
1799 }
1800
1801 if force_rebuild {
1802 self.take_callgraph_store_force_rebuild();
1805 }
1806 let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStore>();
1807 *rx_guard = Some(rx);
1808
1809 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.fetch_add(1, Ordering::SeqCst);
1810
1811 std::thread::spawn(move || {
1812 crate::log_ctx::with_session(session_id, || {
1813 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1814 let built = if force_rebuild {
1815 CallGraphStore::cold_build_with_lease_chunked(
1816 callgraph_dir,
1817 project_root,
1818 &files,
1819 chunk_size,
1820 )
1821 .map(|(store, _)| store)
1822 } else {
1823 CallGraphStore::ensure_built_with_lease_chunked(
1824 callgraph_dir,
1825 project_root,
1826 &files,
1827 chunk_size,
1828 )
1829 .map(|(store, _)| store)
1830 };
1831 match built {
1832 Ok(store) => {
1833 let _ = tx.send(store);
1834 }
1835 Err(error) => {
1836 crate::slog_warn!("callgraph store cold build failed: {}", error);
1837 }
1840 }
1841 });
1842 });
1843 true
1844 }
1845
1846 pub fn callgraph_store_rx(
1849 &self,
1850 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStore>>> {
1851 &self.callgraph_store_rx
1852 }
1853
1854 pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
1857 where
1858 I: IntoIterator<Item = PathBuf>,
1859 {
1860 self.pending_callgraph_store_paths.lock().extend(paths);
1861 }
1862
1863 pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
1865 std::mem::take(&mut *self.pending_callgraph_store_paths.lock())
1866 .into_iter()
1867 .collect()
1868 }
1869
1870 pub fn search_index(&self) -> &RwLock<Option<SearchIndex>> {
1872 &self.search_index
1873 }
1874
1875 pub fn search_index_rx(&self) -> &RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>> {
1877 &self.search_index_rx
1878 }
1879
1880 pub fn add_pending_search_index_paths<I>(&self, paths: I)
1881 where
1882 I: IntoIterator<Item = PathBuf>,
1883 {
1884 self.pending_search_index_paths.lock().extend(paths);
1885 }
1886
1887 pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
1888 std::mem::take(&mut *self.pending_search_index_paths.lock())
1889 .into_iter()
1890 .collect()
1891 }
1892
1893 pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
1894 where
1895 I: IntoIterator<Item = PathBuf>,
1896 {
1897 self.pending_semantic_index_paths.lock().extend(paths);
1898 }
1899
1900 pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
1901 std::mem::take(&mut *self.pending_semantic_index_paths.lock())
1902 .into_iter()
1903 .collect()
1904 }
1905
1906 pub fn mark_pending_semantic_corpus_refresh(&self) {
1907 *self.pending_semantic_corpus_refresh.lock() = true;
1908 }
1909
1910 pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
1911 std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock())
1912 }
1913
1914 pub fn clear_pending_index_updates(&self) {
1915 self.pending_search_index_paths.lock().clear();
1916 self.pending_callgraph_store_paths.lock().clear();
1917 self.pending_tier2_paths.lock().clear();
1918 self.pending_semantic_index_paths.lock().clear();
1919 *self.pending_semantic_corpus_refresh.lock() = false;
1920 }
1921
1922 pub fn inspect_manager(&self) -> Arc<InspectManager> {
1923 Arc::clone(&self.inspect_manager)
1924 }
1925
1926 pub fn add_pending_tier2_paths<I>(&self, paths: I)
1927 where
1928 I: IntoIterator<Item = PathBuf>,
1929 {
1930 self.pending_tier2_paths.lock().extend(paths);
1931 }
1932
1933 pub fn pending_tier2_paths(&self) -> Vec<PathBuf> {
1934 self.pending_tier2_paths.lock().iter().cloned().collect()
1935 }
1936
1937 pub fn remove_pending_tier2_paths<I>(&self, paths: I)
1938 where
1939 I: IntoIterator<Item = PathBuf>,
1940 {
1941 let mut pending = self.pending_tier2_paths.lock();
1942 for path in paths {
1943 pending.remove(&path);
1944 }
1945 }
1946
1947 pub fn take_new_reuse_completions(&self) -> bool {
1952 let current = self.inspect_manager.reuse_completion_count();
1953 let previous = self
1954 .last_seen_reuse_completions
1955 .swap(current, Ordering::SeqCst);
1956 current != previous
1957 }
1958
1959 pub fn reset_tier2_refresh_scheduler(&self) {
1960 self.reset_tier2_refresh_scheduler_at(Instant::now());
1961 }
1962
1963 #[doc(hidden)]
1964 pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
1965 self.tier2_refresh_scheduler
1966 .lock()
1967 .reset_after_configure(now);
1968 }
1969
1970 pub fn request_tier2_refresh_pull(&self) -> bool {
1971 self.tier2_refresh_scheduler
1972 .lock()
1973 .request_pull(!self.is_worktree_bridge())
1974 }
1975
1976 pub fn tick_tier2_refresh_scheduler(
1977 &self,
1978 changed_path_count: usize,
1979 ) -> Option<Tier2TriggerReason> {
1980 self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
1981 }
1982
1983 #[doc(hidden)]
1984 pub fn tick_tier2_refresh_scheduler_at(
1985 &self,
1986 now: Instant,
1987 changed_path_count: usize,
1988 ) -> Option<Tier2TriggerReason> {
1989 let manager = self.inspect_manager();
1990 let can_write = !self.is_worktree_bridge();
1991 let in_flight = manager.tier2_any_in_flight();
1992 let semantic_cold_seed_active = self.semantic_cold_seed_active();
1993 let decision = self.tier2_refresh_scheduler.lock().tick_with_semantic_gate(
1994 now,
1995 changed_path_count,
1996 can_write,
1997 in_flight,
1998 semantic_cold_seed_active,
1999 );
2000
2001 if let Some(reason) = decision {
2002 self.start_tier2_refresh(reason, manager);
2003 }
2004
2005 decision
2006 }
2007
2008 pub fn note_tier2_refresh_started(&self) {
2009 self.note_tier2_refresh_started_at(Instant::now());
2010 }
2011
2012 #[doc(hidden)]
2013 pub fn note_tier2_refresh_started_at(&self, now: Instant) {
2014 self.tier2_refresh_scheduler
2015 .lock()
2016 .note_external_scan_started(now);
2017 }
2018
2019 pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
2020 self.tier2_refresh_scheduler
2021 .lock()
2022 .last_trigger_reason()
2023 .map(Tier2TriggerReason::as_str)
2024 }
2025
2026 #[doc(hidden)]
2027 pub fn tier2_pull_demand_pending(&self) -> bool {
2028 self.tier2_refresh_scheduler.lock().pull_demand_pending()
2029 }
2030
2031 fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
2032 if self.is_worktree_bridge()
2033 || self
2034 .degraded_reasons
2035 .lock()
2036 .iter()
2037 .any(|r| r == "home_root")
2038 || !self.config().inspect.enabled
2039 {
2040 return;
2041 }
2042 let Some(snapshot) = self.tier2_refresh_snapshot() else {
2043 return;
2044 };
2045 let categories = InspectCategory::active()
2046 .iter()
2047 .copied()
2048 .filter(|category| category.is_tier2())
2049 .collect::<Vec<_>>();
2050 let submission =
2051 manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
2052 if submission.has_new_work() {
2053 crate::slog_info!(
2054 "tier2 refresh scheduled: reason={}, categories={:?}",
2055 reason.as_str(),
2056 submission
2057 .newly_queued_categories
2058 .iter()
2059 .map(|category| category.as_str())
2060 .collect::<Vec<_>>()
2061 );
2062 }
2063 for error in submission.errors {
2064 crate::slog_warn!(
2065 "tier2 refresh schedule failed for {}: {}",
2066 error.category,
2067 error.message
2068 );
2069 }
2070 }
2071
2072 fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
2073 self.harness_opt()?;
2074 let config = self.config();
2075 let project_root = config
2076 .project_root
2077 .clone()
2078 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
2079 let project_root = std::fs::canonicalize(&project_root).unwrap_or(project_root);
2080 Some(InspectSnapshot::new(
2081 project_root,
2082 self.inspect_dir(),
2083 config,
2084 self.symbol_cache(),
2085 ))
2086 }
2087
2088 pub fn symbol_cache(&self) -> SharedSymbolCache {
2090 Arc::clone(&self.symbol_cache)
2091 }
2092
2093 pub fn reset_symbol_cache(&self) -> u64 {
2095 self.symbol_cache
2096 .write()
2097 .map(|mut cache| cache.reset())
2098 .unwrap_or(0)
2099 }
2100
2101 pub fn semantic_index(&self) -> &RwLock<Option<SemanticIndex>> {
2103 &self.semantic_index
2104 }
2105
2106 pub fn semantic_index_rx(
2108 &self,
2109 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
2110 &self.semantic_index_rx
2111 }
2112
2113 pub fn semantic_index_status(&self) -> &RwLock<SemanticIndexStatus> {
2114 &self.semantic_index_status
2115 }
2116
2117 pub fn reset_semantic_cold_seed_gate_for_configure(&self) -> u64 {
2120 self.semantic_cold_seed_active
2121 .store(false, Ordering::SeqCst);
2122 self.semantic_callgraph_warm_deferred
2123 .store(false, Ordering::SeqCst);
2124 self.semantic_cold_seed_generation
2125 .fetch_add(1, Ordering::SeqCst)
2126 .wrapping_add(1)
2127 }
2128
2129 pub fn semantic_cold_seed_active_flag(&self) -> Arc<AtomicBool> {
2130 Arc::clone(&self.semantic_cold_seed_active)
2131 }
2132
2133 pub fn semantic_cold_seed_generation_flag(&self) -> Arc<AtomicU64> {
2134 Arc::clone(&self.semantic_cold_seed_generation)
2135 }
2136
2137 pub fn semantic_cold_seed_active(&self) -> bool {
2138 self.semantic_cold_seed_active.load(Ordering::SeqCst)
2139 }
2140
2141 pub fn schedule_semantic_cold_seed_gate_for_configure(&self) {
2142 self.semantic_cold_seed_active.store(true, Ordering::SeqCst);
2143 }
2144
2145 pub fn defer_callgraph_store_warm_for_semantic_cold_seed(&self) {
2146 self.semantic_callgraph_warm_deferred
2147 .store(true, Ordering::SeqCst);
2148 }
2149
2150 fn semantic_callgraph_warm_deferred(&self) -> bool {
2151 self.semantic_callgraph_warm_deferred.load(Ordering::SeqCst)
2152 }
2153
2154 pub fn clear_semantic_cold_seed_gate_and_resume_deferred_work(&self) {
2158 self.resume_semantic_cold_seed_deferred_work(false);
2159 }
2160
2161 pub fn resume_deferred_work_after_semantic_cold_seed_gate_cleared(&self) {
2164 self.resume_semantic_cold_seed_deferred_work(true);
2165 }
2166
2167 fn resume_semantic_cold_seed_deferred_work(&self, force: bool) {
2168 let was_active = self.semantic_cold_seed_active.swap(false, Ordering::SeqCst);
2169 let had_deferred_callgraph = self.semantic_callgraph_warm_deferred();
2170
2171 if force || was_active || had_deferred_callgraph {
2172 let _ = self.request_tier2_refresh_pull();
2173 }
2174
2175 if self
2176 .semantic_callgraph_warm_deferred
2177 .swap(false, Ordering::SeqCst)
2178 {
2179 if !self.config().callgraph_store
2180 || self
2181 .degraded_reasons
2182 .lock()
2183 .iter()
2184 .any(|reason| reason == "home_root")
2185 {
2186 return;
2187 }
2188
2189 match self.callgraph_store_for_ops() {
2190 CallgraphStoreAccess::Ready(_) => {
2191 crate::slog_debug!(
2192 "deferred callgraph store warm completed after semantic cold seed gate cleared"
2193 );
2194 }
2195 CallgraphStoreAccess::Building => {
2196 crate::slog_info!(
2197 "deferred callgraph store warm scheduled after semantic cold seed gate cleared"
2198 );
2199 }
2200 CallgraphStoreAccess::Unavailable => {
2201 crate::slog_info!(
2202 "deferred callgraph store warm unavailable after semantic cold seed gate cleared"
2203 );
2204 }
2205 CallgraphStoreAccess::Error(error) => {
2206 crate::slog_warn!(
2207 "deferred callgraph store warm failed after semantic cold seed gate cleared: {}",
2208 error
2209 );
2210 }
2211 }
2212 }
2213 }
2214
2215 #[doc(hidden)]
2216 pub fn set_semantic_cold_seed_active_for_test(&self, active: bool) {
2217 self.semantic_cold_seed_active
2218 .store(active, Ordering::SeqCst);
2219 }
2220
2221 #[doc(hidden)]
2222 pub fn semantic_callgraph_warm_deferred_for_test(&self) -> bool {
2223 self.semantic_callgraph_warm_deferred()
2224 }
2225
2226 pub fn install_semantic_refresh_worker(
2227 &self,
2228 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
2229 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
2230 worker_slot: SemanticRefreshWorkerSlot,
2231 ) {
2232 self.clear_semantic_refresh_worker();
2233 *self.semantic_refresh_tx.lock() = Some(sender);
2234 *self.semantic_refresh_event_rx.lock() = Some(event_rx);
2235 *self.semantic_refresh_worker.lock() = Some(worker_slot);
2236 }
2237
2238 pub fn clear_semantic_refresh_worker(&self) {
2239 *self.semantic_refresh_tx.lock() = None;
2240 *self.semantic_refresh_event_rx.lock() = None;
2241 if let Some(worker_slot) = self.semantic_refresh_worker.lock().take() {
2242 if let Ok(mut handle) = worker_slot.lock() {
2243 drop(handle.take());
2244 }
2245 }
2246 }
2247
2248 pub fn semantic_refresh_sender(
2249 &self,
2250 ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
2251 self.semantic_refresh_tx.lock().clone()
2252 }
2253
2254 pub fn semantic_refresh_event_rx(
2255 &self,
2256 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
2257 &self.semantic_refresh_event_rx
2258 }
2259
2260 pub fn with_semantic_refresh_retry_attempts_mut<R>(
2261 &self,
2262 f: impl FnOnce(&mut BTreeMap<PathBuf, usize>) -> R,
2263 ) -> R {
2264 let mut attempts = self.semantic_refresh_retry_attempts.lock();
2265 f(&mut attempts)
2266 }
2267
2268 pub fn clear_semantic_refresh_retry_attempts(&self, paths: &[PathBuf]) {
2269 let mut attempts = self.semantic_refresh_retry_attempts.lock();
2270 for path in paths {
2271 attempts.remove(path);
2272 }
2273 }
2274
2275 pub fn clear_all_semantic_refresh_retry_attempts(&self) {
2276 self.semantic_refresh_retry_attempts.lock().clear();
2277 }
2278
2279 pub fn semantic_refresh_circuit_is_open(&self) -> bool {
2280 self.semantic_refresh_circuit.open.load(Ordering::SeqCst)
2281 }
2282
2283 pub fn record_semantic_refresh_transient_failure(&self, trip_threshold: usize) -> bool {
2284 let failures = self
2285 .semantic_refresh_circuit
2286 .consecutive_transient_failures
2287 .fetch_add(1, Ordering::SeqCst)
2288 .saturating_add(1);
2289 if failures >= trip_threshold
2290 && !self
2291 .semantic_refresh_circuit
2292 .open
2293 .swap(true, Ordering::SeqCst)
2294 {
2295 crate::slog_warn!(
2296 "embedding backend appears down; suspending active retries, will resume on next change or successful probe"
2297 );
2298 }
2299 self.semantic_refresh_circuit_is_open()
2300 }
2301
2302 pub fn reset_semantic_refresh_transient_failure_count(&self) {
2303 self.semantic_refresh_circuit
2304 .consecutive_transient_failures
2305 .store(0, Ordering::SeqCst);
2306 }
2307
2308 pub fn reset_semantic_refresh_circuit_after_success(&self) {
2309 self.reset_semantic_refresh_transient_failure_count();
2310 self.semantic_refresh_circuit
2311 .probe_ready
2312 .store(false, Ordering::SeqCst);
2313 if self
2314 .semantic_refresh_circuit
2315 .open
2316 .swap(false, Ordering::SeqCst)
2317 {
2318 crate::slog_info!("embedding backend recovered; resuming normal refresh retries");
2319 }
2320 }
2321
2322 pub fn semantic_refresh_transient_failure_count(&self) -> usize {
2323 self.semantic_refresh_circuit
2324 .consecutive_transient_failures
2325 .load(Ordering::SeqCst)
2326 }
2327
2328 pub fn semantic_refresh_probe_is_scheduled(&self) -> bool {
2329 self.semantic_refresh_circuit
2330 .probe_in_flight
2331 .load(Ordering::SeqCst)
2332 || self
2333 .semantic_refresh_circuit
2334 .probe_ready
2335 .load(Ordering::SeqCst)
2336 }
2337
2338 pub fn take_semantic_refresh_probe_ready(&self) -> bool {
2339 self.semantic_refresh_circuit
2340 .probe_ready
2341 .swap(false, Ordering::SeqCst)
2342 }
2343
2344 pub fn ensure_semantic_refresh_probe_scheduled(&self, delay: Duration) {
2345 if self
2346 .semantic_refresh_circuit
2347 .probe_ready
2348 .load(Ordering::SeqCst)
2349 {
2350 return;
2351 }
2352 if self
2353 .semantic_refresh_circuit
2354 .probe_in_flight
2355 .swap(true, Ordering::SeqCst)
2356 {
2357 return;
2358 }
2359 if self
2360 .semantic_refresh_circuit
2361 .probe_ready
2362 .load(Ordering::SeqCst)
2363 {
2364 self.semantic_refresh_circuit
2365 .probe_in_flight
2366 .store(false, Ordering::SeqCst);
2367 return;
2368 }
2369
2370 let circuit = Arc::clone(&self.semantic_refresh_circuit);
2371 let session_id = crate::log_ctx::current_session();
2372 std::thread::spawn(move || {
2373 crate::log_ctx::with_session(session_id, || {
2374 std::thread::sleep(delay);
2375 circuit.probe_ready.store(true, Ordering::SeqCst);
2376 circuit.probe_in_flight.store(false, Ordering::SeqCst);
2377 });
2378 });
2379 }
2380
2381 pub fn semantic_embedding_model(
2383 &self,
2384 ) -> &parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>> {
2385 &self.semantic_embedding_model
2386 }
2387
2388 pub fn watcher(&self) -> &parking_lot::Mutex<Option<RecommendedWatcher>> {
2390 &self.watcher
2391 }
2392
2393 pub fn watcher_rx(
2395 &self,
2396 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>> {
2397 &self.watcher_rx
2398 }
2399
2400 pub fn install_watcher_runtime(
2403 &self,
2404 rx: crossbeam_channel::Receiver<WatcherDispatchEvent>,
2405 runtime: WatcherThreadHandle,
2406 ) {
2407 *self.watcher_rx.lock() = Some(rx);
2408 *self.watcher_thread.lock() = Some(runtime);
2409 }
2410
2411 pub fn stop_watcher_runtime(&self) {
2414 if let Some(runtime) = self.watcher_thread.lock().take() {
2415 runtime.shutdown_and_join();
2416 }
2417 *self.watcher_rx.lock() = None;
2418 *self.watcher.lock() = None;
2419 }
2420
2421 pub fn lsp(&self) -> parking_lot::MutexGuard<'_, LspManager> {
2423 self.lsp_manager.lock()
2424 }
2425
2426 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
2429 let config = self.config();
2430 if let Some(mut lsp) = self.lsp_manager.try_lock() {
2431 if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
2432 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
2433 }
2434 }
2435 }
2436
2437 pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
2443 if let Some(mut lsp) = self.lsp_manager.try_lock() {
2444 lsp.clear_diagnostics_for_file(file_path)
2445 } else {
2446 false
2447 }
2448 }
2449
2450 pub fn lsp_mark_diagnostics_stale_for_file(&self, file_path: &Path) -> StaleDiagnosticsMark {
2454 if let Some(mut lsp) = self.lsp_manager.try_lock() {
2455 lsp.mark_diagnostics_stale_for_file(file_path)
2456 } else {
2457 StaleDiagnosticsMark::default()
2458 }
2459 }
2460
2461 pub fn lsp_resync_changed_file_for_diagnostics(&self, file_path: &Path) -> bool {
2469 if !file_path.is_file() {
2470 return false;
2471 }
2472
2473 let content = match std::fs::read_to_string(file_path) {
2474 Ok(content) => content,
2475 Err(err) => {
2476 crate::slog_warn!(
2477 "skipping LSP resync for {} after external edit: {}",
2478 file_path.display(),
2479 err
2480 );
2481 return false;
2482 }
2483 };
2484
2485 let config = self.config();
2486 if let Some(mut lsp) = self.lsp_manager.try_lock() {
2487 if let Err(err) = lsp.notify_file_changed(file_path, &content, &config) {
2488 crate::slog_warn!(
2489 "LSP resync failed for {} after external edit: {}",
2490 file_path.display(),
2491 err
2492 );
2493 return false;
2494 }
2495 true
2496 } else {
2497 false
2498 }
2499 }
2500
2501 pub fn lsp_notify_and_collect_diagnostics(
2512 &self,
2513 file_path: &Path,
2514 content: &str,
2515 timeout: std::time::Duration,
2516 ) -> crate::lsp::manager::PostEditWaitOutcome {
2517 let config = self.config();
2518 let Some(mut lsp) = self.lsp_manager.try_lock() else {
2519 return crate::lsp::manager::PostEditWaitOutcome::default();
2520 };
2521
2522 lsp.drain_events();
2525
2526 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
2530
2531 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
2533 {
2534 Ok(v) => v,
2535 Err(e) => {
2536 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
2537 return crate::lsp::manager::PostEditWaitOutcome::default();
2538 }
2539 };
2540
2541 if expected_versions.is_empty() {
2544 return crate::lsp::manager::PostEditWaitOutcome::default();
2545 }
2546
2547 lsp.wait_for_post_edit_diagnostics(
2548 file_path,
2549 &config,
2550 &expected_versions,
2551 &pre_snapshot,
2552 timeout,
2553 )
2554 }
2555
2556 fn custom_lsp_root_markers(&self) -> Vec<String> {
2559 self.config()
2560 .lsp_servers
2561 .iter()
2562 .flat_map(|s| s.root_markers.iter().cloned())
2563 .collect()
2564 }
2565
2566 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
2567 let custom_markers = self.custom_lsp_root_markers();
2568 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
2569 .iter()
2570 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
2571 .cloned()
2572 .map(|path| {
2573 let change_type = if path.exists() {
2574 FileChangeType::CHANGED
2575 } else {
2576 FileChangeType::DELETED
2577 };
2578 (path, change_type)
2579 })
2580 .collect();
2581
2582 self.notify_watched_config_events(&config_paths);
2583 }
2584
2585 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
2586 let paths = params
2587 .get("multi_file_write_paths")
2588 .and_then(|value| value.as_array())?
2589 .iter()
2590 .filter_map(|value| value.as_str())
2591 .map(PathBuf::from)
2592 .collect::<Vec<_>>();
2593
2594 (!paths.is_empty()).then_some(paths)
2595 }
2596
2597 fn watched_file_events_from_params(
2609 params: &serde_json::Value,
2610 extra_markers: &[String],
2611 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
2612 let events = params
2613 .get("multi_file_write_paths")
2614 .and_then(|value| value.as_array())?
2615 .iter()
2616 .filter_map(|entry| {
2617 let path = entry
2619 .get("path")
2620 .and_then(|value| value.as_str())
2621 .map(PathBuf::from)?;
2622
2623 if !is_config_file_path_with_custom(&path, extra_markers) {
2624 return None;
2625 }
2626
2627 let change_type = entry
2628 .get("type")
2629 .and_then(|value| value.as_str())
2630 .and_then(Self::parse_file_change_type)
2631 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
2632
2633 Some((path, change_type))
2634 })
2635 .collect::<Vec<_>>();
2636
2637 (!events.is_empty()).then_some(events)
2638 }
2639
2640 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
2641 match value {
2642 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
2643 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
2644 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
2645 _ => None,
2646 }
2647 }
2648
2649 fn change_type_from_current_state(path: &Path) -> FileChangeType {
2650 if path.exists() {
2651 FileChangeType::CHANGED
2652 } else {
2653 FileChangeType::DELETED
2654 }
2655 }
2656
2657 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
2658 if config_paths.is_empty() {
2659 return;
2660 }
2661
2662 let config = self.config();
2663 if let Some(mut lsp) = self.lsp_manager.try_lock() {
2664 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
2665 crate::slog_warn!("watched-file sync error: {}", e);
2666 }
2667 }
2668 }
2669
2670 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
2671 let custom_markers = self.custom_lsp_root_markers();
2672 if !is_config_file_path_with_custom(file_path, &custom_markers) {
2673 return;
2674 }
2675
2676 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
2677 }
2678
2679 pub fn lsp_post_multi_file_write(
2684 &self,
2685 file_path: &Path,
2686 content: &str,
2687 file_paths: &[PathBuf],
2688 params: &serde_json::Value,
2689 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2690 self.notify_watched_config_files(file_paths);
2691 self.add_pending_tier2_paths(file_paths.iter().cloned());
2692 let _ = self.mark_status_bar_tier2_stale();
2693
2694 let wants_diagnostics = params
2695 .get("diagnostics")
2696 .and_then(|v| v.as_bool())
2697 .unwrap_or(false);
2698
2699 if !wants_diagnostics {
2700 self.lsp_notify_file_changed(file_path, content);
2701 return None;
2702 }
2703
2704 let wait_ms = params
2705 .get("wait_ms")
2706 .and_then(|v| v.as_u64())
2707 .unwrap_or(3000)
2708 .min(10_000);
2709
2710 Some(self.lsp_notify_and_collect_diagnostics(
2711 file_path,
2712 content,
2713 std::time::Duration::from_millis(wait_ms),
2714 ))
2715 }
2716
2717 pub fn lsp_post_write(
2734 &self,
2735 file_path: &Path,
2736 content: &str,
2737 params: &serde_json::Value,
2738 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2739 let wants_diagnostics = params
2740 .get("diagnostics")
2741 .and_then(|v| v.as_bool())
2742 .unwrap_or(false);
2743
2744 let custom_markers = self.custom_lsp_root_markers();
2745 if let Some(file_paths) = Self::multi_file_write_paths(params) {
2746 self.add_pending_tier2_paths(file_paths);
2747 } else {
2748 self.add_pending_tier2_paths([file_path.to_path_buf()]);
2749 }
2750 let _ = self.mark_status_bar_tier2_stale();
2751
2752 if !wants_diagnostics {
2753 if let Some(file_paths) = Self::multi_file_write_paths(params) {
2754 self.notify_watched_config_files(&file_paths);
2755 } else if let Some(config_events) =
2756 Self::watched_file_events_from_params(params, &custom_markers)
2757 {
2758 self.notify_watched_config_events(&config_events);
2759 }
2760 self.lsp_notify_file_changed(file_path, content);
2761 return None;
2762 }
2763
2764 let wait_ms = params
2765 .get("wait_ms")
2766 .and_then(|v| v.as_u64())
2767 .unwrap_or(3000)
2768 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
2771 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
2772 }
2773
2774 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
2775 {
2776 self.notify_watched_config_events(&config_events);
2777 }
2778
2779 Some(self.lsp_notify_and_collect_diagnostics(
2780 file_path,
2781 content,
2782 std::time::Duration::from_millis(wait_ms),
2783 ))
2784 }
2785
2786 pub fn validate_path(
2795 &self,
2796 req_id: &str,
2797 path: &Path,
2798 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
2799 let config = self.config();
2800 let force_restrict = self.request_force_restrict(req_id);
2801 let enforce = config.restrict_to_project_root || force_restrict;
2802 if !enforce {
2805 return Ok(path.to_path_buf());
2806 }
2807 let root = match &config.project_root {
2808 Some(r) => r.clone(),
2809 None if force_restrict => {
2810 return Err(crate::protocol::Response::error(
2811 req_id,
2812 "path_outside_root",
2813 "project root is required when path restriction is forced",
2814 ));
2815 }
2816 None => return Ok(path.to_path_buf()), };
2818 drop(config);
2819
2820 let raw_root = root.clone();
2825 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
2826
2827 let path_for_resolution = if path.is_relative() {
2832 raw_root.join(path)
2833 } else {
2834 path.to_path_buf()
2835 };
2836 let resolved = match std::fs::canonicalize(&path_for_resolution) {
2837 Ok(resolved) => resolved,
2838 Err(_) => {
2839 let normalized = normalize_path(&path_for_resolution);
2840 reject_escaping_symlink(
2841 req_id,
2842 &path_for_resolution,
2843 &normalized,
2844 &resolved_root,
2845 &raw_root,
2846 )?;
2847 resolve_with_existing_ancestors(&normalized)
2848 }
2849 };
2850
2851 if !resolved.starts_with(&resolved_root) {
2852 return Err(path_error_response(req_id, path, &resolved_root));
2853 }
2854
2855 Ok(resolved)
2856 }
2857
2858 pub fn lsp_server_count(&self) -> usize {
2860 self.lsp_manager
2861 .try_lock()
2862 .map(|lsp| lsp.server_count())
2863 .unwrap_or(0)
2864 }
2865
2866 pub fn symbol_cache_stats(&self) -> serde_json::Value {
2868 let entries = self
2869 .symbol_cache
2870 .read()
2871 .map(|cache| cache.len())
2872 .unwrap_or(0);
2873 serde_json::json!({
2874 "local_entries": entries,
2875 "warm_entries": 0,
2876 })
2877 }
2878}
2879
2880#[cfg(test)]
2881mod force_restrict_tests {
2882 use super::*;
2883 use crate::language::StubProvider;
2884 use tempfile::TempDir;
2885
2886 fn test_context(project_root: Option<PathBuf>, restrict_to_project_root: bool) -> AppContext {
2887 AppContext::new(
2888 Box::new(StubProvider),
2889 Config {
2890 project_root,
2891 restrict_to_project_root,
2892 ..Config::default()
2893 },
2894 )
2895 }
2896
2897 #[test]
2898 fn standalone_validate_path_parity_without_force_restrict() {
2899 let root = TempDir::new().expect("root tempdir");
2900 let outside = TempDir::new().expect("outside tempdir");
2901 let outside_path = outside.path().join("outside.txt");
2902
2903 let unrestricted = test_context(Some(root.path().to_path_buf()), false);
2904 assert_eq!(
2905 unrestricted
2906 .validate_path("standalone-unrestricted", &outside_path)
2907 .expect("unrestricted standalone validates"),
2908 outside_path
2909 );
2910
2911 let restricted = test_context(Some(root.path().to_path_buf()), true);
2912 let err = restricted
2913 .validate_path("standalone-restricted", &outside_path)
2914 .expect_err("restricted standalone rejects outside root");
2915 assert_eq!(
2916 serde_json::to_value(err).unwrap()["code"],
2917 "path_outside_root"
2918 );
2919 }
2920
2921 #[test]
2922 fn force_restrict_guard_refcounts_duplicate_request_ids() {
2923 let root = TempDir::new().expect("root tempdir");
2924 let outside = TempDir::new().expect("outside tempdir");
2925 let outside_path = outside.path().join("outside.txt");
2926 let ctx = test_context(Some(root.path().to_path_buf()), false);
2927
2928 assert!(ctx.validate_path("dup", &outside_path).is_ok());
2929 let guard1 = ctx.force_restrict_guard("dup");
2930 let guard2 = ctx.force_restrict_guard("dup");
2931 assert!(ctx.validate_path("dup", &outside_path).is_err());
2932 drop(guard1);
2933 assert!(
2934 ctx.validate_path("dup", &outside_path).is_err(),
2935 "duplicate guard must keep the request over-restricted"
2936 );
2937 drop(guard2);
2938 assert!(ctx.validate_path("dup", &outside_path).is_ok());
2939 }
2940
2941 #[test]
2942 fn with_force_restrict_cleans_up_after_normal_completion_and_panic() {
2943 let root = TempDir::new().expect("root tempdir");
2944 let outside = TempDir::new().expect("outside tempdir");
2945 let outside_path = outside.path().join("outside.txt");
2946 let ctx = test_context(Some(root.path().to_path_buf()), false);
2947
2948 ctx.with_force_restrict("normal", || {
2949 assert!(ctx.validate_path("normal", &outside_path).is_err());
2950 });
2951 assert!(!ctx.request_force_restrict("normal"));
2952 assert!(ctx.validate_path("normal", &outside_path).is_ok());
2953
2954 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2955 ctx.with_force_restrict("panic", || {
2956 assert!(ctx.validate_path("panic", &outside_path).is_err());
2957 panic!("intentional force-restrict cleanup panic");
2958 });
2959 }));
2960 assert!(panicked.is_err());
2961 assert!(!ctx.request_force_restrict("panic"));
2962 assert!(ctx.validate_path("panic", &outside_path).is_ok());
2963 }
2964
2965 #[test]
2966 fn forced_restrict_without_project_root_fails_closed() {
2967 let ctx = test_context(None, false);
2968 let _guard = ctx.force_restrict_guard("missing-root");
2969 let err = ctx
2970 .validate_path("missing-root", Path::new("relative.txt"))
2971 .expect_err("forced restriction without a root must fail closed");
2972 assert_eq!(
2973 serde_json::to_value(err).unwrap()["code"],
2974 "path_outside_root"
2975 );
2976 }
2977}
2978
2979#[cfg(test)]
2980mod callgraph_store_for_ops_tests {
2981 use super::*;
2982 use crate::parser::TreeSitterProvider;
2983 use std::ffi::OsString;
2984 use std::sync::{Barrier, Mutex as StdMutex, MutexGuard, OnceLock};
2985 use tempfile::TempDir;
2986
2987 struct CallgraphWaitWindowEnvGuard {
2988 _guard: MutexGuard<'static, ()>,
2989 previous: Option<OsString>,
2990 }
2991
2992 impl Drop for CallgraphWaitWindowEnvGuard {
2993 fn drop(&mut self) {
2994 unsafe {
2997 match &self.previous {
2998 Some(value) => std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", value),
2999 None => std::env::remove_var("AFT_CALLGRAPH_BUILD_WAIT_MS"),
3000 }
3001 }
3002 }
3003 }
3004
3005 fn force_async_callgraph_builds() -> CallgraphWaitWindowEnvGuard {
3006 static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
3007 let guard = LOCK
3008 .get_or_init(|| StdMutex::new(()))
3009 .lock()
3010 .unwrap_or_else(|error| error.into_inner());
3011 let previous = std::env::var_os("AFT_CALLGRAPH_BUILD_WAIT_MS");
3012 unsafe {
3014 std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", "0");
3015 }
3016 CallgraphWaitWindowEnvGuard {
3017 _guard: guard,
3018 previous,
3019 }
3020 }
3021
3022 fn cold_build_context() -> Arc<AppContext> {
3023 let project = TempDir::new().expect("project tempdir");
3024 let storage = TempDir::new().expect("storage tempdir");
3025 let source_dir = project.path().join("src");
3026 std::fs::create_dir_all(&source_dir).expect("source dir");
3027 std::fs::write(
3028 source_dir.join("lib.rs"),
3029 "pub fn caller() { callee(); }\npub fn callee() {}\n",
3030 )
3031 .expect("source file");
3032
3033 Arc::new(AppContext::new(
3034 Box::new(TreeSitterProvider::new()),
3035 Config {
3036 project_root: Some(project.keep()),
3037 storage_dir: Some(storage.keep()),
3038 callgraph_chunk_size: 1,
3039 ..Config::default()
3040 },
3041 ))
3042 }
3043
3044 fn empty_semantic_index_for_ctx(ctx: &AppContext) -> SemanticIndex {
3045 let project_root = ctx
3046 .config()
3047 .project_root
3048 .clone()
3049 .expect("test context has a project root");
3050 let files: Vec<PathBuf> = Vec::new();
3051 let mut embed = |_texts: Vec<String>| -> Result<Vec<Vec<f32>>, String> { Ok(Vec::new()) };
3052 SemanticIndex::build(&project_root, &files, &mut embed, 1)
3053 .expect("empty semantic index should build")
3054 }
3055
3056 #[test]
3057 fn semantic_ready_event_resumes_deferred_callgraph_and_tier2() {
3058 let _env_guard = force_async_callgraph_builds();
3059 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
3060 let ctx = cold_build_context();
3061 let (tx, rx) = crossbeam_channel::unbounded();
3062 *ctx.semantic_index_rx().lock() = Some(rx);
3063 ctx.schedule_semantic_cold_seed_gate_for_configure();
3064
3065 assert!(matches!(
3066 ctx.callgraph_store_for_ops(),
3067 CallgraphStoreAccess::Building
3068 ));
3069 assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
3070 tx.send(SemanticIndexEvent::Ready(empty_semantic_index_for_ctx(
3071 &ctx,
3072 )))
3073 .expect("send ready event");
3074
3075 crate::runtime_drain::drain_semantic_index_events(&ctx);
3076
3077 assert!(
3078 !ctx.semantic_cold_seed_active(),
3079 "semantic Ready must clear the scheduled cold gate"
3080 );
3081 assert!(
3082 ctx.tier2_pull_demand_pending(),
3083 "semantic Ready must resume deferred Tier-2 work"
3084 );
3085 assert_eq!(
3086 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3087 1,
3088 "semantic Ready must resume the deferred callgraph warm"
3089 );
3090 let rx = ctx
3091 .callgraph_store_rx
3092 .lock()
3093 .as_ref()
3094 .cloned()
3095 .expect("ready resume should install an in-flight callgraph receiver");
3096 rx.recv_timeout(Duration::from_secs(30))
3097 .expect("background cold build should complete");
3098 *ctx.callgraph_store_rx.lock() = None;
3099 }
3100
3101 #[test]
3102 fn semantic_gate_cleared_event_resumes_deferred_callgraph_and_tier2() {
3103 let _env_guard = force_async_callgraph_builds();
3104 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
3105 let ctx = cold_build_context();
3106 ctx.schedule_semantic_cold_seed_gate_for_configure();
3107
3108 assert!(matches!(
3109 ctx.callgraph_store_for_ops(),
3110 CallgraphStoreAccess::Building
3111 ));
3112 assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
3113 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
3114
3115 assert!(
3116 !ctx.semantic_cold_seed_active(),
3117 "cached-load or retry-wait clear must reopen the semantic cold gate"
3118 );
3119 assert!(
3120 ctx.tier2_pull_demand_pending(),
3121 "cached-load or retry-wait clear must resume deferred Tier-2 work"
3122 );
3123 assert_eq!(
3124 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3125 1,
3126 "cached-load or retry-wait clear must resume deferred callgraph warm"
3127 );
3128 let rx = ctx
3129 .callgraph_store_rx
3130 .lock()
3131 .as_ref()
3132 .cloned()
3133 .expect("gate-clear resume should install an in-flight callgraph receiver");
3134 rx.recv_timeout(Duration::from_secs(30))
3135 .expect("background cold build should complete");
3136 *ctx.callgraph_store_rx.lock() = None;
3137 }
3138
3139 #[test]
3140 fn semantic_cold_seed_gate_defers_callgraph_cold_spawn_until_resume() {
3141 let _env_guard = force_async_callgraph_builds();
3142 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
3143 let ctx = cold_build_context();
3144
3145 ctx.set_semantic_cold_seed_active_for_test(true);
3146 assert!(
3147 matches!(
3148 ctx.callgraph_store_for_ops(),
3149 CallgraphStoreAccess::Building
3150 ),
3151 "callgraph ops should degrade as building while the semantic cold gate is active"
3152 );
3153 assert_eq!(
3154 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3155 0,
3156 "semantic cold gate must not spawn a competing callgraph cold build"
3157 );
3158 assert!(ctx.semantic_callgraph_warm_deferred_for_test());
3159
3160 ctx.clear_semantic_cold_seed_gate_and_resume_deferred_work();
3161 assert_eq!(
3162 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3163 1,
3164 "clearing the semantic cold gate should resume the deferred callgraph warm"
3165 );
3166
3167 let rx = ctx
3168 .callgraph_store_rx
3169 .lock()
3170 .as_ref()
3171 .cloned()
3172 .expect("deferred warm should install an in-flight receiver");
3173 rx.recv_timeout(Duration::from_secs(30))
3174 .expect("background cold build should complete");
3175 *ctx.callgraph_store_rx.lock() = None;
3176 }
3177
3178 #[test]
3179 fn semantic_cold_seed_gate_clear_requests_tier2_pull() {
3180 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3181 ctx.schedule_semantic_cold_seed_gate_for_configure();
3182
3183 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
3184
3185 assert!(
3186 !ctx.semantic_cold_seed_active(),
3187 "retry-wait or cached-load events must reopen the semantic cold gate"
3188 );
3189 assert!(
3190 ctx.tier2_pull_demand_pending(),
3191 "clearing the semantic cold gate should kick a Tier-2 pull refresh"
3192 );
3193 }
3194
3195 #[test]
3196 fn semantic_failed_event_clears_scheduled_gate_and_requests_tier2_pull() {
3197 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3198 let (tx, rx) = crossbeam_channel::unbounded();
3199 *ctx.semantic_index_rx().lock() = Some(rx);
3200 ctx.schedule_semantic_cold_seed_gate_for_configure();
3201 tx.send(SemanticIndexEvent::Failed(
3202 "embedding backend failed".to_string(),
3203 ))
3204 .expect("send failed event");
3205
3206 crate::runtime_drain::drain_semantic_index_events(&ctx);
3207
3208 assert!(
3209 !ctx.semantic_cold_seed_active(),
3210 "semantic Failed must clear the scheduled cold gate"
3211 );
3212 assert!(
3213 ctx.tier2_pull_demand_pending(),
3214 "semantic Failed must resume deferred Tier-2 work"
3215 );
3216 }
3217
3218 #[test]
3219 fn semantic_disconnect_clears_scheduled_gate_and_requests_tier2_pull() {
3220 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3221 let (tx, rx) = crossbeam_channel::unbounded::<SemanticIndexEvent>();
3222 *ctx.semantic_index_rx().lock() = Some(rx);
3223 ctx.schedule_semantic_cold_seed_gate_for_configure();
3224 drop(tx);
3225
3226 crate::runtime_drain::drain_semantic_index_events(&ctx);
3227
3228 assert!(
3229 !ctx.semantic_cold_seed_active(),
3230 "semantic worker disconnect must clear the scheduled cold gate"
3231 );
3232 assert!(
3233 ctx.tier2_pull_demand_pending(),
3234 "semantic worker disconnect must resume deferred Tier-2 work"
3235 );
3236 }
3237
3238 #[test]
3239 fn semantic_cold_seed_gate_is_per_context_for_tier2_scheduler() {
3240 let ctx_a = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3241 let ctx_b = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3242 let base = Instant::now();
3243 ctx_a.reset_tier2_refresh_scheduler_at(base);
3244 ctx_b.reset_tier2_refresh_scheduler_at(base);
3245 ctx_a.set_semantic_cold_seed_active_for_test(true);
3246
3247 assert_eq!(
3248 ctx_a.tick_tier2_refresh_scheduler_at(
3249 base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
3250 0,
3251 ),
3252 None,
3253 "root A should defer Tier-2 while its semantic cold seed is active"
3254 );
3255 assert_eq!(
3256 ctx_b.tick_tier2_refresh_scheduler_at(
3257 base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
3258 0,
3259 ),
3260 Some(Tier2TriggerReason::ConfigureWarm),
3261 "root B must not inherit root A's semantic cold gate"
3262 );
3263 }
3264
3265 #[test]
3266 fn concurrent_cold_callgraph_store_for_ops_spawns_one_build() {
3267 let _env_guard = force_async_callgraph_builds();
3268 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
3269
3270 let project = TempDir::new().expect("project tempdir");
3271 let storage = TempDir::new().expect("storage tempdir");
3272 let source_dir = project.path().join("src");
3273 std::fs::create_dir_all(&source_dir).expect("source dir");
3274 std::fs::write(
3275 source_dir.join("lib.rs"),
3276 "pub fn caller() { callee(); }\npub fn callee() {}\n",
3277 )
3278 .expect("source file");
3279
3280 let ctx = Arc::new(AppContext::new(
3281 Box::new(TreeSitterProvider::new()),
3282 Config {
3283 project_root: Some(project.path().to_path_buf()),
3284 storage_dir: Some(storage.path().to_path_buf()),
3285 callgraph_chunk_size: 1,
3286 ..Config::default()
3287 },
3288 ));
3289
3290 let barrier = Arc::new(Barrier::new(3));
3291 let handles = (0..2)
3292 .map(|_| {
3293 let ctx = Arc::clone(&ctx);
3294 let barrier = Arc::clone(&barrier);
3295 std::thread::spawn(move || {
3296 barrier.wait();
3297 matches!(
3298 ctx.callgraph_store_for_ops(),
3299 CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
3300 )
3301 })
3302 })
3303 .collect::<Vec<_>>();
3304
3305 barrier.wait();
3306 for handle in handles {
3307 assert!(
3308 handle.join().expect("callgraph caller thread"),
3309 "cold callgraph ops should report Building or observe the installed store"
3310 );
3311 }
3312
3313 assert_eq!(
3314 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3315 1,
3316 "concurrent cold callers must share one background build"
3317 );
3318
3319 let rx = ctx
3320 .callgraph_store_rx
3321 .lock()
3322 .as_ref()
3323 .cloned()
3324 .expect("in-flight receiver installed before spawn");
3325 rx.recv_timeout(Duration::from_secs(30))
3326 .expect("background cold build should complete");
3327 *ctx.callgraph_store_rx.lock() = None;
3328 }
3329}
3330
3331#[cfg(test)]
3332mod status_emitter_tests {
3333 use super::*;
3334 use crate::parser::TreeSitterProvider;
3335
3336 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
3337 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3338 let (tx, rx) = mpsc::channel();
3339 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
3340 let _ = tx.send(frame);
3341 }))));
3342 (ctx, rx)
3343 }
3344
3345 #[test]
3346 fn status_emitter_signal_triggers_push() {
3347 let (ctx, rx) = ctx_with_frame_rx();
3348 ctx.status_emitter().signal(ctx.build_status_snapshot());
3349 let frame = rx
3350 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3351 .expect("status_changed push");
3352 assert!(matches!(frame, PushFrame::StatusChanged(_)));
3353 }
3354
3355 #[test]
3356 fn status_emitter_debounces_burst() {
3357 let (ctx, rx) = ctx_with_frame_rx();
3358 for _ in 0..10 {
3359 ctx.status_emitter().signal(ctx.build_status_snapshot());
3360 }
3361 let frame = rx
3362 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3363 .expect("status_changed push");
3364 assert!(matches!(frame, PushFrame::StatusChanged(_)));
3365 assert!(rx.try_recv().is_err());
3366 }
3367
3368 #[test]
3369 fn status_emitter_separate_windows_separate_pushes() {
3370 let (ctx, rx) = ctx_with_frame_rx();
3371 ctx.status_emitter().signal(ctx.build_status_snapshot());
3372 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3373 .expect("first push");
3374 ctx.status_emitter().signal(ctx.build_status_snapshot());
3375 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3376 .expect("second push");
3377 }
3378
3379 #[test]
3380 fn status_emitter_no_signal_no_push() {
3381 let (_ctx, rx) = ctx_with_frame_rx();
3382 assert!(rx
3383 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
3384 .is_err());
3385 }
3386
3387 #[test]
3388 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
3389 let (ctx, rx) = ctx_with_frame_rx();
3390 drop(ctx);
3391 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
3392 }
3393
3394 #[test]
3395 fn progress_sender_slot_is_per_context_for_shared_app() {
3396 let app = App::default_shared();
3397 let ctx_a = AppContext::from_app(Arc::clone(&app), Config::default());
3398 let ctx_b = AppContext::from_app(app, Config::default());
3399 let (tx_a, rx_a) = mpsc::channel();
3400 let (tx_b, rx_b) = mpsc::channel();
3401
3402 ctx_a.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
3403 let _ = tx_a.send(frame);
3404 }))));
3405 ctx_b.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
3406 let _ = tx_b.send(frame);
3407 }))));
3408
3409 ctx_a.emit_progress(ProgressFrame {
3410 frame_type: "progress",
3411 request_id: "ctx-a".to_string(),
3412 kind: crate::protocol::ProgressKind::Stdout,
3413 chunk: "a".to_string(),
3414 });
3415 ctx_b.emit_progress(ProgressFrame {
3416 frame_type: "progress",
3417 request_id: "ctx-b".to_string(),
3418 kind: crate::protocol::ProgressKind::Stdout,
3419 chunk: "b".to_string(),
3420 });
3421
3422 match rx_a
3423 .recv_timeout(Duration::from_millis(50))
3424 .expect("ctx A progress frame")
3425 {
3426 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-a"),
3427 other => panic!("unexpected frame for ctx A: {other:?}"),
3428 }
3429 assert!(rx_a.try_recv().is_err());
3430
3431 match rx_b
3432 .recv_timeout(Duration::from_millis(50))
3433 .expect("ctx B progress frame")
3434 {
3435 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-b"),
3436 other => panic!("unexpected frame for ctx B: {other:?}"),
3437 }
3438 assert!(rx_b.try_recv().is_err());
3439 }
3440}
3441
3442#[cfg(test)]
3443mod status_bar_tests {
3444 use super::*;
3445 use crate::parser::TreeSitterProvider;
3446
3447 fn ctx() -> AppContext {
3448 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
3449 }
3450
3451 #[test]
3452 fn status_bar_counts_none_until_tier2_populated() {
3453 let ctx = ctx();
3454 assert!(ctx.status_bar_counts().is_none());
3456
3457 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
3458 let counts = ctx.status_bar_counts().expect("populated");
3459 assert_eq!(counts.dead_code, 5);
3460 assert_eq!(counts.unused_exports, 3);
3461 assert_eq!(counts.duplicates, 7);
3462 assert_eq!(counts.todos, 2);
3463 assert!(!counts.tier2_stale);
3464 assert_eq!(counts.errors, 0);
3466 assert_eq!(counts.warnings, 0);
3467 }
3468
3469 #[test]
3470 fn partial_tier2_does_not_fabricate_zeros() {
3471 let ctx = ctx();
3472 ctx.update_status_bar_tier2(Some(5), None, None, None, true);
3476 assert!(
3477 ctx.status_bar_counts().is_none(),
3478 "bar must not surface until all three Tier-2 categories are real"
3479 );
3480
3481 ctx.update_status_bar_tier2(None, Some(3), None, None, true);
3483 assert!(ctx.status_bar_counts().is_none());
3484
3485 ctx.update_status_bar_tier2(None, None, Some(7), None, false);
3488 let counts = ctx.status_bar_counts().expect("all three real now");
3489 assert_eq!(counts.dead_code, 5);
3490 assert_eq!(counts.unused_exports, 3);
3491 assert_eq!(counts.duplicates, 7);
3492 }
3493
3494 #[test]
3495 fn update_with_none_todos_preserves_last_known_todos() {
3496 let ctx = ctx();
3497 ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
3498 ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
3500 let counts = ctx.status_bar_counts().expect("populated");
3501 assert_eq!(counts.todos, 9);
3502 assert_eq!(counts.dead_code, 2);
3503 }
3504
3505 #[test]
3506 fn update_with_none_count_preserves_last_known_count() {
3507 let ctx = ctx();
3508 ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
3509 ctx.update_status_bar_tier2(Some(11), None, None, None, false);
3512 let counts = ctx.status_bar_counts().expect("populated");
3513 assert_eq!(counts.dead_code, 11);
3514 assert_eq!(counts.unused_exports, 20);
3515 assert_eq!(counts.duplicates, 30);
3516 }
3517
3518 #[test]
3519 fn mark_stale_sets_flag_only_after_populate() {
3520 let ctx = ctx();
3521 ctx.mark_status_bar_tier2_stale();
3523 assert!(ctx.status_bar_counts().is_none());
3524
3525 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), Some(0), false);
3526 ctx.mark_status_bar_tier2_stale();
3527 assert!(ctx.status_bar_counts().expect("populated").tier2_stale);
3528
3529 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), None, false);
3531 assert!(!ctx.status_bar_counts().expect("populated").tier2_stale);
3532 }
3533
3534 #[test]
3539 fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
3540 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
3541 use crate::lsp::registry::ServerKind;
3542 use crate::lsp::roots::ServerKey;
3543
3544 let ctx = ctx();
3545 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); let file = std::path::PathBuf::from("/proj/gone.ts");
3548 {
3549 let mut lsp = ctx.lsp();
3550 lsp.diagnostics_store_mut_for_test().publish(
3551 ServerKey {
3552 kind: ServerKind::TypeScript,
3553 root: std::path::PathBuf::from("/proj"),
3554 },
3555 file.clone(),
3556 vec![StoredDiagnostic {
3557 file: file.clone(),
3558 line: 1,
3559 column: 1,
3560 end_line: 1,
3561 end_column: 2,
3562 severity: DiagnosticSeverity::Error,
3563 message: "boom".into(),
3564 code: None,
3565 source: None,
3566 }],
3567 );
3568 }
3569
3570 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
3572
3573 let removed = ctx.lsp_clear_diagnostics_for_file(&file);
3575 assert!(removed);
3576 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
3577 }
3578
3579 #[test]
3580 fn status_bar_filtered_counts_ignore_environmental_flap() {
3581 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
3582 use crate::lsp::registry::ServerKind;
3583 use crate::lsp::roots::ServerKey;
3584
3585 let ctx = ctx();
3586 let root = if cfg!(windows) {
3587 std::path::PathBuf::from(r"C:\proj")
3588 } else {
3589 std::path::PathBuf::from("/proj")
3590 };
3591 ctx.set_canonical_cache_root(root.clone());
3592 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
3593
3594 let file = root.join("aft.jsonc");
3595 let key = ServerKey {
3596 kind: ServerKind::TypeScript,
3597 root: root.clone(),
3598 };
3599 let env = StoredDiagnostic {
3600 file: file.clone(),
3601 line: 1,
3602 column: 1,
3603 end_line: 1,
3604 end_column: 2,
3605 severity: DiagnosticSeverity::Error,
3606 message: "Failed to load schema from https://example.com/schema.json".into(),
3607 code: None,
3608 source: Some("json".into()),
3609 };
3610
3611 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
3612
3613 {
3614 let mut lsp = ctx.lsp();
3615 lsp.diagnostics_store_mut_for_test()
3616 .publish(key.clone(), file.clone(), vec![env]);
3617 }
3618 assert_eq!(
3619 ctx.status_bar_counts().expect("populated").errors,
3620 0,
3621 "environmental publish must not change status-bar E"
3622 );
3623
3624 {
3625 let mut lsp = ctx.lsp();
3626 lsp.diagnostics_store_mut_for_test()
3627 .publish(key, file, vec![]);
3628 }
3629 assert_eq!(
3630 ctx.status_bar_counts().expect("populated").errors,
3631 0,
3632 "environmental clear must not change status-bar E"
3633 );
3634 }
3635}
3636
3637#[cfg(test)]
3638mod harness_path_tests {
3639 use super::*;
3640 use crate::harness::Harness;
3641 use crate::parser::TreeSitterProvider;
3642
3643 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
3644 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3645 ctx.update_config(|config| {
3646 config.storage_dir = Some(storage_dir);
3647 });
3648 ctx.set_harness(harness);
3649 ctx
3650 }
3651
3652 #[test]
3653 fn harness_dir_resolves_correctly() {
3654 let storage = PathBuf::from("/tmp/cortexkit/aft");
3655 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
3656
3657 assert_eq!(ctx.harness_dir(), storage.join("pi"));
3658 }
3659
3660 #[test]
3661 fn bash_tasks_dir_uses_hash_session() {
3662 let storage = PathBuf::from("/tmp/cortexkit/aft");
3663 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
3664
3665 assert_eq!(
3666 ctx.bash_tasks_dir("ses_abc"),
3667 storage
3668 .join("opencode")
3669 .join("bash-tasks")
3670 .join(hash_session("ses_abc"))
3671 );
3672 }
3673
3674 #[test]
3675 fn backups_dir_includes_path_hash() {
3676 let storage = PathBuf::from("/tmp/cortexkit/aft");
3677 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
3678
3679 assert_eq!(
3680 ctx.backups_dir("ses_abc", "pathhash"),
3681 storage
3682 .join("pi")
3683 .join("backups")
3684 .join(hash_session("ses_abc"))
3685 .join("pathhash")
3686 );
3687 }
3688
3689 #[test]
3690 fn filters_dir_under_harness() {
3691 let storage = PathBuf::from("/tmp/cortexkit/aft");
3692 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
3693
3694 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
3695 }
3696
3697 #[test]
3698 fn trust_file_is_host_global() {
3699 let storage = PathBuf::from("/tmp/cortexkit/aft");
3700 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
3701
3702 assert_eq!(
3703 ctx.trust_file(),
3704 storage.join("trusted-filter-projects.json")
3705 );
3706 }
3707
3708 #[test]
3709 fn same_session_different_harness_resolve_different_paths() {
3710 let storage = PathBuf::from("/tmp/cortexkit/aft");
3711 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
3712 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
3713
3714 assert_ne!(
3715 opencode.bash_tasks_dir("ses_same"),
3716 pi.bash_tasks_dir("ses_same")
3717 );
3718 }
3719}
3720
3721#[cfg(test)]
3722mod gitignore_tests {
3723 use super::*;
3724 use std::fs;
3725 use std::path::Path;
3726 use tempfile::TempDir;
3727
3728 fn make_ctx_with_root(root: &Path) -> AppContext {
3729 let provider = Box::new(crate::parser::TreeSitterProvider::new());
3730 let config = Config {
3731 project_root: Some(root.to_path_buf()),
3732 ..Config::default()
3733 };
3734 AppContext::new(provider, config)
3735 }
3736
3737 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
3744 let Some(matcher) = ctx.gitignore() else {
3745 return false;
3746 };
3747 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
3748 if !canonical.starts_with(matcher.path()) {
3749 return false;
3750 }
3751 let is_dir = canonical.is_dir();
3752 matcher
3753 .matched_path_or_any_parents(&canonical, is_dir)
3754 .is_ignore()
3755 }
3756
3757 fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
3770 let _guard = crate::test_env::process_env_lock();
3771 let tmp = TempDir::new().unwrap();
3772 let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");
3773 let prev_home = std::env::var_os("HOME");
3774 let prev_userprofile = std::env::var_os("USERPROFILE");
3775 unsafe {
3778 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
3779 std::env::set_var("HOME", tmp.path());
3780 std::env::set_var("USERPROFILE", tmp.path());
3781 }
3782 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
3783 unsafe {
3784 match prev_xdg {
3785 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
3786 None => std::env::remove_var("XDG_CONFIG_HOME"),
3787 }
3788 match prev_home {
3789 Some(v) => std::env::set_var("HOME", v),
3790 None => std::env::remove_var("HOME"),
3791 }
3792 match prev_userprofile {
3793 Some(v) => std::env::set_var("USERPROFILE", v),
3794 None => std::env::remove_var("USERPROFILE"),
3795 }
3796 }
3797 match result {
3798 Ok(r) => r,
3799 Err(p) => std::panic::resume_unwind(p),
3800 }
3801 }
3802
3803 #[test]
3804 fn rebuild_gitignore_returns_none_without_project_root() {
3805 let provider = Box::new(crate::parser::TreeSitterProvider::new());
3806 let ctx = AppContext::new(provider, Config::default());
3807 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
3808 assert!(ctx.gitignore().is_none());
3809 }
3810
3811 #[test]
3812 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
3813 let tmp = TempDir::new().unwrap();
3814 let ctx = make_ctx_with_root(tmp.path());
3815 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
3816 assert!(ctx.gitignore().is_none());
3817 }
3818
3819 #[test]
3820 fn matcher_filters_files_in_ignored_dist_dir() {
3821 let tmp = TempDir::new().unwrap();
3822 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
3823 fs::create_dir_all(tmp.path().join("dist")).unwrap();
3824 fs::create_dir_all(tmp.path().join("src")).unwrap();
3825 let dist_file = tmp.path().join("dist").join("bundle.js");
3826 let src_file = tmp.path().join("src").join("app.ts");
3827 fs::write(&dist_file, "x").unwrap();
3828 fs::write(&src_file, "y").unwrap();
3829
3830 let ctx = make_ctx_with_root(tmp.path());
3831 ctx.rebuild_gitignore();
3832
3833 assert!(ctx.gitignore().is_some());
3834 assert!(
3835 is_ignored(&ctx, &dist_file),
3836 "dist/bundle.js should be ignored"
3837 );
3838 assert!(
3839 !is_ignored(&ctx, &src_file),
3840 "src/app.ts should NOT be ignored"
3841 );
3842 }
3843
3844 #[test]
3845 fn matcher_handles_node_modules_and_target() {
3846 let tmp = TempDir::new().unwrap();
3847 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
3848 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
3849 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
3850 let nm_file = tmp.path().join("node_modules/foo/index.js");
3851 let target_file = tmp.path().join("target/debug/aft");
3852 fs::write(&nm_file, "x").unwrap();
3853 fs::write(&target_file, "x").unwrap();
3854
3855 let ctx = make_ctx_with_root(tmp.path());
3856 ctx.rebuild_gitignore();
3857
3858 assert!(is_ignored(&ctx, &nm_file));
3859 assert!(is_ignored(&ctx, &target_file));
3860 }
3861
3862 #[test]
3863 fn matcher_honors_negation_pattern() {
3864 let tmp = TempDir::new().unwrap();
3866 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
3867 let random_log = tmp.path().join("random.log");
3868 let important_log = tmp.path().join("important.log");
3869 fs::write(&random_log, "x").unwrap();
3870 fs::write(&important_log, "y").unwrap();
3871
3872 let ctx = make_ctx_with_root(tmp.path());
3873 ctx.rebuild_gitignore();
3874
3875 assert!(is_ignored(&ctx, &random_log));
3876 assert!(
3877 !is_ignored(&ctx, &important_log),
3878 "negation pattern should un-ignore important.log"
3879 );
3880 }
3881
3882 #[test]
3883 fn rebuild_picks_up_gitignore_changes() {
3884 let tmp = TempDir::new().unwrap();
3885 let ignore_path = tmp.path().join(".gitignore");
3886 fs::write(&ignore_path, "foo.txt\n").unwrap();
3887 let foo = tmp.path().join("foo.txt");
3888 let bar = tmp.path().join("bar.txt");
3889 fs::write(&foo, "").unwrap();
3890 fs::write(&bar, "").unwrap();
3891
3892 let ctx = make_ctx_with_root(tmp.path());
3893 ctx.rebuild_gitignore();
3894 assert!(is_ignored(&ctx, &foo));
3895 assert!(!is_ignored(&ctx, &bar));
3896
3897 fs::write(&ignore_path, "bar.txt\n").unwrap();
3899 ctx.rebuild_gitignore();
3900 assert!(!is_ignored(&ctx, &foo));
3901 assert!(is_ignored(&ctx, &bar));
3902 }
3903
3904 #[test]
3905 fn gitignore_loads_info_exclude_when_present() {
3906 let tmp = TempDir::new().unwrap();
3907 let info_dir = tmp.path().join(".git/info");
3908 fs::create_dir_all(&info_dir).unwrap();
3909 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
3910 let secrets = tmp.path().join("secrets.txt");
3911 let public = tmp.path().join("public.txt");
3912 fs::write(&secrets, "token").unwrap();
3913 fs::write(&public, "ok").unwrap();
3914
3915 let ctx = make_ctx_with_root(tmp.path());
3916 ctx.rebuild_gitignore();
3917
3918 assert!(is_ignored(&ctx, &secrets));
3919 assert!(!is_ignored(&ctx, &public));
3920 }
3921
3922 #[test]
3923 fn matcher_picks_up_nested_gitignore() {
3924 let tmp = TempDir::new().unwrap();
3925 fs::write(tmp.path().join(".gitignore"), "").unwrap();
3927 let sub = tmp.path().join("packages/foo");
3928 fs::create_dir_all(&sub).unwrap();
3929 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
3930 let generated_file = sub.join("generated").join("out.js");
3931 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
3932 fs::write(&generated_file, "x").unwrap();
3933
3934 let ctx = make_ctx_with_root(tmp.path());
3935 ctx.rebuild_gitignore();
3936
3937 assert!(
3938 is_ignored(&ctx, &generated_file),
3939 "nested gitignore in packages/foo/.gitignore should ignore generated/"
3940 );
3941 }
3942}