1use std::collections::{BTreeMap, BTreeSet};
2use std::io::{self, BufWriter};
3use std::path::{Component, Path, PathBuf};
4use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
5use std::sync::{mpsc, Arc, Mutex, RwLock};
6use std::time::{Duration, Instant};
7
8use lsp_types::FileChangeType;
9use notify::RecommendedWatcher;
10use rusqlite::Connection;
11
12use crate::backup::hash_session;
13use crate::backup::BackupStore;
14use crate::bash_background::{BgCompletion, BgTaskRegistry};
15use crate::callgraph_store::{CallGraphStore, CallGraphStoreError};
16use crate::checkpoint::CheckpointStore;
17use crate::config::Config;
18use crate::harness::Harness;
19use crate::inspect::{
20 InspectCategory, InspectManager, InspectSnapshot, Tier2RefreshScheduler, Tier2TriggerReason,
21};
22use crate::language::LanguageProvider;
23use crate::lsp::manager::LspManager;
24use crate::lsp::registry::is_config_file_path_with_custom;
25use crate::parser::{SharedSymbolCache, SymbolCache, TreeSitterProvider};
26use crate::protocol::{
27 ConfigureWarningsFrame, ProgressFrame, PushFrame, StatusChangedFrame, StatusPayload,
28};
29use crate::watcher_filter::{SharedGitignore, WatcherDispatchEvent, WatcherThreadHandle};
30
31pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
32pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
33pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
34const STATUS_DEBOUNCE_MS: u64 = 1_000;
35
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
44pub struct StatusBarCounts {
45 pub errors: usize,
46 pub warnings: usize,
47 pub dead_code: usize,
48 pub unused_exports: usize,
49 pub duplicates: usize,
50 pub todos: usize,
51 pub tier2_stale: bool,
52}
53
54#[derive(Debug, Clone, Default)]
63struct StatusBarTier2 {
64 dead_code: Option<usize>,
65 unused_exports: Option<usize>,
66 duplicates: Option<usize>,
67 todos: Option<usize>,
68 stale: bool,
69}
70
71pub struct StatusEmitter {
72 latest: Arc<Mutex<Option<StatusPayload>>>,
73 notify: mpsc::Sender<()>,
74}
75
76impl StatusEmitter {
77 fn new(progress_sender: SharedProgressSender) -> Self {
78 let (notify, rx) = mpsc::channel();
79 let latest = Arc::new(Mutex::new(None));
80 let latest_for_thread = Arc::clone(&latest);
81 std::thread::spawn(move || {
82 status_debounce_loop(rx, latest_for_thread, progress_sender);
83 });
84 Self { latest, notify }
85 }
86
87 pub fn signal(&self, snapshot: StatusPayload) {
88 if let Ok(mut latest) = self.latest.lock() {
89 *latest = Some(snapshot);
90 }
91 let _ = self.notify.send(());
92 }
93}
94
95fn status_debounce_loop(
96 rx: mpsc::Receiver<()>,
97 latest: Arc<Mutex<Option<StatusPayload>>>,
98 progress_sender: SharedProgressSender,
99) {
100 while rx.recv().is_ok() {
101 let deadline = Instant::now() + Duration::from_millis(STATUS_DEBOUNCE_MS);
102 while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
103 match rx.recv_timeout(remaining) {
104 Ok(()) => continue,
105 Err(mpsc::RecvTimeoutError::Timeout) => break,
106 Err(mpsc::RecvTimeoutError::Disconnected) => return,
107 }
108 }
109
110 let snapshot = latest.lock().ok().and_then(|mut latest| latest.take());
111 let Some(snapshot) = snapshot else { continue };
112 let sender = progress_sender
113 .lock()
114 .ok()
115 .and_then(|sender| sender.clone());
116 if let Some(sender) = sender {
117 sender(PushFrame::StatusChanged(StatusChangedFrame::new(
118 None, snapshot,
119 )));
120 }
121 }
122}
123use crate::cache_freshness::FileFreshness;
124use crate::search_index::SearchIndex;
125use crate::semantic_index::{EmbeddingEntry, SemanticIndex};
126
127#[derive(Debug, Default, Clone)]
131#[doc(hidden)]
132pub struct SemanticRefreshAccounting {
133 #[doc(hidden)]
134 pub pending: usize,
135 #[doc(hidden)]
136 pub in_flight: usize,
137}
138
139#[derive(Debug, Default)]
140struct SemanticRefreshCircuit {
141 consecutive_transient_failures: AtomicUsize,
142 open: AtomicBool,
143 probe_in_flight: AtomicBool,
144 probe_ready: AtomicBool,
145}
146
147fn ensure_refreshing_path(refreshing: &mut Vec<PathBuf>, path: PathBuf) {
148 if !refreshing.iter().any(|existing| existing == &path) {
149 refreshing.push(path);
150 refreshing.sort();
151 }
152}
153
154fn remove_refreshing_path(refreshing: &mut Vec<PathBuf>, path: &Path) {
155 refreshing.retain(|existing| existing != path);
156}
157
158#[derive(Debug, Clone)]
159pub enum SemanticIndexStatus {
160 Disabled,
161 Building {
162 stage: String,
164 files: Option<usize>,
165 entries_done: Option<usize>,
166 entries_total: Option<usize>,
167 },
168 Ready {
169 refreshing: Vec<PathBuf>,
172 #[doc(hidden)]
176 accounting: BTreeMap<PathBuf, SemanticRefreshAccounting>,
177 },
178 Failed(String),
179}
180
181impl SemanticIndexStatus {
182 pub fn ready() -> Self {
183 Self::Ready {
184 refreshing: Vec::new(),
185 accounting: BTreeMap::new(),
186 }
187 }
188
189 pub fn add_refreshing_file(&mut self, path: PathBuf) {
190 if let Self::Ready {
191 refreshing,
192 accounting,
193 } = self
194 {
195 let state = accounting.entry(path.clone()).or_default();
196 state.pending = state.pending.saturating_add(1);
197 ensure_refreshing_path(refreshing, path);
198 }
199 }
200
201 pub fn start_refreshing_file(&mut self, path: PathBuf) {
202 if let Self::Ready {
203 refreshing,
204 accounting,
205 } = self
206 {
207 let state = accounting.entry(path.clone()).or_default();
208 if state.pending == 0 {
209 state.pending = 1;
210 }
211 if state.in_flight == 0 {
212 state.in_flight = state.pending;
213 }
214 ensure_refreshing_path(refreshing, path);
215 }
216 }
217
218 pub fn cancel_refreshing_file(&mut self, path: &Path) {
219 self.finish_refreshing_file(path, false);
220 }
221
222 pub fn complete_refreshing_file(&mut self, path: &Path) {
223 self.finish_refreshing_file(path, true);
224 }
225
226 pub fn remove_refreshing_file(&mut self, path: &Path) {
227 self.complete_refreshing_file(path);
228 }
229
230 fn finish_refreshing_file(&mut self, path: &Path, complete_in_flight: bool) {
231 if let Self::Ready {
232 refreshing,
233 accounting,
234 } = self
235 {
236 let mut keep_refreshing = false;
237 if let Some(state) = accounting.get_mut(path) {
238 let finished = if complete_in_flight {
239 state.in_flight.max(1)
240 } else {
241 1
242 };
243 state.pending = state.pending.saturating_sub(finished);
244 if complete_in_flight {
245 state.in_flight = 0;
246 } else {
247 state.in_flight = state.in_flight.min(state.pending);
248 }
249 keep_refreshing = state.pending > 0;
250 if !keep_refreshing {
251 accounting.remove(path);
252 }
253 }
254
255 if !keep_refreshing {
256 remove_refreshing_path(refreshing, path);
257 }
258 }
259 }
260
261 pub fn refreshing_count(&self) -> usize {
262 match self {
263 Self::Ready { refreshing, .. } => refreshing.len(),
264 _ => 0,
265 }
266 }
267}
268
269pub enum SemanticIndexEvent {
270 Progress {
271 stage: String,
272 files: Option<usize>,
273 entries_done: Option<usize>,
274 entries_total: Option<usize>,
275 },
276 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_notify_and_collect_diagnostics(
2461 &self,
2462 file_path: &Path,
2463 content: &str,
2464 timeout: std::time::Duration,
2465 ) -> crate::lsp::manager::PostEditWaitOutcome {
2466 let config = self.config();
2467 let Some(mut lsp) = self.lsp_manager.try_lock() else {
2468 return crate::lsp::manager::PostEditWaitOutcome::default();
2469 };
2470
2471 lsp.drain_events();
2474
2475 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
2479
2480 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
2482 {
2483 Ok(v) => v,
2484 Err(e) => {
2485 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
2486 return crate::lsp::manager::PostEditWaitOutcome::default();
2487 }
2488 };
2489
2490 if expected_versions.is_empty() {
2493 return crate::lsp::manager::PostEditWaitOutcome::default();
2494 }
2495
2496 lsp.wait_for_post_edit_diagnostics(
2497 file_path,
2498 &config,
2499 &expected_versions,
2500 &pre_snapshot,
2501 timeout,
2502 )
2503 }
2504
2505 fn custom_lsp_root_markers(&self) -> Vec<String> {
2508 self.config()
2509 .lsp_servers
2510 .iter()
2511 .flat_map(|s| s.root_markers.iter().cloned())
2512 .collect()
2513 }
2514
2515 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
2516 let custom_markers = self.custom_lsp_root_markers();
2517 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
2518 .iter()
2519 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
2520 .cloned()
2521 .map(|path| {
2522 let change_type = if path.exists() {
2523 FileChangeType::CHANGED
2524 } else {
2525 FileChangeType::DELETED
2526 };
2527 (path, change_type)
2528 })
2529 .collect();
2530
2531 self.notify_watched_config_events(&config_paths);
2532 }
2533
2534 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
2535 let paths = params
2536 .get("multi_file_write_paths")
2537 .and_then(|value| value.as_array())?
2538 .iter()
2539 .filter_map(|value| value.as_str())
2540 .map(PathBuf::from)
2541 .collect::<Vec<_>>();
2542
2543 (!paths.is_empty()).then_some(paths)
2544 }
2545
2546 fn watched_file_events_from_params(
2558 params: &serde_json::Value,
2559 extra_markers: &[String],
2560 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
2561 let events = params
2562 .get("multi_file_write_paths")
2563 .and_then(|value| value.as_array())?
2564 .iter()
2565 .filter_map(|entry| {
2566 let path = entry
2568 .get("path")
2569 .and_then(|value| value.as_str())
2570 .map(PathBuf::from)?;
2571
2572 if !is_config_file_path_with_custom(&path, extra_markers) {
2573 return None;
2574 }
2575
2576 let change_type = entry
2577 .get("type")
2578 .and_then(|value| value.as_str())
2579 .and_then(Self::parse_file_change_type)
2580 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
2581
2582 Some((path, change_type))
2583 })
2584 .collect::<Vec<_>>();
2585
2586 (!events.is_empty()).then_some(events)
2587 }
2588
2589 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
2590 match value {
2591 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
2592 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
2593 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
2594 _ => None,
2595 }
2596 }
2597
2598 fn change_type_from_current_state(path: &Path) -> FileChangeType {
2599 if path.exists() {
2600 FileChangeType::CHANGED
2601 } else {
2602 FileChangeType::DELETED
2603 }
2604 }
2605
2606 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
2607 if config_paths.is_empty() {
2608 return;
2609 }
2610
2611 let config = self.config();
2612 if let Some(mut lsp) = self.lsp_manager.try_lock() {
2613 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
2614 crate::slog_warn!("watched-file sync error: {}", e);
2615 }
2616 }
2617 }
2618
2619 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
2620 let custom_markers = self.custom_lsp_root_markers();
2621 if !is_config_file_path_with_custom(file_path, &custom_markers) {
2622 return;
2623 }
2624
2625 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
2626 }
2627
2628 pub fn lsp_post_multi_file_write(
2633 &self,
2634 file_path: &Path,
2635 content: &str,
2636 file_paths: &[PathBuf],
2637 params: &serde_json::Value,
2638 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2639 self.notify_watched_config_files(file_paths);
2640 self.add_pending_tier2_paths(file_paths.iter().cloned());
2641 let _ = self.mark_status_bar_tier2_stale();
2642
2643 let wants_diagnostics = params
2644 .get("diagnostics")
2645 .and_then(|v| v.as_bool())
2646 .unwrap_or(false);
2647
2648 if !wants_diagnostics {
2649 self.lsp_notify_file_changed(file_path, content);
2650 return None;
2651 }
2652
2653 let wait_ms = params
2654 .get("wait_ms")
2655 .and_then(|v| v.as_u64())
2656 .unwrap_or(3000)
2657 .min(10_000);
2658
2659 Some(self.lsp_notify_and_collect_diagnostics(
2660 file_path,
2661 content,
2662 std::time::Duration::from_millis(wait_ms),
2663 ))
2664 }
2665
2666 pub fn lsp_post_write(
2683 &self,
2684 file_path: &Path,
2685 content: &str,
2686 params: &serde_json::Value,
2687 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2688 let wants_diagnostics = params
2689 .get("diagnostics")
2690 .and_then(|v| v.as_bool())
2691 .unwrap_or(false);
2692
2693 let custom_markers = self.custom_lsp_root_markers();
2694 if let Some(file_paths) = Self::multi_file_write_paths(params) {
2695 self.add_pending_tier2_paths(file_paths);
2696 } else {
2697 self.add_pending_tier2_paths([file_path.to_path_buf()]);
2698 }
2699 let _ = self.mark_status_bar_tier2_stale();
2700
2701 if !wants_diagnostics {
2702 if let Some(file_paths) = Self::multi_file_write_paths(params) {
2703 self.notify_watched_config_files(&file_paths);
2704 } else if let Some(config_events) =
2705 Self::watched_file_events_from_params(params, &custom_markers)
2706 {
2707 self.notify_watched_config_events(&config_events);
2708 }
2709 self.lsp_notify_file_changed(file_path, content);
2710 return None;
2711 }
2712
2713 let wait_ms = params
2714 .get("wait_ms")
2715 .and_then(|v| v.as_u64())
2716 .unwrap_or(3000)
2717 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
2720 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
2721 }
2722
2723 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
2724 {
2725 self.notify_watched_config_events(&config_events);
2726 }
2727
2728 Some(self.lsp_notify_and_collect_diagnostics(
2729 file_path,
2730 content,
2731 std::time::Duration::from_millis(wait_ms),
2732 ))
2733 }
2734
2735 pub fn validate_path(
2744 &self,
2745 req_id: &str,
2746 path: &Path,
2747 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
2748 let config = self.config();
2749 let force_restrict = self.request_force_restrict(req_id);
2750 let enforce = config.restrict_to_project_root || force_restrict;
2751 if !enforce {
2754 return Ok(path.to_path_buf());
2755 }
2756 let root = match &config.project_root {
2757 Some(r) => r.clone(),
2758 None if force_restrict => {
2759 return Err(crate::protocol::Response::error(
2760 req_id,
2761 "path_outside_root",
2762 "project root is required when path restriction is forced",
2763 ));
2764 }
2765 None => return Ok(path.to_path_buf()), };
2767 drop(config);
2768
2769 let raw_root = root.clone();
2774 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
2775
2776 let path_for_resolution = if path.is_relative() {
2781 raw_root.join(path)
2782 } else {
2783 path.to_path_buf()
2784 };
2785 let resolved = match std::fs::canonicalize(&path_for_resolution) {
2786 Ok(resolved) => resolved,
2787 Err(_) => {
2788 let normalized = normalize_path(&path_for_resolution);
2789 reject_escaping_symlink(
2790 req_id,
2791 &path_for_resolution,
2792 &normalized,
2793 &resolved_root,
2794 &raw_root,
2795 )?;
2796 resolve_with_existing_ancestors(&normalized)
2797 }
2798 };
2799
2800 if !resolved.starts_with(&resolved_root) {
2801 return Err(path_error_response(req_id, path, &resolved_root));
2802 }
2803
2804 Ok(resolved)
2805 }
2806
2807 pub fn lsp_server_count(&self) -> usize {
2809 self.lsp_manager
2810 .try_lock()
2811 .map(|lsp| lsp.server_count())
2812 .unwrap_or(0)
2813 }
2814
2815 pub fn symbol_cache_stats(&self) -> serde_json::Value {
2817 let entries = self
2818 .symbol_cache
2819 .read()
2820 .map(|cache| cache.len())
2821 .unwrap_or(0);
2822 serde_json::json!({
2823 "local_entries": entries,
2824 "warm_entries": 0,
2825 })
2826 }
2827}
2828
2829#[cfg(test)]
2830mod force_restrict_tests {
2831 use super::*;
2832 use crate::language::StubProvider;
2833 use tempfile::TempDir;
2834
2835 fn test_context(project_root: Option<PathBuf>, restrict_to_project_root: bool) -> AppContext {
2836 AppContext::new(
2837 Box::new(StubProvider),
2838 Config {
2839 project_root,
2840 restrict_to_project_root,
2841 ..Config::default()
2842 },
2843 )
2844 }
2845
2846 #[test]
2847 fn standalone_validate_path_parity_without_force_restrict() {
2848 let root = TempDir::new().expect("root tempdir");
2849 let outside = TempDir::new().expect("outside tempdir");
2850 let outside_path = outside.path().join("outside.txt");
2851
2852 let unrestricted = test_context(Some(root.path().to_path_buf()), false);
2853 assert_eq!(
2854 unrestricted
2855 .validate_path("standalone-unrestricted", &outside_path)
2856 .expect("unrestricted standalone validates"),
2857 outside_path
2858 );
2859
2860 let restricted = test_context(Some(root.path().to_path_buf()), true);
2861 let err = restricted
2862 .validate_path("standalone-restricted", &outside_path)
2863 .expect_err("restricted standalone rejects outside root");
2864 assert_eq!(
2865 serde_json::to_value(err).unwrap()["code"],
2866 "path_outside_root"
2867 );
2868 }
2869
2870 #[test]
2871 fn force_restrict_guard_refcounts_duplicate_request_ids() {
2872 let root = TempDir::new().expect("root tempdir");
2873 let outside = TempDir::new().expect("outside tempdir");
2874 let outside_path = outside.path().join("outside.txt");
2875 let ctx = test_context(Some(root.path().to_path_buf()), false);
2876
2877 assert!(ctx.validate_path("dup", &outside_path).is_ok());
2878 let guard1 = ctx.force_restrict_guard("dup");
2879 let guard2 = ctx.force_restrict_guard("dup");
2880 assert!(ctx.validate_path("dup", &outside_path).is_err());
2881 drop(guard1);
2882 assert!(
2883 ctx.validate_path("dup", &outside_path).is_err(),
2884 "duplicate guard must keep the request over-restricted"
2885 );
2886 drop(guard2);
2887 assert!(ctx.validate_path("dup", &outside_path).is_ok());
2888 }
2889
2890 #[test]
2891 fn with_force_restrict_cleans_up_after_normal_completion_and_panic() {
2892 let root = TempDir::new().expect("root tempdir");
2893 let outside = TempDir::new().expect("outside tempdir");
2894 let outside_path = outside.path().join("outside.txt");
2895 let ctx = test_context(Some(root.path().to_path_buf()), false);
2896
2897 ctx.with_force_restrict("normal", || {
2898 assert!(ctx.validate_path("normal", &outside_path).is_err());
2899 });
2900 assert!(!ctx.request_force_restrict("normal"));
2901 assert!(ctx.validate_path("normal", &outside_path).is_ok());
2902
2903 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2904 ctx.with_force_restrict("panic", || {
2905 assert!(ctx.validate_path("panic", &outside_path).is_err());
2906 panic!("intentional force-restrict cleanup panic");
2907 });
2908 }));
2909 assert!(panicked.is_err());
2910 assert!(!ctx.request_force_restrict("panic"));
2911 assert!(ctx.validate_path("panic", &outside_path).is_ok());
2912 }
2913
2914 #[test]
2915 fn forced_restrict_without_project_root_fails_closed() {
2916 let ctx = test_context(None, false);
2917 let _guard = ctx.force_restrict_guard("missing-root");
2918 let err = ctx
2919 .validate_path("missing-root", Path::new("relative.txt"))
2920 .expect_err("forced restriction without a root must fail closed");
2921 assert_eq!(
2922 serde_json::to_value(err).unwrap()["code"],
2923 "path_outside_root"
2924 );
2925 }
2926}
2927
2928#[cfg(test)]
2929mod callgraph_store_for_ops_tests {
2930 use super::*;
2931 use crate::parser::TreeSitterProvider;
2932 use std::ffi::OsString;
2933 use std::sync::{Barrier, Mutex as StdMutex, MutexGuard, OnceLock};
2934 use tempfile::TempDir;
2935
2936 struct CallgraphWaitWindowEnvGuard {
2937 _guard: MutexGuard<'static, ()>,
2938 previous: Option<OsString>,
2939 }
2940
2941 impl Drop for CallgraphWaitWindowEnvGuard {
2942 fn drop(&mut self) {
2943 unsafe {
2946 match &self.previous {
2947 Some(value) => std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", value),
2948 None => std::env::remove_var("AFT_CALLGRAPH_BUILD_WAIT_MS"),
2949 }
2950 }
2951 }
2952 }
2953
2954 fn force_async_callgraph_builds() -> CallgraphWaitWindowEnvGuard {
2955 static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
2956 let guard = LOCK
2957 .get_or_init(|| StdMutex::new(()))
2958 .lock()
2959 .unwrap_or_else(|error| error.into_inner());
2960 let previous = std::env::var_os("AFT_CALLGRAPH_BUILD_WAIT_MS");
2961 unsafe {
2963 std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", "0");
2964 }
2965 CallgraphWaitWindowEnvGuard {
2966 _guard: guard,
2967 previous,
2968 }
2969 }
2970
2971 fn cold_build_context() -> Arc<AppContext> {
2972 let project = TempDir::new().expect("project tempdir");
2973 let storage = TempDir::new().expect("storage tempdir");
2974 let source_dir = project.path().join("src");
2975 std::fs::create_dir_all(&source_dir).expect("source dir");
2976 std::fs::write(
2977 source_dir.join("lib.rs"),
2978 "pub fn caller() { callee(); }\npub fn callee() {}\n",
2979 )
2980 .expect("source file");
2981
2982 Arc::new(AppContext::new(
2983 Box::new(TreeSitterProvider::new()),
2984 Config {
2985 project_root: Some(project.keep()),
2986 storage_dir: Some(storage.keep()),
2987 callgraph_chunk_size: 1,
2988 ..Config::default()
2989 },
2990 ))
2991 }
2992
2993 fn empty_semantic_index_for_ctx(ctx: &AppContext) -> SemanticIndex {
2994 let project_root = ctx
2995 .config()
2996 .project_root
2997 .clone()
2998 .expect("test context has a project root");
2999 let files: Vec<PathBuf> = Vec::new();
3000 let mut embed = |_texts: Vec<String>| -> Result<Vec<Vec<f32>>, String> { Ok(Vec::new()) };
3001 SemanticIndex::build(&project_root, &files, &mut embed, 1)
3002 .expect("empty semantic index should build")
3003 }
3004
3005 #[test]
3006 fn semantic_ready_event_resumes_deferred_callgraph_and_tier2() {
3007 let _env_guard = force_async_callgraph_builds();
3008 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
3009 let ctx = cold_build_context();
3010 let (tx, rx) = crossbeam_channel::unbounded();
3011 *ctx.semantic_index_rx().lock() = Some(rx);
3012 ctx.schedule_semantic_cold_seed_gate_for_configure();
3013
3014 assert!(matches!(
3015 ctx.callgraph_store_for_ops(),
3016 CallgraphStoreAccess::Building
3017 ));
3018 assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
3019 tx.send(SemanticIndexEvent::Ready(empty_semantic_index_for_ctx(
3020 &ctx,
3021 )))
3022 .expect("send ready event");
3023
3024 crate::runtime_drain::drain_semantic_index_events(&ctx);
3025
3026 assert!(
3027 !ctx.semantic_cold_seed_active(),
3028 "semantic Ready must clear the scheduled cold gate"
3029 );
3030 assert!(
3031 ctx.tier2_pull_demand_pending(),
3032 "semantic Ready must resume deferred Tier-2 work"
3033 );
3034 assert_eq!(
3035 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3036 1,
3037 "semantic Ready must resume the deferred callgraph warm"
3038 );
3039 let rx = ctx
3040 .callgraph_store_rx
3041 .lock()
3042 .as_ref()
3043 .cloned()
3044 .expect("ready resume should install an in-flight callgraph receiver");
3045 rx.recv_timeout(Duration::from_secs(30))
3046 .expect("background cold build should complete");
3047 *ctx.callgraph_store_rx.lock() = None;
3048 }
3049
3050 #[test]
3051 fn semantic_gate_cleared_event_resumes_deferred_callgraph_and_tier2() {
3052 let _env_guard = force_async_callgraph_builds();
3053 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
3054 let ctx = cold_build_context();
3055 ctx.schedule_semantic_cold_seed_gate_for_configure();
3056
3057 assert!(matches!(
3058 ctx.callgraph_store_for_ops(),
3059 CallgraphStoreAccess::Building
3060 ));
3061 assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
3062 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
3063
3064 assert!(
3065 !ctx.semantic_cold_seed_active(),
3066 "cached-load or retry-wait clear must reopen the semantic cold gate"
3067 );
3068 assert!(
3069 ctx.tier2_pull_demand_pending(),
3070 "cached-load or retry-wait clear must resume deferred Tier-2 work"
3071 );
3072 assert_eq!(
3073 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3074 1,
3075 "cached-load or retry-wait clear must resume deferred callgraph warm"
3076 );
3077 let rx = ctx
3078 .callgraph_store_rx
3079 .lock()
3080 .as_ref()
3081 .cloned()
3082 .expect("gate-clear resume should install an in-flight callgraph receiver");
3083 rx.recv_timeout(Duration::from_secs(30))
3084 .expect("background cold build should complete");
3085 *ctx.callgraph_store_rx.lock() = None;
3086 }
3087
3088 #[test]
3089 fn semantic_cold_seed_gate_defers_callgraph_cold_spawn_until_resume() {
3090 let _env_guard = force_async_callgraph_builds();
3091 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
3092 let ctx = cold_build_context();
3093
3094 ctx.set_semantic_cold_seed_active_for_test(true);
3095 assert!(
3096 matches!(
3097 ctx.callgraph_store_for_ops(),
3098 CallgraphStoreAccess::Building
3099 ),
3100 "callgraph ops should degrade as building while the semantic cold gate is active"
3101 );
3102 assert_eq!(
3103 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3104 0,
3105 "semantic cold gate must not spawn a competing callgraph cold build"
3106 );
3107 assert!(ctx.semantic_callgraph_warm_deferred_for_test());
3108
3109 ctx.clear_semantic_cold_seed_gate_and_resume_deferred_work();
3110 assert_eq!(
3111 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3112 1,
3113 "clearing the semantic cold gate should resume the deferred callgraph warm"
3114 );
3115
3116 let rx = ctx
3117 .callgraph_store_rx
3118 .lock()
3119 .as_ref()
3120 .cloned()
3121 .expect("deferred warm should install an in-flight receiver");
3122 rx.recv_timeout(Duration::from_secs(30))
3123 .expect("background cold build should complete");
3124 *ctx.callgraph_store_rx.lock() = None;
3125 }
3126
3127 #[test]
3128 fn semantic_cold_seed_gate_clear_requests_tier2_pull() {
3129 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3130 ctx.schedule_semantic_cold_seed_gate_for_configure();
3131
3132 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
3133
3134 assert!(
3135 !ctx.semantic_cold_seed_active(),
3136 "retry-wait or cached-load events must reopen the semantic cold gate"
3137 );
3138 assert!(
3139 ctx.tier2_pull_demand_pending(),
3140 "clearing the semantic cold gate should kick a Tier-2 pull refresh"
3141 );
3142 }
3143
3144 #[test]
3145 fn semantic_failed_event_clears_scheduled_gate_and_requests_tier2_pull() {
3146 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3147 let (tx, rx) = crossbeam_channel::unbounded();
3148 *ctx.semantic_index_rx().lock() = Some(rx);
3149 ctx.schedule_semantic_cold_seed_gate_for_configure();
3150 tx.send(SemanticIndexEvent::Failed(
3151 "embedding backend failed".to_string(),
3152 ))
3153 .expect("send failed event");
3154
3155 crate::runtime_drain::drain_semantic_index_events(&ctx);
3156
3157 assert!(
3158 !ctx.semantic_cold_seed_active(),
3159 "semantic Failed must clear the scheduled cold gate"
3160 );
3161 assert!(
3162 ctx.tier2_pull_demand_pending(),
3163 "semantic Failed must resume deferred Tier-2 work"
3164 );
3165 }
3166
3167 #[test]
3168 fn semantic_disconnect_clears_scheduled_gate_and_requests_tier2_pull() {
3169 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3170 let (tx, rx) = crossbeam_channel::unbounded::<SemanticIndexEvent>();
3171 *ctx.semantic_index_rx().lock() = Some(rx);
3172 ctx.schedule_semantic_cold_seed_gate_for_configure();
3173 drop(tx);
3174
3175 crate::runtime_drain::drain_semantic_index_events(&ctx);
3176
3177 assert!(
3178 !ctx.semantic_cold_seed_active(),
3179 "semantic worker disconnect must clear the scheduled cold gate"
3180 );
3181 assert!(
3182 ctx.tier2_pull_demand_pending(),
3183 "semantic worker disconnect must resume deferred Tier-2 work"
3184 );
3185 }
3186
3187 #[test]
3188 fn semantic_cold_seed_gate_is_per_context_for_tier2_scheduler() {
3189 let ctx_a = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3190 let ctx_b = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3191 let base = Instant::now();
3192 ctx_a.reset_tier2_refresh_scheduler_at(base);
3193 ctx_b.reset_tier2_refresh_scheduler_at(base);
3194 ctx_a.set_semantic_cold_seed_active_for_test(true);
3195
3196 assert_eq!(
3197 ctx_a.tick_tier2_refresh_scheduler_at(
3198 base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
3199 0,
3200 ),
3201 None,
3202 "root A should defer Tier-2 while its semantic cold seed is active"
3203 );
3204 assert_eq!(
3205 ctx_b.tick_tier2_refresh_scheduler_at(
3206 base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
3207 0,
3208 ),
3209 Some(Tier2TriggerReason::ConfigureWarm),
3210 "root B must not inherit root A's semantic cold gate"
3211 );
3212 }
3213
3214 #[test]
3215 fn concurrent_cold_callgraph_store_for_ops_spawns_one_build() {
3216 let _env_guard = force_async_callgraph_builds();
3217 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
3218
3219 let project = TempDir::new().expect("project tempdir");
3220 let storage = TempDir::new().expect("storage tempdir");
3221 let source_dir = project.path().join("src");
3222 std::fs::create_dir_all(&source_dir).expect("source dir");
3223 std::fs::write(
3224 source_dir.join("lib.rs"),
3225 "pub fn caller() { callee(); }\npub fn callee() {}\n",
3226 )
3227 .expect("source file");
3228
3229 let ctx = Arc::new(AppContext::new(
3230 Box::new(TreeSitterProvider::new()),
3231 Config {
3232 project_root: Some(project.path().to_path_buf()),
3233 storage_dir: Some(storage.path().to_path_buf()),
3234 callgraph_chunk_size: 1,
3235 ..Config::default()
3236 },
3237 ));
3238
3239 let barrier = Arc::new(Barrier::new(3));
3240 let handles = (0..2)
3241 .map(|_| {
3242 let ctx = Arc::clone(&ctx);
3243 let barrier = Arc::clone(&barrier);
3244 std::thread::spawn(move || {
3245 barrier.wait();
3246 matches!(
3247 ctx.callgraph_store_for_ops(),
3248 CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
3249 )
3250 })
3251 })
3252 .collect::<Vec<_>>();
3253
3254 barrier.wait();
3255 for handle in handles {
3256 assert!(
3257 handle.join().expect("callgraph caller thread"),
3258 "cold callgraph ops should report Building or observe the installed store"
3259 );
3260 }
3261
3262 assert_eq!(
3263 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3264 1,
3265 "concurrent cold callers must share one background build"
3266 );
3267
3268 let rx = ctx
3269 .callgraph_store_rx
3270 .lock()
3271 .as_ref()
3272 .cloned()
3273 .expect("in-flight receiver installed before spawn");
3274 rx.recv_timeout(Duration::from_secs(30))
3275 .expect("background cold build should complete");
3276 *ctx.callgraph_store_rx.lock() = None;
3277 }
3278}
3279
3280#[cfg(test)]
3281mod status_emitter_tests {
3282 use super::*;
3283 use crate::parser::TreeSitterProvider;
3284
3285 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
3286 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3287 let (tx, rx) = mpsc::channel();
3288 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
3289 let _ = tx.send(frame);
3290 }))));
3291 (ctx, rx)
3292 }
3293
3294 #[test]
3295 fn status_emitter_signal_triggers_push() {
3296 let (ctx, rx) = ctx_with_frame_rx();
3297 ctx.status_emitter().signal(ctx.build_status_snapshot());
3298 let frame = rx
3299 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3300 .expect("status_changed push");
3301 assert!(matches!(frame, PushFrame::StatusChanged(_)));
3302 }
3303
3304 #[test]
3305 fn status_emitter_debounces_burst() {
3306 let (ctx, rx) = ctx_with_frame_rx();
3307 for _ in 0..10 {
3308 ctx.status_emitter().signal(ctx.build_status_snapshot());
3309 }
3310 let frame = rx
3311 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3312 .expect("status_changed push");
3313 assert!(matches!(frame, PushFrame::StatusChanged(_)));
3314 assert!(rx.try_recv().is_err());
3315 }
3316
3317 #[test]
3318 fn status_emitter_separate_windows_separate_pushes() {
3319 let (ctx, rx) = ctx_with_frame_rx();
3320 ctx.status_emitter().signal(ctx.build_status_snapshot());
3321 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3322 .expect("first push");
3323 ctx.status_emitter().signal(ctx.build_status_snapshot());
3324 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3325 .expect("second push");
3326 }
3327
3328 #[test]
3329 fn status_emitter_no_signal_no_push() {
3330 let (_ctx, rx) = ctx_with_frame_rx();
3331 assert!(rx
3332 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
3333 .is_err());
3334 }
3335
3336 #[test]
3337 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
3338 let (ctx, rx) = ctx_with_frame_rx();
3339 drop(ctx);
3340 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
3341 }
3342
3343 #[test]
3344 fn progress_sender_slot_is_per_context_for_shared_app() {
3345 let app = App::default_shared();
3346 let ctx_a = AppContext::from_app(Arc::clone(&app), Config::default());
3347 let ctx_b = AppContext::from_app(app, Config::default());
3348 let (tx_a, rx_a) = mpsc::channel();
3349 let (tx_b, rx_b) = mpsc::channel();
3350
3351 ctx_a.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
3352 let _ = tx_a.send(frame);
3353 }))));
3354 ctx_b.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
3355 let _ = tx_b.send(frame);
3356 }))));
3357
3358 ctx_a.emit_progress(ProgressFrame {
3359 frame_type: "progress",
3360 request_id: "ctx-a".to_string(),
3361 kind: crate::protocol::ProgressKind::Stdout,
3362 chunk: "a".to_string(),
3363 });
3364 ctx_b.emit_progress(ProgressFrame {
3365 frame_type: "progress",
3366 request_id: "ctx-b".to_string(),
3367 kind: crate::protocol::ProgressKind::Stdout,
3368 chunk: "b".to_string(),
3369 });
3370
3371 match rx_a
3372 .recv_timeout(Duration::from_millis(50))
3373 .expect("ctx A progress frame")
3374 {
3375 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-a"),
3376 other => panic!("unexpected frame for ctx A: {other:?}"),
3377 }
3378 assert!(rx_a.try_recv().is_err());
3379
3380 match rx_b
3381 .recv_timeout(Duration::from_millis(50))
3382 .expect("ctx B progress frame")
3383 {
3384 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-b"),
3385 other => panic!("unexpected frame for ctx B: {other:?}"),
3386 }
3387 assert!(rx_b.try_recv().is_err());
3388 }
3389}
3390
3391#[cfg(test)]
3392mod status_bar_tests {
3393 use super::*;
3394 use crate::parser::TreeSitterProvider;
3395
3396 fn ctx() -> AppContext {
3397 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
3398 }
3399
3400 #[test]
3401 fn status_bar_counts_none_until_tier2_populated() {
3402 let ctx = ctx();
3403 assert!(ctx.status_bar_counts().is_none());
3405
3406 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
3407 let counts = ctx.status_bar_counts().expect("populated");
3408 assert_eq!(counts.dead_code, 5);
3409 assert_eq!(counts.unused_exports, 3);
3410 assert_eq!(counts.duplicates, 7);
3411 assert_eq!(counts.todos, 2);
3412 assert!(!counts.tier2_stale);
3413 assert_eq!(counts.errors, 0);
3415 assert_eq!(counts.warnings, 0);
3416 }
3417
3418 #[test]
3419 fn partial_tier2_does_not_fabricate_zeros() {
3420 let ctx = ctx();
3421 ctx.update_status_bar_tier2(Some(5), None, None, None, true);
3425 assert!(
3426 ctx.status_bar_counts().is_none(),
3427 "bar must not surface until all three Tier-2 categories are real"
3428 );
3429
3430 ctx.update_status_bar_tier2(None, Some(3), None, None, true);
3432 assert!(ctx.status_bar_counts().is_none());
3433
3434 ctx.update_status_bar_tier2(None, None, Some(7), None, false);
3437 let counts = ctx.status_bar_counts().expect("all three real now");
3438 assert_eq!(counts.dead_code, 5);
3439 assert_eq!(counts.unused_exports, 3);
3440 assert_eq!(counts.duplicates, 7);
3441 }
3442
3443 #[test]
3444 fn update_with_none_todos_preserves_last_known_todos() {
3445 let ctx = ctx();
3446 ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
3447 ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
3449 let counts = ctx.status_bar_counts().expect("populated");
3450 assert_eq!(counts.todos, 9);
3451 assert_eq!(counts.dead_code, 2);
3452 }
3453
3454 #[test]
3455 fn update_with_none_count_preserves_last_known_count() {
3456 let ctx = ctx();
3457 ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
3458 ctx.update_status_bar_tier2(Some(11), None, None, None, false);
3461 let counts = ctx.status_bar_counts().expect("populated");
3462 assert_eq!(counts.dead_code, 11);
3463 assert_eq!(counts.unused_exports, 20);
3464 assert_eq!(counts.duplicates, 30);
3465 }
3466
3467 #[test]
3468 fn mark_stale_sets_flag_only_after_populate() {
3469 let ctx = ctx();
3470 ctx.mark_status_bar_tier2_stale();
3472 assert!(ctx.status_bar_counts().is_none());
3473
3474 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), Some(0), false);
3475 ctx.mark_status_bar_tier2_stale();
3476 assert!(ctx.status_bar_counts().expect("populated").tier2_stale);
3477
3478 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), None, false);
3480 assert!(!ctx.status_bar_counts().expect("populated").tier2_stale);
3481 }
3482
3483 #[test]
3488 fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
3489 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
3490 use crate::lsp::registry::ServerKind;
3491 use crate::lsp::roots::ServerKey;
3492
3493 let ctx = ctx();
3494 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); let file = std::path::PathBuf::from("/proj/gone.ts");
3497 {
3498 let mut lsp = ctx.lsp();
3499 lsp.diagnostics_store_mut_for_test().publish(
3500 ServerKey {
3501 kind: ServerKind::TypeScript,
3502 root: std::path::PathBuf::from("/proj"),
3503 },
3504 file.clone(),
3505 vec![StoredDiagnostic {
3506 file: file.clone(),
3507 line: 1,
3508 column: 1,
3509 end_line: 1,
3510 end_column: 2,
3511 severity: DiagnosticSeverity::Error,
3512 message: "boom".into(),
3513 code: None,
3514 source: None,
3515 }],
3516 );
3517 }
3518
3519 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
3521
3522 let removed = ctx.lsp_clear_diagnostics_for_file(&file);
3524 assert!(removed);
3525 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
3526 }
3527
3528 #[test]
3529 fn status_bar_filtered_counts_ignore_environmental_flap() {
3530 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
3531 use crate::lsp::registry::ServerKind;
3532 use crate::lsp::roots::ServerKey;
3533
3534 let ctx = ctx();
3535 let root = if cfg!(windows) {
3536 std::path::PathBuf::from(r"C:\proj")
3537 } else {
3538 std::path::PathBuf::from("/proj")
3539 };
3540 ctx.set_canonical_cache_root(root.clone());
3541 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
3542
3543 let file = root.join("aft.jsonc");
3544 let key = ServerKey {
3545 kind: ServerKind::TypeScript,
3546 root: root.clone(),
3547 };
3548 let env = StoredDiagnostic {
3549 file: file.clone(),
3550 line: 1,
3551 column: 1,
3552 end_line: 1,
3553 end_column: 2,
3554 severity: DiagnosticSeverity::Error,
3555 message: "Failed to load schema from https://example.com/schema.json".into(),
3556 code: None,
3557 source: Some("json".into()),
3558 };
3559
3560 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
3561
3562 {
3563 let mut lsp = ctx.lsp();
3564 lsp.diagnostics_store_mut_for_test()
3565 .publish(key.clone(), file.clone(), vec![env]);
3566 }
3567 assert_eq!(
3568 ctx.status_bar_counts().expect("populated").errors,
3569 0,
3570 "environmental publish must not change status-bar E"
3571 );
3572
3573 {
3574 let mut lsp = ctx.lsp();
3575 lsp.diagnostics_store_mut_for_test()
3576 .publish(key, file, vec![]);
3577 }
3578 assert_eq!(
3579 ctx.status_bar_counts().expect("populated").errors,
3580 0,
3581 "environmental clear must not change status-bar E"
3582 );
3583 }
3584}
3585
3586#[cfg(test)]
3587mod harness_path_tests {
3588 use super::*;
3589 use crate::harness::Harness;
3590 use crate::parser::TreeSitterProvider;
3591
3592 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
3593 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3594 ctx.update_config(|config| {
3595 config.storage_dir = Some(storage_dir);
3596 });
3597 ctx.set_harness(harness);
3598 ctx
3599 }
3600
3601 #[test]
3602 fn harness_dir_resolves_correctly() {
3603 let storage = PathBuf::from("/tmp/cortexkit/aft");
3604 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
3605
3606 assert_eq!(ctx.harness_dir(), storage.join("pi"));
3607 }
3608
3609 #[test]
3610 fn bash_tasks_dir_uses_hash_session() {
3611 let storage = PathBuf::from("/tmp/cortexkit/aft");
3612 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
3613
3614 assert_eq!(
3615 ctx.bash_tasks_dir("ses_abc"),
3616 storage
3617 .join("opencode")
3618 .join("bash-tasks")
3619 .join(hash_session("ses_abc"))
3620 );
3621 }
3622
3623 #[test]
3624 fn backups_dir_includes_path_hash() {
3625 let storage = PathBuf::from("/tmp/cortexkit/aft");
3626 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
3627
3628 assert_eq!(
3629 ctx.backups_dir("ses_abc", "pathhash"),
3630 storage
3631 .join("pi")
3632 .join("backups")
3633 .join(hash_session("ses_abc"))
3634 .join("pathhash")
3635 );
3636 }
3637
3638 #[test]
3639 fn filters_dir_under_harness() {
3640 let storage = PathBuf::from("/tmp/cortexkit/aft");
3641 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
3642
3643 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
3644 }
3645
3646 #[test]
3647 fn trust_file_is_host_global() {
3648 let storage = PathBuf::from("/tmp/cortexkit/aft");
3649 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
3650
3651 assert_eq!(
3652 ctx.trust_file(),
3653 storage.join("trusted-filter-projects.json")
3654 );
3655 }
3656
3657 #[test]
3658 fn same_session_different_harness_resolve_different_paths() {
3659 let storage = PathBuf::from("/tmp/cortexkit/aft");
3660 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
3661 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
3662
3663 assert_ne!(
3664 opencode.bash_tasks_dir("ses_same"),
3665 pi.bash_tasks_dir("ses_same")
3666 );
3667 }
3668}
3669
3670#[cfg(test)]
3671mod gitignore_tests {
3672 use super::*;
3673 use std::fs;
3674 use std::path::Path;
3675 use tempfile::TempDir;
3676
3677 fn make_ctx_with_root(root: &Path) -> AppContext {
3678 let provider = Box::new(crate::parser::TreeSitterProvider::new());
3679 let config = Config {
3680 project_root: Some(root.to_path_buf()),
3681 ..Config::default()
3682 };
3683 AppContext::new(provider, config)
3684 }
3685
3686 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
3693 let Some(matcher) = ctx.gitignore() else {
3694 return false;
3695 };
3696 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
3697 if !canonical.starts_with(matcher.path()) {
3698 return false;
3699 }
3700 let is_dir = canonical.is_dir();
3701 matcher
3702 .matched_path_or_any_parents(&canonical, is_dir)
3703 .is_ignore()
3704 }
3705
3706 fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
3717 use std::sync::{Mutex, OnceLock};
3718 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
3719 let _guard = LOCK
3720 .get_or_init(|| Mutex::new(()))
3721 .lock()
3722 .unwrap_or_else(|e| e.into_inner());
3723 let tmp = TempDir::new().unwrap();
3724 let prev = std::env::var_os("XDG_CONFIG_HOME");
3725 unsafe {
3727 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
3728 }
3729 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
3730 unsafe {
3731 match prev {
3732 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
3733 None => std::env::remove_var("XDG_CONFIG_HOME"),
3734 }
3735 }
3736 match result {
3737 Ok(r) => r,
3738 Err(p) => std::panic::resume_unwind(p),
3739 }
3740 }
3741
3742 #[test]
3743 fn rebuild_gitignore_returns_none_without_project_root() {
3744 let provider = Box::new(crate::parser::TreeSitterProvider::new());
3745 let ctx = AppContext::new(provider, Config::default());
3746 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
3747 assert!(ctx.gitignore().is_none());
3748 }
3749
3750 #[test]
3751 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
3752 let tmp = TempDir::new().unwrap();
3753 let ctx = make_ctx_with_root(tmp.path());
3754 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
3755 assert!(ctx.gitignore().is_none());
3756 }
3757
3758 #[test]
3759 fn matcher_filters_files_in_ignored_dist_dir() {
3760 let tmp = TempDir::new().unwrap();
3761 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
3762 fs::create_dir_all(tmp.path().join("dist")).unwrap();
3763 fs::create_dir_all(tmp.path().join("src")).unwrap();
3764 let dist_file = tmp.path().join("dist").join("bundle.js");
3765 let src_file = tmp.path().join("src").join("app.ts");
3766 fs::write(&dist_file, "x").unwrap();
3767 fs::write(&src_file, "y").unwrap();
3768
3769 let ctx = make_ctx_with_root(tmp.path());
3770 ctx.rebuild_gitignore();
3771
3772 assert!(ctx.gitignore().is_some());
3773 assert!(
3774 is_ignored(&ctx, &dist_file),
3775 "dist/bundle.js should be ignored"
3776 );
3777 assert!(
3778 !is_ignored(&ctx, &src_file),
3779 "src/app.ts should NOT be ignored"
3780 );
3781 }
3782
3783 #[test]
3784 fn matcher_handles_node_modules_and_target() {
3785 let tmp = TempDir::new().unwrap();
3786 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
3787 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
3788 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
3789 let nm_file = tmp.path().join("node_modules/foo/index.js");
3790 let target_file = tmp.path().join("target/debug/aft");
3791 fs::write(&nm_file, "x").unwrap();
3792 fs::write(&target_file, "x").unwrap();
3793
3794 let ctx = make_ctx_with_root(tmp.path());
3795 ctx.rebuild_gitignore();
3796
3797 assert!(is_ignored(&ctx, &nm_file));
3798 assert!(is_ignored(&ctx, &target_file));
3799 }
3800
3801 #[test]
3802 fn matcher_honors_negation_pattern() {
3803 let tmp = TempDir::new().unwrap();
3805 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
3806 let random_log = tmp.path().join("random.log");
3807 let important_log = tmp.path().join("important.log");
3808 fs::write(&random_log, "x").unwrap();
3809 fs::write(&important_log, "y").unwrap();
3810
3811 let ctx = make_ctx_with_root(tmp.path());
3812 ctx.rebuild_gitignore();
3813
3814 assert!(is_ignored(&ctx, &random_log));
3815 assert!(
3816 !is_ignored(&ctx, &important_log),
3817 "negation pattern should un-ignore important.log"
3818 );
3819 }
3820
3821 #[test]
3822 fn rebuild_picks_up_gitignore_changes() {
3823 let tmp = TempDir::new().unwrap();
3824 let ignore_path = tmp.path().join(".gitignore");
3825 fs::write(&ignore_path, "foo.txt\n").unwrap();
3826 let foo = tmp.path().join("foo.txt");
3827 let bar = tmp.path().join("bar.txt");
3828 fs::write(&foo, "").unwrap();
3829 fs::write(&bar, "").unwrap();
3830
3831 let ctx = make_ctx_with_root(tmp.path());
3832 ctx.rebuild_gitignore();
3833 assert!(is_ignored(&ctx, &foo));
3834 assert!(!is_ignored(&ctx, &bar));
3835
3836 fs::write(&ignore_path, "bar.txt\n").unwrap();
3838 ctx.rebuild_gitignore();
3839 assert!(!is_ignored(&ctx, &foo));
3840 assert!(is_ignored(&ctx, &bar));
3841 }
3842
3843 #[test]
3844 fn gitignore_loads_info_exclude_when_present() {
3845 let tmp = TempDir::new().unwrap();
3846 let info_dir = tmp.path().join(".git/info");
3847 fs::create_dir_all(&info_dir).unwrap();
3848 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
3849 let secrets = tmp.path().join("secrets.txt");
3850 let public = tmp.path().join("public.txt");
3851 fs::write(&secrets, "token").unwrap();
3852 fs::write(&public, "ok").unwrap();
3853
3854 let ctx = make_ctx_with_root(tmp.path());
3855 ctx.rebuild_gitignore();
3856
3857 assert!(is_ignored(&ctx, &secrets));
3858 assert!(!is_ignored(&ctx, &public));
3859 }
3860
3861 #[test]
3862 fn matcher_picks_up_nested_gitignore() {
3863 let tmp = TempDir::new().unwrap();
3864 fs::write(tmp.path().join(".gitignore"), "").unwrap();
3866 let sub = tmp.path().join("packages/foo");
3867 fs::create_dir_all(&sub).unwrap();
3868 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
3869 let generated_file = sub.join("generated").join("out.js");
3870 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
3871 fs::write(&generated_file, "x").unwrap();
3872
3873 let ctx = make_ctx_with_root(tmp.path());
3874 ctx.rebuild_gitignore();
3875
3876 assert!(
3877 is_ignored(&ctx, &generated_file),
3878 "nested gitignore in packages/foo/.gitignore should ignore generated/"
3879 );
3880 }
3881}