1use std::cell::{Ref, RefCell, RefMut};
2use std::collections::{BTreeMap, BTreeSet};
3use std::io::{self, BufWriter};
4use std::path::{Component, Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{mpsc, Arc, Mutex, OnceLock};
7use std::time::{Duration, Instant};
8
9use lsp_types::FileChangeType;
10use notify::RecommendedWatcher;
11use rusqlite::Connection;
12
13use crate::backup::hash_session;
14use crate::backup::BackupStore;
15use crate::bash_background::{BgCompletion, BgTaskRegistry};
16use crate::callgraph::CallGraph;
17use crate::callgraph_store::{CallGraphStore, CallGraphStoreError};
18use crate::checkpoint::CheckpointStore;
19use crate::config::Config;
20use crate::harness::Harness;
21use crate::inspect::{
22 InspectCategory, InspectManager, InspectSnapshot, Tier2RefreshScheduler, Tier2TriggerReason,
23};
24use crate::language::LanguageProvider;
25use crate::lsp::manager::LspManager;
26use crate::lsp::registry::is_config_file_path_with_custom;
27use crate::parser::{SharedSymbolCache, SymbolCache};
28use crate::protocol::{
29 ConfigureWarningsFrame, ProgressFrame, PushFrame, StatusChangedFrame, StatusPayload,
30};
31use crate::watcher_filter::{SharedGitignore, WatcherDispatchEvent, WatcherThreadHandle};
32
33pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
34pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
35pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
36const STATUS_DEBOUNCE_MS: u64 = 1_000;
37
38#[derive(Debug, Clone, Default, PartialEq, Eq)]
46pub struct StatusBarCounts {
47 pub errors: usize,
48 pub warnings: usize,
49 pub dead_code: usize,
50 pub unused_exports: usize,
51 pub duplicates: usize,
52 pub todos: usize,
53 pub tier2_stale: bool,
54}
55
56#[derive(Debug, Clone, Default)]
65struct StatusBarTier2 {
66 dead_code: Option<usize>,
67 unused_exports: Option<usize>,
68 duplicates: Option<usize>,
69 todos: Option<usize>,
70 stale: bool,
71}
72
73pub struct StatusEmitter {
74 latest: Arc<Mutex<Option<StatusPayload>>>,
75 notify: mpsc::Sender<()>,
76}
77
78impl StatusEmitter {
79 fn new(progress_sender: SharedProgressSender) -> Self {
80 let (notify, rx) = mpsc::channel();
81 let latest = Arc::new(Mutex::new(None));
82 let latest_for_thread = Arc::clone(&latest);
83 std::thread::spawn(move || {
84 status_debounce_loop(rx, latest_for_thread, progress_sender);
85 });
86 Self { latest, notify }
87 }
88
89 pub fn signal(&self, snapshot: StatusPayload) {
90 if let Ok(mut latest) = self.latest.lock() {
91 *latest = Some(snapshot);
92 }
93 let _ = self.notify.send(());
94 }
95}
96
97fn status_debounce_loop(
98 rx: mpsc::Receiver<()>,
99 latest: Arc<Mutex<Option<StatusPayload>>>,
100 progress_sender: SharedProgressSender,
101) {
102 while rx.recv().is_ok() {
103 let deadline = Instant::now() + Duration::from_millis(STATUS_DEBOUNCE_MS);
104 while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
105 match rx.recv_timeout(remaining) {
106 Ok(()) => continue,
107 Err(mpsc::RecvTimeoutError::Timeout) => break,
108 Err(mpsc::RecvTimeoutError::Disconnected) => return,
109 }
110 }
111
112 let snapshot = latest.lock().ok().and_then(|mut latest| latest.take());
113 let Some(snapshot) = snapshot else { continue };
114 let sender = progress_sender
115 .lock()
116 .ok()
117 .and_then(|sender| sender.clone());
118 if let Some(sender) = sender {
119 sender(PushFrame::StatusChanged(StatusChangedFrame::new(
120 None, snapshot,
121 )));
122 }
123 }
124}
125use crate::cache_freshness::FileFreshness;
126use crate::search_index::SearchIndex;
127use crate::semantic_index::{EmbeddingEntry, SemanticIndex};
128
129#[derive(Debug, Default)]
133struct SemanticRefreshAccounting {
134 pending: usize,
135 in_flight: usize,
136}
137
138static SEMANTIC_REFRESH_ACCOUNTING: OnceLock<Mutex<BTreeMap<PathBuf, SemanticRefreshAccounting>>> =
139 OnceLock::new();
140
141fn semantic_refresh_accounting() -> &'static Mutex<BTreeMap<PathBuf, SemanticRefreshAccounting>> {
142 SEMANTIC_REFRESH_ACCOUNTING.get_or_init(|| Mutex::new(BTreeMap::new()))
143}
144
145fn clear_semantic_refresh_accounting() {
146 if let Some(accounting) = SEMANTIC_REFRESH_ACCOUNTING.get() {
147 if let Ok(mut accounting) = accounting.lock() {
148 accounting.clear();
149 }
150 }
151}
152
153fn ensure_refreshing_path(refreshing: &mut Vec<PathBuf>, path: PathBuf) {
154 if !refreshing.iter().any(|existing| existing == &path) {
155 refreshing.push(path);
156 refreshing.sort();
157 }
158}
159
160fn remove_refreshing_path(refreshing: &mut Vec<PathBuf>, path: &Path) {
161 refreshing.retain(|existing| existing != path);
162}
163
164#[derive(Debug, Clone)]
165pub enum SemanticIndexStatus {
166 Disabled,
167 Building {
168 stage: String,
170 files: Option<usize>,
171 entries_done: Option<usize>,
172 entries_total: Option<usize>,
173 },
174 Ready {
175 refreshing: Vec<PathBuf>,
178 },
179 Failed(String),
180}
181
182impl SemanticIndexStatus {
183 pub fn ready() -> Self {
184 clear_semantic_refresh_accounting();
185 Self::Ready {
186 refreshing: Vec::new(),
187 }
188 }
189
190 pub fn add_refreshing_file(&mut self, path: PathBuf) {
191 if let Self::Ready { refreshing } = self {
192 if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
193 let state = accounting.entry(path.clone()).or_default();
194 state.pending = state.pending.saturating_add(1);
195 }
196 ensure_refreshing_path(refreshing, path);
197 }
198 }
199
200 pub fn start_refreshing_file(&mut self, path: PathBuf) {
201 if let Self::Ready { refreshing } = self {
202 if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
203 let state = accounting.entry(path.clone()).or_default();
204 if state.pending == 0 {
205 state.pending = 1;
206 }
207 if state.in_flight == 0 {
208 state.in_flight = state.pending;
209 }
210 }
211 ensure_refreshing_path(refreshing, path);
212 }
213 }
214
215 pub fn cancel_refreshing_file(&mut self, path: &Path) {
216 self.finish_refreshing_file(path, false);
217 }
218
219 pub fn complete_refreshing_file(&mut self, path: &Path) {
220 self.finish_refreshing_file(path, true);
221 }
222
223 pub fn remove_refreshing_file(&mut self, path: &Path) {
224 self.complete_refreshing_file(path);
225 }
226
227 fn finish_refreshing_file(&mut self, path: &Path, complete_in_flight: bool) {
228 if let Self::Ready { refreshing } = self {
229 let mut keep_refreshing = false;
230 let mut accounting_checked = false;
231 if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
232 accounting_checked = true;
233 if let Some(state) = accounting.get_mut(path) {
234 let finished = if complete_in_flight {
235 state.in_flight.max(1)
236 } else {
237 1
238 };
239 state.pending = state.pending.saturating_sub(finished);
240 if complete_in_flight {
241 state.in_flight = 0;
242 } else {
243 state.in_flight = state.in_flight.min(state.pending);
244 }
245 keep_refreshing = state.pending > 0;
246 if !keep_refreshing {
247 accounting.remove(path);
248 }
249 }
250 }
251
252 if !accounting_checked || !keep_refreshing {
253 remove_refreshing_path(refreshing, path);
254 }
255 }
256 }
257
258 pub fn refreshing_count(&self) -> usize {
259 match self {
260 Self::Ready { refreshing } => refreshing.len(),
261 _ => 0,
262 }
263 }
264}
265
266pub enum SemanticIndexEvent {
267 Progress {
268 stage: String,
269 files: Option<usize>,
270 entries_done: Option<usize>,
271 entries_total: Option<usize>,
272 },
273 Ready(SemanticIndex),
274 Failed(String),
275}
276
277#[derive(Debug, Clone)]
278pub enum SemanticRefreshRequest {
279 Files {
280 paths: Vec<PathBuf>,
281 },
282 Corpus,
286}
287
288#[derive(Debug)]
289pub enum SemanticRefreshEvent {
290 Started {
291 paths: Vec<PathBuf>,
292 },
293 CorpusStarted {
294 files: usize,
295 },
296 Completed {
297 added_entries: Vec<EmbeddingEntry>,
298 updated_metadata: Vec<(PathBuf, FileFreshness)>,
299 completed_paths: Vec<PathBuf>,
300 },
301 CorpusCompleted {
302 index: SemanticIndex,
303 changed: usize,
304 added: usize,
305 deleted: usize,
306 total_processed: usize,
307 },
308 Failed {
309 paths: Vec<PathBuf>,
310 error: String,
311 },
312 CorpusFailed {
313 error: String,
314 },
315}
316
317pub type SemanticRefreshWorkerSlot = Arc<Mutex<Option<std::thread::JoinHandle<()>>>>;
318
319fn normalize_path(path: &Path) -> PathBuf {
323 let mut result = PathBuf::new();
324 for component in path.components() {
325 match component {
326 Component::ParentDir => {
327 if !result.pop() {
329 result.push(component);
330 }
331 }
332 Component::CurDir => {} _ => result.push(component),
334 }
335 }
336 result
337}
338
339fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
340 let mut existing = path.to_path_buf();
341 let mut tail_segments = Vec::new();
342
343 while !existing.exists() {
344 if let Some(name) = existing.file_name() {
345 tail_segments.push(name.to_owned());
346 } else {
347 break;
348 }
349
350 existing = match existing.parent() {
351 Some(parent) => parent.to_path_buf(),
352 None => break,
353 };
354 }
355
356 let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
357 for segment in tail_segments.into_iter().rev() {
358 resolved.push(segment);
359 }
360
361 resolved
362}
363
364fn path_error_response(
365 req_id: &str,
366 path: &Path,
367 resolved_root: &Path,
368) -> crate::protocol::Response {
369 crate::protocol::Response::error(
370 req_id,
371 "path_outside_root",
372 format!(
373 "path '{}' is outside the project root '{}'",
374 path.display(),
375 resolved_root.display()
376 ),
377 )
378}
379
380fn reject_escaping_symlink(
390 req_id: &str,
391 original_path: &Path,
392 candidate: &Path,
393 resolved_root: &Path,
394 raw_root: &Path,
395) -> Result<(), crate::protocol::Response> {
396 let mut current = PathBuf::new();
397
398 for component in candidate.components() {
399 current.push(component);
400
401 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
402 continue;
403 };
404
405 if !metadata.file_type().is_symlink() {
406 continue;
407 }
408
409 let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
418 if !inside_root {
419 continue;
420 }
421
422 iterative_follow_chain(req_id, original_path, ¤t, resolved_root)?;
423 }
424
425 Ok(())
426}
427
428fn iterative_follow_chain(
431 req_id: &str,
432 original_path: &Path,
433 start: &Path,
434 resolved_root: &Path,
435) -> Result<(), crate::protocol::Response> {
436 let mut link = start.to_path_buf();
437 let mut depth = 0usize;
438
439 loop {
440 if depth > 40 {
441 return Err(path_error_response(req_id, original_path, resolved_root));
442 }
443
444 let target = match std::fs::read_link(&link) {
445 Ok(t) => t,
446 Err(_) => {
447 return Err(path_error_response(req_id, original_path, resolved_root));
449 }
450 };
451
452 let resolved_target = if target.is_absolute() {
453 normalize_path(&target)
454 } else {
455 let parent = link.parent().unwrap_or_else(|| Path::new(""));
456 normalize_path(&parent.join(&target))
457 };
458
459 let canonical_target =
463 std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
464
465 if !canonical_target.starts_with(resolved_root)
466 && !resolved_target.starts_with(resolved_root)
467 {
468 return Err(path_error_response(req_id, original_path, resolved_root));
469 }
470
471 match std::fs::symlink_metadata(&resolved_target) {
473 Ok(meta) if meta.file_type().is_symlink() => {
474 link = resolved_target;
475 depth += 1;
476 }
477 _ => break, }
479 }
480
481 Ok(())
482}
483
484pub struct AppContext {
494 provider: Box<dyn LanguageProvider>,
495 backup: RefCell<BackupStore>,
496 checkpoint: RefCell<CheckpointStore>,
497 db: RefCell<Option<Arc<Mutex<Connection>>>>,
498 config: RefCell<Config>,
499 pub harness: RefCell<Option<Harness>>,
500 canonical_cache_root: RefCell<Option<PathBuf>>,
501 is_worktree_bridge: RefCell<bool>,
502 git_common_dir: RefCell<Option<PathBuf>>,
503 degraded_reasons: RefCell<Vec<String>>,
511 callgraph: RefCell<Option<CallGraph>>,
512 callgraph_store: RefCell<Option<CallGraphStore>>,
513 callgraph_store_force_rebuild: RefCell<bool>,
514 callgraph_store_rx: RefCell<Option<crossbeam_channel::Receiver<CallGraphStore>>>,
515 pending_callgraph_store_paths: RefCell<BTreeSet<PathBuf>>,
516 search_index: RefCell<Option<SearchIndex>>,
517 search_index_rx: RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>>,
518 pending_search_index_paths: RefCell<BTreeSet<PathBuf>>,
519 symbol_cache: SharedSymbolCache,
520 inspect_manager: Arc<InspectManager>,
521 tier2_refresh_scheduler: RefCell<Tier2RefreshScheduler>,
522 semantic_index: RefCell<Option<SemanticIndex>>,
523 semantic_index_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
524 semantic_index_status: RefCell<SemanticIndexStatus>,
525 pending_semantic_index_paths: RefCell<BTreeSet<PathBuf>>,
526 pending_semantic_corpus_refresh: RefCell<bool>,
527 semantic_refresh_tx: RefCell<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>,
528 semantic_refresh_event_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
529 semantic_refresh_worker: RefCell<Option<SemanticRefreshWorkerSlot>>,
530 semantic_embedding_model: RefCell<Option<crate::semantic_index::EmbeddingModel>>,
531 watcher: RefCell<Option<RecommendedWatcher>>,
532 watcher_rx: RefCell<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>>,
533 watcher_thread: RefCell<Option<WatcherThreadHandle>>,
534 lsp_manager: RefCell<LspManager>,
535 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
539 stdout_writer: SharedStdoutWriter,
540 progress_sender: SharedProgressSender,
541 configure_generation: AtomicU64,
542 last_seen_reuse_completions: AtomicU64,
546 configure_warnings_tx: mpsc::Sender<(u64, ConfigureWarningsFrame)>,
547 configure_warnings_rx: mpsc::Receiver<(u64, ConfigureWarningsFrame)>,
548 status_emitter: StatusEmitter,
549 bash_background: BgTaskRegistry,
550 filter_registry: crate::compress::SharedFilterRegistry,
557 filter_registry_loaded: std::sync::atomic::AtomicBool,
560 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
565 gitignore: SharedGitignore,
572 gitignore_generation: Arc<AtomicU64>,
573 status_bar_tier2: RefCell<StatusBarTier2>,
577 tsconfig_membership: RefCell<crate::lsp::tsconfig_membership::TsconfigMembershipCache>,
584}
585
586impl Drop for AppContext {
587 fn drop(&mut self) {
588 if let Some(runtime) = self.watcher_thread.get_mut().take() {
589 runtime.shutdown_and_join();
590 }
591 }
592}
593
594pub enum CallgraphStoreAccess<'a> {
602 Ready(RefMut<'a, CallGraphStore>),
604 Building,
606 Unavailable,
608 Error(CallGraphStoreError),
610}
611
612fn callgraph_build_wait_window() -> Duration {
617 std::env::var("AFT_CALLGRAPH_BUILD_WAIT_MS")
618 .ok()
619 .and_then(|raw| raw.parse::<u64>().ok())
620 .map(Duration::from_millis)
621 .unwrap_or(Duration::ZERO)
622}
623
624impl AppContext {
625 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
626 let bash_compress_enabled = config.experimental_bash_compress;
627 let progress_sender = Arc::new(Mutex::new(None));
628 let stdout_writer = Arc::new(Mutex::new(BufWriter::new(io::stdout())));
629 let (configure_warnings_tx, configure_warnings_rx) = mpsc::channel();
630 let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
631 let symbol_cache = provider
632 .as_any()
633 .downcast_ref::<crate::parser::TreeSitterProvider>()
634 .map(|provider| provider.symbol_cache())
635 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
636 let lsp_child_registry = crate::lsp::child_registry::LspChildRegistry::new();
637 let mut lsp_manager = LspManager::new();
638 lsp_manager.set_child_registry(lsp_child_registry.clone());
639 lsp_manager.set_diagnostic_capacity(config.diagnostic_cache_size);
642 AppContext {
643 provider,
644 backup: RefCell::new(BackupStore::new()),
645 checkpoint: RefCell::new(CheckpointStore::new()),
646 db: RefCell::new(None),
647 config: RefCell::new(config),
648 harness: RefCell::new(None),
649 canonical_cache_root: RefCell::new(None),
650 is_worktree_bridge: RefCell::new(false),
651 git_common_dir: RefCell::new(None),
652 degraded_reasons: RefCell::new(Vec::new()),
653 callgraph: RefCell::new(None),
654 callgraph_store: RefCell::new(None),
655 callgraph_store_force_rebuild: RefCell::new(false),
656 callgraph_store_rx: RefCell::new(None),
657 pending_callgraph_store_paths: RefCell::new(BTreeSet::new()),
658 search_index: RefCell::new(None),
659 search_index_rx: RefCell::new(None),
660 pending_search_index_paths: RefCell::new(BTreeSet::new()),
661 symbol_cache,
662 inspect_manager: Arc::new(InspectManager::new()),
663 tier2_refresh_scheduler: RefCell::new(Tier2RefreshScheduler::new()),
664 semantic_index: RefCell::new(None),
665 semantic_index_rx: RefCell::new(None),
666 semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
667 pending_semantic_index_paths: RefCell::new(BTreeSet::new()),
668 pending_semantic_corpus_refresh: RefCell::new(false),
669 semantic_refresh_tx: RefCell::new(None),
670 semantic_refresh_event_rx: RefCell::new(None),
671 semantic_refresh_worker: RefCell::new(None),
672 semantic_embedding_model: RefCell::new(None),
673 watcher: RefCell::new(None),
674 watcher_rx: RefCell::new(None),
675 watcher_thread: RefCell::new(None),
676 lsp_manager: RefCell::new(lsp_manager),
677 lsp_child_registry,
678 stdout_writer,
679 progress_sender: Arc::clone(&progress_sender),
680 configure_generation: AtomicU64::new(0),
681 last_seen_reuse_completions: AtomicU64::new(0),
682 configure_warnings_tx,
683 configure_warnings_rx,
684 status_emitter,
685 bash_background: BgTaskRegistry::new(progress_sender),
686 filter_registry: Arc::new(std::sync::RwLock::new(
687 crate::compress::toml_filter::FilterRegistry::default(),
688 )),
689 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
690 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
691 gitignore: Arc::new(std::sync::RwLock::new(None)),
692 gitignore_generation: Arc::new(AtomicU64::new(0)),
693 status_bar_tier2: RefCell::new(StatusBarTier2::default()),
694 tsconfig_membership: RefCell::new(
695 crate::lsp::tsconfig_membership::TsconfigMembershipCache::new(),
696 ),
697 }
698 }
699
700 pub fn status_bar_counts(&self) -> Option<StatusBarCounts> {
706 let tier2 = self.status_bar_tier2.borrow();
707 let (Some(dead_code), Some(unused_exports), Some(duplicates)) =
711 (tier2.dead_code, tier2.unused_exports, tier2.duplicates)
712 else {
713 return None;
714 };
715 let (errors, warnings) = self.status_bar_error_warning_counts();
716 Some(StatusBarCounts {
717 errors,
718 warnings,
719 dead_code,
720 unused_exports,
721 duplicates,
722 todos: tier2.todos.unwrap_or(0),
723 tier2_stale: tier2.stale,
724 })
725 }
726
727 fn status_bar_error_warning_counts(&self) -> (usize, usize) {
733 let Some(root) = self.canonical_cache_root_opt() else {
734 return self.lsp_manager.borrow().warm_error_warning_counts();
737 };
738 let mut membership = self.tsconfig_membership.borrow_mut();
739 self.lsp_manager
740 .borrow()
741 .filtered_error_warning_counts(|file| {
742 file.starts_with(&root) && !membership.should_skip_diagnostics(file)
743 })
744 }
745
746 pub fn clear_tsconfig_membership_cache(&self) {
750 self.tsconfig_membership.borrow_mut().clear();
751 }
752
753 pub fn mark_status_bar_tier2_stale(&self) -> bool {
759 let mut tier2 = self.status_bar_tier2.borrow_mut();
760 if tier2.dead_code.is_some() && tier2.unused_exports.is_some() && tier2.duplicates.is_some()
762 {
763 let changed = !tier2.stale;
764 tier2.stale = true;
765 return changed;
766 }
767 false
768 }
769
770 pub fn update_status_bar_tier2(
776 &self,
777 dead_code: Option<usize>,
778 unused_exports: Option<usize>,
779 duplicates: Option<usize>,
780 todos: Option<usize>,
781 stale: bool,
782 ) {
783 let mut tier2 = self.status_bar_tier2.borrow_mut();
784 if let Some(dead_code) = dead_code {
785 tier2.dead_code = Some(dead_code);
786 }
787 if let Some(unused_exports) = unused_exports {
788 tier2.unused_exports = Some(unused_exports);
789 }
790 if let Some(duplicates) = duplicates {
791 tier2.duplicates = Some(duplicates);
792 }
793 if let Some(todos) = todos {
794 tier2.todos = Some(todos);
795 }
796 tier2.stale = stale;
797 }
798
799 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
802 self.gitignore
803 .read()
804 .unwrap_or_else(|poisoned| poisoned.into_inner())
805 .clone()
806 }
807
808 pub fn shared_gitignore(&self) -> SharedGitignore {
810 Arc::clone(&self.gitignore)
811 }
812
813 pub fn gitignore_generation(&self) -> Arc<AtomicU64> {
817 Arc::clone(&self.gitignore_generation)
818 }
819
820 fn set_gitignore(&self, matcher: Option<Arc<ignore::gitignore::Gitignore>>) {
821 *self
822 .gitignore
823 .write()
824 .unwrap_or_else(|poisoned| poisoned.into_inner()) = matcher;
825 self.gitignore_generation.fetch_add(1, Ordering::SeqCst);
826 }
827
828 pub fn clear_gitignore(&self) {
850 self.set_gitignore(None);
851 }
852
853 pub fn rebuild_gitignore(&self) {
854 use ignore::gitignore::GitignoreBuilder;
855 use std::path::Path;
856 let root_raw = match self.config().project_root.clone() {
857 Some(r) => r,
858 None => {
859 self.set_gitignore(None);
860 return;
861 }
862 };
863 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
871 let mut builder = GitignoreBuilder::new(&root);
872 if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
877 if global_ignore.is_file() {
878 if let Some(err) = builder.add(&global_ignore) {
879 crate::slog_warn!(
880 "global gitignore parse error in {}: {}",
881 global_ignore.display(),
882 err
883 );
884 }
885 }
886 }
887 let root_ignore = Path::new(&root).join(".gitignore");
889 if root_ignore.exists() {
890 if let Some(err) = builder.add(&root_ignore) {
891 crate::slog_warn!(
892 "gitignore parse error in {}: {}",
893 root_ignore.display(),
894 err
895 );
896 }
897 }
898 let root_aftignore = Path::new(&root).join(".aftignore");
903 if root_aftignore.exists() {
904 if let Some(err) = builder.add(&root_aftignore) {
905 crate::slog_warn!(
906 "aftignore parse error in {}: {}",
907 root_aftignore.display(),
908 err
909 );
910 }
911 }
912 let info_exclude = self
917 .git_common_dir
918 .borrow()
919 .clone()
920 .unwrap_or_else(|| Path::new(&root).join(".git"))
921 .join("info")
922 .join("exclude");
923 if info_exclude.exists() {
924 if let Some(err) = builder.add(&info_exclude) {
925 crate::slog_warn!(
926 "gitignore parse error in {}: {}",
927 info_exclude.display(),
928 err
929 );
930 }
931 }
932 let walker = ignore::WalkBuilder::new(&root)
938 .standard_filters(true)
939 .hidden(false)
947 .filter_entry(|entry| {
948 let name = entry.file_name().to_string_lossy();
949 !matches!(
950 name.as_ref(),
951 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
952 )
953 })
954 .build();
955 for entry in walker.flatten() {
956 let file_name = entry.file_name();
957 let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
958 let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
959 if is_nested_gitignore || is_nested_aftignore {
960 if let Some(err) = builder.add(entry.path()) {
961 crate::slog_warn!(
962 "nested ignore parse error in {}: {}",
963 entry.path().display(),
964 err
965 );
966 }
967 }
968 }
969 match builder.build() {
970 Ok(gi) => {
971 let count = gi.num_ignores();
972 if count > 0 {
973 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
974 self.set_gitignore(Some(Arc::new(gi)));
975 } else {
976 self.set_gitignore(None);
977 }
978 }
979 Err(err) => {
980 crate::slog_warn!("gitignore matcher build failed: {}", err);
981 self.set_gitignore(None);
982 }
983 }
984 }
985
986 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
989 Arc::clone(&self.bash_compress_flag)
990 }
991
992 pub fn sync_bash_compress_flag(&self) {
996 let value = self.config().experimental_bash_compress;
997 self.bash_compress_flag
998 .store(value, std::sync::atomic::Ordering::Relaxed);
999 }
1000
1001 pub fn set_bash_compress_enabled(&self, enabled: bool) {
1002 self.config_mut().experimental_bash_compress = enabled;
1003 self.bash_compress_flag
1004 .store(enabled, std::sync::atomic::Ordering::Relaxed);
1005 }
1006
1007 pub fn filter_registry(
1011 &self,
1012 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
1013 self.ensure_filter_registry_loaded();
1014 match self.filter_registry.read() {
1015 Ok(g) => g,
1016 Err(poisoned) => poisoned.into_inner(),
1017 }
1018 }
1019
1020 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
1024 self.ensure_filter_registry_loaded();
1025 Arc::clone(&self.filter_registry)
1026 }
1027
1028 pub fn reset_filter_registry(&self) {
1032 let new_registry = crate::compress::build_registry_for_context(self);
1033 match self.filter_registry.write() {
1034 Ok(mut slot) => *slot = new_registry,
1035 Err(poisoned) => *poisoned.into_inner() = new_registry,
1036 }
1037 self.filter_registry_loaded
1038 .store(true, std::sync::atomic::Ordering::Release);
1039 }
1040
1041 fn ensure_filter_registry_loaded(&self) {
1042 use std::sync::atomic::Ordering;
1043 if self.filter_registry_loaded.load(Ordering::Acquire) {
1044 return;
1045 }
1046 let new_registry = crate::compress::build_registry_for_context(self);
1049 if let Ok(mut slot) = self.filter_registry.write() {
1050 *slot = new_registry;
1051 self.filter_registry_loaded.store(true, Ordering::Release);
1052 }
1053 }
1054
1055 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
1058 self.lsp_child_registry.clone()
1059 }
1060
1061 pub fn stdout_writer(&self) -> SharedStdoutWriter {
1062 Arc::clone(&self.stdout_writer)
1063 }
1064
1065 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
1066 if let Ok(mut progress_sender) = self.progress_sender.lock() {
1067 *progress_sender = sender;
1068 }
1069 }
1070
1071 pub fn emit_progress(&self, frame: ProgressFrame) {
1072 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
1073 return;
1074 };
1075 if let Some(sender) = progress_sender.as_ref() {
1076 sender(PushFrame::Progress(frame));
1077 }
1078 }
1079
1080 pub fn status_emitter(&self) -> &StatusEmitter {
1081 &self.status_emitter
1082 }
1083
1084 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
1092 self.progress_sender
1093 .lock()
1094 .ok()
1095 .and_then(|sender| sender.clone())
1096 }
1097
1098 pub fn advance_configure_generation(&self) -> u64 {
1099 self.configure_generation
1100 .fetch_add(1, Ordering::SeqCst)
1101 .wrapping_add(1)
1102 }
1103
1104 pub fn configure_generation(&self) -> u64 {
1105 self.configure_generation.load(Ordering::SeqCst)
1106 }
1107
1108 pub fn configure_warnings_sender(&self) -> mpsc::Sender<(u64, ConfigureWarningsFrame)> {
1109 self.configure_warnings_tx.clone()
1110 }
1111
1112 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
1113 let mut warnings = Vec::new();
1114 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
1115 warnings.push(warning);
1116 }
1117 warnings
1118 }
1119
1120 pub fn bash_background(&self) -> &BgTaskRegistry {
1121 &self.bash_background
1122 }
1123
1124 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
1125 self.bash_background.drain_completions()
1126 }
1127
1128 pub fn provider(&self) -> &dyn LanguageProvider {
1130 self.provider.as_ref()
1131 }
1132
1133 pub fn backup(&self) -> &RefCell<BackupStore> {
1135 &self.backup
1136 }
1137
1138 pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
1140 &self.checkpoint
1141 }
1142
1143 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
1144 *self.db.borrow_mut() = Some(conn);
1145 }
1146
1147 pub fn clear_db(&self) {
1148 *self.db.borrow_mut() = None;
1149 }
1150
1151 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
1152 self.db.borrow().clone()
1153 }
1154
1155 pub fn config(&self) -> Ref<'_, Config> {
1157 self.config.borrow()
1158 }
1159
1160 pub fn config_mut(&self) -> RefMut<'_, Config> {
1162 self.config.borrow_mut()
1163 }
1164
1165 pub fn set_harness(&self, harness: Harness) {
1166 *self.harness.borrow_mut() = Some(harness);
1167 self.bash_background.set_harness(harness);
1168 }
1169
1170 pub fn harness_opt(&self) -> Option<Harness> {
1171 *self.harness.borrow()
1172 }
1173
1174 pub fn harness(&self) -> Harness {
1175 self.harness_opt()
1176 .expect("harness set by configure before any tool call")
1177 }
1178
1179 pub fn storage_dir(&self) -> PathBuf {
1180 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
1181 }
1182
1183 pub fn harness_dir(&self) -> PathBuf {
1184 self.storage_dir().join(self.harness().as_str())
1185 }
1186
1187 pub fn inspect_dir(&self) -> PathBuf {
1188 self.harness_dir().join("inspect")
1189 }
1190
1191 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
1192 self.harness_dir()
1193 .join("bash-tasks")
1194 .join(hash_session(session_id))
1195 }
1196
1197 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
1198 self.harness_dir()
1199 .join("backups")
1200 .join(hash_session(session_id))
1201 .join(path_hash)
1202 }
1203
1204 pub fn filters_dir(&self) -> PathBuf {
1205 self.harness_dir().join("filters")
1206 }
1207
1208 pub fn trust_file(&self) -> PathBuf {
1210 self.storage_dir().join("trusted-filter-projects.json")
1211 }
1212
1213 pub fn set_canonical_cache_root(&self, root: PathBuf) {
1214 debug_assert!(root.is_absolute());
1215 *self.canonical_cache_root.borrow_mut() = Some(root);
1216 }
1217
1218 pub fn canonical_cache_root(&self) -> PathBuf {
1219 self.canonical_cache_root
1220 .borrow()
1221 .clone()
1222 .expect("canonical_cache_root accessed before handle_configure")
1223 }
1224
1225 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
1226 self.canonical_cache_root.borrow().clone()
1227 }
1228
1229 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
1230 *self.is_worktree_bridge.borrow_mut() = is_worktree_bridge;
1231 *self.git_common_dir.borrow_mut() = git_common_dir;
1232 }
1233
1234 pub fn is_worktree_bridge(&self) -> bool {
1235 *self.is_worktree_bridge.borrow()
1236 }
1237
1238 pub fn git_common_dir(&self) -> Option<PathBuf> {
1239 self.git_common_dir.borrow().clone()
1240 }
1241
1242 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
1246 *self.degraded_reasons.borrow_mut() = reasons;
1247 }
1248
1249 pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
1250 let reason = reason.into();
1251 let mut reasons = self.degraded_reasons.borrow_mut();
1252 if reasons.iter().any(|existing| existing == &reason) {
1253 return false;
1254 }
1255 reasons.push(reason);
1256 true
1257 }
1258
1259 pub fn degraded_reasons(&self) -> Vec<String> {
1263 self.degraded_reasons.borrow().clone()
1264 }
1265
1266 pub fn is_degraded(&self) -> bool {
1268 !self.degraded_reasons.borrow().is_empty()
1269 }
1270
1271 pub fn cache_role(&self) -> &'static str {
1272 if self.canonical_cache_root.borrow().is_none() {
1273 "not_initialized"
1274 } else if self.is_worktree_bridge() {
1275 "worktree"
1276 } else {
1277 "main"
1278 }
1279 }
1280
1281 pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
1283 &self.callgraph
1284 }
1285
1286 pub fn callgraph_store(&self) -> &RefCell<Option<CallGraphStore>> {
1288 &self.callgraph_store
1289 }
1290
1291 pub fn mark_callgraph_store_force_rebuild(&self) {
1292 *self.callgraph_store_force_rebuild.borrow_mut() = true;
1293 }
1294
1295 fn take_callgraph_store_force_rebuild(&self) -> bool {
1296 let force = *self.callgraph_store_force_rebuild.borrow();
1297 *self.callgraph_store_force_rebuild.borrow_mut() = false;
1298 force
1299 }
1300
1301 pub fn callgraph_store_dir(&self) -> PathBuf {
1302 match self.harness_opt() {
1303 Some(harness) => self.storage_dir().join(harness.as_str()).join("callgraph"),
1304 None => self.storage_dir().join("callgraph"),
1305 }
1306 }
1307
1308 pub fn ensure_callgraph_store(
1309 &self,
1310 ) -> Result<Option<RefMut<'_, CallGraphStore>>, CallGraphStoreError> {
1311 self.ensure_callgraph_store_with_flag(true)
1312 }
1313
1314 fn ensure_callgraph_store_with_flag(
1315 &self,
1316 respect_config_flag: bool,
1317 ) -> Result<Option<RefMut<'_, CallGraphStore>>, CallGraphStoreError> {
1318 if respect_config_flag && !self.config().callgraph_store {
1319 return Ok(None);
1320 }
1321 if self.callgraph_store.borrow().is_none() {
1322 let Some(project_root) = self.callgraph_project_root() else {
1323 return Ok(None);
1324 };
1325 let callgraph_dir = self.callgraph_store_dir();
1326 let force_rebuild = self.take_callgraph_store_force_rebuild();
1327 let store = if self.is_worktree_bridge() {
1328 CallGraphStore::open_readonly(callgraph_dir, project_root)?
1329 } else if force_rebuild {
1330 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1331 let (store, _stats) =
1332 CallGraphStore::cold_build_with_lease(callgraph_dir, project_root, &files)?;
1333 Some(store)
1334 } else if CallGraphStore::needs_cold_build(&callgraph_dir, &project_root)? {
1335 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1336 let (store, _stats) =
1337 CallGraphStore::ensure_built_with_lease(callgraph_dir, project_root, &files)?;
1338 Some(store)
1339 } else {
1340 Some(CallGraphStore::open(callgraph_dir, project_root)?)
1341 };
1342 *self.callgraph_store.borrow_mut() = store;
1343 }
1344 let borrow = self.callgraph_store.borrow_mut();
1345 Ok(RefMut::filter_map(borrow, Option::as_mut).ok())
1346 }
1347
1348 fn callgraph_project_root(&self) -> Option<PathBuf> {
1351 self.canonical_cache_root_opt().or_else(|| {
1352 self.config()
1353 .project_root
1354 .clone()
1355 .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
1356 })
1357 }
1358
1359 pub fn revalidate_callgraph_store_generation(&self) {
1377 if self.callgraph_store_rx.borrow().is_some() {
1380 return;
1381 }
1382 let superseded = self
1383 .callgraph_store
1384 .borrow()
1385 .as_ref()
1386 .is_some_and(|store| !store.is_current());
1387 if superseded {
1388 *self.callgraph_store.borrow_mut() = None;
1389 }
1390 }
1391
1392 pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess<'_> {
1393 self.revalidate_callgraph_store_generation();
1397 if self.callgraph_store.borrow().is_some() {
1398 let borrow = self.callgraph_store.borrow_mut();
1399 return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1400 Some(store) => CallgraphStoreAccess::Ready(store),
1401 None => CallgraphStoreAccess::Unavailable,
1402 };
1403 }
1404
1405 if self.callgraph_store_rx.borrow().is_some() {
1407 return CallgraphStoreAccess::Building;
1408 }
1409
1410 let Some(project_root) = self.callgraph_project_root() else {
1411 return CallgraphStoreAccess::Unavailable;
1412 };
1413 let callgraph_dir = self.callgraph_store_dir();
1414
1415 if self.is_worktree_bridge() {
1418 match CallGraphStore::open_readonly(callgraph_dir, project_root) {
1419 Ok(Some(store)) => {
1420 *self.callgraph_store.borrow_mut() = Some(store);
1421 let borrow = self.callgraph_store.borrow_mut();
1422 return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1423 Some(store) => CallgraphStoreAccess::Ready(store),
1424 None => CallgraphStoreAccess::Unavailable,
1425 };
1426 }
1427 Ok(None) | Err(_) => return CallgraphStoreAccess::Unavailable,
1428 }
1429 }
1430
1431 let force_rebuild = *self.callgraph_store_force_rebuild.borrow();
1432 if !force_rebuild {
1435 match CallGraphStore::needs_cold_build(&callgraph_dir, &project_root) {
1436 Ok(false) => match CallGraphStore::open(callgraph_dir, project_root) {
1437 Ok(store) => {
1438 *self.callgraph_store.borrow_mut() = Some(store);
1439 let borrow = self.callgraph_store.borrow_mut();
1440 return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1441 Some(store) => CallgraphStoreAccess::Ready(store),
1442 None => CallgraphStoreAccess::Unavailable,
1443 };
1444 }
1445 Err(error) => return CallgraphStoreAccess::Error(error),
1446 },
1447 Ok(true) => {}
1448 Err(error) => return CallgraphStoreAccess::Error(error),
1449 }
1450 }
1451
1452 self.spawn_callgraph_store_cold_build(project_root, callgraph_dir, force_rebuild);
1460
1461 let wait = callgraph_build_wait_window();
1462 if !wait.is_zero() {
1463 let received = {
1464 let rx_ref = self.callgraph_store_rx.borrow();
1465 let Some(rx) = rx_ref.as_ref() else {
1466 return CallgraphStoreAccess::Building;
1467 };
1468 rx.recv_timeout(wait)
1469 };
1470 match received {
1471 Ok(store) => {
1472 let pending = self.take_pending_callgraph_store_paths();
1476 if !pending.is_empty() {
1477 if let Err(error) = store.refresh_files(&pending) {
1478 crate::slog_warn!(
1479 "callgraph store inline post-build refresh failed: {}",
1480 error
1481 );
1482 let _ = store.mark_files_stale(&pending);
1483 }
1484 }
1485 *self.callgraph_store.borrow_mut() = Some(store);
1486 *self.callgraph_store_rx.borrow_mut() = None;
1487 let borrow = self.callgraph_store.borrow_mut();
1488 return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1489 Some(store) => CallgraphStoreAccess::Ready(store),
1490 None => CallgraphStoreAccess::Unavailable,
1491 };
1492 }
1493 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1494 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
1495 *self.callgraph_store_rx.borrow_mut() = None;
1498 }
1499 }
1500 }
1501 CallgraphStoreAccess::Building
1502 }
1503
1504 fn spawn_callgraph_store_cold_build(
1509 &self,
1510 project_root: PathBuf,
1511 callgraph_dir: PathBuf,
1512 force_rebuild: bool,
1513 ) {
1514 if force_rebuild {
1515 self.take_callgraph_store_force_rebuild();
1518 }
1519 let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStore>();
1520 *self.callgraph_store_rx.borrow_mut() = Some(rx);
1521 let session_id = crate::log_ctx::current_session();
1522 std::thread::spawn(move || {
1523 crate::log_ctx::with_session(session_id, || {
1524 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1525 let built = if force_rebuild {
1526 CallGraphStore::cold_build_with_lease(callgraph_dir, project_root, &files)
1527 .map(|(store, _)| store)
1528 } else {
1529 CallGraphStore::ensure_built_with_lease(callgraph_dir, project_root, &files)
1530 .map(|(store, _)| store)
1531 };
1532 match built {
1533 Ok(store) => {
1534 let _ = tx.send(store);
1535 }
1536 Err(error) => {
1537 crate::slog_warn!("callgraph store cold build failed: {}", error);
1538 }
1541 }
1542 });
1543 });
1544 }
1545
1546 pub fn callgraph_store_rx(
1549 &self,
1550 ) -> &RefCell<Option<crossbeam_channel::Receiver<CallGraphStore>>> {
1551 &self.callgraph_store_rx
1552 }
1553
1554 pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
1557 where
1558 I: IntoIterator<Item = PathBuf>,
1559 {
1560 self.pending_callgraph_store_paths
1561 .borrow_mut()
1562 .extend(paths);
1563 }
1564
1565 pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
1567 std::mem::take(&mut *self.pending_callgraph_store_paths.borrow_mut())
1568 .into_iter()
1569 .collect()
1570 }
1571
1572 pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
1574 &self.search_index
1575 }
1576
1577 pub fn search_index_rx(&self) -> &RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>> {
1579 &self.search_index_rx
1580 }
1581
1582 pub fn add_pending_search_index_paths<I>(&self, paths: I)
1583 where
1584 I: IntoIterator<Item = PathBuf>,
1585 {
1586 self.pending_search_index_paths.borrow_mut().extend(paths);
1587 }
1588
1589 pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
1590 std::mem::take(&mut *self.pending_search_index_paths.borrow_mut())
1591 .into_iter()
1592 .collect()
1593 }
1594
1595 pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
1596 where
1597 I: IntoIterator<Item = PathBuf>,
1598 {
1599 self.pending_semantic_index_paths.borrow_mut().extend(paths);
1600 }
1601
1602 pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
1603 std::mem::take(&mut *self.pending_semantic_index_paths.borrow_mut())
1604 .into_iter()
1605 .collect()
1606 }
1607
1608 pub fn mark_pending_semantic_corpus_refresh(&self) {
1609 *self.pending_semantic_corpus_refresh.borrow_mut() = true;
1610 }
1611
1612 pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
1613 std::mem::take(&mut *self.pending_semantic_corpus_refresh.borrow_mut())
1614 }
1615
1616 pub fn clear_pending_index_updates(&self) {
1617 self.pending_search_index_paths.borrow_mut().clear();
1618 self.pending_callgraph_store_paths.borrow_mut().clear();
1619 self.pending_semantic_index_paths.borrow_mut().clear();
1620 *self.pending_semantic_corpus_refresh.borrow_mut() = false;
1621 }
1622
1623 pub fn inspect_manager(&self) -> Arc<InspectManager> {
1624 Arc::clone(&self.inspect_manager)
1625 }
1626
1627 pub fn take_new_reuse_completions(&self) -> bool {
1632 let current = self.inspect_manager.reuse_completion_count();
1633 let previous = self
1634 .last_seen_reuse_completions
1635 .swap(current, Ordering::SeqCst);
1636 current != previous
1637 }
1638
1639 pub fn reset_tier2_refresh_scheduler(&self) {
1640 self.reset_tier2_refresh_scheduler_at(Instant::now());
1641 }
1642
1643 #[doc(hidden)]
1644 pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
1645 self.tier2_refresh_scheduler
1646 .borrow_mut()
1647 .reset_after_configure(now);
1648 }
1649
1650 pub fn request_tier2_refresh_pull(&self) -> bool {
1651 self.tier2_refresh_scheduler
1652 .borrow_mut()
1653 .request_pull(!self.is_worktree_bridge())
1654 }
1655
1656 pub fn tick_tier2_refresh_scheduler(
1657 &self,
1658 changed_path_count: usize,
1659 ) -> Option<Tier2TriggerReason> {
1660 self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
1661 }
1662
1663 #[doc(hidden)]
1664 pub fn tick_tier2_refresh_scheduler_at(
1665 &self,
1666 now: Instant,
1667 changed_path_count: usize,
1668 ) -> Option<Tier2TriggerReason> {
1669 let manager = self.inspect_manager();
1670 let can_write = !self.is_worktree_bridge();
1671 let in_flight = manager.tier2_any_in_flight();
1672 let decision = self.tier2_refresh_scheduler.borrow_mut().tick(
1673 now,
1674 changed_path_count,
1675 can_write,
1676 in_flight,
1677 );
1678
1679 if let Some(reason) = decision {
1680 self.start_tier2_refresh(reason, manager);
1681 }
1682
1683 decision
1684 }
1685
1686 pub fn note_tier2_refresh_started(&self) {
1687 self.note_tier2_refresh_started_at(Instant::now());
1688 }
1689
1690 #[doc(hidden)]
1691 pub fn note_tier2_refresh_started_at(&self, now: Instant) {
1692 self.tier2_refresh_scheduler
1693 .borrow_mut()
1694 .note_external_scan_started(now);
1695 }
1696
1697 pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
1698 self.tier2_refresh_scheduler
1699 .borrow()
1700 .last_trigger_reason()
1701 .map(Tier2TriggerReason::as_str)
1702 }
1703
1704 #[doc(hidden)]
1705 pub fn tier2_pull_demand_pending(&self) -> bool {
1706 self.tier2_refresh_scheduler.borrow().pull_demand_pending()
1707 }
1708
1709 fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
1710 if self.is_worktree_bridge()
1711 || self
1712 .degraded_reasons
1713 .borrow()
1714 .iter()
1715 .any(|r| r == "home_root")
1716 || !self.config().inspect.enabled
1717 {
1718 return;
1719 }
1720 let Some(snapshot) = self.tier2_refresh_snapshot() else {
1721 return;
1722 };
1723 let categories = InspectCategory::active()
1724 .iter()
1725 .copied()
1726 .filter(|category| category.is_tier2())
1727 .collect::<Vec<_>>();
1728 let submission =
1729 manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
1730 if submission.has_new_work() {
1731 crate::slog_info!(
1732 "tier2 refresh scheduled: reason={}, categories={:?}",
1733 reason.as_str(),
1734 submission
1735 .newly_queued_categories
1736 .iter()
1737 .map(|category| category.as_str())
1738 .collect::<Vec<_>>()
1739 );
1740 }
1741 for error in submission.errors {
1742 crate::slog_warn!(
1743 "tier2 refresh schedule failed for {}: {}",
1744 error.category,
1745 error.message
1746 );
1747 }
1748 }
1749
1750 fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
1751 self.harness_opt()?;
1752 let config = self.config().clone();
1753 let project_root = config
1754 .project_root
1755 .clone()
1756 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1757 let project_root = std::fs::canonicalize(&project_root).unwrap_or(project_root);
1758 Some(InspectSnapshot::new(
1759 project_root,
1760 self.inspect_dir(),
1761 Arc::new(config),
1762 self.symbol_cache(),
1763 ))
1764 }
1765
1766 pub fn symbol_cache(&self) -> SharedSymbolCache {
1768 Arc::clone(&self.symbol_cache)
1769 }
1770
1771 pub fn reset_symbol_cache(&self) -> u64 {
1773 self.symbol_cache
1774 .write()
1775 .map(|mut cache| cache.reset())
1776 .unwrap_or(0)
1777 }
1778
1779 pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
1781 &self.semantic_index
1782 }
1783
1784 pub fn semantic_index_rx(
1786 &self,
1787 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
1788 &self.semantic_index_rx
1789 }
1790
1791 pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
1792 &self.semantic_index_status
1793 }
1794
1795 pub fn install_semantic_refresh_worker(
1796 &self,
1797 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
1798 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
1799 worker_slot: SemanticRefreshWorkerSlot,
1800 ) {
1801 self.clear_semantic_refresh_worker();
1802 *self.semantic_refresh_tx.borrow_mut() = Some(sender);
1803 *self.semantic_refresh_event_rx.borrow_mut() = Some(event_rx);
1804 *self.semantic_refresh_worker.borrow_mut() = Some(worker_slot);
1805 }
1806
1807 pub fn clear_semantic_refresh_worker(&self) {
1808 *self.semantic_refresh_tx.borrow_mut() = None;
1809 *self.semantic_refresh_event_rx.borrow_mut() = None;
1810 if let Some(worker_slot) = self.semantic_refresh_worker.borrow_mut().take() {
1811 if let Ok(mut handle) = worker_slot.lock() {
1812 drop(handle.take());
1813 }
1814 }
1815 }
1816
1817 pub fn semantic_refresh_sender(
1818 &self,
1819 ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
1820 self.semantic_refresh_tx.borrow().clone()
1821 }
1822
1823 pub fn semantic_refresh_event_rx(
1824 &self,
1825 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
1826 &self.semantic_refresh_event_rx
1827 }
1828
1829 pub fn semantic_embedding_model(
1831 &self,
1832 ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
1833 &self.semantic_embedding_model
1834 }
1835
1836 pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
1838 &self.watcher
1839 }
1840
1841 pub fn watcher_rx(
1843 &self,
1844 ) -> &RefCell<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>> {
1845 &self.watcher_rx
1846 }
1847
1848 pub fn install_watcher_runtime(
1851 &self,
1852 rx: crossbeam_channel::Receiver<WatcherDispatchEvent>,
1853 runtime: WatcherThreadHandle,
1854 ) {
1855 *self.watcher_rx.borrow_mut() = Some(rx);
1856 *self.watcher_thread.borrow_mut() = Some(runtime);
1857 }
1858
1859 pub fn stop_watcher_runtime(&self) {
1862 if let Some(runtime) = self.watcher_thread.borrow_mut().take() {
1863 runtime.shutdown_and_join();
1864 }
1865 *self.watcher_rx.borrow_mut() = None;
1866 *self.watcher.borrow_mut() = None;
1867 }
1868
1869 pub fn lsp(&self) -> RefMut<'_, LspManager> {
1871 self.lsp_manager.borrow_mut()
1872 }
1873
1874 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
1877 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1878 let config = self.config();
1879 if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
1880 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1881 }
1882 }
1883 }
1884
1885 pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
1891 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1892 lsp.clear_diagnostics_for_file(file_path)
1893 } else {
1894 false
1895 }
1896 }
1897
1898 pub fn lsp_notify_and_collect_diagnostics(
1909 &self,
1910 file_path: &Path,
1911 content: &str,
1912 timeout: std::time::Duration,
1913 ) -> crate::lsp::manager::PostEditWaitOutcome {
1914 let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
1915 return crate::lsp::manager::PostEditWaitOutcome::default();
1916 };
1917
1918 lsp.drain_events();
1921
1922 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
1926
1927 let config = self.config();
1929 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
1930 {
1931 Ok(v) => v,
1932 Err(e) => {
1933 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1934 return crate::lsp::manager::PostEditWaitOutcome::default();
1935 }
1936 };
1937
1938 if expected_versions.is_empty() {
1941 return crate::lsp::manager::PostEditWaitOutcome::default();
1942 }
1943
1944 lsp.wait_for_post_edit_diagnostics(
1945 file_path,
1946 &config,
1947 &expected_versions,
1948 &pre_snapshot,
1949 timeout,
1950 )
1951 }
1952
1953 fn custom_lsp_root_markers(&self) -> Vec<String> {
1956 self.config()
1957 .lsp_servers
1958 .iter()
1959 .flat_map(|s| s.root_markers.iter().cloned())
1960 .collect()
1961 }
1962
1963 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
1964 let custom_markers = self.custom_lsp_root_markers();
1965 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
1966 .iter()
1967 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
1968 .cloned()
1969 .map(|path| {
1970 let change_type = if path.exists() {
1971 FileChangeType::CHANGED
1972 } else {
1973 FileChangeType::DELETED
1974 };
1975 (path, change_type)
1976 })
1977 .collect();
1978
1979 self.notify_watched_config_events(&config_paths);
1980 }
1981
1982 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
1983 let paths = params
1984 .get("multi_file_write_paths")
1985 .and_then(|value| value.as_array())?
1986 .iter()
1987 .filter_map(|value| value.as_str())
1988 .map(PathBuf::from)
1989 .collect::<Vec<_>>();
1990
1991 (!paths.is_empty()).then_some(paths)
1992 }
1993
1994 fn watched_file_events_from_params(
2006 params: &serde_json::Value,
2007 extra_markers: &[String],
2008 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
2009 let events = params
2010 .get("multi_file_write_paths")
2011 .and_then(|value| value.as_array())?
2012 .iter()
2013 .filter_map(|entry| {
2014 let path = entry
2016 .get("path")
2017 .and_then(|value| value.as_str())
2018 .map(PathBuf::from)?;
2019
2020 if !is_config_file_path_with_custom(&path, extra_markers) {
2021 return None;
2022 }
2023
2024 let change_type = entry
2025 .get("type")
2026 .and_then(|value| value.as_str())
2027 .and_then(Self::parse_file_change_type)
2028 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
2029
2030 Some((path, change_type))
2031 })
2032 .collect::<Vec<_>>();
2033
2034 (!events.is_empty()).then_some(events)
2035 }
2036
2037 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
2038 match value {
2039 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
2040 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
2041 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
2042 _ => None,
2043 }
2044 }
2045
2046 fn change_type_from_current_state(path: &Path) -> FileChangeType {
2047 if path.exists() {
2048 FileChangeType::CHANGED
2049 } else {
2050 FileChangeType::DELETED
2051 }
2052 }
2053
2054 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
2055 if config_paths.is_empty() {
2056 return;
2057 }
2058
2059 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
2060 let config = self.config();
2061 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
2062 crate::slog_warn!("watched-file sync error: {}", e);
2063 }
2064 }
2065 }
2066
2067 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
2068 let custom_markers = self.custom_lsp_root_markers();
2069 if !is_config_file_path_with_custom(file_path, &custom_markers) {
2070 return;
2071 }
2072
2073 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
2074 }
2075
2076 pub fn lsp_post_multi_file_write(
2081 &self,
2082 file_path: &Path,
2083 content: &str,
2084 file_paths: &[PathBuf],
2085 params: &serde_json::Value,
2086 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2087 self.notify_watched_config_files(file_paths);
2088
2089 let wants_diagnostics = params
2090 .get("diagnostics")
2091 .and_then(|v| v.as_bool())
2092 .unwrap_or(false);
2093
2094 if !wants_diagnostics {
2095 self.lsp_notify_file_changed(file_path, content);
2096 return None;
2097 }
2098
2099 let wait_ms = params
2100 .get("wait_ms")
2101 .and_then(|v| v.as_u64())
2102 .unwrap_or(3000)
2103 .min(10_000);
2104
2105 Some(self.lsp_notify_and_collect_diagnostics(
2106 file_path,
2107 content,
2108 std::time::Duration::from_millis(wait_ms),
2109 ))
2110 }
2111
2112 pub fn lsp_post_write(
2129 &self,
2130 file_path: &Path,
2131 content: &str,
2132 params: &serde_json::Value,
2133 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2134 let wants_diagnostics = params
2135 .get("diagnostics")
2136 .and_then(|v| v.as_bool())
2137 .unwrap_or(false);
2138
2139 let custom_markers = self.custom_lsp_root_markers();
2140
2141 if !wants_diagnostics {
2142 if let Some(file_paths) = Self::multi_file_write_paths(params) {
2143 self.notify_watched_config_files(&file_paths);
2144 } else if let Some(config_events) =
2145 Self::watched_file_events_from_params(params, &custom_markers)
2146 {
2147 self.notify_watched_config_events(&config_events);
2148 }
2149 self.lsp_notify_file_changed(file_path, content);
2150 return None;
2151 }
2152
2153 let wait_ms = params
2154 .get("wait_ms")
2155 .and_then(|v| v.as_u64())
2156 .unwrap_or(3000)
2157 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
2160 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
2161 }
2162
2163 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
2164 {
2165 self.notify_watched_config_events(&config_events);
2166 }
2167
2168 Some(self.lsp_notify_and_collect_diagnostics(
2169 file_path,
2170 content,
2171 std::time::Duration::from_millis(wait_ms),
2172 ))
2173 }
2174
2175 pub fn validate_path(
2184 &self,
2185 req_id: &str,
2186 path: &Path,
2187 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
2188 let config = self.config();
2189 if !config.restrict_to_project_root {
2191 return Ok(path.to_path_buf());
2192 }
2193 let root = match &config.project_root {
2194 Some(r) => r.clone(),
2195 None => return Ok(path.to_path_buf()), };
2197 drop(config);
2198
2199 let raw_root = root.clone();
2204 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
2205
2206 let path_for_resolution = if path.is_relative() {
2211 raw_root.join(path)
2212 } else {
2213 path.to_path_buf()
2214 };
2215 let resolved = match std::fs::canonicalize(&path_for_resolution) {
2216 Ok(resolved) => resolved,
2217 Err(_) => {
2218 let normalized = normalize_path(&path_for_resolution);
2219 reject_escaping_symlink(
2220 req_id,
2221 &path_for_resolution,
2222 &normalized,
2223 &resolved_root,
2224 &raw_root,
2225 )?;
2226 resolve_with_existing_ancestors(&normalized)
2227 }
2228 };
2229
2230 if !resolved.starts_with(&resolved_root) {
2231 return Err(path_error_response(req_id, path, &resolved_root));
2232 }
2233
2234 Ok(resolved)
2235 }
2236
2237 pub fn lsp_server_count(&self) -> usize {
2239 self.lsp_manager
2240 .try_borrow()
2241 .map(|lsp| lsp.server_count())
2242 .unwrap_or(0)
2243 }
2244
2245 pub fn symbol_cache_stats(&self) -> serde_json::Value {
2247 let entries = self
2248 .symbol_cache
2249 .read()
2250 .map(|cache| cache.len())
2251 .unwrap_or(0);
2252 serde_json::json!({
2253 "local_entries": entries,
2254 "warm_entries": 0,
2255 })
2256 }
2257}
2258
2259#[cfg(test)]
2260mod status_emitter_tests {
2261 use super::*;
2262 use crate::parser::TreeSitterProvider;
2263
2264 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
2265 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2266 let (tx, rx) = mpsc::channel();
2267 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
2268 let _ = tx.send(frame);
2269 }))));
2270 (ctx, rx)
2271 }
2272
2273 #[test]
2274 fn status_emitter_signal_triggers_push() {
2275 let (ctx, rx) = ctx_with_frame_rx();
2276 ctx.status_emitter().signal(ctx.build_status_snapshot());
2277 let frame = rx
2278 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2279 .expect("status_changed push");
2280 assert!(matches!(frame, PushFrame::StatusChanged(_)));
2281 }
2282
2283 #[test]
2284 fn status_emitter_debounces_burst() {
2285 let (ctx, rx) = ctx_with_frame_rx();
2286 for _ in 0..10 {
2287 ctx.status_emitter().signal(ctx.build_status_snapshot());
2288 }
2289 let frame = rx
2290 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2291 .expect("status_changed push");
2292 assert!(matches!(frame, PushFrame::StatusChanged(_)));
2293 assert!(rx.try_recv().is_err());
2294 }
2295
2296 #[test]
2297 fn status_emitter_separate_windows_separate_pushes() {
2298 let (ctx, rx) = ctx_with_frame_rx();
2299 ctx.status_emitter().signal(ctx.build_status_snapshot());
2300 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2301 .expect("first push");
2302 ctx.status_emitter().signal(ctx.build_status_snapshot());
2303 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2304 .expect("second push");
2305 }
2306
2307 #[test]
2308 fn status_emitter_no_signal_no_push() {
2309 let (_ctx, rx) = ctx_with_frame_rx();
2310 assert!(rx
2311 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
2312 .is_err());
2313 }
2314
2315 #[test]
2316 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
2317 let (ctx, rx) = ctx_with_frame_rx();
2318 drop(ctx);
2319 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
2320 }
2321}
2322
2323#[cfg(test)]
2324mod status_bar_tests {
2325 use super::*;
2326 use crate::parser::TreeSitterProvider;
2327
2328 fn ctx() -> AppContext {
2329 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
2330 }
2331
2332 #[test]
2333 fn status_bar_counts_none_until_tier2_populated() {
2334 let ctx = ctx();
2335 assert!(ctx.status_bar_counts().is_none());
2337
2338 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
2339 let counts = ctx.status_bar_counts().expect("populated");
2340 assert_eq!(counts.dead_code, 5);
2341 assert_eq!(counts.unused_exports, 3);
2342 assert_eq!(counts.duplicates, 7);
2343 assert_eq!(counts.todos, 2);
2344 assert!(!counts.tier2_stale);
2345 assert_eq!(counts.errors, 0);
2347 assert_eq!(counts.warnings, 0);
2348 }
2349
2350 #[test]
2351 fn partial_tier2_does_not_fabricate_zeros() {
2352 let ctx = ctx();
2353 ctx.update_status_bar_tier2(Some(5), None, None, None, true);
2357 assert!(
2358 ctx.status_bar_counts().is_none(),
2359 "bar must not surface until all three Tier-2 categories are real"
2360 );
2361
2362 ctx.update_status_bar_tier2(None, Some(3), None, None, true);
2364 assert!(ctx.status_bar_counts().is_none());
2365
2366 ctx.update_status_bar_tier2(None, None, Some(7), None, false);
2369 let counts = ctx.status_bar_counts().expect("all three real now");
2370 assert_eq!(counts.dead_code, 5);
2371 assert_eq!(counts.unused_exports, 3);
2372 assert_eq!(counts.duplicates, 7);
2373 }
2374
2375 #[test]
2376 fn update_with_none_todos_preserves_last_known_todos() {
2377 let ctx = ctx();
2378 ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
2379 ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
2381 let counts = ctx.status_bar_counts().expect("populated");
2382 assert_eq!(counts.todos, 9);
2383 assert_eq!(counts.dead_code, 2);
2384 }
2385
2386 #[test]
2387 fn update_with_none_count_preserves_last_known_count() {
2388 let ctx = ctx();
2389 ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
2390 ctx.update_status_bar_tier2(Some(11), None, None, None, false);
2393 let counts = ctx.status_bar_counts().expect("populated");
2394 assert_eq!(counts.dead_code, 11);
2395 assert_eq!(counts.unused_exports, 20);
2396 assert_eq!(counts.duplicates, 30);
2397 }
2398
2399 #[test]
2400 fn mark_stale_sets_flag_only_after_populate() {
2401 let ctx = ctx();
2402 ctx.mark_status_bar_tier2_stale();
2404 assert!(ctx.status_bar_counts().is_none());
2405
2406 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), Some(0), false);
2407 ctx.mark_status_bar_tier2_stale();
2408 assert!(ctx.status_bar_counts().expect("populated").tier2_stale);
2409
2410 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), None, false);
2412 assert!(!ctx.status_bar_counts().expect("populated").tier2_stale);
2413 }
2414
2415 #[test]
2420 fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
2421 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
2422 use crate::lsp::registry::ServerKind;
2423 use crate::lsp::roots::ServerKey;
2424
2425 let ctx = ctx();
2426 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); let file = std::path::PathBuf::from("/proj/gone.ts");
2429 {
2430 let mut lsp = ctx.lsp();
2431 lsp.diagnostics_store_mut_for_test().publish(
2432 ServerKey {
2433 kind: ServerKind::TypeScript,
2434 root: std::path::PathBuf::from("/proj"),
2435 },
2436 file.clone(),
2437 vec![StoredDiagnostic {
2438 file: file.clone(),
2439 line: 1,
2440 column: 1,
2441 end_line: 1,
2442 end_column: 2,
2443 severity: DiagnosticSeverity::Error,
2444 message: "boom".into(),
2445 code: None,
2446 source: None,
2447 }],
2448 );
2449 }
2450
2451 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
2453
2454 let removed = ctx.lsp_clear_diagnostics_for_file(&file);
2456 assert!(removed);
2457 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
2458 }
2459
2460 #[test]
2461 fn status_bar_filtered_counts_ignore_environmental_flap() {
2462 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
2463 use crate::lsp::registry::ServerKind;
2464 use crate::lsp::roots::ServerKey;
2465
2466 let ctx = ctx();
2467 let root = if cfg!(windows) {
2468 std::path::PathBuf::from(r"C:\proj")
2469 } else {
2470 std::path::PathBuf::from("/proj")
2471 };
2472 ctx.set_canonical_cache_root(root.clone());
2473 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
2474
2475 let file = root.join("aft.jsonc");
2476 let key = ServerKey {
2477 kind: ServerKind::TypeScript,
2478 root: root.clone(),
2479 };
2480 let env = StoredDiagnostic {
2481 file: file.clone(),
2482 line: 1,
2483 column: 1,
2484 end_line: 1,
2485 end_column: 2,
2486 severity: DiagnosticSeverity::Error,
2487 message: "Failed to load schema from https://example.com/schema.json".into(),
2488 code: None,
2489 source: Some("json".into()),
2490 };
2491
2492 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
2493
2494 {
2495 let mut lsp = ctx.lsp();
2496 lsp.diagnostics_store_mut_for_test()
2497 .publish(key.clone(), file.clone(), vec![env]);
2498 }
2499 assert_eq!(
2500 ctx.status_bar_counts().expect("populated").errors,
2501 0,
2502 "environmental publish must not change status-bar E"
2503 );
2504
2505 {
2506 let mut lsp = ctx.lsp();
2507 lsp.diagnostics_store_mut_for_test()
2508 .publish(key, file, vec![]);
2509 }
2510 assert_eq!(
2511 ctx.status_bar_counts().expect("populated").errors,
2512 0,
2513 "environmental clear must not change status-bar E"
2514 );
2515 }
2516}
2517
2518#[cfg(test)]
2519mod harness_path_tests {
2520 use super::*;
2521 use crate::harness::Harness;
2522 use crate::parser::TreeSitterProvider;
2523
2524 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
2525 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2526 ctx.config_mut().storage_dir = Some(storage_dir);
2527 ctx.set_harness(harness);
2528 ctx
2529 }
2530
2531 #[test]
2532 fn harness_dir_resolves_correctly() {
2533 let storage = PathBuf::from("/tmp/cortexkit/aft");
2534 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2535
2536 assert_eq!(ctx.harness_dir(), storage.join("pi"));
2537 }
2538
2539 #[test]
2540 fn bash_tasks_dir_uses_hash_session() {
2541 let storage = PathBuf::from("/tmp/cortexkit/aft");
2542 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2543
2544 assert_eq!(
2545 ctx.bash_tasks_dir("ses_abc"),
2546 storage
2547 .join("opencode")
2548 .join("bash-tasks")
2549 .join(hash_session("ses_abc"))
2550 );
2551 }
2552
2553 #[test]
2554 fn backups_dir_includes_path_hash() {
2555 let storage = PathBuf::from("/tmp/cortexkit/aft");
2556 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2557
2558 assert_eq!(
2559 ctx.backups_dir("ses_abc", "pathhash"),
2560 storage
2561 .join("pi")
2562 .join("backups")
2563 .join(hash_session("ses_abc"))
2564 .join("pathhash")
2565 );
2566 }
2567
2568 #[test]
2569 fn filters_dir_under_harness() {
2570 let storage = PathBuf::from("/tmp/cortexkit/aft");
2571 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2572
2573 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
2574 }
2575
2576 #[test]
2577 fn trust_file_is_host_global() {
2578 let storage = PathBuf::from("/tmp/cortexkit/aft");
2579 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2580
2581 assert_eq!(
2582 ctx.trust_file(),
2583 storage.join("trusted-filter-projects.json")
2584 );
2585 }
2586
2587 #[test]
2588 fn same_session_different_harness_resolve_different_paths() {
2589 let storage = PathBuf::from("/tmp/cortexkit/aft");
2590 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2591 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
2592
2593 assert_ne!(
2594 opencode.bash_tasks_dir("ses_same"),
2595 pi.bash_tasks_dir("ses_same")
2596 );
2597 }
2598}
2599
2600#[cfg(test)]
2601mod gitignore_tests {
2602 use super::*;
2603 use std::fs;
2604 use std::path::Path;
2605 use tempfile::TempDir;
2606
2607 fn make_ctx_with_root(root: &Path) -> AppContext {
2608 let provider = Box::new(crate::parser::TreeSitterProvider::new());
2609 let config = Config {
2610 project_root: Some(root.to_path_buf()),
2611 ..Config::default()
2612 };
2613 AppContext::new(provider, config)
2614 }
2615
2616 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
2623 let Some(matcher) = ctx.gitignore() else {
2624 return false;
2625 };
2626 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
2627 if !canonical.starts_with(matcher.path()) {
2628 return false;
2629 }
2630 let is_dir = canonical.is_dir();
2631 matcher
2632 .matched_path_or_any_parents(&canonical, is_dir)
2633 .is_ignore()
2634 }
2635
2636 fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
2647 use std::sync::{Mutex, OnceLock};
2648 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2649 let _guard = LOCK
2650 .get_or_init(|| Mutex::new(()))
2651 .lock()
2652 .unwrap_or_else(|e| e.into_inner());
2653 let tmp = TempDir::new().unwrap();
2654 let prev = std::env::var_os("XDG_CONFIG_HOME");
2655 unsafe {
2657 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
2658 }
2659 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
2660 unsafe {
2661 match prev {
2662 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
2663 None => std::env::remove_var("XDG_CONFIG_HOME"),
2664 }
2665 }
2666 match result {
2667 Ok(r) => r,
2668 Err(p) => std::panic::resume_unwind(p),
2669 }
2670 }
2671
2672 #[test]
2673 fn rebuild_gitignore_returns_none_without_project_root() {
2674 let provider = Box::new(crate::parser::TreeSitterProvider::new());
2675 let ctx = AppContext::new(provider, Config::default());
2676 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2677 assert!(ctx.gitignore().is_none());
2678 }
2679
2680 #[test]
2681 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
2682 let tmp = TempDir::new().unwrap();
2683 let ctx = make_ctx_with_root(tmp.path());
2684 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2685 assert!(ctx.gitignore().is_none());
2686 }
2687
2688 #[test]
2689 fn matcher_filters_files_in_ignored_dist_dir() {
2690 let tmp = TempDir::new().unwrap();
2691 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
2692 fs::create_dir_all(tmp.path().join("dist")).unwrap();
2693 fs::create_dir_all(tmp.path().join("src")).unwrap();
2694 let dist_file = tmp.path().join("dist").join("bundle.js");
2695 let src_file = tmp.path().join("src").join("app.ts");
2696 fs::write(&dist_file, "x").unwrap();
2697 fs::write(&src_file, "y").unwrap();
2698
2699 let ctx = make_ctx_with_root(tmp.path());
2700 ctx.rebuild_gitignore();
2701
2702 assert!(ctx.gitignore().is_some());
2703 assert!(
2704 is_ignored(&ctx, &dist_file),
2705 "dist/bundle.js should be ignored"
2706 );
2707 assert!(
2708 !is_ignored(&ctx, &src_file),
2709 "src/app.ts should NOT be ignored"
2710 );
2711 }
2712
2713 #[test]
2714 fn matcher_handles_node_modules_and_target() {
2715 let tmp = TempDir::new().unwrap();
2716 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
2717 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
2718 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
2719 let nm_file = tmp.path().join("node_modules/foo/index.js");
2720 let target_file = tmp.path().join("target/debug/aft");
2721 fs::write(&nm_file, "x").unwrap();
2722 fs::write(&target_file, "x").unwrap();
2723
2724 let ctx = make_ctx_with_root(tmp.path());
2725 ctx.rebuild_gitignore();
2726
2727 assert!(is_ignored(&ctx, &nm_file));
2728 assert!(is_ignored(&ctx, &target_file));
2729 }
2730
2731 #[test]
2732 fn matcher_honors_negation_pattern() {
2733 let tmp = TempDir::new().unwrap();
2735 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
2736 let random_log = tmp.path().join("random.log");
2737 let important_log = tmp.path().join("important.log");
2738 fs::write(&random_log, "x").unwrap();
2739 fs::write(&important_log, "y").unwrap();
2740
2741 let ctx = make_ctx_with_root(tmp.path());
2742 ctx.rebuild_gitignore();
2743
2744 assert!(is_ignored(&ctx, &random_log));
2745 assert!(
2746 !is_ignored(&ctx, &important_log),
2747 "negation pattern should un-ignore important.log"
2748 );
2749 }
2750
2751 #[test]
2752 fn rebuild_picks_up_gitignore_changes() {
2753 let tmp = TempDir::new().unwrap();
2754 let ignore_path = tmp.path().join(".gitignore");
2755 fs::write(&ignore_path, "foo.txt\n").unwrap();
2756 let foo = tmp.path().join("foo.txt");
2757 let bar = tmp.path().join("bar.txt");
2758 fs::write(&foo, "").unwrap();
2759 fs::write(&bar, "").unwrap();
2760
2761 let ctx = make_ctx_with_root(tmp.path());
2762 ctx.rebuild_gitignore();
2763 assert!(is_ignored(&ctx, &foo));
2764 assert!(!is_ignored(&ctx, &bar));
2765
2766 fs::write(&ignore_path, "bar.txt\n").unwrap();
2768 ctx.rebuild_gitignore();
2769 assert!(!is_ignored(&ctx, &foo));
2770 assert!(is_ignored(&ctx, &bar));
2771 }
2772
2773 #[test]
2774 fn gitignore_loads_info_exclude_when_present() {
2775 let tmp = TempDir::new().unwrap();
2776 let info_dir = tmp.path().join(".git/info");
2777 fs::create_dir_all(&info_dir).unwrap();
2778 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
2779 let secrets = tmp.path().join("secrets.txt");
2780 let public = tmp.path().join("public.txt");
2781 fs::write(&secrets, "token").unwrap();
2782 fs::write(&public, "ok").unwrap();
2783
2784 let ctx = make_ctx_with_root(tmp.path());
2785 ctx.rebuild_gitignore();
2786
2787 assert!(is_ignored(&ctx, &secrets));
2788 assert!(!is_ignored(&ctx, &public));
2789 }
2790
2791 #[test]
2792 fn matcher_picks_up_nested_gitignore() {
2793 let tmp = TempDir::new().unwrap();
2794 fs::write(tmp.path().join(".gitignore"), "").unwrap();
2796 let sub = tmp.path().join("packages/foo");
2797 fs::create_dir_all(&sub).unwrap();
2798 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
2799 let generated_file = sub.join("generated").join("out.js");
2800 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
2801 fs::write(&generated_file, "x").unwrap();
2802
2803 let ctx = make_ctx_with_root(tmp.path());
2804 ctx.rebuild_gitignore();
2805
2806 assert!(
2807 is_ignored(&ctx, &generated_file),
2808 "nested gitignore in packages/foo/.gitignore should ignore generated/"
2809 );
2810 }
2811}