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};
31
32pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
33pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
34pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
35const STATUS_DEBOUNCE_MS: u64 = 1_000;
36
37#[derive(Debug, Clone, Default, PartialEq, Eq)]
45pub struct StatusBarCounts {
46 pub errors: usize,
47 pub warnings: usize,
48 pub dead_code: usize,
49 pub unused_exports: usize,
50 pub duplicates: usize,
51 pub todos: usize,
52 pub tier2_stale: bool,
53}
54
55#[derive(Debug, Clone, Default)]
64struct StatusBarTier2 {
65 dead_code: Option<usize>,
66 unused_exports: Option<usize>,
67 duplicates: Option<usize>,
68 todos: Option<usize>,
69 stale: bool,
70}
71
72pub struct StatusEmitter {
73 latest: Arc<Mutex<Option<StatusPayload>>>,
74 notify: mpsc::Sender<()>,
75}
76
77impl StatusEmitter {
78 fn new(progress_sender: SharedProgressSender) -> Self {
79 let (notify, rx) = mpsc::channel();
80 let latest = Arc::new(Mutex::new(None));
81 let latest_for_thread = Arc::clone(&latest);
82 std::thread::spawn(move || {
83 status_debounce_loop(rx, latest_for_thread, progress_sender);
84 });
85 Self { latest, notify }
86 }
87
88 pub fn signal(&self, snapshot: StatusPayload) {
89 if let Ok(mut latest) = self.latest.lock() {
90 *latest = Some(snapshot);
91 }
92 let _ = self.notify.send(());
93 }
94}
95
96fn status_debounce_loop(
97 rx: mpsc::Receiver<()>,
98 latest: Arc<Mutex<Option<StatusPayload>>>,
99 progress_sender: SharedProgressSender,
100) {
101 while rx.recv().is_ok() {
102 let deadline = Instant::now() + Duration::from_millis(STATUS_DEBOUNCE_MS);
103 while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
104 match rx.recv_timeout(remaining) {
105 Ok(()) => continue,
106 Err(mpsc::RecvTimeoutError::Timeout) => break,
107 Err(mpsc::RecvTimeoutError::Disconnected) => return,
108 }
109 }
110
111 let snapshot = latest.lock().ok().and_then(|mut latest| latest.take());
112 let Some(snapshot) = snapshot else { continue };
113 let sender = progress_sender
114 .lock()
115 .ok()
116 .and_then(|sender| sender.clone());
117 if let Some(sender) = sender {
118 sender(PushFrame::StatusChanged(StatusChangedFrame::new(
119 None, snapshot,
120 )));
121 }
122 }
123}
124use crate::cache_freshness::FileFreshness;
125use crate::search_index::SearchIndex;
126use crate::semantic_index::{EmbeddingEntry, SemanticIndex};
127
128#[derive(Debug, Default)]
132struct SemanticRefreshAccounting {
133 pending: usize,
134 in_flight: usize,
135}
136
137static SEMANTIC_REFRESH_ACCOUNTING: OnceLock<Mutex<BTreeMap<PathBuf, SemanticRefreshAccounting>>> =
138 OnceLock::new();
139
140fn semantic_refresh_accounting() -> &'static Mutex<BTreeMap<PathBuf, SemanticRefreshAccounting>> {
141 SEMANTIC_REFRESH_ACCOUNTING.get_or_init(|| Mutex::new(BTreeMap::new()))
142}
143
144fn clear_semantic_refresh_accounting() {
145 if let Some(accounting) = SEMANTIC_REFRESH_ACCOUNTING.get() {
146 if let Ok(mut accounting) = accounting.lock() {
147 accounting.clear();
148 }
149 }
150}
151
152fn ensure_refreshing_path(refreshing: &mut Vec<PathBuf>, path: PathBuf) {
153 if !refreshing.iter().any(|existing| existing == &path) {
154 refreshing.push(path);
155 refreshing.sort();
156 }
157}
158
159fn remove_refreshing_path(refreshing: &mut Vec<PathBuf>, path: &Path) {
160 refreshing.retain(|existing| existing != path);
161}
162
163#[derive(Debug, Clone)]
164pub enum SemanticIndexStatus {
165 Disabled,
166 Building {
167 stage: String,
169 files: Option<usize>,
170 entries_done: Option<usize>,
171 entries_total: Option<usize>,
172 },
173 Ready {
174 refreshing: Vec<PathBuf>,
177 },
178 Failed(String),
179}
180
181impl SemanticIndexStatus {
182 pub fn ready() -> Self {
183 clear_semantic_refresh_accounting();
184 Self::Ready {
185 refreshing: Vec::new(),
186 }
187 }
188
189 pub fn add_refreshing_file(&mut self, path: PathBuf) {
190 if let Self::Ready { refreshing } = self {
191 if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
192 let state = accounting.entry(path.clone()).or_default();
193 state.pending = state.pending.saturating_add(1);
194 }
195 ensure_refreshing_path(refreshing, path);
196 }
197 }
198
199 pub fn start_refreshing_file(&mut self, path: PathBuf) {
200 if let Self::Ready { refreshing } = self {
201 if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
202 let state = accounting.entry(path.clone()).or_default();
203 if state.pending == 0 {
204 state.pending = 1;
205 }
206 if state.in_flight == 0 {
207 state.in_flight = state.pending;
208 }
209 }
210 ensure_refreshing_path(refreshing, path);
211 }
212 }
213
214 pub fn cancel_refreshing_file(&mut self, path: &Path) {
215 self.finish_refreshing_file(path, false);
216 }
217
218 pub fn complete_refreshing_file(&mut self, path: &Path) {
219 self.finish_refreshing_file(path, true);
220 }
221
222 pub fn remove_refreshing_file(&mut self, path: &Path) {
223 self.complete_refreshing_file(path);
224 }
225
226 fn finish_refreshing_file(&mut self, path: &Path, complete_in_flight: bool) {
227 if let Self::Ready { refreshing } = self {
228 let mut keep_refreshing = false;
229 let mut accounting_checked = false;
230 if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
231 accounting_checked = true;
232 if let Some(state) = accounting.get_mut(path) {
233 let finished = if complete_in_flight {
234 state.in_flight.max(1)
235 } else {
236 1
237 };
238 state.pending = state.pending.saturating_sub(finished);
239 if complete_in_flight {
240 state.in_flight = 0;
241 } else {
242 state.in_flight = state.in_flight.min(state.pending);
243 }
244 keep_refreshing = state.pending > 0;
245 if !keep_refreshing {
246 accounting.remove(path);
247 }
248 }
249 }
250
251 if !accounting_checked || !keep_refreshing {
252 remove_refreshing_path(refreshing, path);
253 }
254 }
255 }
256
257 pub fn refreshing_count(&self) -> usize {
258 match self {
259 Self::Ready { refreshing } => refreshing.len(),
260 _ => 0,
261 }
262 }
263}
264
265pub enum SemanticIndexEvent {
266 Progress {
267 stage: String,
268 files: Option<usize>,
269 entries_done: Option<usize>,
270 entries_total: Option<usize>,
271 },
272 Ready(SemanticIndex),
273 Failed(String),
274}
275
276#[derive(Debug, Clone)]
277pub enum SemanticRefreshRequest {
278 Files {
279 paths: Vec<PathBuf>,
280 },
281 Corpus,
285}
286
287#[derive(Debug)]
288pub enum SemanticRefreshEvent {
289 Started {
290 paths: Vec<PathBuf>,
291 },
292 CorpusStarted {
293 files: usize,
294 },
295 Completed {
296 added_entries: Vec<EmbeddingEntry>,
297 updated_metadata: Vec<(PathBuf, FileFreshness)>,
298 completed_paths: Vec<PathBuf>,
299 },
300 CorpusCompleted {
301 index: SemanticIndex,
302 changed: usize,
303 added: usize,
304 deleted: usize,
305 total_processed: usize,
306 },
307 Failed {
308 paths: Vec<PathBuf>,
309 error: String,
310 },
311 CorpusFailed {
312 error: String,
313 },
314}
315
316pub type SemanticRefreshWorkerSlot = Arc<Mutex<Option<std::thread::JoinHandle<()>>>>;
317
318fn normalize_path(path: &Path) -> PathBuf {
322 let mut result = PathBuf::new();
323 for component in path.components() {
324 match component {
325 Component::ParentDir => {
326 if !result.pop() {
328 result.push(component);
329 }
330 }
331 Component::CurDir => {} _ => result.push(component),
333 }
334 }
335 result
336}
337
338fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
339 let mut existing = path.to_path_buf();
340 let mut tail_segments = Vec::new();
341
342 while !existing.exists() {
343 if let Some(name) = existing.file_name() {
344 tail_segments.push(name.to_owned());
345 } else {
346 break;
347 }
348
349 existing = match existing.parent() {
350 Some(parent) => parent.to_path_buf(),
351 None => break,
352 };
353 }
354
355 let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
356 for segment in tail_segments.into_iter().rev() {
357 resolved.push(segment);
358 }
359
360 resolved
361}
362
363fn path_error_response(
364 req_id: &str,
365 path: &Path,
366 resolved_root: &Path,
367) -> crate::protocol::Response {
368 crate::protocol::Response::error(
369 req_id,
370 "path_outside_root",
371 format!(
372 "path '{}' is outside the project root '{}'",
373 path.display(),
374 resolved_root.display()
375 ),
376 )
377}
378
379fn reject_escaping_symlink(
389 req_id: &str,
390 original_path: &Path,
391 candidate: &Path,
392 resolved_root: &Path,
393 raw_root: &Path,
394) -> Result<(), crate::protocol::Response> {
395 let mut current = PathBuf::new();
396
397 for component in candidate.components() {
398 current.push(component);
399
400 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
401 continue;
402 };
403
404 if !metadata.file_type().is_symlink() {
405 continue;
406 }
407
408 let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
417 if !inside_root {
418 continue;
419 }
420
421 iterative_follow_chain(req_id, original_path, ¤t, resolved_root)?;
422 }
423
424 Ok(())
425}
426
427fn iterative_follow_chain(
430 req_id: &str,
431 original_path: &Path,
432 start: &Path,
433 resolved_root: &Path,
434) -> Result<(), crate::protocol::Response> {
435 let mut link = start.to_path_buf();
436 let mut depth = 0usize;
437
438 loop {
439 if depth > 40 {
440 return Err(path_error_response(req_id, original_path, resolved_root));
441 }
442
443 let target = match std::fs::read_link(&link) {
444 Ok(t) => t,
445 Err(_) => {
446 return Err(path_error_response(req_id, original_path, resolved_root));
448 }
449 };
450
451 let resolved_target = if target.is_absolute() {
452 normalize_path(&target)
453 } else {
454 let parent = link.parent().unwrap_or_else(|| Path::new(""));
455 normalize_path(&parent.join(&target))
456 };
457
458 let canonical_target =
462 std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
463
464 if !canonical_target.starts_with(resolved_root)
465 && !resolved_target.starts_with(resolved_root)
466 {
467 return Err(path_error_response(req_id, original_path, resolved_root));
468 }
469
470 match std::fs::symlink_metadata(&resolved_target) {
472 Ok(meta) if meta.file_type().is_symlink() => {
473 link = resolved_target;
474 depth += 1;
475 }
476 _ => break, }
478 }
479
480 Ok(())
481}
482
483pub struct AppContext {
493 provider: Box<dyn LanguageProvider>,
494 backup: RefCell<BackupStore>,
495 checkpoint: RefCell<CheckpointStore>,
496 db: RefCell<Option<Arc<Mutex<Connection>>>>,
497 config: RefCell<Config>,
498 pub harness: RefCell<Option<Harness>>,
499 canonical_cache_root: RefCell<Option<PathBuf>>,
500 is_worktree_bridge: RefCell<bool>,
501 git_common_dir: RefCell<Option<PathBuf>>,
502 degraded_reasons: RefCell<Vec<String>>,
510 callgraph: RefCell<Option<CallGraph>>,
511 callgraph_store: RefCell<Option<CallGraphStore>>,
512 callgraph_store_force_rebuild: RefCell<bool>,
513 callgraph_store_rx: RefCell<Option<crossbeam_channel::Receiver<CallGraphStore>>>,
514 pending_callgraph_store_paths: RefCell<BTreeSet<PathBuf>>,
515 search_index: RefCell<Option<SearchIndex>>,
516 search_index_rx: RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>>,
517 pending_search_index_paths: RefCell<BTreeSet<PathBuf>>,
518 symbol_cache: SharedSymbolCache,
519 inspect_manager: Arc<InspectManager>,
520 tier2_refresh_scheduler: RefCell<Tier2RefreshScheduler>,
521 semantic_index: RefCell<Option<SemanticIndex>>,
522 semantic_index_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
523 semantic_index_status: RefCell<SemanticIndexStatus>,
524 pending_semantic_index_paths: RefCell<BTreeSet<PathBuf>>,
525 pending_semantic_corpus_refresh: RefCell<bool>,
526 semantic_refresh_tx: RefCell<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>,
527 semantic_refresh_event_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
528 semantic_refresh_worker: RefCell<Option<SemanticRefreshWorkerSlot>>,
529 semantic_embedding_model: RefCell<Option<crate::semantic_index::EmbeddingModel>>,
530 watcher: RefCell<Option<RecommendedWatcher>>,
531 watcher_rx: RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>>,
532 lsp_manager: RefCell<LspManager>,
533 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
537 stdout_writer: SharedStdoutWriter,
538 progress_sender: SharedProgressSender,
539 configure_generation: AtomicU64,
540 last_seen_reuse_completions: AtomicU64,
544 configure_warnings_tx: mpsc::Sender<(u64, ConfigureWarningsFrame)>,
545 configure_warnings_rx: mpsc::Receiver<(u64, ConfigureWarningsFrame)>,
546 status_emitter: StatusEmitter,
547 bash_background: BgTaskRegistry,
548 filter_registry: crate::compress::SharedFilterRegistry,
555 filter_registry_loaded: std::sync::atomic::AtomicBool,
558 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
563 gitignore: RefCell<Option<Arc<ignore::gitignore::Gitignore>>>,
570 status_bar_tier2: RefCell<StatusBarTier2>,
574 tsconfig_membership: RefCell<crate::lsp::tsconfig_membership::TsconfigMembershipCache>,
581}
582
583pub enum CallgraphStoreAccess<'a> {
591 Ready(RefMut<'a, CallGraphStore>),
593 Building,
595 Unavailable,
597 Error(CallGraphStoreError),
599}
600
601fn callgraph_build_wait_window() -> Duration {
606 std::env::var("AFT_CALLGRAPH_BUILD_WAIT_MS")
607 .ok()
608 .and_then(|raw| raw.parse::<u64>().ok())
609 .map(Duration::from_millis)
610 .unwrap_or(Duration::ZERO)
611}
612
613impl AppContext {
614 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
615 let bash_compress_enabled = config.experimental_bash_compress;
616 let progress_sender = Arc::new(Mutex::new(None));
617 let stdout_writer = Arc::new(Mutex::new(BufWriter::new(io::stdout())));
618 let (configure_warnings_tx, configure_warnings_rx) = mpsc::channel();
619 let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
620 let symbol_cache = provider
621 .as_any()
622 .downcast_ref::<crate::parser::TreeSitterProvider>()
623 .map(|provider| provider.symbol_cache())
624 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
625 let lsp_child_registry = crate::lsp::child_registry::LspChildRegistry::new();
626 let mut lsp_manager = LspManager::new();
627 lsp_manager.set_child_registry(lsp_child_registry.clone());
628 lsp_manager.set_diagnostic_capacity(config.diagnostic_cache_size);
631 AppContext {
632 provider,
633 backup: RefCell::new(BackupStore::new()),
634 checkpoint: RefCell::new(CheckpointStore::new()),
635 db: RefCell::new(None),
636 config: RefCell::new(config),
637 harness: RefCell::new(None),
638 canonical_cache_root: RefCell::new(None),
639 is_worktree_bridge: RefCell::new(false),
640 git_common_dir: RefCell::new(None),
641 degraded_reasons: RefCell::new(Vec::new()),
642 callgraph: RefCell::new(None),
643 callgraph_store: RefCell::new(None),
644 callgraph_store_force_rebuild: RefCell::new(false),
645 callgraph_store_rx: RefCell::new(None),
646 pending_callgraph_store_paths: RefCell::new(BTreeSet::new()),
647 search_index: RefCell::new(None),
648 search_index_rx: RefCell::new(None),
649 pending_search_index_paths: RefCell::new(BTreeSet::new()),
650 symbol_cache,
651 inspect_manager: Arc::new(InspectManager::new()),
652 tier2_refresh_scheduler: RefCell::new(Tier2RefreshScheduler::new()),
653 semantic_index: RefCell::new(None),
654 semantic_index_rx: RefCell::new(None),
655 semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
656 pending_semantic_index_paths: RefCell::new(BTreeSet::new()),
657 pending_semantic_corpus_refresh: RefCell::new(false),
658 semantic_refresh_tx: RefCell::new(None),
659 semantic_refresh_event_rx: RefCell::new(None),
660 semantic_refresh_worker: RefCell::new(None),
661 semantic_embedding_model: RefCell::new(None),
662 watcher: RefCell::new(None),
663 watcher_rx: RefCell::new(None),
664 lsp_manager: RefCell::new(lsp_manager),
665 lsp_child_registry,
666 stdout_writer,
667 progress_sender: Arc::clone(&progress_sender),
668 configure_generation: AtomicU64::new(0),
669 last_seen_reuse_completions: AtomicU64::new(0),
670 configure_warnings_tx,
671 configure_warnings_rx,
672 status_emitter,
673 bash_background: BgTaskRegistry::new(progress_sender),
674 filter_registry: Arc::new(std::sync::RwLock::new(
675 crate::compress::toml_filter::FilterRegistry::default(),
676 )),
677 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
678 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
679 gitignore: RefCell::new(None),
680 status_bar_tier2: RefCell::new(StatusBarTier2::default()),
681 tsconfig_membership: RefCell::new(
682 crate::lsp::tsconfig_membership::TsconfigMembershipCache::new(),
683 ),
684 }
685 }
686
687 pub fn status_bar_counts(&self) -> Option<StatusBarCounts> {
693 let tier2 = self.status_bar_tier2.borrow();
694 let (Some(dead_code), Some(unused_exports), Some(duplicates)) =
698 (tier2.dead_code, tier2.unused_exports, tier2.duplicates)
699 else {
700 return None;
701 };
702 let (errors, warnings) = self.status_bar_error_warning_counts();
703 Some(StatusBarCounts {
704 errors,
705 warnings,
706 dead_code,
707 unused_exports,
708 duplicates,
709 todos: tier2.todos.unwrap_or(0),
710 tier2_stale: tier2.stale,
711 })
712 }
713
714 fn status_bar_error_warning_counts(&self) -> (usize, usize) {
720 let Some(root) = self.canonical_cache_root_opt() else {
721 return self.lsp_manager.borrow().warm_error_warning_counts();
724 };
725 let mut membership = self.tsconfig_membership.borrow_mut();
726 self.lsp_manager
727 .borrow()
728 .filtered_error_warning_counts(|file| {
729 file.starts_with(&root) && !membership.should_skip_diagnostics(file)
730 })
731 }
732
733 pub fn clear_tsconfig_membership_cache(&self) {
737 self.tsconfig_membership.borrow_mut().clear();
738 }
739
740 pub fn mark_status_bar_tier2_stale(&self) -> bool {
746 let mut tier2 = self.status_bar_tier2.borrow_mut();
747 if tier2.dead_code.is_some() && tier2.unused_exports.is_some() && tier2.duplicates.is_some()
749 {
750 let changed = !tier2.stale;
751 tier2.stale = true;
752 return changed;
753 }
754 false
755 }
756
757 pub fn update_status_bar_tier2(
763 &self,
764 dead_code: Option<usize>,
765 unused_exports: Option<usize>,
766 duplicates: Option<usize>,
767 todos: Option<usize>,
768 stale: bool,
769 ) {
770 let mut tier2 = self.status_bar_tier2.borrow_mut();
771 if let Some(dead_code) = dead_code {
772 tier2.dead_code = Some(dead_code);
773 }
774 if let Some(unused_exports) = unused_exports {
775 tier2.unused_exports = Some(unused_exports);
776 }
777 if let Some(duplicates) = duplicates {
778 tier2.duplicates = Some(duplicates);
779 }
780 if let Some(todos) = todos {
781 tier2.todos = Some(todos);
782 }
783 tier2.stale = stale;
784 }
785
786 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
789 self.gitignore.borrow().clone()
790 }
791
792 pub fn clear_gitignore(&self) {
814 *self.gitignore.borrow_mut() = None;
815 }
816
817 pub fn rebuild_gitignore(&self) {
818 use ignore::gitignore::GitignoreBuilder;
819 use std::path::Path;
820 let root_raw = match self.config().project_root.clone() {
821 Some(r) => r,
822 None => {
823 *self.gitignore.borrow_mut() = None;
824 return;
825 }
826 };
827 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
835 let mut builder = GitignoreBuilder::new(&root);
836 if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
841 if global_ignore.is_file() {
842 if let Some(err) = builder.add(&global_ignore) {
843 crate::slog_warn!(
844 "global gitignore parse error in {}: {}",
845 global_ignore.display(),
846 err
847 );
848 }
849 }
850 }
851 let root_ignore = Path::new(&root).join(".gitignore");
853 if root_ignore.exists() {
854 if let Some(err) = builder.add(&root_ignore) {
855 crate::slog_warn!(
856 "gitignore parse error in {}: {}",
857 root_ignore.display(),
858 err
859 );
860 }
861 }
862 let root_aftignore = Path::new(&root).join(".aftignore");
867 if root_aftignore.exists() {
868 if let Some(err) = builder.add(&root_aftignore) {
869 crate::slog_warn!(
870 "aftignore parse error in {}: {}",
871 root_aftignore.display(),
872 err
873 );
874 }
875 }
876 let info_exclude = self
881 .git_common_dir
882 .borrow()
883 .clone()
884 .unwrap_or_else(|| Path::new(&root).join(".git"))
885 .join("info")
886 .join("exclude");
887 if info_exclude.exists() {
888 if let Some(err) = builder.add(&info_exclude) {
889 crate::slog_warn!(
890 "gitignore parse error in {}: {}",
891 info_exclude.display(),
892 err
893 );
894 }
895 }
896 let walker = ignore::WalkBuilder::new(&root)
902 .standard_filters(true)
903 .hidden(false)
911 .filter_entry(|entry| {
912 let name = entry.file_name().to_string_lossy();
913 !matches!(
914 name.as_ref(),
915 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
916 )
917 })
918 .build();
919 for entry in walker.flatten() {
920 let file_name = entry.file_name();
921 let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
922 let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
923 if is_nested_gitignore || is_nested_aftignore {
924 if let Some(err) = builder.add(entry.path()) {
925 crate::slog_warn!(
926 "nested ignore parse error in {}: {}",
927 entry.path().display(),
928 err
929 );
930 }
931 }
932 }
933 match builder.build() {
934 Ok(gi) => {
935 let count = gi.num_ignores();
936 if count > 0 {
937 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
938 *self.gitignore.borrow_mut() = Some(Arc::new(gi));
939 } else {
940 *self.gitignore.borrow_mut() = None;
941 }
942 }
943 Err(err) => {
944 crate::slog_warn!("gitignore matcher build failed: {}", err);
945 *self.gitignore.borrow_mut() = None;
946 }
947 }
948 }
949
950 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
953 Arc::clone(&self.bash_compress_flag)
954 }
955
956 pub fn sync_bash_compress_flag(&self) {
960 let value = self.config().experimental_bash_compress;
961 self.bash_compress_flag
962 .store(value, std::sync::atomic::Ordering::Relaxed);
963 }
964
965 pub fn set_bash_compress_enabled(&self, enabled: bool) {
966 self.config_mut().experimental_bash_compress = enabled;
967 self.bash_compress_flag
968 .store(enabled, std::sync::atomic::Ordering::Relaxed);
969 }
970
971 pub fn filter_registry(
975 &self,
976 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
977 self.ensure_filter_registry_loaded();
978 match self.filter_registry.read() {
979 Ok(g) => g,
980 Err(poisoned) => poisoned.into_inner(),
981 }
982 }
983
984 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
988 self.ensure_filter_registry_loaded();
989 Arc::clone(&self.filter_registry)
990 }
991
992 pub fn reset_filter_registry(&self) {
996 let new_registry = crate::compress::build_registry_for_context(self);
997 match self.filter_registry.write() {
998 Ok(mut slot) => *slot = new_registry,
999 Err(poisoned) => *poisoned.into_inner() = new_registry,
1000 }
1001 self.filter_registry_loaded
1002 .store(true, std::sync::atomic::Ordering::Release);
1003 }
1004
1005 fn ensure_filter_registry_loaded(&self) {
1006 use std::sync::atomic::Ordering;
1007 if self.filter_registry_loaded.load(Ordering::Acquire) {
1008 return;
1009 }
1010 let new_registry = crate::compress::build_registry_for_context(self);
1013 if let Ok(mut slot) = self.filter_registry.write() {
1014 *slot = new_registry;
1015 self.filter_registry_loaded.store(true, Ordering::Release);
1016 }
1017 }
1018
1019 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
1022 self.lsp_child_registry.clone()
1023 }
1024
1025 pub fn stdout_writer(&self) -> SharedStdoutWriter {
1026 Arc::clone(&self.stdout_writer)
1027 }
1028
1029 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
1030 if let Ok(mut progress_sender) = self.progress_sender.lock() {
1031 *progress_sender = sender;
1032 }
1033 }
1034
1035 pub fn emit_progress(&self, frame: ProgressFrame) {
1036 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
1037 return;
1038 };
1039 if let Some(sender) = progress_sender.as_ref() {
1040 sender(PushFrame::Progress(frame));
1041 }
1042 }
1043
1044 pub fn status_emitter(&self) -> &StatusEmitter {
1045 &self.status_emitter
1046 }
1047
1048 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
1056 self.progress_sender
1057 .lock()
1058 .ok()
1059 .and_then(|sender| sender.clone())
1060 }
1061
1062 pub fn advance_configure_generation(&self) -> u64 {
1063 self.configure_generation
1064 .fetch_add(1, Ordering::SeqCst)
1065 .wrapping_add(1)
1066 }
1067
1068 pub fn configure_generation(&self) -> u64 {
1069 self.configure_generation.load(Ordering::SeqCst)
1070 }
1071
1072 pub fn configure_warnings_sender(&self) -> mpsc::Sender<(u64, ConfigureWarningsFrame)> {
1073 self.configure_warnings_tx.clone()
1074 }
1075
1076 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
1077 let mut warnings = Vec::new();
1078 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
1079 warnings.push(warning);
1080 }
1081 warnings
1082 }
1083
1084 pub fn bash_background(&self) -> &BgTaskRegistry {
1085 &self.bash_background
1086 }
1087
1088 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
1089 self.bash_background.drain_completions()
1090 }
1091
1092 pub fn provider(&self) -> &dyn LanguageProvider {
1094 self.provider.as_ref()
1095 }
1096
1097 pub fn backup(&self) -> &RefCell<BackupStore> {
1099 &self.backup
1100 }
1101
1102 pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
1104 &self.checkpoint
1105 }
1106
1107 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
1108 *self.db.borrow_mut() = Some(conn);
1109 }
1110
1111 pub fn clear_db(&self) {
1112 *self.db.borrow_mut() = None;
1113 }
1114
1115 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
1116 self.db.borrow().clone()
1117 }
1118
1119 pub fn config(&self) -> Ref<'_, Config> {
1121 self.config.borrow()
1122 }
1123
1124 pub fn config_mut(&self) -> RefMut<'_, Config> {
1126 self.config.borrow_mut()
1127 }
1128
1129 pub fn set_harness(&self, harness: Harness) {
1130 *self.harness.borrow_mut() = Some(harness);
1131 self.bash_background.set_harness(harness);
1132 }
1133
1134 pub fn harness_opt(&self) -> Option<Harness> {
1135 *self.harness.borrow()
1136 }
1137
1138 pub fn harness(&self) -> Harness {
1139 self.harness_opt()
1140 .expect("harness set by configure before any tool call")
1141 }
1142
1143 pub fn storage_dir(&self) -> PathBuf {
1144 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
1145 }
1146
1147 pub fn harness_dir(&self) -> PathBuf {
1148 self.storage_dir().join(self.harness().as_str())
1149 }
1150
1151 pub fn inspect_dir(&self) -> PathBuf {
1152 self.harness_dir().join("inspect")
1153 }
1154
1155 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
1156 self.harness_dir()
1157 .join("bash-tasks")
1158 .join(hash_session(session_id))
1159 }
1160
1161 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
1162 self.harness_dir()
1163 .join("backups")
1164 .join(hash_session(session_id))
1165 .join(path_hash)
1166 }
1167
1168 pub fn filters_dir(&self) -> PathBuf {
1169 self.harness_dir().join("filters")
1170 }
1171
1172 pub fn trust_file(&self) -> PathBuf {
1174 self.storage_dir().join("trusted-filter-projects.json")
1175 }
1176
1177 pub fn set_canonical_cache_root(&self, root: PathBuf) {
1178 debug_assert!(root.is_absolute());
1179 *self.canonical_cache_root.borrow_mut() = Some(root);
1180 }
1181
1182 pub fn canonical_cache_root(&self) -> PathBuf {
1183 self.canonical_cache_root
1184 .borrow()
1185 .clone()
1186 .expect("canonical_cache_root accessed before handle_configure")
1187 }
1188
1189 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
1190 self.canonical_cache_root.borrow().clone()
1191 }
1192
1193 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
1194 *self.is_worktree_bridge.borrow_mut() = is_worktree_bridge;
1195 *self.git_common_dir.borrow_mut() = git_common_dir;
1196 }
1197
1198 pub fn is_worktree_bridge(&self) -> bool {
1199 *self.is_worktree_bridge.borrow()
1200 }
1201
1202 pub fn git_common_dir(&self) -> Option<PathBuf> {
1203 self.git_common_dir.borrow().clone()
1204 }
1205
1206 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
1210 *self.degraded_reasons.borrow_mut() = reasons;
1211 }
1212
1213 pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
1214 let reason = reason.into();
1215 let mut reasons = self.degraded_reasons.borrow_mut();
1216 if reasons.iter().any(|existing| existing == &reason) {
1217 return false;
1218 }
1219 reasons.push(reason);
1220 true
1221 }
1222
1223 pub fn degraded_reasons(&self) -> Vec<String> {
1227 self.degraded_reasons.borrow().clone()
1228 }
1229
1230 pub fn is_degraded(&self) -> bool {
1232 !self.degraded_reasons.borrow().is_empty()
1233 }
1234
1235 pub fn cache_role(&self) -> &'static str {
1236 if self.canonical_cache_root.borrow().is_none() {
1237 "not_initialized"
1238 } else if self.is_worktree_bridge() {
1239 "worktree"
1240 } else {
1241 "main"
1242 }
1243 }
1244
1245 pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
1247 &self.callgraph
1248 }
1249
1250 pub fn callgraph_store(&self) -> &RefCell<Option<CallGraphStore>> {
1252 &self.callgraph_store
1253 }
1254
1255 pub fn mark_callgraph_store_force_rebuild(&self) {
1256 *self.callgraph_store_force_rebuild.borrow_mut() = true;
1257 }
1258
1259 fn take_callgraph_store_force_rebuild(&self) -> bool {
1260 let force = *self.callgraph_store_force_rebuild.borrow();
1261 *self.callgraph_store_force_rebuild.borrow_mut() = false;
1262 force
1263 }
1264
1265 pub fn callgraph_store_dir(&self) -> PathBuf {
1266 match self.harness_opt() {
1267 Some(harness) => self.storage_dir().join(harness.as_str()).join("callgraph"),
1268 None => self.storage_dir().join("callgraph"),
1269 }
1270 }
1271
1272 pub fn ensure_callgraph_store(
1273 &self,
1274 ) -> Result<Option<RefMut<'_, CallGraphStore>>, CallGraphStoreError> {
1275 self.ensure_callgraph_store_with_flag(true)
1276 }
1277
1278 fn ensure_callgraph_store_with_flag(
1279 &self,
1280 respect_config_flag: bool,
1281 ) -> Result<Option<RefMut<'_, CallGraphStore>>, CallGraphStoreError> {
1282 if respect_config_flag && !self.config().callgraph_store {
1283 return Ok(None);
1284 }
1285 if self.callgraph_store.borrow().is_none() {
1286 let Some(project_root) = self.callgraph_project_root() else {
1287 return Ok(None);
1288 };
1289 let callgraph_dir = self.callgraph_store_dir();
1290 let force_rebuild = self.take_callgraph_store_force_rebuild();
1291 let store = if self.is_worktree_bridge() {
1292 CallGraphStore::open_readonly(callgraph_dir, project_root)?
1293 } else if force_rebuild {
1294 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1295 let (store, _stats) =
1296 CallGraphStore::cold_build_with_lease(callgraph_dir, project_root, &files)?;
1297 Some(store)
1298 } else if CallGraphStore::needs_cold_build(&callgraph_dir, &project_root)? {
1299 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1300 let (store, _stats) =
1301 CallGraphStore::ensure_built_with_lease(callgraph_dir, project_root, &files)?;
1302 Some(store)
1303 } else {
1304 Some(CallGraphStore::open(callgraph_dir, project_root)?)
1305 };
1306 *self.callgraph_store.borrow_mut() = store;
1307 }
1308 let borrow = self.callgraph_store.borrow_mut();
1309 Ok(RefMut::filter_map(borrow, Option::as_mut).ok())
1310 }
1311
1312 fn callgraph_project_root(&self) -> Option<PathBuf> {
1315 self.canonical_cache_root_opt().or_else(|| {
1316 self.config()
1317 .project_root
1318 .clone()
1319 .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
1320 })
1321 }
1322
1323 pub fn revalidate_callgraph_store_generation(&self) {
1341 if self.callgraph_store_rx.borrow().is_some() {
1344 return;
1345 }
1346 let superseded = self
1347 .callgraph_store
1348 .borrow()
1349 .as_ref()
1350 .is_some_and(|store| !store.is_current());
1351 if superseded {
1352 *self.callgraph_store.borrow_mut() = None;
1353 }
1354 }
1355
1356 pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess<'_> {
1357 self.revalidate_callgraph_store_generation();
1361 if self.callgraph_store.borrow().is_some() {
1362 let borrow = self.callgraph_store.borrow_mut();
1363 return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1364 Some(store) => CallgraphStoreAccess::Ready(store),
1365 None => CallgraphStoreAccess::Unavailable,
1366 };
1367 }
1368
1369 if self.callgraph_store_rx.borrow().is_some() {
1371 return CallgraphStoreAccess::Building;
1372 }
1373
1374 let Some(project_root) = self.callgraph_project_root() else {
1375 return CallgraphStoreAccess::Unavailable;
1376 };
1377 let callgraph_dir = self.callgraph_store_dir();
1378
1379 if self.is_worktree_bridge() {
1382 match CallGraphStore::open_readonly(callgraph_dir, project_root) {
1383 Ok(Some(store)) => {
1384 *self.callgraph_store.borrow_mut() = Some(store);
1385 let borrow = self.callgraph_store.borrow_mut();
1386 return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1387 Some(store) => CallgraphStoreAccess::Ready(store),
1388 None => CallgraphStoreAccess::Unavailable,
1389 };
1390 }
1391 Ok(None) | Err(_) => return CallgraphStoreAccess::Unavailable,
1392 }
1393 }
1394
1395 let force_rebuild = *self.callgraph_store_force_rebuild.borrow();
1396 if !force_rebuild {
1399 match CallGraphStore::needs_cold_build(&callgraph_dir, &project_root) {
1400 Ok(false) => match CallGraphStore::open(callgraph_dir, project_root) {
1401 Ok(store) => {
1402 *self.callgraph_store.borrow_mut() = Some(store);
1403 let borrow = self.callgraph_store.borrow_mut();
1404 return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1405 Some(store) => CallgraphStoreAccess::Ready(store),
1406 None => CallgraphStoreAccess::Unavailable,
1407 };
1408 }
1409 Err(error) => return CallgraphStoreAccess::Error(error),
1410 },
1411 Ok(true) => {}
1412 Err(error) => return CallgraphStoreAccess::Error(error),
1413 }
1414 }
1415
1416 self.spawn_callgraph_store_cold_build(project_root, callgraph_dir, force_rebuild);
1424
1425 let wait = callgraph_build_wait_window();
1426 if !wait.is_zero() {
1427 let received = {
1428 let rx_ref = self.callgraph_store_rx.borrow();
1429 let Some(rx) = rx_ref.as_ref() else {
1430 return CallgraphStoreAccess::Building;
1431 };
1432 rx.recv_timeout(wait)
1433 };
1434 match received {
1435 Ok(store) => {
1436 let pending = self.take_pending_callgraph_store_paths();
1440 if !pending.is_empty() {
1441 if let Err(error) = store.refresh_files(&pending) {
1442 crate::slog_warn!(
1443 "callgraph store inline post-build refresh failed: {}",
1444 error
1445 );
1446 let _ = store.mark_files_stale(&pending);
1447 }
1448 }
1449 *self.callgraph_store.borrow_mut() = Some(store);
1450 *self.callgraph_store_rx.borrow_mut() = None;
1451 let borrow = self.callgraph_store.borrow_mut();
1452 return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1453 Some(store) => CallgraphStoreAccess::Ready(store),
1454 None => CallgraphStoreAccess::Unavailable,
1455 };
1456 }
1457 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1458 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
1459 *self.callgraph_store_rx.borrow_mut() = None;
1462 }
1463 }
1464 }
1465 CallgraphStoreAccess::Building
1466 }
1467
1468 fn spawn_callgraph_store_cold_build(
1473 &self,
1474 project_root: PathBuf,
1475 callgraph_dir: PathBuf,
1476 force_rebuild: bool,
1477 ) {
1478 if force_rebuild {
1479 self.take_callgraph_store_force_rebuild();
1482 }
1483 let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStore>();
1484 *self.callgraph_store_rx.borrow_mut() = Some(rx);
1485 let session_id = crate::log_ctx::current_session();
1486 std::thread::spawn(move || {
1487 crate::log_ctx::with_session(session_id, || {
1488 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1489 let built = if force_rebuild {
1490 CallGraphStore::cold_build_with_lease(callgraph_dir, project_root, &files)
1491 .map(|(store, _)| store)
1492 } else {
1493 CallGraphStore::ensure_built_with_lease(callgraph_dir, project_root, &files)
1494 .map(|(store, _)| store)
1495 };
1496 match built {
1497 Ok(store) => {
1498 let _ = tx.send(store);
1499 }
1500 Err(error) => {
1501 crate::slog_warn!("callgraph store cold build failed: {}", error);
1502 }
1505 }
1506 });
1507 });
1508 }
1509
1510 pub fn callgraph_store_rx(
1513 &self,
1514 ) -> &RefCell<Option<crossbeam_channel::Receiver<CallGraphStore>>> {
1515 &self.callgraph_store_rx
1516 }
1517
1518 pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
1521 where
1522 I: IntoIterator<Item = PathBuf>,
1523 {
1524 self.pending_callgraph_store_paths
1525 .borrow_mut()
1526 .extend(paths);
1527 }
1528
1529 pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
1531 std::mem::take(&mut *self.pending_callgraph_store_paths.borrow_mut())
1532 .into_iter()
1533 .collect()
1534 }
1535
1536 pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
1538 &self.search_index
1539 }
1540
1541 pub fn search_index_rx(&self) -> &RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>> {
1543 &self.search_index_rx
1544 }
1545
1546 pub fn add_pending_search_index_paths<I>(&self, paths: I)
1547 where
1548 I: IntoIterator<Item = PathBuf>,
1549 {
1550 self.pending_search_index_paths.borrow_mut().extend(paths);
1551 }
1552
1553 pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
1554 std::mem::take(&mut *self.pending_search_index_paths.borrow_mut())
1555 .into_iter()
1556 .collect()
1557 }
1558
1559 pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
1560 where
1561 I: IntoIterator<Item = PathBuf>,
1562 {
1563 self.pending_semantic_index_paths.borrow_mut().extend(paths);
1564 }
1565
1566 pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
1567 std::mem::take(&mut *self.pending_semantic_index_paths.borrow_mut())
1568 .into_iter()
1569 .collect()
1570 }
1571
1572 pub fn mark_pending_semantic_corpus_refresh(&self) {
1573 *self.pending_semantic_corpus_refresh.borrow_mut() = true;
1574 }
1575
1576 pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
1577 std::mem::take(&mut *self.pending_semantic_corpus_refresh.borrow_mut())
1578 }
1579
1580 pub fn clear_pending_index_updates(&self) {
1581 self.pending_search_index_paths.borrow_mut().clear();
1582 self.pending_callgraph_store_paths.borrow_mut().clear();
1583 self.pending_semantic_index_paths.borrow_mut().clear();
1584 *self.pending_semantic_corpus_refresh.borrow_mut() = false;
1585 }
1586
1587 pub fn inspect_manager(&self) -> Arc<InspectManager> {
1588 Arc::clone(&self.inspect_manager)
1589 }
1590
1591 pub fn take_new_reuse_completions(&self) -> bool {
1596 let current = self.inspect_manager.reuse_completion_count();
1597 let previous = self
1598 .last_seen_reuse_completions
1599 .swap(current, Ordering::SeqCst);
1600 current != previous
1601 }
1602
1603 pub fn reset_tier2_refresh_scheduler(&self) {
1604 self.reset_tier2_refresh_scheduler_at(Instant::now());
1605 }
1606
1607 #[doc(hidden)]
1608 pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
1609 self.tier2_refresh_scheduler
1610 .borrow_mut()
1611 .reset_after_configure(now);
1612 }
1613
1614 pub fn request_tier2_refresh_pull(&self) -> bool {
1615 self.tier2_refresh_scheduler
1616 .borrow_mut()
1617 .request_pull(!self.is_worktree_bridge())
1618 }
1619
1620 pub fn tick_tier2_refresh_scheduler(
1621 &self,
1622 changed_path_count: usize,
1623 ) -> Option<Tier2TriggerReason> {
1624 self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
1625 }
1626
1627 #[doc(hidden)]
1628 pub fn tick_tier2_refresh_scheduler_at(
1629 &self,
1630 now: Instant,
1631 changed_path_count: usize,
1632 ) -> Option<Tier2TriggerReason> {
1633 let manager = self.inspect_manager();
1634 let can_write = !self.is_worktree_bridge();
1635 let in_flight = manager.tier2_any_in_flight();
1636 let decision = self.tier2_refresh_scheduler.borrow_mut().tick(
1637 now,
1638 changed_path_count,
1639 can_write,
1640 in_flight,
1641 );
1642
1643 if let Some(reason) = decision {
1644 self.start_tier2_refresh(reason, manager);
1645 }
1646
1647 decision
1648 }
1649
1650 pub fn note_tier2_refresh_started(&self) {
1651 self.note_tier2_refresh_started_at(Instant::now());
1652 }
1653
1654 #[doc(hidden)]
1655 pub fn note_tier2_refresh_started_at(&self, now: Instant) {
1656 self.tier2_refresh_scheduler
1657 .borrow_mut()
1658 .note_external_scan_started(now);
1659 }
1660
1661 pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
1662 self.tier2_refresh_scheduler
1663 .borrow()
1664 .last_trigger_reason()
1665 .map(Tier2TriggerReason::as_str)
1666 }
1667
1668 #[doc(hidden)]
1669 pub fn tier2_pull_demand_pending(&self) -> bool {
1670 self.tier2_refresh_scheduler.borrow().pull_demand_pending()
1671 }
1672
1673 fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
1674 if self.is_worktree_bridge()
1675 || self
1676 .degraded_reasons
1677 .borrow()
1678 .iter()
1679 .any(|r| r == "home_root")
1680 || !self.config().inspect.enabled
1681 {
1682 return;
1683 }
1684 let Some(snapshot) = self.tier2_refresh_snapshot() else {
1685 return;
1686 };
1687 let categories = InspectCategory::active()
1688 .iter()
1689 .copied()
1690 .filter(|category| category.is_tier2())
1691 .collect::<Vec<_>>();
1692 let submission =
1693 manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
1694 if submission.has_new_work() {
1695 crate::slog_info!(
1696 "tier2 refresh scheduled: reason={}, categories={:?}",
1697 reason.as_str(),
1698 submission
1699 .newly_queued_categories
1700 .iter()
1701 .map(|category| category.as_str())
1702 .collect::<Vec<_>>()
1703 );
1704 }
1705 for error in submission.errors {
1706 crate::slog_warn!(
1707 "tier2 refresh schedule failed for {}: {}",
1708 error.category,
1709 error.message
1710 );
1711 }
1712 }
1713
1714 fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
1715 self.harness_opt()?;
1716 let config = self.config().clone();
1717 let project_root = config
1718 .project_root
1719 .clone()
1720 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1721 let project_root = std::fs::canonicalize(&project_root).unwrap_or(project_root);
1722 Some(InspectSnapshot::new(
1723 project_root,
1724 self.inspect_dir(),
1725 Arc::new(config),
1726 self.symbol_cache(),
1727 ))
1728 }
1729
1730 pub fn symbol_cache(&self) -> SharedSymbolCache {
1732 Arc::clone(&self.symbol_cache)
1733 }
1734
1735 pub fn reset_symbol_cache(&self) -> u64 {
1737 self.symbol_cache
1738 .write()
1739 .map(|mut cache| cache.reset())
1740 .unwrap_or(0)
1741 }
1742
1743 pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
1745 &self.semantic_index
1746 }
1747
1748 pub fn semantic_index_rx(
1750 &self,
1751 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
1752 &self.semantic_index_rx
1753 }
1754
1755 pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
1756 &self.semantic_index_status
1757 }
1758
1759 pub fn install_semantic_refresh_worker(
1760 &self,
1761 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
1762 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
1763 worker_slot: SemanticRefreshWorkerSlot,
1764 ) {
1765 self.clear_semantic_refresh_worker();
1766 *self.semantic_refresh_tx.borrow_mut() = Some(sender);
1767 *self.semantic_refresh_event_rx.borrow_mut() = Some(event_rx);
1768 *self.semantic_refresh_worker.borrow_mut() = Some(worker_slot);
1769 }
1770
1771 pub fn clear_semantic_refresh_worker(&self) {
1772 *self.semantic_refresh_tx.borrow_mut() = None;
1773 *self.semantic_refresh_event_rx.borrow_mut() = None;
1774 if let Some(worker_slot) = self.semantic_refresh_worker.borrow_mut().take() {
1775 if let Ok(mut handle) = worker_slot.lock() {
1776 drop(handle.take());
1777 }
1778 }
1779 }
1780
1781 pub fn semantic_refresh_sender(
1782 &self,
1783 ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
1784 self.semantic_refresh_tx.borrow().clone()
1785 }
1786
1787 pub fn semantic_refresh_event_rx(
1788 &self,
1789 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
1790 &self.semantic_refresh_event_rx
1791 }
1792
1793 pub fn semantic_embedding_model(
1795 &self,
1796 ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
1797 &self.semantic_embedding_model
1798 }
1799
1800 pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
1802 &self.watcher
1803 }
1804
1805 pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
1807 &self.watcher_rx
1808 }
1809
1810 pub fn lsp(&self) -> RefMut<'_, LspManager> {
1812 self.lsp_manager.borrow_mut()
1813 }
1814
1815 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
1818 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1819 let config = self.config();
1820 if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
1821 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1822 }
1823 }
1824 }
1825
1826 pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
1832 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1833 lsp.clear_diagnostics_for_file(file_path)
1834 } else {
1835 false
1836 }
1837 }
1838
1839 pub fn lsp_notify_and_collect_diagnostics(
1850 &self,
1851 file_path: &Path,
1852 content: &str,
1853 timeout: std::time::Duration,
1854 ) -> crate::lsp::manager::PostEditWaitOutcome {
1855 let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
1856 return crate::lsp::manager::PostEditWaitOutcome::default();
1857 };
1858
1859 lsp.drain_events();
1862
1863 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
1867
1868 let config = self.config();
1870 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
1871 {
1872 Ok(v) => v,
1873 Err(e) => {
1874 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1875 return crate::lsp::manager::PostEditWaitOutcome::default();
1876 }
1877 };
1878
1879 if expected_versions.is_empty() {
1882 return crate::lsp::manager::PostEditWaitOutcome::default();
1883 }
1884
1885 lsp.wait_for_post_edit_diagnostics(
1886 file_path,
1887 &config,
1888 &expected_versions,
1889 &pre_snapshot,
1890 timeout,
1891 )
1892 }
1893
1894 fn custom_lsp_root_markers(&self) -> Vec<String> {
1897 self.config()
1898 .lsp_servers
1899 .iter()
1900 .flat_map(|s| s.root_markers.iter().cloned())
1901 .collect()
1902 }
1903
1904 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
1905 let custom_markers = self.custom_lsp_root_markers();
1906 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
1907 .iter()
1908 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
1909 .cloned()
1910 .map(|path| {
1911 let change_type = if path.exists() {
1912 FileChangeType::CHANGED
1913 } else {
1914 FileChangeType::DELETED
1915 };
1916 (path, change_type)
1917 })
1918 .collect();
1919
1920 self.notify_watched_config_events(&config_paths);
1921 }
1922
1923 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
1924 let paths = params
1925 .get("multi_file_write_paths")
1926 .and_then(|value| value.as_array())?
1927 .iter()
1928 .filter_map(|value| value.as_str())
1929 .map(PathBuf::from)
1930 .collect::<Vec<_>>();
1931
1932 (!paths.is_empty()).then_some(paths)
1933 }
1934
1935 fn watched_file_events_from_params(
1947 params: &serde_json::Value,
1948 extra_markers: &[String],
1949 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
1950 let events = params
1951 .get("multi_file_write_paths")
1952 .and_then(|value| value.as_array())?
1953 .iter()
1954 .filter_map(|entry| {
1955 let path = entry
1957 .get("path")
1958 .and_then(|value| value.as_str())
1959 .map(PathBuf::from)?;
1960
1961 if !is_config_file_path_with_custom(&path, extra_markers) {
1962 return None;
1963 }
1964
1965 let change_type = entry
1966 .get("type")
1967 .and_then(|value| value.as_str())
1968 .and_then(Self::parse_file_change_type)
1969 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
1970
1971 Some((path, change_type))
1972 })
1973 .collect::<Vec<_>>();
1974
1975 (!events.is_empty()).then_some(events)
1976 }
1977
1978 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
1979 match value {
1980 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
1981 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
1982 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
1983 _ => None,
1984 }
1985 }
1986
1987 fn change_type_from_current_state(path: &Path) -> FileChangeType {
1988 if path.exists() {
1989 FileChangeType::CHANGED
1990 } else {
1991 FileChangeType::DELETED
1992 }
1993 }
1994
1995 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
1996 if config_paths.is_empty() {
1997 return;
1998 }
1999
2000 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
2001 let config = self.config();
2002 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
2003 crate::slog_warn!("watched-file sync error: {}", e);
2004 }
2005 }
2006 }
2007
2008 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
2009 let custom_markers = self.custom_lsp_root_markers();
2010 if !is_config_file_path_with_custom(file_path, &custom_markers) {
2011 return;
2012 }
2013
2014 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
2015 }
2016
2017 pub fn lsp_post_multi_file_write(
2022 &self,
2023 file_path: &Path,
2024 content: &str,
2025 file_paths: &[PathBuf],
2026 params: &serde_json::Value,
2027 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2028 self.notify_watched_config_files(file_paths);
2029
2030 let wants_diagnostics = params
2031 .get("diagnostics")
2032 .and_then(|v| v.as_bool())
2033 .unwrap_or(false);
2034
2035 if !wants_diagnostics {
2036 self.lsp_notify_file_changed(file_path, content);
2037 return None;
2038 }
2039
2040 let wait_ms = params
2041 .get("wait_ms")
2042 .and_then(|v| v.as_u64())
2043 .unwrap_or(3000)
2044 .min(10_000);
2045
2046 Some(self.lsp_notify_and_collect_diagnostics(
2047 file_path,
2048 content,
2049 std::time::Duration::from_millis(wait_ms),
2050 ))
2051 }
2052
2053 pub fn lsp_post_write(
2070 &self,
2071 file_path: &Path,
2072 content: &str,
2073 params: &serde_json::Value,
2074 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2075 let wants_diagnostics = params
2076 .get("diagnostics")
2077 .and_then(|v| v.as_bool())
2078 .unwrap_or(false);
2079
2080 let custom_markers = self.custom_lsp_root_markers();
2081
2082 if !wants_diagnostics {
2083 if let Some(file_paths) = Self::multi_file_write_paths(params) {
2084 self.notify_watched_config_files(&file_paths);
2085 } else if let Some(config_events) =
2086 Self::watched_file_events_from_params(params, &custom_markers)
2087 {
2088 self.notify_watched_config_events(&config_events);
2089 }
2090 self.lsp_notify_file_changed(file_path, content);
2091 return None;
2092 }
2093
2094 let wait_ms = params
2095 .get("wait_ms")
2096 .and_then(|v| v.as_u64())
2097 .unwrap_or(3000)
2098 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
2101 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
2102 }
2103
2104 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
2105 {
2106 self.notify_watched_config_events(&config_events);
2107 }
2108
2109 Some(self.lsp_notify_and_collect_diagnostics(
2110 file_path,
2111 content,
2112 std::time::Duration::from_millis(wait_ms),
2113 ))
2114 }
2115
2116 pub fn validate_path(
2125 &self,
2126 req_id: &str,
2127 path: &Path,
2128 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
2129 let config = self.config();
2130 if !config.restrict_to_project_root {
2132 return Ok(path.to_path_buf());
2133 }
2134 let root = match &config.project_root {
2135 Some(r) => r.clone(),
2136 None => return Ok(path.to_path_buf()), };
2138 drop(config);
2139
2140 let raw_root = root.clone();
2145 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
2146
2147 let path_for_resolution = if path.is_relative() {
2152 raw_root.join(path)
2153 } else {
2154 path.to_path_buf()
2155 };
2156 let resolved = match std::fs::canonicalize(&path_for_resolution) {
2157 Ok(resolved) => resolved,
2158 Err(_) => {
2159 let normalized = normalize_path(&path_for_resolution);
2160 reject_escaping_symlink(
2161 req_id,
2162 &path_for_resolution,
2163 &normalized,
2164 &resolved_root,
2165 &raw_root,
2166 )?;
2167 resolve_with_existing_ancestors(&normalized)
2168 }
2169 };
2170
2171 if !resolved.starts_with(&resolved_root) {
2172 return Err(path_error_response(req_id, path, &resolved_root));
2173 }
2174
2175 Ok(resolved)
2176 }
2177
2178 pub fn lsp_server_count(&self) -> usize {
2180 self.lsp_manager
2181 .try_borrow()
2182 .map(|lsp| lsp.server_count())
2183 .unwrap_or(0)
2184 }
2185
2186 pub fn symbol_cache_stats(&self) -> serde_json::Value {
2188 let entries = self
2189 .symbol_cache
2190 .read()
2191 .map(|cache| cache.len())
2192 .unwrap_or(0);
2193 serde_json::json!({
2194 "local_entries": entries,
2195 "warm_entries": 0,
2196 })
2197 }
2198}
2199
2200#[cfg(test)]
2201mod status_emitter_tests {
2202 use super::*;
2203 use crate::parser::TreeSitterProvider;
2204
2205 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
2206 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2207 let (tx, rx) = mpsc::channel();
2208 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
2209 let _ = tx.send(frame);
2210 }))));
2211 (ctx, rx)
2212 }
2213
2214 #[test]
2215 fn status_emitter_signal_triggers_push() {
2216 let (ctx, rx) = ctx_with_frame_rx();
2217 ctx.status_emitter().signal(ctx.build_status_snapshot());
2218 let frame = rx
2219 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2220 .expect("status_changed push");
2221 assert!(matches!(frame, PushFrame::StatusChanged(_)));
2222 }
2223
2224 #[test]
2225 fn status_emitter_debounces_burst() {
2226 let (ctx, rx) = ctx_with_frame_rx();
2227 for _ in 0..10 {
2228 ctx.status_emitter().signal(ctx.build_status_snapshot());
2229 }
2230 let frame = rx
2231 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2232 .expect("status_changed push");
2233 assert!(matches!(frame, PushFrame::StatusChanged(_)));
2234 assert!(rx.try_recv().is_err());
2235 }
2236
2237 #[test]
2238 fn status_emitter_separate_windows_separate_pushes() {
2239 let (ctx, rx) = ctx_with_frame_rx();
2240 ctx.status_emitter().signal(ctx.build_status_snapshot());
2241 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2242 .expect("first push");
2243 ctx.status_emitter().signal(ctx.build_status_snapshot());
2244 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2245 .expect("second push");
2246 }
2247
2248 #[test]
2249 fn status_emitter_no_signal_no_push() {
2250 let (_ctx, rx) = ctx_with_frame_rx();
2251 assert!(rx
2252 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
2253 .is_err());
2254 }
2255
2256 #[test]
2257 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
2258 let (ctx, rx) = ctx_with_frame_rx();
2259 drop(ctx);
2260 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
2261 }
2262}
2263
2264#[cfg(test)]
2265mod status_bar_tests {
2266 use super::*;
2267 use crate::parser::TreeSitterProvider;
2268
2269 fn ctx() -> AppContext {
2270 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
2271 }
2272
2273 #[test]
2274 fn status_bar_counts_none_until_tier2_populated() {
2275 let ctx = ctx();
2276 assert!(ctx.status_bar_counts().is_none());
2278
2279 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
2280 let counts = ctx.status_bar_counts().expect("populated");
2281 assert_eq!(counts.dead_code, 5);
2282 assert_eq!(counts.unused_exports, 3);
2283 assert_eq!(counts.duplicates, 7);
2284 assert_eq!(counts.todos, 2);
2285 assert!(!counts.tier2_stale);
2286 assert_eq!(counts.errors, 0);
2288 assert_eq!(counts.warnings, 0);
2289 }
2290
2291 #[test]
2292 fn partial_tier2_does_not_fabricate_zeros() {
2293 let ctx = ctx();
2294 ctx.update_status_bar_tier2(Some(5), None, None, None, true);
2298 assert!(
2299 ctx.status_bar_counts().is_none(),
2300 "bar must not surface until all three Tier-2 categories are real"
2301 );
2302
2303 ctx.update_status_bar_tier2(None, Some(3), None, None, true);
2305 assert!(ctx.status_bar_counts().is_none());
2306
2307 ctx.update_status_bar_tier2(None, None, Some(7), None, false);
2310 let counts = ctx.status_bar_counts().expect("all three real now");
2311 assert_eq!(counts.dead_code, 5);
2312 assert_eq!(counts.unused_exports, 3);
2313 assert_eq!(counts.duplicates, 7);
2314 }
2315
2316 #[test]
2317 fn update_with_none_todos_preserves_last_known_todos() {
2318 let ctx = ctx();
2319 ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
2320 ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
2322 let counts = ctx.status_bar_counts().expect("populated");
2323 assert_eq!(counts.todos, 9);
2324 assert_eq!(counts.dead_code, 2);
2325 }
2326
2327 #[test]
2328 fn update_with_none_count_preserves_last_known_count() {
2329 let ctx = ctx();
2330 ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
2331 ctx.update_status_bar_tier2(Some(11), None, None, None, false);
2334 let counts = ctx.status_bar_counts().expect("populated");
2335 assert_eq!(counts.dead_code, 11);
2336 assert_eq!(counts.unused_exports, 20);
2337 assert_eq!(counts.duplicates, 30);
2338 }
2339
2340 #[test]
2341 fn mark_stale_sets_flag_only_after_populate() {
2342 let ctx = ctx();
2343 ctx.mark_status_bar_tier2_stale();
2345 assert!(ctx.status_bar_counts().is_none());
2346
2347 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), Some(0), false);
2348 ctx.mark_status_bar_tier2_stale();
2349 assert!(ctx.status_bar_counts().expect("populated").tier2_stale);
2350
2351 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), None, false);
2353 assert!(!ctx.status_bar_counts().expect("populated").tier2_stale);
2354 }
2355
2356 #[test]
2361 fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
2362 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
2363 use crate::lsp::registry::ServerKind;
2364 use crate::lsp::roots::ServerKey;
2365
2366 let ctx = ctx();
2367 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); let file = std::path::PathBuf::from("/proj/gone.ts");
2370 {
2371 let mut lsp = ctx.lsp();
2372 lsp.diagnostics_store_mut_for_test().publish(
2373 ServerKey {
2374 kind: ServerKind::TypeScript,
2375 root: std::path::PathBuf::from("/proj"),
2376 },
2377 file.clone(),
2378 vec![StoredDiagnostic {
2379 file: file.clone(),
2380 line: 1,
2381 column: 1,
2382 end_line: 1,
2383 end_column: 2,
2384 severity: DiagnosticSeverity::Error,
2385 message: "boom".into(),
2386 code: None,
2387 source: None,
2388 }],
2389 );
2390 }
2391
2392 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
2394
2395 let removed = ctx.lsp_clear_diagnostics_for_file(&file);
2397 assert!(removed);
2398 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
2399 }
2400}
2401
2402#[cfg(test)]
2403mod harness_path_tests {
2404 use super::*;
2405 use crate::harness::Harness;
2406 use crate::parser::TreeSitterProvider;
2407
2408 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
2409 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2410 ctx.config_mut().storage_dir = Some(storage_dir);
2411 ctx.set_harness(harness);
2412 ctx
2413 }
2414
2415 #[test]
2416 fn harness_dir_resolves_correctly() {
2417 let storage = PathBuf::from("/tmp/cortexkit/aft");
2418 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2419
2420 assert_eq!(ctx.harness_dir(), storage.join("pi"));
2421 }
2422
2423 #[test]
2424 fn bash_tasks_dir_uses_hash_session() {
2425 let storage = PathBuf::from("/tmp/cortexkit/aft");
2426 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2427
2428 assert_eq!(
2429 ctx.bash_tasks_dir("ses_abc"),
2430 storage
2431 .join("opencode")
2432 .join("bash-tasks")
2433 .join(hash_session("ses_abc"))
2434 );
2435 }
2436
2437 #[test]
2438 fn backups_dir_includes_path_hash() {
2439 let storage = PathBuf::from("/tmp/cortexkit/aft");
2440 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2441
2442 assert_eq!(
2443 ctx.backups_dir("ses_abc", "pathhash"),
2444 storage
2445 .join("pi")
2446 .join("backups")
2447 .join(hash_session("ses_abc"))
2448 .join("pathhash")
2449 );
2450 }
2451
2452 #[test]
2453 fn filters_dir_under_harness() {
2454 let storage = PathBuf::from("/tmp/cortexkit/aft");
2455 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2456
2457 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
2458 }
2459
2460 #[test]
2461 fn trust_file_is_host_global() {
2462 let storage = PathBuf::from("/tmp/cortexkit/aft");
2463 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2464
2465 assert_eq!(
2466 ctx.trust_file(),
2467 storage.join("trusted-filter-projects.json")
2468 );
2469 }
2470
2471 #[test]
2472 fn same_session_different_harness_resolve_different_paths() {
2473 let storage = PathBuf::from("/tmp/cortexkit/aft");
2474 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2475 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
2476
2477 assert_ne!(
2478 opencode.bash_tasks_dir("ses_same"),
2479 pi.bash_tasks_dir("ses_same")
2480 );
2481 }
2482}
2483
2484#[cfg(test)]
2485mod gitignore_tests {
2486 use super::*;
2487 use std::fs;
2488 use std::path::Path;
2489 use tempfile::TempDir;
2490
2491 fn make_ctx_with_root(root: &Path) -> AppContext {
2492 let provider = Box::new(crate::parser::TreeSitterProvider::new());
2493 let config = Config {
2494 project_root: Some(root.to_path_buf()),
2495 ..Config::default()
2496 };
2497 AppContext::new(provider, config)
2498 }
2499
2500 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
2507 let Some(matcher) = ctx.gitignore() else {
2508 return false;
2509 };
2510 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
2511 if !canonical.starts_with(matcher.path()) {
2512 return false;
2513 }
2514 let is_dir = canonical.is_dir();
2515 matcher
2516 .matched_path_or_any_parents(&canonical, is_dir)
2517 .is_ignore()
2518 }
2519
2520 fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
2531 use std::sync::{Mutex, OnceLock};
2532 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2533 let _guard = LOCK
2534 .get_or_init(|| Mutex::new(()))
2535 .lock()
2536 .unwrap_or_else(|e| e.into_inner());
2537 let tmp = TempDir::new().unwrap();
2538 let prev = std::env::var_os("XDG_CONFIG_HOME");
2539 unsafe {
2541 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
2542 }
2543 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
2544 unsafe {
2545 match prev {
2546 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
2547 None => std::env::remove_var("XDG_CONFIG_HOME"),
2548 }
2549 }
2550 match result {
2551 Ok(r) => r,
2552 Err(p) => std::panic::resume_unwind(p),
2553 }
2554 }
2555
2556 #[test]
2557 fn rebuild_gitignore_returns_none_without_project_root() {
2558 let provider = Box::new(crate::parser::TreeSitterProvider::new());
2559 let ctx = AppContext::new(provider, Config::default());
2560 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2561 assert!(ctx.gitignore().is_none());
2562 }
2563
2564 #[test]
2565 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
2566 let tmp = TempDir::new().unwrap();
2567 let ctx = make_ctx_with_root(tmp.path());
2568 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2569 assert!(ctx.gitignore().is_none());
2570 }
2571
2572 #[test]
2573 fn matcher_filters_files_in_ignored_dist_dir() {
2574 let tmp = TempDir::new().unwrap();
2575 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
2576 fs::create_dir_all(tmp.path().join("dist")).unwrap();
2577 fs::create_dir_all(tmp.path().join("src")).unwrap();
2578 let dist_file = tmp.path().join("dist").join("bundle.js");
2579 let src_file = tmp.path().join("src").join("app.ts");
2580 fs::write(&dist_file, "x").unwrap();
2581 fs::write(&src_file, "y").unwrap();
2582
2583 let ctx = make_ctx_with_root(tmp.path());
2584 ctx.rebuild_gitignore();
2585
2586 assert!(ctx.gitignore().is_some());
2587 assert!(
2588 is_ignored(&ctx, &dist_file),
2589 "dist/bundle.js should be ignored"
2590 );
2591 assert!(
2592 !is_ignored(&ctx, &src_file),
2593 "src/app.ts should NOT be ignored"
2594 );
2595 }
2596
2597 #[test]
2598 fn matcher_handles_node_modules_and_target() {
2599 let tmp = TempDir::new().unwrap();
2600 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
2601 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
2602 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
2603 let nm_file = tmp.path().join("node_modules/foo/index.js");
2604 let target_file = tmp.path().join("target/debug/aft");
2605 fs::write(&nm_file, "x").unwrap();
2606 fs::write(&target_file, "x").unwrap();
2607
2608 let ctx = make_ctx_with_root(tmp.path());
2609 ctx.rebuild_gitignore();
2610
2611 assert!(is_ignored(&ctx, &nm_file));
2612 assert!(is_ignored(&ctx, &target_file));
2613 }
2614
2615 #[test]
2616 fn matcher_honors_negation_pattern() {
2617 let tmp = TempDir::new().unwrap();
2619 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
2620 let random_log = tmp.path().join("random.log");
2621 let important_log = tmp.path().join("important.log");
2622 fs::write(&random_log, "x").unwrap();
2623 fs::write(&important_log, "y").unwrap();
2624
2625 let ctx = make_ctx_with_root(tmp.path());
2626 ctx.rebuild_gitignore();
2627
2628 assert!(is_ignored(&ctx, &random_log));
2629 assert!(
2630 !is_ignored(&ctx, &important_log),
2631 "negation pattern should un-ignore important.log"
2632 );
2633 }
2634
2635 #[test]
2636 fn rebuild_picks_up_gitignore_changes() {
2637 let tmp = TempDir::new().unwrap();
2638 let ignore_path = tmp.path().join(".gitignore");
2639 fs::write(&ignore_path, "foo.txt\n").unwrap();
2640 let foo = tmp.path().join("foo.txt");
2641 let bar = tmp.path().join("bar.txt");
2642 fs::write(&foo, "").unwrap();
2643 fs::write(&bar, "").unwrap();
2644
2645 let ctx = make_ctx_with_root(tmp.path());
2646 ctx.rebuild_gitignore();
2647 assert!(is_ignored(&ctx, &foo));
2648 assert!(!is_ignored(&ctx, &bar));
2649
2650 fs::write(&ignore_path, "bar.txt\n").unwrap();
2652 ctx.rebuild_gitignore();
2653 assert!(!is_ignored(&ctx, &foo));
2654 assert!(is_ignored(&ctx, &bar));
2655 }
2656
2657 #[test]
2658 fn gitignore_loads_info_exclude_when_present() {
2659 let tmp = TempDir::new().unwrap();
2660 let info_dir = tmp.path().join(".git/info");
2661 fs::create_dir_all(&info_dir).unwrap();
2662 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
2663 let secrets = tmp.path().join("secrets.txt");
2664 let public = tmp.path().join("public.txt");
2665 fs::write(&secrets, "token").unwrap();
2666 fs::write(&public, "ok").unwrap();
2667
2668 let ctx = make_ctx_with_root(tmp.path());
2669 ctx.rebuild_gitignore();
2670
2671 assert!(is_ignored(&ctx, &secrets));
2672 assert!(!is_ignored(&ctx, &public));
2673 }
2674
2675 #[test]
2676 fn matcher_picks_up_nested_gitignore() {
2677 let tmp = TempDir::new().unwrap();
2678 fs::write(tmp.path().join(".gitignore"), "").unwrap();
2680 let sub = tmp.path().join("packages/foo");
2681 fs::create_dir_all(&sub).unwrap();
2682 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
2683 let generated_file = sub.join("generated").join("out.js");
2684 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
2685 fs::write(&generated_file, "x").unwrap();
2686
2687 let ctx = make_ctx_with_root(tmp.path());
2688 ctx.rebuild_gitignore();
2689
2690 assert!(
2691 is_ignored(&ctx, &generated_file),
2692 "nested gitignore in packages/foo/.gitignore should ignore generated/"
2693 );
2694 }
2695}