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 pub harness: parking_lot::Mutex<Option<Harness>>,
587 canonical_cache_root: parking_lot::Mutex<Option<PathBuf>>,
588 is_worktree_bridge: parking_lot::Mutex<bool>,
589 git_common_dir: parking_lot::Mutex<Option<PathBuf>>,
590 degraded_reasons: parking_lot::Mutex<Vec<String>>,
597 callgraph_store: RwLock<Option<Arc<CallGraphStore>>>,
598 callgraph_store_force_rebuild: parking_lot::Mutex<bool>,
599 callgraph_store_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStore>>>,
600 pending_callgraph_store_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
601 search_index: RwLock<Option<SearchIndex>>,
602 search_index_rx: RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>>,
603 pending_search_index_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
604 symbol_cache: SharedSymbolCache,
605 inspect_manager: Arc<InspectManager>,
606 tier2_refresh_scheduler: parking_lot::Mutex<Tier2RefreshScheduler>,
607 pending_tier2_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
608 semantic_index: RwLock<Option<SemanticIndex>>,
609 semantic_index_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
610 semantic_index_status: RwLock<SemanticIndexStatus>,
611 semantic_cold_seed_active: Arc<AtomicBool>,
615 semantic_cold_seed_generation: Arc<AtomicU64>,
618 semantic_callgraph_warm_deferred: AtomicBool,
619 pending_semantic_index_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
620 pending_semantic_corpus_refresh: parking_lot::Mutex<bool>,
621 semantic_refresh_tx:
622 parking_lot::Mutex<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>,
623 semantic_refresh_event_rx:
624 parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
625 semantic_refresh_worker: parking_lot::Mutex<Option<SemanticRefreshWorkerSlot>>,
626 semantic_refresh_retry_attempts: parking_lot::Mutex<BTreeMap<PathBuf, usize>>,
627 semantic_refresh_circuit: Arc<SemanticRefreshCircuit>,
628 semantic_embedding_model: parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>>,
629 watcher: parking_lot::Mutex<Option<RecommendedWatcher>>,
630 watcher_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>>,
631 watcher_thread: parking_lot::Mutex<Option<WatcherThreadHandle>>,
632 lsp_manager: parking_lot::Mutex<LspManager>,
633 configure_generation: AtomicU64,
634 last_seen_reuse_completions: AtomicU64,
638 configure_warnings_tx: crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)>,
639 configure_warnings_rx: crossbeam_channel::Receiver<(u64, ConfigureWarningsFrame)>,
640 progress_sender: SharedProgressSender,
643 status_emitter: StatusEmitter,
644 status_bar_last_emitted: RwLock<Option<StatusBarCounts>>,
648 bash_background: BgTaskRegistry,
649 filter_registry: crate::compress::SharedFilterRegistry,
656 filter_registry_loaded: std::sync::atomic::AtomicBool,
659 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
664 gitignore: SharedGitignore,
671 gitignore_generation: Arc<AtomicU64>,
672 status_bar_tier2: RwLock<StatusBarTier2>,
676 tsconfig_membership:
683 parking_lot::Mutex<crate::lsp::tsconfig_membership::TsconfigMembershipCache>,
684}
685
686impl Drop for AppContext {
687 fn drop(&mut self) {
688 if let Some(runtime) = self.watcher_thread.get_mut().take() {
689 runtime.shutdown_and_join();
690 }
691 }
692}
693
694pub enum CallgraphStoreAccess {
702 Ready(Arc<CallGraphStore>),
704 Building,
706 Unavailable,
708 Error(CallGraphStoreError),
710}
711
712fn callgraph_build_wait_window() -> Duration {
717 std::env::var("AFT_CALLGRAPH_BUILD_WAIT_MS")
718 .ok()
719 .and_then(|raw| raw.parse::<u64>().ok())
720 .map(Duration::from_millis)
721 .unwrap_or(Duration::ZERO)
722}
723
724static CALLGRAPH_COLD_BUILD_SPAWN_COUNT: AtomicUsize = AtomicUsize::new(0);
725
726#[doc(hidden)]
727pub fn reset_callgraph_cold_build_spawn_count_for_test() {
728 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
729}
730
731#[doc(hidden)]
732pub fn callgraph_cold_build_spawn_count_for_test() -> usize {
733 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst)
734}
735
736impl AppContext {
737 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
738 Self::with_app_and_provider(App::default_shared(), provider, config)
739 }
740
741 pub fn from_app(app: Arc<App>, config: Config) -> Self {
742 let provider = app.create_provider();
743 Self::with_app_and_provider(app, provider, config)
744 }
745
746 pub fn with_app_and_provider(
747 app: Arc<App>,
748 provider: Box<dyn LanguageProvider>,
749 config: Config,
750 ) -> Self {
751 let bash_compress_enabled = config.experimental_bash_compress;
752 let (configure_warnings_tx, configure_warnings_rx) = crossbeam_channel::unbounded();
753 let progress_sender: SharedProgressSender = Arc::new(Mutex::new(None));
754 let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
755 let symbol_cache = provider
756 .as_any()
757 .downcast_ref::<TreeSitterProvider>()
758 .map(|provider| provider.symbol_cache())
759 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
760 let mut lsp_manager = LspManager::new();
761 lsp_manager.set_child_registry(app.lsp_child_registry());
762 lsp_manager.set_diagnostic_capacity(config.diagnostic_cache_size);
765 AppContext {
766 app: Arc::clone(&app),
767 provider,
768 backup: parking_lot::Mutex::new(BackupStore::new()),
769 checkpoint: parking_lot::Mutex::new(CheckpointStore::new()),
770 config: RwLock::new(Arc::new(config)),
771 harness: parking_lot::Mutex::new(None),
772 canonical_cache_root: parking_lot::Mutex::new(None),
773 is_worktree_bridge: parking_lot::Mutex::new(false),
774 git_common_dir: parking_lot::Mutex::new(None),
775 degraded_reasons: parking_lot::Mutex::new(Vec::new()),
776 callgraph_store: RwLock::new(None),
777 callgraph_store_force_rebuild: parking_lot::Mutex::new(false),
778 callgraph_store_rx: parking_lot::Mutex::new(None),
779 pending_callgraph_store_paths: parking_lot::Mutex::new(BTreeSet::new()),
780 search_index: RwLock::new(None),
781 search_index_rx: RwLock::new(None),
782 pending_search_index_paths: parking_lot::Mutex::new(BTreeSet::new()),
783 symbol_cache,
784 inspect_manager: Arc::new(InspectManager::new()),
785 tier2_refresh_scheduler: parking_lot::Mutex::new(Tier2RefreshScheduler::new()),
786 pending_tier2_paths: parking_lot::Mutex::new(BTreeSet::new()),
787 semantic_index: RwLock::new(None),
788 semantic_index_rx: parking_lot::Mutex::new(None),
789 semantic_index_status: RwLock::new(SemanticIndexStatus::Disabled),
790 semantic_cold_seed_active: Arc::new(AtomicBool::new(false)),
791 semantic_cold_seed_generation: Arc::new(AtomicU64::new(0)),
792 semantic_callgraph_warm_deferred: AtomicBool::new(false),
793 pending_semantic_index_paths: parking_lot::Mutex::new(BTreeSet::new()),
794 pending_semantic_corpus_refresh: parking_lot::Mutex::new(false),
795 semantic_refresh_tx: parking_lot::Mutex::new(None),
796 semantic_refresh_event_rx: parking_lot::Mutex::new(None),
797 semantic_refresh_worker: parking_lot::Mutex::new(None),
798 semantic_refresh_retry_attempts: parking_lot::Mutex::new(BTreeMap::new()),
799 semantic_refresh_circuit: Arc::new(SemanticRefreshCircuit::default()),
800 semantic_embedding_model: parking_lot::Mutex::new(None),
801 watcher: parking_lot::Mutex::new(None),
802 watcher_rx: parking_lot::Mutex::new(None),
803 watcher_thread: parking_lot::Mutex::new(None),
804 lsp_manager: parking_lot::Mutex::new(lsp_manager),
805 configure_generation: AtomicU64::new(0),
806 last_seen_reuse_completions: AtomicU64::new(0),
807 configure_warnings_tx,
808 configure_warnings_rx,
809 progress_sender: Arc::clone(&progress_sender),
810 status_emitter,
811 status_bar_last_emitted: RwLock::new(None),
812 bash_background: BgTaskRegistry::new(Arc::clone(&progress_sender)),
813 filter_registry: Arc::new(std::sync::RwLock::new(
814 crate::compress::toml_filter::FilterRegistry::default(),
815 )),
816 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
817 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
818 gitignore: Arc::new(std::sync::RwLock::new(None)),
819 gitignore_generation: Arc::new(AtomicU64::new(0)),
820 status_bar_tier2: RwLock::new(StatusBarTier2::default()),
821 tsconfig_membership: parking_lot::Mutex::new(
822 crate::lsp::tsconfig_membership::TsconfigMembershipCache::new(),
823 ),
824 }
825 }
826
827 pub fn status_bar_counts(&self) -> Option<StatusBarCounts> {
833 let (dead_code, unused_exports, duplicates, todos, tier2_stale) = {
839 let tier2 = self
840 .status_bar_tier2
841 .read()
842 .unwrap_or_else(std::sync::PoisonError::into_inner);
843 let (Some(dead_code), Some(unused_exports), Some(duplicates)) =
844 (tier2.dead_code, tier2.unused_exports, tier2.duplicates)
845 else {
846 return None;
847 };
848 (
849 dead_code,
850 unused_exports,
851 duplicates,
852 tier2.todos.unwrap_or(0),
853 tier2.stale,
854 )
855 };
856 let (errors, warnings) = self.status_bar_error_warning_counts();
857 Some(StatusBarCounts {
858 errors,
859 warnings,
860 dead_code,
861 unused_exports,
862 duplicates,
863 todos,
864 tier2_stale,
865 })
866 }
867
868 pub fn should_emit_status_bar(&self, counts: &StatusBarCounts) -> bool {
869 let mut last = self
870 .status_bar_last_emitted
871 .write()
872 .unwrap_or_else(std::sync::PoisonError::into_inner);
873 if last.as_ref() == Some(counts) {
874 return false;
875 }
876 *last = Some(counts.clone());
877 true
878 }
879
880 fn status_bar_error_warning_counts(&self) -> (usize, usize) {
886 let Some(root) = self.canonical_cache_root_opt() else {
887 return self.lsp_manager.lock().warm_error_warning_counts();
890 };
891 let lsp = self.lsp_manager.lock();
892 let mut membership = self.tsconfig_membership.lock();
893 lsp.filtered_error_warning_counts(|file| {
894 file.starts_with(&root) && !membership.should_skip_diagnostics(file)
895 })
896 }
897
898 pub fn clear_tsconfig_membership_cache(&self) {
902 self.tsconfig_membership.lock().clear();
903 }
904
905 pub fn mark_status_bar_tier2_stale(&self) -> bool {
911 let mut tier2 = self
912 .status_bar_tier2
913 .write()
914 .unwrap_or_else(std::sync::PoisonError::into_inner);
915 if tier2.dead_code.is_some() && tier2.unused_exports.is_some() && tier2.duplicates.is_some()
917 {
918 let changed = !tier2.stale;
919 tier2.stale = true;
920 return changed;
921 }
922 false
923 }
924
925 pub fn update_status_bar_tier2(
931 &self,
932 dead_code: Option<usize>,
933 unused_exports: Option<usize>,
934 duplicates: Option<usize>,
935 todos: Option<usize>,
936 stale: bool,
937 ) {
938 let mut tier2 = self
939 .status_bar_tier2
940 .write()
941 .unwrap_or_else(std::sync::PoisonError::into_inner);
942 if let Some(dead_code) = dead_code {
943 tier2.dead_code = Some(dead_code);
944 }
945 if let Some(unused_exports) = unused_exports {
946 tier2.unused_exports = Some(unused_exports);
947 }
948 if let Some(duplicates) = duplicates {
949 tier2.duplicates = Some(duplicates);
950 }
951 if let Some(todos) = todos {
952 tier2.todos = Some(todos);
953 }
954 tier2.stale = stale;
955 }
956
957 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
960 self.gitignore
961 .read()
962 .unwrap_or_else(|poisoned| poisoned.into_inner())
963 .clone()
964 }
965
966 pub fn shared_gitignore(&self) -> SharedGitignore {
968 Arc::clone(&self.gitignore)
969 }
970
971 pub fn gitignore_generation(&self) -> Arc<AtomicU64> {
975 Arc::clone(&self.gitignore_generation)
976 }
977
978 fn set_gitignore(&self, matcher: Option<Arc<ignore::gitignore::Gitignore>>) {
979 *self
980 .gitignore
981 .write()
982 .unwrap_or_else(|poisoned| poisoned.into_inner()) = matcher;
983 self.gitignore_generation.fetch_add(1, Ordering::SeqCst);
984 }
985
986 pub fn clear_gitignore(&self) {
1008 self.set_gitignore(None);
1009 }
1010
1011 pub fn rebuild_gitignore(&self) {
1012 use ignore::gitignore::GitignoreBuilder;
1013 use std::path::Path;
1014 let root_raw = match self.config().project_root.clone() {
1015 Some(r) => r,
1016 None => {
1017 self.set_gitignore(None);
1018 return;
1019 }
1020 };
1021 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
1029 let mut builder = GitignoreBuilder::new(&root);
1030 if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
1035 if global_ignore.is_file() {
1036 if let Some(err) = builder.add(&global_ignore) {
1037 crate::slog_warn!(
1038 "global gitignore parse error in {}: {}",
1039 global_ignore.display(),
1040 err
1041 );
1042 }
1043 }
1044 }
1045 let root_ignore = Path::new(&root).join(".gitignore");
1047 if root_ignore.exists() {
1048 if let Some(err) = builder.add(&root_ignore) {
1049 crate::slog_warn!(
1050 "gitignore parse error in {}: {}",
1051 root_ignore.display(),
1052 err
1053 );
1054 }
1055 }
1056 let root_aftignore = Path::new(&root).join(".aftignore");
1061 if root_aftignore.exists() {
1062 if let Some(err) = builder.add(&root_aftignore) {
1063 crate::slog_warn!(
1064 "aftignore parse error in {}: {}",
1065 root_aftignore.display(),
1066 err
1067 );
1068 }
1069 }
1070 let info_exclude = self
1075 .git_common_dir
1076 .lock()
1077 .clone()
1078 .unwrap_or_else(|| Path::new(&root).join(".git"))
1079 .join("info")
1080 .join("exclude");
1081 if info_exclude.exists() {
1082 if let Some(err) = builder.add(&info_exclude) {
1083 crate::slog_warn!(
1084 "gitignore parse error in {}: {}",
1085 info_exclude.display(),
1086 err
1087 );
1088 }
1089 }
1090 let walker = ignore::WalkBuilder::new(&root)
1096 .standard_filters(true)
1097 .hidden(false)
1105 .filter_entry(|entry| {
1106 let name = entry.file_name().to_string_lossy();
1107 !matches!(
1108 name.as_ref(),
1109 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
1110 )
1111 })
1112 .build();
1113 for entry in walker.flatten() {
1114 let file_name = entry.file_name();
1115 let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
1116 let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
1117 if is_nested_gitignore || is_nested_aftignore {
1118 if let Some(err) = builder.add(entry.path()) {
1119 crate::slog_warn!(
1120 "nested ignore parse error in {}: {}",
1121 entry.path().display(),
1122 err
1123 );
1124 }
1125 }
1126 }
1127 match builder.build() {
1128 Ok(gi) => {
1129 let count = gi.num_ignores();
1130 if count > 0 {
1131 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
1132 self.set_gitignore(Some(Arc::new(gi)));
1133 } else {
1134 self.set_gitignore(None);
1135 }
1136 }
1137 Err(err) => {
1138 crate::slog_warn!("gitignore matcher build failed: {}", err);
1139 self.set_gitignore(None);
1140 }
1141 }
1142 }
1143
1144 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
1147 Arc::clone(&self.bash_compress_flag)
1148 }
1149
1150 pub fn sync_bash_compress_flag(&self) {
1154 let value = self.config().experimental_bash_compress;
1155 self.bash_compress_flag
1156 .store(value, std::sync::atomic::Ordering::Relaxed);
1157 }
1158
1159 pub fn set_bash_compress_enabled(&self, enabled: bool) {
1160 self.update_config(|config| {
1161 config.experimental_bash_compress = enabled;
1162 });
1163 self.bash_compress_flag
1164 .store(enabled, std::sync::atomic::Ordering::Relaxed);
1165 }
1166
1167 pub fn filter_registry(
1171 &self,
1172 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
1173 self.ensure_filter_registry_loaded();
1174 match self.filter_registry.read() {
1175 Ok(g) => g,
1176 Err(poisoned) => poisoned.into_inner(),
1177 }
1178 }
1179
1180 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
1184 self.ensure_filter_registry_loaded();
1185 Arc::clone(&self.filter_registry)
1186 }
1187
1188 pub fn reset_filter_registry(&self) {
1192 let new_registry = crate::compress::build_registry_for_context(self);
1193 match self.filter_registry.write() {
1194 Ok(mut slot) => *slot = new_registry,
1195 Err(poisoned) => *poisoned.into_inner() = new_registry,
1196 }
1197 self.filter_registry_loaded
1198 .store(true, std::sync::atomic::Ordering::Release);
1199 }
1200
1201 fn ensure_filter_registry_loaded(&self) {
1202 use std::sync::atomic::Ordering;
1203 if self.filter_registry_loaded.load(Ordering::Acquire) {
1204 return;
1205 }
1206 let new_registry = crate::compress::build_registry_for_context(self);
1209 if let Ok(mut slot) = self.filter_registry.write() {
1210 *slot = new_registry;
1211 self.filter_registry_loaded.store(true, Ordering::Release);
1212 }
1213 }
1214
1215 pub fn app(&self) -> Arc<App> {
1216 Arc::clone(&self.app)
1217 }
1218
1219 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
1222 self.app.lsp_child_registry()
1223 }
1224
1225 pub fn stdout_writer(&self) -> SharedStdoutWriter {
1226 self.app.stdout_writer()
1227 }
1228
1229 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
1230 if let Ok(mut progress_sender) = self.progress_sender.lock() {
1231 *progress_sender = sender;
1232 }
1233 }
1234
1235 pub fn emit_progress(&self, frame: ProgressFrame) {
1236 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
1237 return;
1238 };
1239 if let Some(sender) = progress_sender.as_ref() {
1240 sender(PushFrame::Progress(frame));
1241 }
1242 }
1243
1244 pub fn status_emitter(&self) -> &StatusEmitter {
1245 &self.status_emitter
1246 }
1247
1248 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
1256 self.progress_sender
1257 .lock()
1258 .ok()
1259 .and_then(|sender| sender.clone())
1260 }
1261
1262 pub fn advance_configure_generation(&self) -> u64 {
1263 self.configure_generation
1264 .fetch_add(1, Ordering::SeqCst)
1265 .wrapping_add(1)
1266 }
1267
1268 pub fn configure_generation(&self) -> u64 {
1269 self.configure_generation.load(Ordering::SeqCst)
1270 }
1271
1272 pub fn configure_warnings_sender(
1273 &self,
1274 ) -> crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)> {
1275 self.configure_warnings_tx.clone()
1276 }
1277
1278 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
1279 let mut warnings = Vec::new();
1280 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
1281 warnings.push(warning);
1282 }
1283 warnings
1284 }
1285
1286 pub fn bash_background(&self) -> &BgTaskRegistry {
1287 &self.bash_background
1288 }
1289
1290 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
1291 self.bash_background.drain_completions()
1292 }
1293
1294 pub fn provider(&self) -> &dyn LanguageProvider {
1296 self.provider.as_ref()
1297 }
1298
1299 pub fn backup(&self) -> &parking_lot::Mutex<BackupStore> {
1301 &self.backup
1302 }
1303
1304 pub fn checkpoint(&self) -> &parking_lot::Mutex<CheckpointStore> {
1306 &self.checkpoint
1307 }
1308
1309 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
1310 self.app.set_db(conn);
1311 }
1312
1313 pub fn clear_db(&self) {
1314 self.app.clear_db();
1315 }
1316
1317 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
1318 self.app.db()
1319 }
1320
1321 pub fn config(&self) -> Arc<Config> {
1323 let guard = match self.config.read() {
1324 Ok(guard) => guard,
1325 Err(poisoned) => poisoned.into_inner(),
1326 };
1327 Arc::clone(&*guard)
1328 }
1329
1330 pub fn set_config(&self, config: Config) {
1332 let next = Arc::new(config);
1333 match self.config.write() {
1334 Ok(mut guard) => *guard = next,
1335 Err(poisoned) => *poisoned.into_inner() = next,
1336 }
1337 }
1338
1339 pub fn update_config(&self, update: impl FnOnce(&mut Config)) {
1341 let mut next = self.config().as_ref().clone();
1342 update(&mut next);
1343 self.set_config(next);
1344 }
1345
1346 pub fn set_harness(&self, harness: Harness) {
1347 self.bash_background.set_harness(harness.clone());
1348 *self.harness.lock() = Some(harness);
1349 }
1350
1351 pub fn harness_opt(&self) -> Option<Harness> {
1352 self.harness.lock().clone()
1353 }
1354
1355 pub fn harness(&self) -> Harness {
1356 self.harness_opt()
1357 .expect("harness set by configure before any tool call")
1358 }
1359
1360 pub fn storage_dir(&self) -> PathBuf {
1361 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
1362 }
1363
1364 pub fn harness_dir(&self) -> PathBuf {
1365 self.storage_dir().join(self.harness().storage_segment())
1366 }
1367
1368 pub fn inspect_dir(&self) -> PathBuf {
1369 self.harness_dir().join("inspect")
1370 }
1371
1372 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
1373 self.harness_dir()
1374 .join("bash-tasks")
1375 .join(hash_session(session_id))
1376 }
1377
1378 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
1379 self.harness_dir()
1380 .join("backups")
1381 .join(hash_session(session_id))
1382 .join(path_hash)
1383 }
1384
1385 pub fn filters_dir(&self) -> PathBuf {
1386 self.harness_dir().join("filters")
1387 }
1388
1389 pub fn trust_file(&self) -> PathBuf {
1391 self.storage_dir().join("trusted-filter-projects.json")
1392 }
1393
1394 pub fn set_canonical_cache_root(&self, root: PathBuf) {
1395 debug_assert!(root.is_absolute());
1396 *self.canonical_cache_root.lock() = Some(root);
1397 }
1398
1399 pub fn canonical_cache_root(&self) -> PathBuf {
1400 self.canonical_cache_root
1401 .lock()
1402 .clone()
1403 .expect("canonical_cache_root accessed before handle_configure")
1404 }
1405
1406 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
1407 self.canonical_cache_root.lock().clone()
1408 }
1409
1410 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
1411 *self.is_worktree_bridge.lock() = is_worktree_bridge;
1412 *self.git_common_dir.lock() = git_common_dir;
1413 }
1414
1415 pub fn is_worktree_bridge(&self) -> bool {
1416 *self.is_worktree_bridge.lock()
1417 }
1418
1419 pub fn git_common_dir(&self) -> Option<PathBuf> {
1420 self.git_common_dir.lock().clone()
1421 }
1422
1423 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
1427 *self.degraded_reasons.lock() = reasons;
1428 }
1429
1430 pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
1431 let reason = reason.into();
1432 let mut reasons = self.degraded_reasons.lock();
1433 if reasons.iter().any(|existing| existing == &reason) {
1434 return false;
1435 }
1436 reasons.push(reason);
1437 true
1438 }
1439
1440 pub fn degraded_reasons(&self) -> Vec<String> {
1444 self.degraded_reasons.lock().clone()
1445 }
1446
1447 pub fn is_degraded(&self) -> bool {
1449 !self.degraded_reasons.lock().is_empty()
1450 }
1451
1452 pub fn cache_role(&self) -> &'static str {
1453 if self.canonical_cache_root.lock().is_none() {
1454 "not_initialized"
1455 } else if self.is_worktree_bridge() {
1456 "worktree"
1457 } else {
1458 "main"
1459 }
1460 }
1461
1462 pub fn callgraph_store(&self) -> &RwLock<Option<Arc<CallGraphStore>>> {
1464 &self.callgraph_store
1465 }
1466
1467 pub fn mark_callgraph_store_force_rebuild(&self) {
1468 *self.callgraph_store_force_rebuild.lock() = true;
1469 }
1470
1471 fn take_callgraph_store_force_rebuild(&self) -> bool {
1472 let mut force = self.callgraph_store_force_rebuild.lock();
1473 let was_forced = *force;
1474 *force = false;
1475 was_forced
1476 }
1477
1478 pub fn callgraph_store_dir(&self) -> PathBuf {
1479 match self.harness_opt() {
1480 Some(harness) => self
1481 .storage_dir()
1482 .join(harness.storage_segment())
1483 .join("callgraph"),
1484 None => self.storage_dir().join("callgraph"),
1485 }
1486 }
1487
1488 pub fn ensure_callgraph_store(
1489 &self,
1490 ) -> Result<Option<Arc<CallGraphStore>>, CallGraphStoreError> {
1491 self.ensure_callgraph_store_with_flag(true)
1492 }
1493
1494 fn ensure_callgraph_store_with_flag(
1495 &self,
1496 respect_config_flag: bool,
1497 ) -> Result<Option<Arc<CallGraphStore>>, CallGraphStoreError> {
1498 if respect_config_flag && !self.config().callgraph_store {
1499 return Ok(None);
1500 }
1501 if let Some(store) = {
1502 let guard = self
1503 .callgraph_store
1504 .read()
1505 .unwrap_or_else(std::sync::PoisonError::into_inner);
1506 guard.as_ref().map(Arc::clone)
1507 } {
1508 return Ok(Some(store));
1509 }
1510
1511 let Some(project_root) = self.callgraph_project_root() else {
1512 return Ok(None);
1513 };
1514 let callgraph_dir = self.callgraph_store_dir();
1515 let force_rebuild = self.take_callgraph_store_force_rebuild();
1516 let store = if self.is_worktree_bridge() {
1517 CallGraphStore::open_readonly(callgraph_dir, project_root)?
1518 } else if force_rebuild {
1519 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1520 let (store, _stats) = CallGraphStore::cold_build_with_lease_chunked(
1521 callgraph_dir,
1522 project_root,
1523 &files,
1524 self.config().callgraph_chunk_size,
1525 )?;
1526 Some(store)
1527 } else if CallGraphStore::needs_cold_build(&callgraph_dir, &project_root)? {
1528 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1529 let (store, _stats) = CallGraphStore::ensure_built_with_lease_chunked(
1530 callgraph_dir,
1531 project_root,
1532 &files,
1533 self.config().callgraph_chunk_size,
1534 )?;
1535 Some(store)
1536 } else {
1537 Some(CallGraphStore::open(callgraph_dir, project_root)?)
1538 };
1539
1540 let Some(store) = store else {
1541 return Ok(None);
1542 };
1543 let store = Arc::new(store);
1544 {
1545 let mut guard = self
1546 .callgraph_store
1547 .write()
1548 .unwrap_or_else(std::sync::PoisonError::into_inner);
1549 *guard = Some(Arc::clone(&store));
1550 }
1551 Ok(Some(store))
1552 }
1553
1554 fn callgraph_project_root(&self) -> Option<PathBuf> {
1557 self.canonical_cache_root_opt().or_else(|| {
1558 self.config()
1559 .project_root
1560 .clone()
1561 .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
1562 })
1563 }
1564
1565 pub fn revalidate_callgraph_store_generation(&self) {
1583 if self.callgraph_store_rx.lock().is_some() {
1586 return;
1587 }
1588 let superseded = {
1589 let guard = self
1590 .callgraph_store
1591 .read()
1592 .unwrap_or_else(std::sync::PoisonError::into_inner);
1593 guard.as_ref().is_some_and(|store| !store.is_current())
1594 };
1595 if superseded {
1596 let mut guard = self
1597 .callgraph_store
1598 .write()
1599 .unwrap_or_else(std::sync::PoisonError::into_inner);
1600 *guard = None;
1601 }
1602 }
1603
1604 pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess {
1605 self.revalidate_callgraph_store_generation();
1609 if let Some(store) = {
1610 let guard = self
1611 .callgraph_store
1612 .read()
1613 .unwrap_or_else(std::sync::PoisonError::into_inner);
1614 guard.as_ref().map(Arc::clone)
1615 } {
1616 return CallgraphStoreAccess::Ready(store);
1617 }
1618
1619 if self.callgraph_store_rx.lock().is_some() {
1621 return CallgraphStoreAccess::Building;
1622 }
1623
1624 let Some(project_root) = self.callgraph_project_root() else {
1625 return CallgraphStoreAccess::Unavailable;
1626 };
1627 let callgraph_dir = self.callgraph_store_dir();
1628
1629 if self.is_worktree_bridge() {
1632 match CallGraphStore::open_readonly(callgraph_dir, project_root) {
1633 Ok(Some(store)) => {
1634 let store = Arc::new(store);
1635 {
1636 let mut guard = self
1637 .callgraph_store
1638 .write()
1639 .unwrap_or_else(std::sync::PoisonError::into_inner);
1640 *guard = Some(Arc::clone(&store));
1641 }
1642 return CallgraphStoreAccess::Ready(store);
1643 }
1644 Ok(None) | Err(_) => return CallgraphStoreAccess::Unavailable,
1645 }
1646 }
1647
1648 let force_rebuild = *self.callgraph_store_force_rebuild.lock();
1649 if !force_rebuild {
1652 match CallGraphStore::needs_cold_build(&callgraph_dir, &project_root) {
1653 Ok(false) => match CallGraphStore::open(callgraph_dir, project_root) {
1654 Ok(store) => {
1655 let store = Arc::new(store);
1656 {
1657 let mut guard = self
1658 .callgraph_store
1659 .write()
1660 .unwrap_or_else(std::sync::PoisonError::into_inner);
1661 *guard = Some(Arc::clone(&store));
1662 }
1663 return CallgraphStoreAccess::Ready(store);
1664 }
1665 Err(error) => return CallgraphStoreAccess::Error(error),
1666 },
1667 Ok(true) => {}
1668 Err(error) => return CallgraphStoreAccess::Error(error),
1669 }
1670 }
1671
1672 if self.semantic_cold_seed_active() {
1673 self.defer_callgraph_store_warm_for_semantic_cold_seed();
1674 return CallgraphStoreAccess::Building;
1675 }
1676
1677 if !self.spawn_callgraph_store_cold_build(project_root, callgraph_dir, force_rebuild) {
1685 return CallgraphStoreAccess::Building;
1686 }
1687
1688 let wait = callgraph_build_wait_window();
1689 if !wait.is_zero() {
1690 let received = {
1691 let rx_ref = self.callgraph_store_rx.lock();
1692 let Some(rx) = rx_ref.as_ref() else {
1693 return CallgraphStoreAccess::Building;
1694 };
1695 rx.recv_timeout(wait)
1696 };
1697 match received {
1698 Ok(store) => {
1699 let pending = self.take_pending_callgraph_store_paths();
1703 if !pending.is_empty() {
1704 if let Err(error) = store.refresh_files(&pending) {
1705 crate::slog_warn!(
1706 "callgraph store inline post-build refresh failed: {}",
1707 error
1708 );
1709 let _ = store.mark_files_stale(&pending);
1710 }
1711 }
1712 let store = Arc::new(store);
1713 {
1714 let mut guard = self
1715 .callgraph_store
1716 .write()
1717 .unwrap_or_else(std::sync::PoisonError::into_inner);
1718 *guard = Some(Arc::clone(&store));
1719 }
1720 *self.callgraph_store_rx.lock() = None;
1721 return CallgraphStoreAccess::Ready(store);
1722 }
1723 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1724 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
1725 *self.callgraph_store_rx.lock() = None;
1728 }
1729 }
1730 }
1731 CallgraphStoreAccess::Building
1732 }
1733
1734 fn spawn_callgraph_store_cold_build(
1741 &self,
1742 project_root: PathBuf,
1743 callgraph_dir: PathBuf,
1744 force_rebuild: bool,
1745 ) -> bool {
1746 let session_id = crate::log_ctx::current_session();
1747 let chunk_size = self.config().callgraph_chunk_size;
1748
1749 let mut rx_guard = self.callgraph_store_rx.lock();
1750 if rx_guard.is_some() {
1751 return false;
1752 }
1753
1754 if force_rebuild {
1755 self.take_callgraph_store_force_rebuild();
1758 }
1759 let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStore>();
1760 *rx_guard = Some(rx);
1761
1762 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.fetch_add(1, Ordering::SeqCst);
1763
1764 std::thread::spawn(move || {
1765 crate::log_ctx::with_session(session_id, || {
1766 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1767 let built = if force_rebuild {
1768 CallGraphStore::cold_build_with_lease_chunked(
1769 callgraph_dir,
1770 project_root,
1771 &files,
1772 chunk_size,
1773 )
1774 .map(|(store, _)| store)
1775 } else {
1776 CallGraphStore::ensure_built_with_lease_chunked(
1777 callgraph_dir,
1778 project_root,
1779 &files,
1780 chunk_size,
1781 )
1782 .map(|(store, _)| store)
1783 };
1784 match built {
1785 Ok(store) => {
1786 let _ = tx.send(store);
1787 }
1788 Err(error) => {
1789 crate::slog_warn!("callgraph store cold build failed: {}", error);
1790 }
1793 }
1794 });
1795 });
1796 true
1797 }
1798
1799 pub fn callgraph_store_rx(
1802 &self,
1803 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStore>>> {
1804 &self.callgraph_store_rx
1805 }
1806
1807 pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
1810 where
1811 I: IntoIterator<Item = PathBuf>,
1812 {
1813 self.pending_callgraph_store_paths.lock().extend(paths);
1814 }
1815
1816 pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
1818 std::mem::take(&mut *self.pending_callgraph_store_paths.lock())
1819 .into_iter()
1820 .collect()
1821 }
1822
1823 pub fn search_index(&self) -> &RwLock<Option<SearchIndex>> {
1825 &self.search_index
1826 }
1827
1828 pub fn search_index_rx(&self) -> &RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>> {
1830 &self.search_index_rx
1831 }
1832
1833 pub fn add_pending_search_index_paths<I>(&self, paths: I)
1834 where
1835 I: IntoIterator<Item = PathBuf>,
1836 {
1837 self.pending_search_index_paths.lock().extend(paths);
1838 }
1839
1840 pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
1841 std::mem::take(&mut *self.pending_search_index_paths.lock())
1842 .into_iter()
1843 .collect()
1844 }
1845
1846 pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
1847 where
1848 I: IntoIterator<Item = PathBuf>,
1849 {
1850 self.pending_semantic_index_paths.lock().extend(paths);
1851 }
1852
1853 pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
1854 std::mem::take(&mut *self.pending_semantic_index_paths.lock())
1855 .into_iter()
1856 .collect()
1857 }
1858
1859 pub fn mark_pending_semantic_corpus_refresh(&self) {
1860 *self.pending_semantic_corpus_refresh.lock() = true;
1861 }
1862
1863 pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
1864 std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock())
1865 }
1866
1867 pub fn clear_pending_index_updates(&self) {
1868 self.pending_search_index_paths.lock().clear();
1869 self.pending_callgraph_store_paths.lock().clear();
1870 self.pending_tier2_paths.lock().clear();
1871 self.pending_semantic_index_paths.lock().clear();
1872 *self.pending_semantic_corpus_refresh.lock() = false;
1873 }
1874
1875 pub fn inspect_manager(&self) -> Arc<InspectManager> {
1876 Arc::clone(&self.inspect_manager)
1877 }
1878
1879 pub fn add_pending_tier2_paths<I>(&self, paths: I)
1880 where
1881 I: IntoIterator<Item = PathBuf>,
1882 {
1883 self.pending_tier2_paths.lock().extend(paths);
1884 }
1885
1886 pub fn pending_tier2_paths(&self) -> Vec<PathBuf> {
1887 self.pending_tier2_paths.lock().iter().cloned().collect()
1888 }
1889
1890 pub fn remove_pending_tier2_paths<I>(&self, paths: I)
1891 where
1892 I: IntoIterator<Item = PathBuf>,
1893 {
1894 let mut pending = self.pending_tier2_paths.lock();
1895 for path in paths {
1896 pending.remove(&path);
1897 }
1898 }
1899
1900 pub fn take_new_reuse_completions(&self) -> bool {
1905 let current = self.inspect_manager.reuse_completion_count();
1906 let previous = self
1907 .last_seen_reuse_completions
1908 .swap(current, Ordering::SeqCst);
1909 current != previous
1910 }
1911
1912 pub fn reset_tier2_refresh_scheduler(&self) {
1913 self.reset_tier2_refresh_scheduler_at(Instant::now());
1914 }
1915
1916 #[doc(hidden)]
1917 pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
1918 self.tier2_refresh_scheduler
1919 .lock()
1920 .reset_after_configure(now);
1921 }
1922
1923 pub fn request_tier2_refresh_pull(&self) -> bool {
1924 self.tier2_refresh_scheduler
1925 .lock()
1926 .request_pull(!self.is_worktree_bridge())
1927 }
1928
1929 pub fn tick_tier2_refresh_scheduler(
1930 &self,
1931 changed_path_count: usize,
1932 ) -> Option<Tier2TriggerReason> {
1933 self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
1934 }
1935
1936 #[doc(hidden)]
1937 pub fn tick_tier2_refresh_scheduler_at(
1938 &self,
1939 now: Instant,
1940 changed_path_count: usize,
1941 ) -> Option<Tier2TriggerReason> {
1942 let manager = self.inspect_manager();
1943 let can_write = !self.is_worktree_bridge();
1944 let in_flight = manager.tier2_any_in_flight();
1945 let semantic_cold_seed_active = self.semantic_cold_seed_active();
1946 let decision = self.tier2_refresh_scheduler.lock().tick_with_semantic_gate(
1947 now,
1948 changed_path_count,
1949 can_write,
1950 in_flight,
1951 semantic_cold_seed_active,
1952 );
1953
1954 if let Some(reason) = decision {
1955 self.start_tier2_refresh(reason, manager);
1956 }
1957
1958 decision
1959 }
1960
1961 pub fn note_tier2_refresh_started(&self) {
1962 self.note_tier2_refresh_started_at(Instant::now());
1963 }
1964
1965 #[doc(hidden)]
1966 pub fn note_tier2_refresh_started_at(&self, now: Instant) {
1967 self.tier2_refresh_scheduler
1968 .lock()
1969 .note_external_scan_started(now);
1970 }
1971
1972 pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
1973 self.tier2_refresh_scheduler
1974 .lock()
1975 .last_trigger_reason()
1976 .map(Tier2TriggerReason::as_str)
1977 }
1978
1979 #[doc(hidden)]
1980 pub fn tier2_pull_demand_pending(&self) -> bool {
1981 self.tier2_refresh_scheduler.lock().pull_demand_pending()
1982 }
1983
1984 fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
1985 if self.is_worktree_bridge()
1986 || self
1987 .degraded_reasons
1988 .lock()
1989 .iter()
1990 .any(|r| r == "home_root")
1991 || !self.config().inspect.enabled
1992 {
1993 return;
1994 }
1995 let Some(snapshot) = self.tier2_refresh_snapshot() else {
1996 return;
1997 };
1998 let categories = InspectCategory::active()
1999 .iter()
2000 .copied()
2001 .filter(|category| category.is_tier2())
2002 .collect::<Vec<_>>();
2003 let submission =
2004 manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
2005 if submission.has_new_work() {
2006 crate::slog_info!(
2007 "tier2 refresh scheduled: reason={}, categories={:?}",
2008 reason.as_str(),
2009 submission
2010 .newly_queued_categories
2011 .iter()
2012 .map(|category| category.as_str())
2013 .collect::<Vec<_>>()
2014 );
2015 }
2016 for error in submission.errors {
2017 crate::slog_warn!(
2018 "tier2 refresh schedule failed for {}: {}",
2019 error.category,
2020 error.message
2021 );
2022 }
2023 }
2024
2025 fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
2026 self.harness_opt()?;
2027 let config = self.config();
2028 let project_root = config
2029 .project_root
2030 .clone()
2031 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
2032 let project_root = std::fs::canonicalize(&project_root).unwrap_or(project_root);
2033 Some(InspectSnapshot::new(
2034 project_root,
2035 self.inspect_dir(),
2036 config,
2037 self.symbol_cache(),
2038 ))
2039 }
2040
2041 pub fn symbol_cache(&self) -> SharedSymbolCache {
2043 Arc::clone(&self.symbol_cache)
2044 }
2045
2046 pub fn reset_symbol_cache(&self) -> u64 {
2048 self.symbol_cache
2049 .write()
2050 .map(|mut cache| cache.reset())
2051 .unwrap_or(0)
2052 }
2053
2054 pub fn semantic_index(&self) -> &RwLock<Option<SemanticIndex>> {
2056 &self.semantic_index
2057 }
2058
2059 pub fn semantic_index_rx(
2061 &self,
2062 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
2063 &self.semantic_index_rx
2064 }
2065
2066 pub fn semantic_index_status(&self) -> &RwLock<SemanticIndexStatus> {
2067 &self.semantic_index_status
2068 }
2069
2070 pub fn reset_semantic_cold_seed_gate_for_configure(&self) -> u64 {
2073 self.semantic_cold_seed_active
2074 .store(false, Ordering::SeqCst);
2075 self.semantic_callgraph_warm_deferred
2076 .store(false, Ordering::SeqCst);
2077 self.semantic_cold_seed_generation
2078 .fetch_add(1, Ordering::SeqCst)
2079 .wrapping_add(1)
2080 }
2081
2082 pub fn semantic_cold_seed_active_flag(&self) -> Arc<AtomicBool> {
2083 Arc::clone(&self.semantic_cold_seed_active)
2084 }
2085
2086 pub fn semantic_cold_seed_generation_flag(&self) -> Arc<AtomicU64> {
2087 Arc::clone(&self.semantic_cold_seed_generation)
2088 }
2089
2090 pub fn semantic_cold_seed_active(&self) -> bool {
2091 self.semantic_cold_seed_active.load(Ordering::SeqCst)
2092 }
2093
2094 pub fn schedule_semantic_cold_seed_gate_for_configure(&self) {
2095 self.semantic_cold_seed_active.store(true, Ordering::SeqCst);
2096 }
2097
2098 pub fn defer_callgraph_store_warm_for_semantic_cold_seed(&self) {
2099 self.semantic_callgraph_warm_deferred
2100 .store(true, Ordering::SeqCst);
2101 }
2102
2103 fn semantic_callgraph_warm_deferred(&self) -> bool {
2104 self.semantic_callgraph_warm_deferred.load(Ordering::SeqCst)
2105 }
2106
2107 pub fn clear_semantic_cold_seed_gate_and_resume_deferred_work(&self) {
2111 self.resume_semantic_cold_seed_deferred_work(false);
2112 }
2113
2114 pub fn resume_deferred_work_after_semantic_cold_seed_gate_cleared(&self) {
2117 self.resume_semantic_cold_seed_deferred_work(true);
2118 }
2119
2120 fn resume_semantic_cold_seed_deferred_work(&self, force: bool) {
2121 let was_active = self.semantic_cold_seed_active.swap(false, Ordering::SeqCst);
2122 let had_deferred_callgraph = self.semantic_callgraph_warm_deferred();
2123
2124 if force || was_active || had_deferred_callgraph {
2125 let _ = self.request_tier2_refresh_pull();
2126 }
2127
2128 if self
2129 .semantic_callgraph_warm_deferred
2130 .swap(false, Ordering::SeqCst)
2131 {
2132 if !self.config().callgraph_store
2133 || self
2134 .degraded_reasons
2135 .lock()
2136 .iter()
2137 .any(|reason| reason == "home_root")
2138 {
2139 return;
2140 }
2141
2142 match self.callgraph_store_for_ops() {
2143 CallgraphStoreAccess::Ready(_) => {
2144 crate::slog_debug!(
2145 "deferred callgraph store warm completed after semantic cold seed gate cleared"
2146 );
2147 }
2148 CallgraphStoreAccess::Building => {
2149 crate::slog_info!(
2150 "deferred callgraph store warm scheduled after semantic cold seed gate cleared"
2151 );
2152 }
2153 CallgraphStoreAccess::Unavailable => {
2154 crate::slog_info!(
2155 "deferred callgraph store warm unavailable after semantic cold seed gate cleared"
2156 );
2157 }
2158 CallgraphStoreAccess::Error(error) => {
2159 crate::slog_warn!(
2160 "deferred callgraph store warm failed after semantic cold seed gate cleared: {}",
2161 error
2162 );
2163 }
2164 }
2165 }
2166 }
2167
2168 #[doc(hidden)]
2169 pub fn set_semantic_cold_seed_active_for_test(&self, active: bool) {
2170 self.semantic_cold_seed_active
2171 .store(active, Ordering::SeqCst);
2172 }
2173
2174 #[doc(hidden)]
2175 pub fn semantic_callgraph_warm_deferred_for_test(&self) -> bool {
2176 self.semantic_callgraph_warm_deferred()
2177 }
2178
2179 pub fn install_semantic_refresh_worker(
2180 &self,
2181 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
2182 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
2183 worker_slot: SemanticRefreshWorkerSlot,
2184 ) {
2185 self.clear_semantic_refresh_worker();
2186 *self.semantic_refresh_tx.lock() = Some(sender);
2187 *self.semantic_refresh_event_rx.lock() = Some(event_rx);
2188 *self.semantic_refresh_worker.lock() = Some(worker_slot);
2189 }
2190
2191 pub fn clear_semantic_refresh_worker(&self) {
2192 *self.semantic_refresh_tx.lock() = None;
2193 *self.semantic_refresh_event_rx.lock() = None;
2194 if let Some(worker_slot) = self.semantic_refresh_worker.lock().take() {
2195 if let Ok(mut handle) = worker_slot.lock() {
2196 drop(handle.take());
2197 }
2198 }
2199 }
2200
2201 pub fn semantic_refresh_sender(
2202 &self,
2203 ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
2204 self.semantic_refresh_tx.lock().clone()
2205 }
2206
2207 pub fn semantic_refresh_event_rx(
2208 &self,
2209 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
2210 &self.semantic_refresh_event_rx
2211 }
2212
2213 pub fn with_semantic_refresh_retry_attempts_mut<R>(
2214 &self,
2215 f: impl FnOnce(&mut BTreeMap<PathBuf, usize>) -> R,
2216 ) -> R {
2217 let mut attempts = self.semantic_refresh_retry_attempts.lock();
2218 f(&mut attempts)
2219 }
2220
2221 pub fn clear_semantic_refresh_retry_attempts(&self, paths: &[PathBuf]) {
2222 let mut attempts = self.semantic_refresh_retry_attempts.lock();
2223 for path in paths {
2224 attempts.remove(path);
2225 }
2226 }
2227
2228 pub fn clear_all_semantic_refresh_retry_attempts(&self) {
2229 self.semantic_refresh_retry_attempts.lock().clear();
2230 }
2231
2232 pub fn semantic_refresh_circuit_is_open(&self) -> bool {
2233 self.semantic_refresh_circuit.open.load(Ordering::SeqCst)
2234 }
2235
2236 pub fn record_semantic_refresh_transient_failure(&self, trip_threshold: usize) -> bool {
2237 let failures = self
2238 .semantic_refresh_circuit
2239 .consecutive_transient_failures
2240 .fetch_add(1, Ordering::SeqCst)
2241 .saturating_add(1);
2242 if failures >= trip_threshold
2243 && !self
2244 .semantic_refresh_circuit
2245 .open
2246 .swap(true, Ordering::SeqCst)
2247 {
2248 crate::slog_warn!(
2249 "embedding backend appears down; suspending active retries, will resume on next change or successful probe"
2250 );
2251 }
2252 self.semantic_refresh_circuit_is_open()
2253 }
2254
2255 pub fn reset_semantic_refresh_transient_failure_count(&self) {
2256 self.semantic_refresh_circuit
2257 .consecutive_transient_failures
2258 .store(0, Ordering::SeqCst);
2259 }
2260
2261 pub fn reset_semantic_refresh_circuit_after_success(&self) {
2262 self.reset_semantic_refresh_transient_failure_count();
2263 self.semantic_refresh_circuit
2264 .probe_ready
2265 .store(false, Ordering::SeqCst);
2266 if self
2267 .semantic_refresh_circuit
2268 .open
2269 .swap(false, Ordering::SeqCst)
2270 {
2271 crate::slog_info!("embedding backend recovered; resuming normal refresh retries");
2272 }
2273 }
2274
2275 pub fn semantic_refresh_transient_failure_count(&self) -> usize {
2276 self.semantic_refresh_circuit
2277 .consecutive_transient_failures
2278 .load(Ordering::SeqCst)
2279 }
2280
2281 pub fn semantic_refresh_probe_is_scheduled(&self) -> bool {
2282 self.semantic_refresh_circuit
2283 .probe_in_flight
2284 .load(Ordering::SeqCst)
2285 || self
2286 .semantic_refresh_circuit
2287 .probe_ready
2288 .load(Ordering::SeqCst)
2289 }
2290
2291 pub fn take_semantic_refresh_probe_ready(&self) -> bool {
2292 self.semantic_refresh_circuit
2293 .probe_ready
2294 .swap(false, Ordering::SeqCst)
2295 }
2296
2297 pub fn ensure_semantic_refresh_probe_scheduled(&self, delay: Duration) {
2298 if self
2299 .semantic_refresh_circuit
2300 .probe_ready
2301 .load(Ordering::SeqCst)
2302 {
2303 return;
2304 }
2305 if self
2306 .semantic_refresh_circuit
2307 .probe_in_flight
2308 .swap(true, Ordering::SeqCst)
2309 {
2310 return;
2311 }
2312 if self
2313 .semantic_refresh_circuit
2314 .probe_ready
2315 .load(Ordering::SeqCst)
2316 {
2317 self.semantic_refresh_circuit
2318 .probe_in_flight
2319 .store(false, Ordering::SeqCst);
2320 return;
2321 }
2322
2323 let circuit = Arc::clone(&self.semantic_refresh_circuit);
2324 let session_id = crate::log_ctx::current_session();
2325 std::thread::spawn(move || {
2326 crate::log_ctx::with_session(session_id, || {
2327 std::thread::sleep(delay);
2328 circuit.probe_ready.store(true, Ordering::SeqCst);
2329 circuit.probe_in_flight.store(false, Ordering::SeqCst);
2330 });
2331 });
2332 }
2333
2334 pub fn semantic_embedding_model(
2336 &self,
2337 ) -> &parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>> {
2338 &self.semantic_embedding_model
2339 }
2340
2341 pub fn watcher(&self) -> &parking_lot::Mutex<Option<RecommendedWatcher>> {
2343 &self.watcher
2344 }
2345
2346 pub fn watcher_rx(
2348 &self,
2349 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>> {
2350 &self.watcher_rx
2351 }
2352
2353 pub fn install_watcher_runtime(
2356 &self,
2357 rx: crossbeam_channel::Receiver<WatcherDispatchEvent>,
2358 runtime: WatcherThreadHandle,
2359 ) {
2360 *self.watcher_rx.lock() = Some(rx);
2361 *self.watcher_thread.lock() = Some(runtime);
2362 }
2363
2364 pub fn stop_watcher_runtime(&self) {
2367 if let Some(runtime) = self.watcher_thread.lock().take() {
2368 runtime.shutdown_and_join();
2369 }
2370 *self.watcher_rx.lock() = None;
2371 *self.watcher.lock() = None;
2372 }
2373
2374 pub fn lsp(&self) -> parking_lot::MutexGuard<'_, LspManager> {
2376 self.lsp_manager.lock()
2377 }
2378
2379 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
2382 let config = self.config();
2383 if let Some(mut lsp) = self.lsp_manager.try_lock() {
2384 if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
2385 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
2386 }
2387 }
2388 }
2389
2390 pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
2396 if let Some(mut lsp) = self.lsp_manager.try_lock() {
2397 lsp.clear_diagnostics_for_file(file_path)
2398 } else {
2399 false
2400 }
2401 }
2402
2403 pub fn lsp_notify_and_collect_diagnostics(
2414 &self,
2415 file_path: &Path,
2416 content: &str,
2417 timeout: std::time::Duration,
2418 ) -> crate::lsp::manager::PostEditWaitOutcome {
2419 let config = self.config();
2420 let Some(mut lsp) = self.lsp_manager.try_lock() else {
2421 return crate::lsp::manager::PostEditWaitOutcome::default();
2422 };
2423
2424 lsp.drain_events();
2427
2428 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
2432
2433 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
2435 {
2436 Ok(v) => v,
2437 Err(e) => {
2438 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
2439 return crate::lsp::manager::PostEditWaitOutcome::default();
2440 }
2441 };
2442
2443 if expected_versions.is_empty() {
2446 return crate::lsp::manager::PostEditWaitOutcome::default();
2447 }
2448
2449 lsp.wait_for_post_edit_diagnostics(
2450 file_path,
2451 &config,
2452 &expected_versions,
2453 &pre_snapshot,
2454 timeout,
2455 )
2456 }
2457
2458 fn custom_lsp_root_markers(&self) -> Vec<String> {
2461 self.config()
2462 .lsp_servers
2463 .iter()
2464 .flat_map(|s| s.root_markers.iter().cloned())
2465 .collect()
2466 }
2467
2468 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
2469 let custom_markers = self.custom_lsp_root_markers();
2470 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
2471 .iter()
2472 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
2473 .cloned()
2474 .map(|path| {
2475 let change_type = if path.exists() {
2476 FileChangeType::CHANGED
2477 } else {
2478 FileChangeType::DELETED
2479 };
2480 (path, change_type)
2481 })
2482 .collect();
2483
2484 self.notify_watched_config_events(&config_paths);
2485 }
2486
2487 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
2488 let paths = params
2489 .get("multi_file_write_paths")
2490 .and_then(|value| value.as_array())?
2491 .iter()
2492 .filter_map(|value| value.as_str())
2493 .map(PathBuf::from)
2494 .collect::<Vec<_>>();
2495
2496 (!paths.is_empty()).then_some(paths)
2497 }
2498
2499 fn watched_file_events_from_params(
2511 params: &serde_json::Value,
2512 extra_markers: &[String],
2513 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
2514 let events = params
2515 .get("multi_file_write_paths")
2516 .and_then(|value| value.as_array())?
2517 .iter()
2518 .filter_map(|entry| {
2519 let path = entry
2521 .get("path")
2522 .and_then(|value| value.as_str())
2523 .map(PathBuf::from)?;
2524
2525 if !is_config_file_path_with_custom(&path, extra_markers) {
2526 return None;
2527 }
2528
2529 let change_type = entry
2530 .get("type")
2531 .and_then(|value| value.as_str())
2532 .and_then(Self::parse_file_change_type)
2533 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
2534
2535 Some((path, change_type))
2536 })
2537 .collect::<Vec<_>>();
2538
2539 (!events.is_empty()).then_some(events)
2540 }
2541
2542 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
2543 match value {
2544 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
2545 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
2546 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
2547 _ => None,
2548 }
2549 }
2550
2551 fn change_type_from_current_state(path: &Path) -> FileChangeType {
2552 if path.exists() {
2553 FileChangeType::CHANGED
2554 } else {
2555 FileChangeType::DELETED
2556 }
2557 }
2558
2559 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
2560 if config_paths.is_empty() {
2561 return;
2562 }
2563
2564 let config = self.config();
2565 if let Some(mut lsp) = self.lsp_manager.try_lock() {
2566 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
2567 crate::slog_warn!("watched-file sync error: {}", e);
2568 }
2569 }
2570 }
2571
2572 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
2573 let custom_markers = self.custom_lsp_root_markers();
2574 if !is_config_file_path_with_custom(file_path, &custom_markers) {
2575 return;
2576 }
2577
2578 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
2579 }
2580
2581 pub fn lsp_post_multi_file_write(
2586 &self,
2587 file_path: &Path,
2588 content: &str,
2589 file_paths: &[PathBuf],
2590 params: &serde_json::Value,
2591 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2592 self.notify_watched_config_files(file_paths);
2593 self.add_pending_tier2_paths(file_paths.iter().cloned());
2594 let _ = self.mark_status_bar_tier2_stale();
2595
2596 let wants_diagnostics = params
2597 .get("diagnostics")
2598 .and_then(|v| v.as_bool())
2599 .unwrap_or(false);
2600
2601 if !wants_diagnostics {
2602 self.lsp_notify_file_changed(file_path, content);
2603 return None;
2604 }
2605
2606 let wait_ms = params
2607 .get("wait_ms")
2608 .and_then(|v| v.as_u64())
2609 .unwrap_or(3000)
2610 .min(10_000);
2611
2612 Some(self.lsp_notify_and_collect_diagnostics(
2613 file_path,
2614 content,
2615 std::time::Duration::from_millis(wait_ms),
2616 ))
2617 }
2618
2619 pub fn lsp_post_write(
2636 &self,
2637 file_path: &Path,
2638 content: &str,
2639 params: &serde_json::Value,
2640 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2641 let wants_diagnostics = params
2642 .get("diagnostics")
2643 .and_then(|v| v.as_bool())
2644 .unwrap_or(false);
2645
2646 let custom_markers = self.custom_lsp_root_markers();
2647 if let Some(file_paths) = Self::multi_file_write_paths(params) {
2648 self.add_pending_tier2_paths(file_paths);
2649 } else {
2650 self.add_pending_tier2_paths([file_path.to_path_buf()]);
2651 }
2652 let _ = self.mark_status_bar_tier2_stale();
2653
2654 if !wants_diagnostics {
2655 if let Some(file_paths) = Self::multi_file_write_paths(params) {
2656 self.notify_watched_config_files(&file_paths);
2657 } else if let Some(config_events) =
2658 Self::watched_file_events_from_params(params, &custom_markers)
2659 {
2660 self.notify_watched_config_events(&config_events);
2661 }
2662 self.lsp_notify_file_changed(file_path, content);
2663 return None;
2664 }
2665
2666 let wait_ms = params
2667 .get("wait_ms")
2668 .and_then(|v| v.as_u64())
2669 .unwrap_or(3000)
2670 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
2673 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
2674 }
2675
2676 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
2677 {
2678 self.notify_watched_config_events(&config_events);
2679 }
2680
2681 Some(self.lsp_notify_and_collect_diagnostics(
2682 file_path,
2683 content,
2684 std::time::Duration::from_millis(wait_ms),
2685 ))
2686 }
2687
2688 pub fn validate_path(
2697 &self,
2698 req_id: &str,
2699 path: &Path,
2700 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
2701 let config = self.config();
2702 if !config.restrict_to_project_root {
2704 return Ok(path.to_path_buf());
2705 }
2706 let root = match &config.project_root {
2707 Some(r) => r.clone(),
2708 None => return Ok(path.to_path_buf()), };
2710 drop(config);
2711
2712 let raw_root = root.clone();
2717 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
2718
2719 let path_for_resolution = if path.is_relative() {
2724 raw_root.join(path)
2725 } else {
2726 path.to_path_buf()
2727 };
2728 let resolved = match std::fs::canonicalize(&path_for_resolution) {
2729 Ok(resolved) => resolved,
2730 Err(_) => {
2731 let normalized = normalize_path(&path_for_resolution);
2732 reject_escaping_symlink(
2733 req_id,
2734 &path_for_resolution,
2735 &normalized,
2736 &resolved_root,
2737 &raw_root,
2738 )?;
2739 resolve_with_existing_ancestors(&normalized)
2740 }
2741 };
2742
2743 if !resolved.starts_with(&resolved_root) {
2744 return Err(path_error_response(req_id, path, &resolved_root));
2745 }
2746
2747 Ok(resolved)
2748 }
2749
2750 pub fn lsp_server_count(&self) -> usize {
2752 self.lsp_manager
2753 .try_lock()
2754 .map(|lsp| lsp.server_count())
2755 .unwrap_or(0)
2756 }
2757
2758 pub fn symbol_cache_stats(&self) -> serde_json::Value {
2760 let entries = self
2761 .symbol_cache
2762 .read()
2763 .map(|cache| cache.len())
2764 .unwrap_or(0);
2765 serde_json::json!({
2766 "local_entries": entries,
2767 "warm_entries": 0,
2768 })
2769 }
2770}
2771
2772#[cfg(test)]
2773mod callgraph_store_for_ops_tests {
2774 use super::*;
2775 use crate::parser::TreeSitterProvider;
2776 use std::ffi::OsString;
2777 use std::sync::{Barrier, Mutex as StdMutex, MutexGuard, OnceLock};
2778 use tempfile::TempDir;
2779
2780 struct CallgraphWaitWindowEnvGuard {
2781 _guard: MutexGuard<'static, ()>,
2782 previous: Option<OsString>,
2783 }
2784
2785 impl Drop for CallgraphWaitWindowEnvGuard {
2786 fn drop(&mut self) {
2787 unsafe {
2790 match &self.previous {
2791 Some(value) => std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", value),
2792 None => std::env::remove_var("AFT_CALLGRAPH_BUILD_WAIT_MS"),
2793 }
2794 }
2795 }
2796 }
2797
2798 fn force_async_callgraph_builds() -> CallgraphWaitWindowEnvGuard {
2799 static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
2800 let guard = LOCK
2801 .get_or_init(|| StdMutex::new(()))
2802 .lock()
2803 .unwrap_or_else(|error| error.into_inner());
2804 let previous = std::env::var_os("AFT_CALLGRAPH_BUILD_WAIT_MS");
2805 unsafe {
2807 std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", "0");
2808 }
2809 CallgraphWaitWindowEnvGuard {
2810 _guard: guard,
2811 previous,
2812 }
2813 }
2814
2815 fn cold_build_context() -> Arc<AppContext> {
2816 let project = TempDir::new().expect("project tempdir");
2817 let storage = TempDir::new().expect("storage tempdir");
2818 let source_dir = project.path().join("src");
2819 std::fs::create_dir_all(&source_dir).expect("source dir");
2820 std::fs::write(
2821 source_dir.join("lib.rs"),
2822 "pub fn caller() { callee(); }\npub fn callee() {}\n",
2823 )
2824 .expect("source file");
2825
2826 Arc::new(AppContext::new(
2827 Box::new(TreeSitterProvider::new()),
2828 Config {
2829 project_root: Some(project.keep()),
2830 storage_dir: Some(storage.keep()),
2831 callgraph_chunk_size: 1,
2832 ..Config::default()
2833 },
2834 ))
2835 }
2836
2837 fn empty_semantic_index_for_ctx(ctx: &AppContext) -> SemanticIndex {
2838 let project_root = ctx
2839 .config()
2840 .project_root
2841 .clone()
2842 .expect("test context has a project root");
2843 let files: Vec<PathBuf> = Vec::new();
2844 let mut embed = |_texts: Vec<String>| -> Result<Vec<Vec<f32>>, String> { Ok(Vec::new()) };
2845 SemanticIndex::build(&project_root, &files, &mut embed, 1)
2846 .expect("empty semantic index should build")
2847 }
2848
2849 #[test]
2850 fn semantic_ready_event_resumes_deferred_callgraph_and_tier2() {
2851 let _env_guard = force_async_callgraph_builds();
2852 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
2853 let ctx = cold_build_context();
2854 let (tx, rx) = crossbeam_channel::unbounded();
2855 *ctx.semantic_index_rx().lock() = Some(rx);
2856 ctx.schedule_semantic_cold_seed_gate_for_configure();
2857
2858 assert!(matches!(
2859 ctx.callgraph_store_for_ops(),
2860 CallgraphStoreAccess::Building
2861 ));
2862 assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
2863 tx.send(SemanticIndexEvent::Ready(empty_semantic_index_for_ctx(
2864 &ctx,
2865 )))
2866 .expect("send ready event");
2867
2868 crate::runtime_drain::drain_semantic_index_events(&ctx);
2869
2870 assert!(
2871 !ctx.semantic_cold_seed_active(),
2872 "semantic Ready must clear the scheduled cold gate"
2873 );
2874 assert!(
2875 ctx.tier2_pull_demand_pending(),
2876 "semantic Ready must resume deferred Tier-2 work"
2877 );
2878 assert_eq!(
2879 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
2880 1,
2881 "semantic Ready must resume the deferred callgraph warm"
2882 );
2883 let rx = ctx
2884 .callgraph_store_rx
2885 .lock()
2886 .as_ref()
2887 .cloned()
2888 .expect("ready resume should install an in-flight callgraph receiver");
2889 rx.recv_timeout(Duration::from_secs(30))
2890 .expect("background cold build should complete");
2891 *ctx.callgraph_store_rx.lock() = None;
2892 }
2893
2894 #[test]
2895 fn semantic_gate_cleared_event_resumes_deferred_callgraph_and_tier2() {
2896 let _env_guard = force_async_callgraph_builds();
2897 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
2898 let ctx = cold_build_context();
2899 ctx.schedule_semantic_cold_seed_gate_for_configure();
2900
2901 assert!(matches!(
2902 ctx.callgraph_store_for_ops(),
2903 CallgraphStoreAccess::Building
2904 ));
2905 assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
2906 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
2907
2908 assert!(
2909 !ctx.semantic_cold_seed_active(),
2910 "cached-load or retry-wait clear must reopen the semantic cold gate"
2911 );
2912 assert!(
2913 ctx.tier2_pull_demand_pending(),
2914 "cached-load or retry-wait clear must resume deferred Tier-2 work"
2915 );
2916 assert_eq!(
2917 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
2918 1,
2919 "cached-load or retry-wait clear must resume deferred callgraph warm"
2920 );
2921 let rx = ctx
2922 .callgraph_store_rx
2923 .lock()
2924 .as_ref()
2925 .cloned()
2926 .expect("gate-clear resume should install an in-flight callgraph receiver");
2927 rx.recv_timeout(Duration::from_secs(30))
2928 .expect("background cold build should complete");
2929 *ctx.callgraph_store_rx.lock() = None;
2930 }
2931
2932 #[test]
2933 fn semantic_cold_seed_gate_defers_callgraph_cold_spawn_until_resume() {
2934 let _env_guard = force_async_callgraph_builds();
2935 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
2936 let ctx = cold_build_context();
2937
2938 ctx.set_semantic_cold_seed_active_for_test(true);
2939 assert!(
2940 matches!(
2941 ctx.callgraph_store_for_ops(),
2942 CallgraphStoreAccess::Building
2943 ),
2944 "callgraph ops should degrade as building while the semantic cold gate is active"
2945 );
2946 assert_eq!(
2947 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
2948 0,
2949 "semantic cold gate must not spawn a competing callgraph cold build"
2950 );
2951 assert!(ctx.semantic_callgraph_warm_deferred_for_test());
2952
2953 ctx.clear_semantic_cold_seed_gate_and_resume_deferred_work();
2954 assert_eq!(
2955 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
2956 1,
2957 "clearing the semantic cold gate should resume the deferred callgraph warm"
2958 );
2959
2960 let rx = ctx
2961 .callgraph_store_rx
2962 .lock()
2963 .as_ref()
2964 .cloned()
2965 .expect("deferred warm should install an in-flight receiver");
2966 rx.recv_timeout(Duration::from_secs(30))
2967 .expect("background cold build should complete");
2968 *ctx.callgraph_store_rx.lock() = None;
2969 }
2970
2971 #[test]
2972 fn semantic_cold_seed_gate_clear_requests_tier2_pull() {
2973 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2974 ctx.schedule_semantic_cold_seed_gate_for_configure();
2975
2976 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
2977
2978 assert!(
2979 !ctx.semantic_cold_seed_active(),
2980 "retry-wait or cached-load events must reopen the semantic cold gate"
2981 );
2982 assert!(
2983 ctx.tier2_pull_demand_pending(),
2984 "clearing the semantic cold gate should kick a Tier-2 pull refresh"
2985 );
2986 }
2987
2988 #[test]
2989 fn semantic_failed_event_clears_scheduled_gate_and_requests_tier2_pull() {
2990 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2991 let (tx, rx) = crossbeam_channel::unbounded();
2992 *ctx.semantic_index_rx().lock() = Some(rx);
2993 ctx.schedule_semantic_cold_seed_gate_for_configure();
2994 tx.send(SemanticIndexEvent::Failed(
2995 "embedding backend failed".to_string(),
2996 ))
2997 .expect("send failed event");
2998
2999 crate::runtime_drain::drain_semantic_index_events(&ctx);
3000
3001 assert!(
3002 !ctx.semantic_cold_seed_active(),
3003 "semantic Failed must clear the scheduled cold gate"
3004 );
3005 assert!(
3006 ctx.tier2_pull_demand_pending(),
3007 "semantic Failed must resume deferred Tier-2 work"
3008 );
3009 }
3010
3011 #[test]
3012 fn semantic_disconnect_clears_scheduled_gate_and_requests_tier2_pull() {
3013 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3014 let (tx, rx) = crossbeam_channel::unbounded::<SemanticIndexEvent>();
3015 *ctx.semantic_index_rx().lock() = Some(rx);
3016 ctx.schedule_semantic_cold_seed_gate_for_configure();
3017 drop(tx);
3018
3019 crate::runtime_drain::drain_semantic_index_events(&ctx);
3020
3021 assert!(
3022 !ctx.semantic_cold_seed_active(),
3023 "semantic worker disconnect must clear the scheduled cold gate"
3024 );
3025 assert!(
3026 ctx.tier2_pull_demand_pending(),
3027 "semantic worker disconnect must resume deferred Tier-2 work"
3028 );
3029 }
3030
3031 #[test]
3032 fn semantic_cold_seed_gate_is_per_context_for_tier2_scheduler() {
3033 let ctx_a = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3034 let ctx_b = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3035 let base = Instant::now();
3036 ctx_a.reset_tier2_refresh_scheduler_at(base);
3037 ctx_b.reset_tier2_refresh_scheduler_at(base);
3038 ctx_a.set_semantic_cold_seed_active_for_test(true);
3039
3040 assert_eq!(
3041 ctx_a.tick_tier2_refresh_scheduler_at(
3042 base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
3043 0,
3044 ),
3045 None,
3046 "root A should defer Tier-2 while its semantic cold seed is active"
3047 );
3048 assert_eq!(
3049 ctx_b.tick_tier2_refresh_scheduler_at(
3050 base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
3051 0,
3052 ),
3053 Some(Tier2TriggerReason::ConfigureWarm),
3054 "root B must not inherit root A's semantic cold gate"
3055 );
3056 }
3057
3058 #[test]
3059 fn concurrent_cold_callgraph_store_for_ops_spawns_one_build() {
3060 let _env_guard = force_async_callgraph_builds();
3061 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
3062
3063 let project = TempDir::new().expect("project tempdir");
3064 let storage = TempDir::new().expect("storage tempdir");
3065 let source_dir = project.path().join("src");
3066 std::fs::create_dir_all(&source_dir).expect("source dir");
3067 std::fs::write(
3068 source_dir.join("lib.rs"),
3069 "pub fn caller() { callee(); }\npub fn callee() {}\n",
3070 )
3071 .expect("source file");
3072
3073 let ctx = Arc::new(AppContext::new(
3074 Box::new(TreeSitterProvider::new()),
3075 Config {
3076 project_root: Some(project.path().to_path_buf()),
3077 storage_dir: Some(storage.path().to_path_buf()),
3078 callgraph_chunk_size: 1,
3079 ..Config::default()
3080 },
3081 ));
3082
3083 let barrier = Arc::new(Barrier::new(3));
3084 let handles = (0..2)
3085 .map(|_| {
3086 let ctx = Arc::clone(&ctx);
3087 let barrier = Arc::clone(&barrier);
3088 std::thread::spawn(move || {
3089 barrier.wait();
3090 matches!(
3091 ctx.callgraph_store_for_ops(),
3092 CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
3093 )
3094 })
3095 })
3096 .collect::<Vec<_>>();
3097
3098 barrier.wait();
3099 for handle in handles {
3100 assert!(
3101 handle.join().expect("callgraph caller thread"),
3102 "cold callgraph ops should report Building or observe the installed store"
3103 );
3104 }
3105
3106 assert_eq!(
3107 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3108 1,
3109 "concurrent cold callers must share one background build"
3110 );
3111
3112 let rx = ctx
3113 .callgraph_store_rx
3114 .lock()
3115 .as_ref()
3116 .cloned()
3117 .expect("in-flight receiver installed before spawn");
3118 rx.recv_timeout(Duration::from_secs(30))
3119 .expect("background cold build should complete");
3120 *ctx.callgraph_store_rx.lock() = None;
3121 }
3122}
3123
3124#[cfg(test)]
3125mod status_emitter_tests {
3126 use super::*;
3127 use crate::parser::TreeSitterProvider;
3128
3129 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
3130 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3131 let (tx, rx) = mpsc::channel();
3132 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
3133 let _ = tx.send(frame);
3134 }))));
3135 (ctx, rx)
3136 }
3137
3138 #[test]
3139 fn status_emitter_signal_triggers_push() {
3140 let (ctx, rx) = ctx_with_frame_rx();
3141 ctx.status_emitter().signal(ctx.build_status_snapshot());
3142 let frame = rx
3143 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3144 .expect("status_changed push");
3145 assert!(matches!(frame, PushFrame::StatusChanged(_)));
3146 }
3147
3148 #[test]
3149 fn status_emitter_debounces_burst() {
3150 let (ctx, rx) = ctx_with_frame_rx();
3151 for _ in 0..10 {
3152 ctx.status_emitter().signal(ctx.build_status_snapshot());
3153 }
3154 let frame = rx
3155 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3156 .expect("status_changed push");
3157 assert!(matches!(frame, PushFrame::StatusChanged(_)));
3158 assert!(rx.try_recv().is_err());
3159 }
3160
3161 #[test]
3162 fn status_emitter_separate_windows_separate_pushes() {
3163 let (ctx, rx) = ctx_with_frame_rx();
3164 ctx.status_emitter().signal(ctx.build_status_snapshot());
3165 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3166 .expect("first push");
3167 ctx.status_emitter().signal(ctx.build_status_snapshot());
3168 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3169 .expect("second push");
3170 }
3171
3172 #[test]
3173 fn status_emitter_no_signal_no_push() {
3174 let (_ctx, rx) = ctx_with_frame_rx();
3175 assert!(rx
3176 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
3177 .is_err());
3178 }
3179
3180 #[test]
3181 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
3182 let (ctx, rx) = ctx_with_frame_rx();
3183 drop(ctx);
3184 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
3185 }
3186
3187 #[test]
3188 fn progress_sender_slot_is_per_context_for_shared_app() {
3189 let app = App::default_shared();
3190 let ctx_a = AppContext::from_app(Arc::clone(&app), Config::default());
3191 let ctx_b = AppContext::from_app(app, Config::default());
3192 let (tx_a, rx_a) = mpsc::channel();
3193 let (tx_b, rx_b) = mpsc::channel();
3194
3195 ctx_a.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
3196 let _ = tx_a.send(frame);
3197 }))));
3198 ctx_b.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
3199 let _ = tx_b.send(frame);
3200 }))));
3201
3202 ctx_a.emit_progress(ProgressFrame {
3203 frame_type: "progress",
3204 request_id: "ctx-a".to_string(),
3205 kind: crate::protocol::ProgressKind::Stdout,
3206 chunk: "a".to_string(),
3207 });
3208 ctx_b.emit_progress(ProgressFrame {
3209 frame_type: "progress",
3210 request_id: "ctx-b".to_string(),
3211 kind: crate::protocol::ProgressKind::Stdout,
3212 chunk: "b".to_string(),
3213 });
3214
3215 match rx_a
3216 .recv_timeout(Duration::from_millis(50))
3217 .expect("ctx A progress frame")
3218 {
3219 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-a"),
3220 other => panic!("unexpected frame for ctx A: {other:?}"),
3221 }
3222 assert!(rx_a.try_recv().is_err());
3223
3224 match rx_b
3225 .recv_timeout(Duration::from_millis(50))
3226 .expect("ctx B progress frame")
3227 {
3228 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-b"),
3229 other => panic!("unexpected frame for ctx B: {other:?}"),
3230 }
3231 assert!(rx_b.try_recv().is_err());
3232 }
3233}
3234
3235#[cfg(test)]
3236mod status_bar_tests {
3237 use super::*;
3238 use crate::parser::TreeSitterProvider;
3239
3240 fn ctx() -> AppContext {
3241 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
3242 }
3243
3244 #[test]
3245 fn status_bar_counts_none_until_tier2_populated() {
3246 let ctx = ctx();
3247 assert!(ctx.status_bar_counts().is_none());
3249
3250 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
3251 let counts = ctx.status_bar_counts().expect("populated");
3252 assert_eq!(counts.dead_code, 5);
3253 assert_eq!(counts.unused_exports, 3);
3254 assert_eq!(counts.duplicates, 7);
3255 assert_eq!(counts.todos, 2);
3256 assert!(!counts.tier2_stale);
3257 assert_eq!(counts.errors, 0);
3259 assert_eq!(counts.warnings, 0);
3260 }
3261
3262 #[test]
3263 fn partial_tier2_does_not_fabricate_zeros() {
3264 let ctx = ctx();
3265 ctx.update_status_bar_tier2(Some(5), None, None, None, true);
3269 assert!(
3270 ctx.status_bar_counts().is_none(),
3271 "bar must not surface until all three Tier-2 categories are real"
3272 );
3273
3274 ctx.update_status_bar_tier2(None, Some(3), None, None, true);
3276 assert!(ctx.status_bar_counts().is_none());
3277
3278 ctx.update_status_bar_tier2(None, None, Some(7), None, false);
3281 let counts = ctx.status_bar_counts().expect("all three real now");
3282 assert_eq!(counts.dead_code, 5);
3283 assert_eq!(counts.unused_exports, 3);
3284 assert_eq!(counts.duplicates, 7);
3285 }
3286
3287 #[test]
3288 fn update_with_none_todos_preserves_last_known_todos() {
3289 let ctx = ctx();
3290 ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
3291 ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
3293 let counts = ctx.status_bar_counts().expect("populated");
3294 assert_eq!(counts.todos, 9);
3295 assert_eq!(counts.dead_code, 2);
3296 }
3297
3298 #[test]
3299 fn update_with_none_count_preserves_last_known_count() {
3300 let ctx = ctx();
3301 ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
3302 ctx.update_status_bar_tier2(Some(11), None, None, None, false);
3305 let counts = ctx.status_bar_counts().expect("populated");
3306 assert_eq!(counts.dead_code, 11);
3307 assert_eq!(counts.unused_exports, 20);
3308 assert_eq!(counts.duplicates, 30);
3309 }
3310
3311 #[test]
3312 fn mark_stale_sets_flag_only_after_populate() {
3313 let ctx = ctx();
3314 ctx.mark_status_bar_tier2_stale();
3316 assert!(ctx.status_bar_counts().is_none());
3317
3318 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), Some(0), false);
3319 ctx.mark_status_bar_tier2_stale();
3320 assert!(ctx.status_bar_counts().expect("populated").tier2_stale);
3321
3322 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), None, false);
3324 assert!(!ctx.status_bar_counts().expect("populated").tier2_stale);
3325 }
3326
3327 #[test]
3332 fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
3333 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
3334 use crate::lsp::registry::ServerKind;
3335 use crate::lsp::roots::ServerKey;
3336
3337 let ctx = ctx();
3338 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); let file = std::path::PathBuf::from("/proj/gone.ts");
3341 {
3342 let mut lsp = ctx.lsp();
3343 lsp.diagnostics_store_mut_for_test().publish(
3344 ServerKey {
3345 kind: ServerKind::TypeScript,
3346 root: std::path::PathBuf::from("/proj"),
3347 },
3348 file.clone(),
3349 vec![StoredDiagnostic {
3350 file: file.clone(),
3351 line: 1,
3352 column: 1,
3353 end_line: 1,
3354 end_column: 2,
3355 severity: DiagnosticSeverity::Error,
3356 message: "boom".into(),
3357 code: None,
3358 source: None,
3359 }],
3360 );
3361 }
3362
3363 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
3365
3366 let removed = ctx.lsp_clear_diagnostics_for_file(&file);
3368 assert!(removed);
3369 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
3370 }
3371
3372 #[test]
3373 fn status_bar_filtered_counts_ignore_environmental_flap() {
3374 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
3375 use crate::lsp::registry::ServerKind;
3376 use crate::lsp::roots::ServerKey;
3377
3378 let ctx = ctx();
3379 let root = if cfg!(windows) {
3380 std::path::PathBuf::from(r"C:\proj")
3381 } else {
3382 std::path::PathBuf::from("/proj")
3383 };
3384 ctx.set_canonical_cache_root(root.clone());
3385 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
3386
3387 let file = root.join("aft.jsonc");
3388 let key = ServerKey {
3389 kind: ServerKind::TypeScript,
3390 root: root.clone(),
3391 };
3392 let env = StoredDiagnostic {
3393 file: file.clone(),
3394 line: 1,
3395 column: 1,
3396 end_line: 1,
3397 end_column: 2,
3398 severity: DiagnosticSeverity::Error,
3399 message: "Failed to load schema from https://example.com/schema.json".into(),
3400 code: None,
3401 source: Some("json".into()),
3402 };
3403
3404 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
3405
3406 {
3407 let mut lsp = ctx.lsp();
3408 lsp.diagnostics_store_mut_for_test()
3409 .publish(key.clone(), file.clone(), vec![env]);
3410 }
3411 assert_eq!(
3412 ctx.status_bar_counts().expect("populated").errors,
3413 0,
3414 "environmental publish must not change status-bar E"
3415 );
3416
3417 {
3418 let mut lsp = ctx.lsp();
3419 lsp.diagnostics_store_mut_for_test()
3420 .publish(key, file, vec![]);
3421 }
3422 assert_eq!(
3423 ctx.status_bar_counts().expect("populated").errors,
3424 0,
3425 "environmental clear must not change status-bar E"
3426 );
3427 }
3428}
3429
3430#[cfg(test)]
3431mod harness_path_tests {
3432 use super::*;
3433 use crate::harness::Harness;
3434 use crate::parser::TreeSitterProvider;
3435
3436 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
3437 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3438 ctx.update_config(|config| {
3439 config.storage_dir = Some(storage_dir);
3440 });
3441 ctx.set_harness(harness);
3442 ctx
3443 }
3444
3445 #[test]
3446 fn harness_dir_resolves_correctly() {
3447 let storage = PathBuf::from("/tmp/cortexkit/aft");
3448 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
3449
3450 assert_eq!(ctx.harness_dir(), storage.join("pi"));
3451 }
3452
3453 #[test]
3454 fn bash_tasks_dir_uses_hash_session() {
3455 let storage = PathBuf::from("/tmp/cortexkit/aft");
3456 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
3457
3458 assert_eq!(
3459 ctx.bash_tasks_dir("ses_abc"),
3460 storage
3461 .join("opencode")
3462 .join("bash-tasks")
3463 .join(hash_session("ses_abc"))
3464 );
3465 }
3466
3467 #[test]
3468 fn backups_dir_includes_path_hash() {
3469 let storage = PathBuf::from("/tmp/cortexkit/aft");
3470 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
3471
3472 assert_eq!(
3473 ctx.backups_dir("ses_abc", "pathhash"),
3474 storage
3475 .join("pi")
3476 .join("backups")
3477 .join(hash_session("ses_abc"))
3478 .join("pathhash")
3479 );
3480 }
3481
3482 #[test]
3483 fn filters_dir_under_harness() {
3484 let storage = PathBuf::from("/tmp/cortexkit/aft");
3485 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
3486
3487 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
3488 }
3489
3490 #[test]
3491 fn trust_file_is_host_global() {
3492 let storage = PathBuf::from("/tmp/cortexkit/aft");
3493 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
3494
3495 assert_eq!(
3496 ctx.trust_file(),
3497 storage.join("trusted-filter-projects.json")
3498 );
3499 }
3500
3501 #[test]
3502 fn same_session_different_harness_resolve_different_paths() {
3503 let storage = PathBuf::from("/tmp/cortexkit/aft");
3504 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
3505 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
3506
3507 assert_ne!(
3508 opencode.bash_tasks_dir("ses_same"),
3509 pi.bash_tasks_dir("ses_same")
3510 );
3511 }
3512}
3513
3514#[cfg(test)]
3515mod gitignore_tests {
3516 use super::*;
3517 use std::fs;
3518 use std::path::Path;
3519 use tempfile::TempDir;
3520
3521 fn make_ctx_with_root(root: &Path) -> AppContext {
3522 let provider = Box::new(crate::parser::TreeSitterProvider::new());
3523 let config = Config {
3524 project_root: Some(root.to_path_buf()),
3525 ..Config::default()
3526 };
3527 AppContext::new(provider, config)
3528 }
3529
3530 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
3537 let Some(matcher) = ctx.gitignore() else {
3538 return false;
3539 };
3540 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
3541 if !canonical.starts_with(matcher.path()) {
3542 return false;
3543 }
3544 let is_dir = canonical.is_dir();
3545 matcher
3546 .matched_path_or_any_parents(&canonical, is_dir)
3547 .is_ignore()
3548 }
3549
3550 fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
3561 use std::sync::{Mutex, OnceLock};
3562 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
3563 let _guard = LOCK
3564 .get_or_init(|| Mutex::new(()))
3565 .lock()
3566 .unwrap_or_else(|e| e.into_inner());
3567 let tmp = TempDir::new().unwrap();
3568 let prev = std::env::var_os("XDG_CONFIG_HOME");
3569 unsafe {
3571 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
3572 }
3573 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
3574 unsafe {
3575 match prev {
3576 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
3577 None => std::env::remove_var("XDG_CONFIG_HOME"),
3578 }
3579 }
3580 match result {
3581 Ok(r) => r,
3582 Err(p) => std::panic::resume_unwind(p),
3583 }
3584 }
3585
3586 #[test]
3587 fn rebuild_gitignore_returns_none_without_project_root() {
3588 let provider = Box::new(crate::parser::TreeSitterProvider::new());
3589 let ctx = AppContext::new(provider, Config::default());
3590 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
3591 assert!(ctx.gitignore().is_none());
3592 }
3593
3594 #[test]
3595 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
3596 let tmp = TempDir::new().unwrap();
3597 let ctx = make_ctx_with_root(tmp.path());
3598 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
3599 assert!(ctx.gitignore().is_none());
3600 }
3601
3602 #[test]
3603 fn matcher_filters_files_in_ignored_dist_dir() {
3604 let tmp = TempDir::new().unwrap();
3605 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
3606 fs::create_dir_all(tmp.path().join("dist")).unwrap();
3607 fs::create_dir_all(tmp.path().join("src")).unwrap();
3608 let dist_file = tmp.path().join("dist").join("bundle.js");
3609 let src_file = tmp.path().join("src").join("app.ts");
3610 fs::write(&dist_file, "x").unwrap();
3611 fs::write(&src_file, "y").unwrap();
3612
3613 let ctx = make_ctx_with_root(tmp.path());
3614 ctx.rebuild_gitignore();
3615
3616 assert!(ctx.gitignore().is_some());
3617 assert!(
3618 is_ignored(&ctx, &dist_file),
3619 "dist/bundle.js should be ignored"
3620 );
3621 assert!(
3622 !is_ignored(&ctx, &src_file),
3623 "src/app.ts should NOT be ignored"
3624 );
3625 }
3626
3627 #[test]
3628 fn matcher_handles_node_modules_and_target() {
3629 let tmp = TempDir::new().unwrap();
3630 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
3631 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
3632 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
3633 let nm_file = tmp.path().join("node_modules/foo/index.js");
3634 let target_file = tmp.path().join("target/debug/aft");
3635 fs::write(&nm_file, "x").unwrap();
3636 fs::write(&target_file, "x").unwrap();
3637
3638 let ctx = make_ctx_with_root(tmp.path());
3639 ctx.rebuild_gitignore();
3640
3641 assert!(is_ignored(&ctx, &nm_file));
3642 assert!(is_ignored(&ctx, &target_file));
3643 }
3644
3645 #[test]
3646 fn matcher_honors_negation_pattern() {
3647 let tmp = TempDir::new().unwrap();
3649 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
3650 let random_log = tmp.path().join("random.log");
3651 let important_log = tmp.path().join("important.log");
3652 fs::write(&random_log, "x").unwrap();
3653 fs::write(&important_log, "y").unwrap();
3654
3655 let ctx = make_ctx_with_root(tmp.path());
3656 ctx.rebuild_gitignore();
3657
3658 assert!(is_ignored(&ctx, &random_log));
3659 assert!(
3660 !is_ignored(&ctx, &important_log),
3661 "negation pattern should un-ignore important.log"
3662 );
3663 }
3664
3665 #[test]
3666 fn rebuild_picks_up_gitignore_changes() {
3667 let tmp = TempDir::new().unwrap();
3668 let ignore_path = tmp.path().join(".gitignore");
3669 fs::write(&ignore_path, "foo.txt\n").unwrap();
3670 let foo = tmp.path().join("foo.txt");
3671 let bar = tmp.path().join("bar.txt");
3672 fs::write(&foo, "").unwrap();
3673 fs::write(&bar, "").unwrap();
3674
3675 let ctx = make_ctx_with_root(tmp.path());
3676 ctx.rebuild_gitignore();
3677 assert!(is_ignored(&ctx, &foo));
3678 assert!(!is_ignored(&ctx, &bar));
3679
3680 fs::write(&ignore_path, "bar.txt\n").unwrap();
3682 ctx.rebuild_gitignore();
3683 assert!(!is_ignored(&ctx, &foo));
3684 assert!(is_ignored(&ctx, &bar));
3685 }
3686
3687 #[test]
3688 fn gitignore_loads_info_exclude_when_present() {
3689 let tmp = TempDir::new().unwrap();
3690 let info_dir = tmp.path().join(".git/info");
3691 fs::create_dir_all(&info_dir).unwrap();
3692 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
3693 let secrets = tmp.path().join("secrets.txt");
3694 let public = tmp.path().join("public.txt");
3695 fs::write(&secrets, "token").unwrap();
3696 fs::write(&public, "ok").unwrap();
3697
3698 let ctx = make_ctx_with_root(tmp.path());
3699 ctx.rebuild_gitignore();
3700
3701 assert!(is_ignored(&ctx, &secrets));
3702 assert!(!is_ignored(&ctx, &public));
3703 }
3704
3705 #[test]
3706 fn matcher_picks_up_nested_gitignore() {
3707 let tmp = TempDir::new().unwrap();
3708 fs::write(tmp.path().join(".gitignore"), "").unwrap();
3710 let sub = tmp.path().join("packages/foo");
3711 fs::create_dir_all(&sub).unwrap();
3712 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
3713 let generated_file = sub.join("generated").join("out.js");
3714 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
3715 fs::write(&generated_file, "x").unwrap();
3716
3717 let ctx = make_ctx_with_root(tmp.path());
3718 ctx.rebuild_gitignore();
3719
3720 assert!(
3721 is_ignored(&ctx, &generated_file),
3722 "nested gitignore in packages/foo/.gitignore should ignore generated/"
3723 );
3724 }
3725}