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 { paths: Vec<PathBuf> },
279 Corpus { current_files: Vec<PathBuf> },
280}
281
282#[derive(Debug)]
283pub enum SemanticRefreshEvent {
284 Started {
285 paths: Vec<PathBuf>,
286 },
287 Completed {
288 added_entries: Vec<EmbeddingEntry>,
289 updated_metadata: Vec<(PathBuf, FileFreshness)>,
290 completed_paths: Vec<PathBuf>,
291 },
292 CorpusCompleted {
293 index: SemanticIndex,
294 changed: usize,
295 added: usize,
296 deleted: usize,
297 total_processed: usize,
298 },
299 Failed {
300 paths: Vec<PathBuf>,
301 error: String,
302 },
303 CorpusFailed {
304 error: String,
305 },
306}
307
308pub type SemanticRefreshWorkerSlot = Arc<Mutex<Option<std::thread::JoinHandle<()>>>>;
309
310fn normalize_path(path: &Path) -> PathBuf {
314 let mut result = PathBuf::new();
315 for component in path.components() {
316 match component {
317 Component::ParentDir => {
318 if !result.pop() {
320 result.push(component);
321 }
322 }
323 Component::CurDir => {} _ => result.push(component),
325 }
326 }
327 result
328}
329
330fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
331 let mut existing = path.to_path_buf();
332 let mut tail_segments = Vec::new();
333
334 while !existing.exists() {
335 if let Some(name) = existing.file_name() {
336 tail_segments.push(name.to_owned());
337 } else {
338 break;
339 }
340
341 existing = match existing.parent() {
342 Some(parent) => parent.to_path_buf(),
343 None => break,
344 };
345 }
346
347 let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
348 for segment in tail_segments.into_iter().rev() {
349 resolved.push(segment);
350 }
351
352 resolved
353}
354
355fn path_error_response(
356 req_id: &str,
357 path: &Path,
358 resolved_root: &Path,
359) -> crate::protocol::Response {
360 crate::protocol::Response::error(
361 req_id,
362 "path_outside_root",
363 format!(
364 "path '{}' is outside the project root '{}'",
365 path.display(),
366 resolved_root.display()
367 ),
368 )
369}
370
371fn reject_escaping_symlink(
381 req_id: &str,
382 original_path: &Path,
383 candidate: &Path,
384 resolved_root: &Path,
385 raw_root: &Path,
386) -> Result<(), crate::protocol::Response> {
387 let mut current = PathBuf::new();
388
389 for component in candidate.components() {
390 current.push(component);
391
392 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
393 continue;
394 };
395
396 if !metadata.file_type().is_symlink() {
397 continue;
398 }
399
400 let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
409 if !inside_root {
410 continue;
411 }
412
413 iterative_follow_chain(req_id, original_path, ¤t, resolved_root)?;
414 }
415
416 Ok(())
417}
418
419fn iterative_follow_chain(
422 req_id: &str,
423 original_path: &Path,
424 start: &Path,
425 resolved_root: &Path,
426) -> Result<(), crate::protocol::Response> {
427 let mut link = start.to_path_buf();
428 let mut depth = 0usize;
429
430 loop {
431 if depth > 40 {
432 return Err(path_error_response(req_id, original_path, resolved_root));
433 }
434
435 let target = match std::fs::read_link(&link) {
436 Ok(t) => t,
437 Err(_) => {
438 return Err(path_error_response(req_id, original_path, resolved_root));
440 }
441 };
442
443 let resolved_target = if target.is_absolute() {
444 normalize_path(&target)
445 } else {
446 let parent = link.parent().unwrap_or_else(|| Path::new(""));
447 normalize_path(&parent.join(&target))
448 };
449
450 let canonical_target =
454 std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
455
456 if !canonical_target.starts_with(resolved_root)
457 && !resolved_target.starts_with(resolved_root)
458 {
459 return Err(path_error_response(req_id, original_path, resolved_root));
460 }
461
462 match std::fs::symlink_metadata(&resolved_target) {
464 Ok(meta) if meta.file_type().is_symlink() => {
465 link = resolved_target;
466 depth += 1;
467 }
468 _ => break, }
470 }
471
472 Ok(())
473}
474
475pub struct AppContext {
485 provider: Box<dyn LanguageProvider>,
486 backup: RefCell<BackupStore>,
487 checkpoint: RefCell<CheckpointStore>,
488 db: RefCell<Option<Arc<Mutex<Connection>>>>,
489 config: RefCell<Config>,
490 pub harness: RefCell<Option<Harness>>,
491 canonical_cache_root: RefCell<Option<PathBuf>>,
492 is_worktree_bridge: RefCell<bool>,
493 git_common_dir: RefCell<Option<PathBuf>>,
494 degraded_reasons: RefCell<Vec<String>>,
502 callgraph: RefCell<Option<CallGraph>>,
503 callgraph_store: RefCell<Option<CallGraphStore>>,
504 callgraph_store_force_rebuild: RefCell<bool>,
505 callgraph_store_rx: RefCell<Option<crossbeam_channel::Receiver<CallGraphStore>>>,
506 pending_callgraph_store_paths: RefCell<BTreeSet<PathBuf>>,
507 search_index: RefCell<Option<SearchIndex>>,
508 search_index_rx: RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>>,
509 pending_search_index_paths: RefCell<BTreeSet<PathBuf>>,
510 symbol_cache: SharedSymbolCache,
511 inspect_manager: Arc<InspectManager>,
512 tier2_refresh_scheduler: RefCell<Tier2RefreshScheduler>,
513 semantic_index: RefCell<Option<SemanticIndex>>,
514 semantic_index_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
515 semantic_index_status: RefCell<SemanticIndexStatus>,
516 pending_semantic_index_paths: RefCell<BTreeSet<PathBuf>>,
517 pending_semantic_corpus_refresh: RefCell<bool>,
518 semantic_refresh_tx: RefCell<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>,
519 semantic_refresh_event_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
520 semantic_refresh_worker: RefCell<Option<SemanticRefreshWorkerSlot>>,
521 semantic_embedding_model: RefCell<Option<crate::semantic_index::EmbeddingModel>>,
522 watcher: RefCell<Option<RecommendedWatcher>>,
523 watcher_rx: RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>>,
524 lsp_manager: RefCell<LspManager>,
525 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
529 stdout_writer: SharedStdoutWriter,
530 progress_sender: SharedProgressSender,
531 configure_generation: AtomicU64,
532 last_seen_reuse_completions: AtomicU64,
536 configure_warnings_tx: mpsc::Sender<(u64, ConfigureWarningsFrame)>,
537 configure_warnings_rx: mpsc::Receiver<(u64, ConfigureWarningsFrame)>,
538 status_emitter: StatusEmitter,
539 bash_background: BgTaskRegistry,
540 filter_registry: crate::compress::SharedFilterRegistry,
547 filter_registry_loaded: std::sync::atomic::AtomicBool,
550 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
555 gitignore: RefCell<Option<Arc<ignore::gitignore::Gitignore>>>,
562 status_bar_tier2: RefCell<StatusBarTier2>,
566 tsconfig_membership: RefCell<crate::lsp::tsconfig_membership::TsconfigMembershipCache>,
573}
574
575pub enum CallgraphStoreAccess<'a> {
583 Ready(RefMut<'a, CallGraphStore>),
585 Building,
587 Unavailable,
589 Error(CallGraphStoreError),
591}
592
593fn callgraph_build_wait_window() -> Duration {
598 std::env::var("AFT_CALLGRAPH_BUILD_WAIT_MS")
599 .ok()
600 .and_then(|raw| raw.parse::<u64>().ok())
601 .map(Duration::from_millis)
602 .unwrap_or(Duration::ZERO)
603}
604
605impl AppContext {
606 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
607 let bash_compress_enabled = config.experimental_bash_compress;
608 let progress_sender = Arc::new(Mutex::new(None));
609 let stdout_writer = Arc::new(Mutex::new(BufWriter::new(io::stdout())));
610 let (configure_warnings_tx, configure_warnings_rx) = mpsc::channel();
611 let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
612 let symbol_cache = provider
613 .as_any()
614 .downcast_ref::<crate::parser::TreeSitterProvider>()
615 .map(|provider| provider.symbol_cache())
616 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
617 let lsp_child_registry = crate::lsp::child_registry::LspChildRegistry::new();
618 let mut lsp_manager = LspManager::new();
619 lsp_manager.set_child_registry(lsp_child_registry.clone());
620 lsp_manager.set_diagnostic_capacity(config.diagnostic_cache_size);
623 AppContext {
624 provider,
625 backup: RefCell::new(BackupStore::new()),
626 checkpoint: RefCell::new(CheckpointStore::new()),
627 db: RefCell::new(None),
628 config: RefCell::new(config),
629 harness: RefCell::new(None),
630 canonical_cache_root: RefCell::new(None),
631 is_worktree_bridge: RefCell::new(false),
632 git_common_dir: RefCell::new(None),
633 degraded_reasons: RefCell::new(Vec::new()),
634 callgraph: RefCell::new(None),
635 callgraph_store: RefCell::new(None),
636 callgraph_store_force_rebuild: RefCell::new(false),
637 callgraph_store_rx: RefCell::new(None),
638 pending_callgraph_store_paths: RefCell::new(BTreeSet::new()),
639 search_index: RefCell::new(None),
640 search_index_rx: RefCell::new(None),
641 pending_search_index_paths: RefCell::new(BTreeSet::new()),
642 symbol_cache,
643 inspect_manager: Arc::new(InspectManager::new()),
644 tier2_refresh_scheduler: RefCell::new(Tier2RefreshScheduler::new()),
645 semantic_index: RefCell::new(None),
646 semantic_index_rx: RefCell::new(None),
647 semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
648 pending_semantic_index_paths: RefCell::new(BTreeSet::new()),
649 pending_semantic_corpus_refresh: RefCell::new(false),
650 semantic_refresh_tx: RefCell::new(None),
651 semantic_refresh_event_rx: RefCell::new(None),
652 semantic_refresh_worker: RefCell::new(None),
653 semantic_embedding_model: RefCell::new(None),
654 watcher: RefCell::new(None),
655 watcher_rx: RefCell::new(None),
656 lsp_manager: RefCell::new(lsp_manager),
657 lsp_child_registry,
658 stdout_writer,
659 progress_sender: Arc::clone(&progress_sender),
660 configure_generation: AtomicU64::new(0),
661 last_seen_reuse_completions: AtomicU64::new(0),
662 configure_warnings_tx,
663 configure_warnings_rx,
664 status_emitter,
665 bash_background: BgTaskRegistry::new(progress_sender),
666 filter_registry: Arc::new(std::sync::RwLock::new(
667 crate::compress::toml_filter::FilterRegistry::default(),
668 )),
669 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
670 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
671 gitignore: RefCell::new(None),
672 status_bar_tier2: RefCell::new(StatusBarTier2::default()),
673 tsconfig_membership: RefCell::new(
674 crate::lsp::tsconfig_membership::TsconfigMembershipCache::new(),
675 ),
676 }
677 }
678
679 pub fn status_bar_counts(&self) -> Option<StatusBarCounts> {
685 let tier2 = self.status_bar_tier2.borrow();
686 let (Some(dead_code), Some(unused_exports), Some(duplicates)) =
690 (tier2.dead_code, tier2.unused_exports, tier2.duplicates)
691 else {
692 return None;
693 };
694 let (errors, warnings) = self.status_bar_error_warning_counts();
695 Some(StatusBarCounts {
696 errors,
697 warnings,
698 dead_code,
699 unused_exports,
700 duplicates,
701 todos: tier2.todos.unwrap_or(0),
702 tier2_stale: tier2.stale,
703 })
704 }
705
706 fn status_bar_error_warning_counts(&self) -> (usize, usize) {
712 let Some(root) = self.canonical_cache_root_opt() else {
713 return self.lsp_manager.borrow().warm_error_warning_counts();
716 };
717 let mut membership = self.tsconfig_membership.borrow_mut();
718 self.lsp_manager
719 .borrow()
720 .filtered_error_warning_counts(|file| {
721 file.starts_with(&root) && !membership.should_skip_diagnostics(file)
722 })
723 }
724
725 pub fn clear_tsconfig_membership_cache(&self) {
729 self.tsconfig_membership.borrow_mut().clear();
730 }
731
732 pub fn mark_status_bar_tier2_stale(&self) -> bool {
738 let mut tier2 = self.status_bar_tier2.borrow_mut();
739 if tier2.dead_code.is_some() && tier2.unused_exports.is_some() && tier2.duplicates.is_some()
741 {
742 let changed = !tier2.stale;
743 tier2.stale = true;
744 return changed;
745 }
746 false
747 }
748
749 pub fn update_status_bar_tier2(
755 &self,
756 dead_code: Option<usize>,
757 unused_exports: Option<usize>,
758 duplicates: Option<usize>,
759 todos: Option<usize>,
760 stale: bool,
761 ) {
762 let mut tier2 = self.status_bar_tier2.borrow_mut();
763 if let Some(dead_code) = dead_code {
764 tier2.dead_code = Some(dead_code);
765 }
766 if let Some(unused_exports) = unused_exports {
767 tier2.unused_exports = Some(unused_exports);
768 }
769 if let Some(duplicates) = duplicates {
770 tier2.duplicates = Some(duplicates);
771 }
772 if let Some(todos) = todos {
773 tier2.todos = Some(todos);
774 }
775 tier2.stale = stale;
776 }
777
778 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
781 self.gitignore.borrow().clone()
782 }
783
784 pub fn clear_gitignore(&self) {
806 *self.gitignore.borrow_mut() = None;
807 }
808
809 pub fn rebuild_gitignore(&self) {
810 use ignore::gitignore::GitignoreBuilder;
811 use std::path::Path;
812 let root_raw = match self.config().project_root.clone() {
813 Some(r) => r,
814 None => {
815 *self.gitignore.borrow_mut() = None;
816 return;
817 }
818 };
819 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
827 let mut builder = GitignoreBuilder::new(&root);
828 if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
833 if global_ignore.is_file() {
834 if let Some(err) = builder.add(&global_ignore) {
835 crate::slog_warn!(
836 "global gitignore parse error in {}: {}",
837 global_ignore.display(),
838 err
839 );
840 }
841 }
842 }
843 let root_ignore = Path::new(&root).join(".gitignore");
845 if root_ignore.exists() {
846 if let Some(err) = builder.add(&root_ignore) {
847 crate::slog_warn!(
848 "gitignore parse error in {}: {}",
849 root_ignore.display(),
850 err
851 );
852 }
853 }
854 let root_aftignore = Path::new(&root).join(".aftignore");
859 if root_aftignore.exists() {
860 if let Some(err) = builder.add(&root_aftignore) {
861 crate::slog_warn!(
862 "aftignore parse error in {}: {}",
863 root_aftignore.display(),
864 err
865 );
866 }
867 }
868 let info_exclude = self
873 .git_common_dir
874 .borrow()
875 .clone()
876 .unwrap_or_else(|| Path::new(&root).join(".git"))
877 .join("info")
878 .join("exclude");
879 if info_exclude.exists() {
880 if let Some(err) = builder.add(&info_exclude) {
881 crate::slog_warn!(
882 "gitignore parse error in {}: {}",
883 info_exclude.display(),
884 err
885 );
886 }
887 }
888 let walker = ignore::WalkBuilder::new(&root)
894 .standard_filters(true)
895 .hidden(false)
903 .filter_entry(|entry| {
904 let name = entry.file_name().to_string_lossy();
905 !matches!(
906 name.as_ref(),
907 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
908 )
909 })
910 .build();
911 for entry in walker.flatten() {
912 let file_name = entry.file_name();
913 let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
914 let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
915 if is_nested_gitignore || is_nested_aftignore {
916 if let Some(err) = builder.add(entry.path()) {
917 crate::slog_warn!(
918 "nested ignore parse error in {}: {}",
919 entry.path().display(),
920 err
921 );
922 }
923 }
924 }
925 match builder.build() {
926 Ok(gi) => {
927 let count = gi.num_ignores();
928 if count > 0 {
929 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
930 *self.gitignore.borrow_mut() = Some(Arc::new(gi));
931 } else {
932 *self.gitignore.borrow_mut() = None;
933 }
934 }
935 Err(err) => {
936 crate::slog_warn!("gitignore matcher build failed: {}", err);
937 *self.gitignore.borrow_mut() = None;
938 }
939 }
940 }
941
942 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
945 Arc::clone(&self.bash_compress_flag)
946 }
947
948 pub fn sync_bash_compress_flag(&self) {
952 let value = self.config().experimental_bash_compress;
953 self.bash_compress_flag
954 .store(value, std::sync::atomic::Ordering::Relaxed);
955 }
956
957 pub fn set_bash_compress_enabled(&self, enabled: bool) {
958 self.config_mut().experimental_bash_compress = enabled;
959 self.bash_compress_flag
960 .store(enabled, std::sync::atomic::Ordering::Relaxed);
961 }
962
963 pub fn filter_registry(
967 &self,
968 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
969 self.ensure_filter_registry_loaded();
970 match self.filter_registry.read() {
971 Ok(g) => g,
972 Err(poisoned) => poisoned.into_inner(),
973 }
974 }
975
976 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
980 self.ensure_filter_registry_loaded();
981 Arc::clone(&self.filter_registry)
982 }
983
984 pub fn reset_filter_registry(&self) {
988 let new_registry = crate::compress::build_registry_for_context(self);
989 match self.filter_registry.write() {
990 Ok(mut slot) => *slot = new_registry,
991 Err(poisoned) => *poisoned.into_inner() = new_registry,
992 }
993 self.filter_registry_loaded
994 .store(true, std::sync::atomic::Ordering::Release);
995 }
996
997 fn ensure_filter_registry_loaded(&self) {
998 use std::sync::atomic::Ordering;
999 if self.filter_registry_loaded.load(Ordering::Acquire) {
1000 return;
1001 }
1002 let new_registry = crate::compress::build_registry_for_context(self);
1005 if let Ok(mut slot) = self.filter_registry.write() {
1006 *slot = new_registry;
1007 self.filter_registry_loaded.store(true, Ordering::Release);
1008 }
1009 }
1010
1011 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
1014 self.lsp_child_registry.clone()
1015 }
1016
1017 pub fn stdout_writer(&self) -> SharedStdoutWriter {
1018 Arc::clone(&self.stdout_writer)
1019 }
1020
1021 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
1022 if let Ok(mut progress_sender) = self.progress_sender.lock() {
1023 *progress_sender = sender;
1024 }
1025 }
1026
1027 pub fn emit_progress(&self, frame: ProgressFrame) {
1028 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
1029 return;
1030 };
1031 if let Some(sender) = progress_sender.as_ref() {
1032 sender(PushFrame::Progress(frame));
1033 }
1034 }
1035
1036 pub fn status_emitter(&self) -> &StatusEmitter {
1037 &self.status_emitter
1038 }
1039
1040 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
1048 self.progress_sender
1049 .lock()
1050 .ok()
1051 .and_then(|sender| sender.clone())
1052 }
1053
1054 pub fn advance_configure_generation(&self) -> u64 {
1055 self.configure_generation
1056 .fetch_add(1, Ordering::SeqCst)
1057 .wrapping_add(1)
1058 }
1059
1060 pub fn configure_generation(&self) -> u64 {
1061 self.configure_generation.load(Ordering::SeqCst)
1062 }
1063
1064 pub fn configure_warnings_sender(&self) -> mpsc::Sender<(u64, ConfigureWarningsFrame)> {
1065 self.configure_warnings_tx.clone()
1066 }
1067
1068 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
1069 let mut warnings = Vec::new();
1070 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
1071 warnings.push(warning);
1072 }
1073 warnings
1074 }
1075
1076 pub fn bash_background(&self) -> &BgTaskRegistry {
1077 &self.bash_background
1078 }
1079
1080 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
1081 self.bash_background.drain_completions()
1082 }
1083
1084 pub fn provider(&self) -> &dyn LanguageProvider {
1086 self.provider.as_ref()
1087 }
1088
1089 pub fn backup(&self) -> &RefCell<BackupStore> {
1091 &self.backup
1092 }
1093
1094 pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
1096 &self.checkpoint
1097 }
1098
1099 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
1100 *self.db.borrow_mut() = Some(conn);
1101 }
1102
1103 pub fn clear_db(&self) {
1104 *self.db.borrow_mut() = None;
1105 }
1106
1107 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
1108 self.db.borrow().clone()
1109 }
1110
1111 pub fn config(&self) -> Ref<'_, Config> {
1113 self.config.borrow()
1114 }
1115
1116 pub fn config_mut(&self) -> RefMut<'_, Config> {
1118 self.config.borrow_mut()
1119 }
1120
1121 pub fn set_harness(&self, harness: Harness) {
1122 *self.harness.borrow_mut() = Some(harness);
1123 self.bash_background.set_harness(harness);
1124 }
1125
1126 pub fn harness_opt(&self) -> Option<Harness> {
1127 *self.harness.borrow()
1128 }
1129
1130 pub fn harness(&self) -> Harness {
1131 self.harness_opt()
1132 .expect("harness set by configure before any tool call")
1133 }
1134
1135 pub fn storage_dir(&self) -> PathBuf {
1136 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
1137 }
1138
1139 pub fn harness_dir(&self) -> PathBuf {
1140 self.storage_dir().join(self.harness().as_str())
1141 }
1142
1143 pub fn inspect_dir(&self) -> PathBuf {
1144 self.harness_dir().join("inspect")
1145 }
1146
1147 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
1148 self.harness_dir()
1149 .join("bash-tasks")
1150 .join(hash_session(session_id))
1151 }
1152
1153 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
1154 self.harness_dir()
1155 .join("backups")
1156 .join(hash_session(session_id))
1157 .join(path_hash)
1158 }
1159
1160 pub fn filters_dir(&self) -> PathBuf {
1161 self.harness_dir().join("filters")
1162 }
1163
1164 pub fn trust_file(&self) -> PathBuf {
1166 self.storage_dir().join("trusted-filter-projects.json")
1167 }
1168
1169 pub fn set_canonical_cache_root(&self, root: PathBuf) {
1170 debug_assert!(root.is_absolute());
1171 *self.canonical_cache_root.borrow_mut() = Some(root);
1172 }
1173
1174 pub fn canonical_cache_root(&self) -> PathBuf {
1175 self.canonical_cache_root
1176 .borrow()
1177 .clone()
1178 .expect("canonical_cache_root accessed before handle_configure")
1179 }
1180
1181 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
1182 self.canonical_cache_root.borrow().clone()
1183 }
1184
1185 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
1186 *self.is_worktree_bridge.borrow_mut() = is_worktree_bridge;
1187 *self.git_common_dir.borrow_mut() = git_common_dir;
1188 }
1189
1190 pub fn is_worktree_bridge(&self) -> bool {
1191 *self.is_worktree_bridge.borrow()
1192 }
1193
1194 pub fn git_common_dir(&self) -> Option<PathBuf> {
1195 self.git_common_dir.borrow().clone()
1196 }
1197
1198 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
1202 *self.degraded_reasons.borrow_mut() = reasons;
1203 }
1204
1205 pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
1206 let reason = reason.into();
1207 let mut reasons = self.degraded_reasons.borrow_mut();
1208 if reasons.iter().any(|existing| existing == &reason) {
1209 return false;
1210 }
1211 reasons.push(reason);
1212 true
1213 }
1214
1215 pub fn degraded_reasons(&self) -> Vec<String> {
1219 self.degraded_reasons.borrow().clone()
1220 }
1221
1222 pub fn is_degraded(&self) -> bool {
1224 !self.degraded_reasons.borrow().is_empty()
1225 }
1226
1227 pub fn cache_role(&self) -> &'static str {
1228 if self.canonical_cache_root.borrow().is_none() {
1229 "not_initialized"
1230 } else if self.is_worktree_bridge() {
1231 "worktree"
1232 } else {
1233 "main"
1234 }
1235 }
1236
1237 pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
1239 &self.callgraph
1240 }
1241
1242 pub fn callgraph_store(&self) -> &RefCell<Option<CallGraphStore>> {
1244 &self.callgraph_store
1245 }
1246
1247 pub fn mark_callgraph_store_force_rebuild(&self) {
1248 *self.callgraph_store_force_rebuild.borrow_mut() = true;
1249 }
1250
1251 fn take_callgraph_store_force_rebuild(&self) -> bool {
1252 let force = *self.callgraph_store_force_rebuild.borrow();
1253 *self.callgraph_store_force_rebuild.borrow_mut() = false;
1254 force
1255 }
1256
1257 pub fn callgraph_store_dir(&self) -> PathBuf {
1258 match self.harness_opt() {
1259 Some(harness) => self.storage_dir().join(harness.as_str()).join("callgraph"),
1260 None => self.storage_dir().join("callgraph"),
1261 }
1262 }
1263
1264 pub fn ensure_callgraph_store(
1265 &self,
1266 ) -> Result<Option<RefMut<'_, CallGraphStore>>, CallGraphStoreError> {
1267 self.ensure_callgraph_store_with_flag(true)
1268 }
1269
1270 fn ensure_callgraph_store_with_flag(
1271 &self,
1272 respect_config_flag: bool,
1273 ) -> Result<Option<RefMut<'_, CallGraphStore>>, CallGraphStoreError> {
1274 if respect_config_flag && !self.config().callgraph_store {
1275 return Ok(None);
1276 }
1277 if self.callgraph_store.borrow().is_none() {
1278 let Some(project_root) = self.callgraph_project_root() else {
1279 return Ok(None);
1280 };
1281 let callgraph_dir = self.callgraph_store_dir();
1282 let force_rebuild = self.take_callgraph_store_force_rebuild();
1283 let store = if self.is_worktree_bridge() {
1284 CallGraphStore::open_readonly(callgraph_dir, project_root)?
1285 } else if force_rebuild {
1286 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1287 let (store, _stats) =
1288 CallGraphStore::cold_build_with_lease(callgraph_dir, project_root, &files)?;
1289 Some(store)
1290 } else if CallGraphStore::needs_cold_build(&callgraph_dir, &project_root)? {
1291 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1292 let (store, _stats) =
1293 CallGraphStore::ensure_built_with_lease(callgraph_dir, project_root, &files)?;
1294 Some(store)
1295 } else {
1296 Some(CallGraphStore::open(callgraph_dir, project_root)?)
1297 };
1298 *self.callgraph_store.borrow_mut() = store;
1299 }
1300 let borrow = self.callgraph_store.borrow_mut();
1301 Ok(RefMut::filter_map(borrow, Option::as_mut).ok())
1302 }
1303
1304 fn callgraph_project_root(&self) -> Option<PathBuf> {
1307 self.canonical_cache_root_opt().or_else(|| {
1308 self.config()
1309 .project_root
1310 .clone()
1311 .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
1312 })
1313 }
1314
1315 pub fn revalidate_callgraph_store_generation(&self) {
1333 if self.callgraph_store_rx.borrow().is_some() {
1336 return;
1337 }
1338 let superseded = self
1339 .callgraph_store
1340 .borrow()
1341 .as_ref()
1342 .is_some_and(|store| !store.is_current());
1343 if superseded {
1344 *self.callgraph_store.borrow_mut() = None;
1345 }
1346 }
1347
1348 pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess<'_> {
1349 self.revalidate_callgraph_store_generation();
1353 if self.callgraph_store.borrow().is_some() {
1354 let borrow = self.callgraph_store.borrow_mut();
1355 return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1356 Some(store) => CallgraphStoreAccess::Ready(store),
1357 None => CallgraphStoreAccess::Unavailable,
1358 };
1359 }
1360
1361 if self.callgraph_store_rx.borrow().is_some() {
1363 return CallgraphStoreAccess::Building;
1364 }
1365
1366 let Some(project_root) = self.callgraph_project_root() else {
1367 return CallgraphStoreAccess::Unavailable;
1368 };
1369 let callgraph_dir = self.callgraph_store_dir();
1370
1371 if self.is_worktree_bridge() {
1374 match CallGraphStore::open_readonly(callgraph_dir, project_root) {
1375 Ok(Some(store)) => {
1376 *self.callgraph_store.borrow_mut() = Some(store);
1377 let borrow = self.callgraph_store.borrow_mut();
1378 return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1379 Some(store) => CallgraphStoreAccess::Ready(store),
1380 None => CallgraphStoreAccess::Unavailable,
1381 };
1382 }
1383 Ok(None) | Err(_) => return CallgraphStoreAccess::Unavailable,
1384 }
1385 }
1386
1387 let force_rebuild = *self.callgraph_store_force_rebuild.borrow();
1388 if !force_rebuild {
1391 match CallGraphStore::needs_cold_build(&callgraph_dir, &project_root) {
1392 Ok(false) => match CallGraphStore::open(callgraph_dir, project_root) {
1393 Ok(store) => {
1394 *self.callgraph_store.borrow_mut() = Some(store);
1395 let borrow = self.callgraph_store.borrow_mut();
1396 return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1397 Some(store) => CallgraphStoreAccess::Ready(store),
1398 None => CallgraphStoreAccess::Unavailable,
1399 };
1400 }
1401 Err(error) => return CallgraphStoreAccess::Error(error),
1402 },
1403 Ok(true) => {}
1404 Err(error) => return CallgraphStoreAccess::Error(error),
1405 }
1406 }
1407
1408 self.spawn_callgraph_store_cold_build(project_root, callgraph_dir, force_rebuild);
1416
1417 let wait = callgraph_build_wait_window();
1418 if !wait.is_zero() {
1419 let received = {
1420 let rx_ref = self.callgraph_store_rx.borrow();
1421 let Some(rx) = rx_ref.as_ref() else {
1422 return CallgraphStoreAccess::Building;
1423 };
1424 rx.recv_timeout(wait)
1425 };
1426 match received {
1427 Ok(store) => {
1428 let pending = self.take_pending_callgraph_store_paths();
1432 if !pending.is_empty() {
1433 if let Err(error) = store.refresh_files(&pending) {
1434 crate::slog_warn!(
1435 "callgraph store inline post-build refresh failed: {}",
1436 error
1437 );
1438 let _ = store.mark_files_stale(&pending);
1439 }
1440 }
1441 *self.callgraph_store.borrow_mut() = Some(store);
1442 *self.callgraph_store_rx.borrow_mut() = None;
1443 let borrow = self.callgraph_store.borrow_mut();
1444 return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1445 Some(store) => CallgraphStoreAccess::Ready(store),
1446 None => CallgraphStoreAccess::Unavailable,
1447 };
1448 }
1449 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1450 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
1451 *self.callgraph_store_rx.borrow_mut() = None;
1454 }
1455 }
1456 }
1457 CallgraphStoreAccess::Building
1458 }
1459
1460 fn spawn_callgraph_store_cold_build(
1465 &self,
1466 project_root: PathBuf,
1467 callgraph_dir: PathBuf,
1468 force_rebuild: bool,
1469 ) {
1470 if force_rebuild {
1471 self.take_callgraph_store_force_rebuild();
1474 }
1475 let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStore>();
1476 *self.callgraph_store_rx.borrow_mut() = Some(rx);
1477 let session_id = crate::log_ctx::current_session();
1478 std::thread::spawn(move || {
1479 crate::log_ctx::with_session(session_id, || {
1480 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1481 let built = if force_rebuild {
1482 CallGraphStore::cold_build_with_lease(callgraph_dir, project_root, &files)
1483 .map(|(store, _)| store)
1484 } else {
1485 CallGraphStore::ensure_built_with_lease(callgraph_dir, project_root, &files)
1486 .map(|(store, _)| store)
1487 };
1488 match built {
1489 Ok(store) => {
1490 let _ = tx.send(store);
1491 }
1492 Err(error) => {
1493 crate::slog_warn!("callgraph store cold build failed: {}", error);
1494 }
1497 }
1498 });
1499 });
1500 }
1501
1502 pub fn callgraph_store_rx(
1505 &self,
1506 ) -> &RefCell<Option<crossbeam_channel::Receiver<CallGraphStore>>> {
1507 &self.callgraph_store_rx
1508 }
1509
1510 pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
1513 where
1514 I: IntoIterator<Item = PathBuf>,
1515 {
1516 self.pending_callgraph_store_paths
1517 .borrow_mut()
1518 .extend(paths);
1519 }
1520
1521 pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
1523 std::mem::take(&mut *self.pending_callgraph_store_paths.borrow_mut())
1524 .into_iter()
1525 .collect()
1526 }
1527
1528 pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
1530 &self.search_index
1531 }
1532
1533 pub fn search_index_rx(&self) -> &RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>> {
1535 &self.search_index_rx
1536 }
1537
1538 pub fn add_pending_search_index_paths<I>(&self, paths: I)
1539 where
1540 I: IntoIterator<Item = PathBuf>,
1541 {
1542 self.pending_search_index_paths.borrow_mut().extend(paths);
1543 }
1544
1545 pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
1546 std::mem::take(&mut *self.pending_search_index_paths.borrow_mut())
1547 .into_iter()
1548 .collect()
1549 }
1550
1551 pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
1552 where
1553 I: IntoIterator<Item = PathBuf>,
1554 {
1555 self.pending_semantic_index_paths.borrow_mut().extend(paths);
1556 }
1557
1558 pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
1559 std::mem::take(&mut *self.pending_semantic_index_paths.borrow_mut())
1560 .into_iter()
1561 .collect()
1562 }
1563
1564 pub fn mark_pending_semantic_corpus_refresh(&self) {
1565 *self.pending_semantic_corpus_refresh.borrow_mut() = true;
1566 }
1567
1568 pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
1569 std::mem::take(&mut *self.pending_semantic_corpus_refresh.borrow_mut())
1570 }
1571
1572 pub fn clear_pending_index_updates(&self) {
1573 self.pending_search_index_paths.borrow_mut().clear();
1574 self.pending_callgraph_store_paths.borrow_mut().clear();
1575 self.pending_semantic_index_paths.borrow_mut().clear();
1576 *self.pending_semantic_corpus_refresh.borrow_mut() = false;
1577 }
1578
1579 pub fn inspect_manager(&self) -> Arc<InspectManager> {
1580 Arc::clone(&self.inspect_manager)
1581 }
1582
1583 pub fn take_new_reuse_completions(&self) -> bool {
1588 let current = self.inspect_manager.reuse_completion_count();
1589 let previous = self
1590 .last_seen_reuse_completions
1591 .swap(current, Ordering::SeqCst);
1592 current != previous
1593 }
1594
1595 pub fn reset_tier2_refresh_scheduler(&self) {
1596 self.reset_tier2_refresh_scheduler_at(Instant::now());
1597 }
1598
1599 #[doc(hidden)]
1600 pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
1601 self.tier2_refresh_scheduler
1602 .borrow_mut()
1603 .reset_after_configure(now);
1604 }
1605
1606 pub fn request_tier2_refresh_pull(&self) -> bool {
1607 self.tier2_refresh_scheduler
1608 .borrow_mut()
1609 .request_pull(!self.is_worktree_bridge())
1610 }
1611
1612 pub fn tick_tier2_refresh_scheduler(
1613 &self,
1614 changed_path_count: usize,
1615 ) -> Option<Tier2TriggerReason> {
1616 self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
1617 }
1618
1619 #[doc(hidden)]
1620 pub fn tick_tier2_refresh_scheduler_at(
1621 &self,
1622 now: Instant,
1623 changed_path_count: usize,
1624 ) -> Option<Tier2TriggerReason> {
1625 let manager = self.inspect_manager();
1626 let can_write = !self.is_worktree_bridge();
1627 let in_flight = manager.tier2_any_in_flight();
1628 let decision = self.tier2_refresh_scheduler.borrow_mut().tick(
1629 now,
1630 changed_path_count,
1631 can_write,
1632 in_flight,
1633 );
1634
1635 if let Some(reason) = decision {
1636 self.start_tier2_refresh(reason, manager);
1637 }
1638
1639 decision
1640 }
1641
1642 pub fn note_tier2_refresh_started(&self) {
1643 self.note_tier2_refresh_started_at(Instant::now());
1644 }
1645
1646 #[doc(hidden)]
1647 pub fn note_tier2_refresh_started_at(&self, now: Instant) {
1648 self.tier2_refresh_scheduler
1649 .borrow_mut()
1650 .note_external_scan_started(now);
1651 }
1652
1653 pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
1654 self.tier2_refresh_scheduler
1655 .borrow()
1656 .last_trigger_reason()
1657 .map(Tier2TriggerReason::as_str)
1658 }
1659
1660 #[doc(hidden)]
1661 pub fn tier2_pull_demand_pending(&self) -> bool {
1662 self.tier2_refresh_scheduler.borrow().pull_demand_pending()
1663 }
1664
1665 fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
1666 if self.is_worktree_bridge()
1667 || self
1668 .degraded_reasons
1669 .borrow()
1670 .iter()
1671 .any(|r| r == "home_root")
1672 || !self.config().inspect.enabled
1673 {
1674 return;
1675 }
1676 let Some(snapshot) = self.tier2_refresh_snapshot() else {
1677 return;
1678 };
1679 let categories = InspectCategory::active()
1680 .iter()
1681 .copied()
1682 .filter(|category| category.is_tier2())
1683 .collect::<Vec<_>>();
1684 let submission =
1685 manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
1686 if submission.has_new_work() {
1687 crate::slog_info!(
1688 "tier2 refresh scheduled: reason={}, categories={:?}",
1689 reason.as_str(),
1690 submission
1691 .newly_queued_categories
1692 .iter()
1693 .map(|category| category.as_str())
1694 .collect::<Vec<_>>()
1695 );
1696 }
1697 for error in submission.errors {
1698 crate::slog_warn!(
1699 "tier2 refresh schedule failed for {}: {}",
1700 error.category,
1701 error.message
1702 );
1703 }
1704 }
1705
1706 fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
1707 self.harness_opt()?;
1708 let config = self.config().clone();
1709 let project_root = config
1710 .project_root
1711 .clone()
1712 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1713 let project_root = std::fs::canonicalize(&project_root).unwrap_or(project_root);
1714 Some(InspectSnapshot::new(
1715 project_root,
1716 self.inspect_dir(),
1717 Arc::new(config),
1718 self.symbol_cache(),
1719 ))
1720 }
1721
1722 pub fn symbol_cache(&self) -> SharedSymbolCache {
1724 Arc::clone(&self.symbol_cache)
1725 }
1726
1727 pub fn reset_symbol_cache(&self) -> u64 {
1729 self.symbol_cache
1730 .write()
1731 .map(|mut cache| cache.reset())
1732 .unwrap_or(0)
1733 }
1734
1735 pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
1737 &self.semantic_index
1738 }
1739
1740 pub fn semantic_index_rx(
1742 &self,
1743 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
1744 &self.semantic_index_rx
1745 }
1746
1747 pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
1748 &self.semantic_index_status
1749 }
1750
1751 pub fn install_semantic_refresh_worker(
1752 &self,
1753 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
1754 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
1755 worker_slot: SemanticRefreshWorkerSlot,
1756 ) {
1757 self.clear_semantic_refresh_worker();
1758 *self.semantic_refresh_tx.borrow_mut() = Some(sender);
1759 *self.semantic_refresh_event_rx.borrow_mut() = Some(event_rx);
1760 *self.semantic_refresh_worker.borrow_mut() = Some(worker_slot);
1761 }
1762
1763 pub fn clear_semantic_refresh_worker(&self) {
1764 *self.semantic_refresh_tx.borrow_mut() = None;
1765 *self.semantic_refresh_event_rx.borrow_mut() = None;
1766 if let Some(worker_slot) = self.semantic_refresh_worker.borrow_mut().take() {
1767 if let Ok(mut handle) = worker_slot.lock() {
1768 drop(handle.take());
1769 }
1770 }
1771 }
1772
1773 pub fn semantic_refresh_sender(
1774 &self,
1775 ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
1776 self.semantic_refresh_tx.borrow().clone()
1777 }
1778
1779 pub fn semantic_refresh_event_rx(
1780 &self,
1781 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
1782 &self.semantic_refresh_event_rx
1783 }
1784
1785 pub fn semantic_embedding_model(
1787 &self,
1788 ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
1789 &self.semantic_embedding_model
1790 }
1791
1792 pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
1794 &self.watcher
1795 }
1796
1797 pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
1799 &self.watcher_rx
1800 }
1801
1802 pub fn lsp(&self) -> RefMut<'_, LspManager> {
1804 self.lsp_manager.borrow_mut()
1805 }
1806
1807 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
1810 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1811 let config = self.config();
1812 if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
1813 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1814 }
1815 }
1816 }
1817
1818 pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
1824 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1825 lsp.clear_diagnostics_for_file(file_path)
1826 } else {
1827 false
1828 }
1829 }
1830
1831 pub fn lsp_notify_and_collect_diagnostics(
1842 &self,
1843 file_path: &Path,
1844 content: &str,
1845 timeout: std::time::Duration,
1846 ) -> crate::lsp::manager::PostEditWaitOutcome {
1847 let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
1848 return crate::lsp::manager::PostEditWaitOutcome::default();
1849 };
1850
1851 lsp.drain_events();
1854
1855 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
1859
1860 let config = self.config();
1862 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
1863 {
1864 Ok(v) => v,
1865 Err(e) => {
1866 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1867 return crate::lsp::manager::PostEditWaitOutcome::default();
1868 }
1869 };
1870
1871 if expected_versions.is_empty() {
1874 return crate::lsp::manager::PostEditWaitOutcome::default();
1875 }
1876
1877 lsp.wait_for_post_edit_diagnostics(
1878 file_path,
1879 &config,
1880 &expected_versions,
1881 &pre_snapshot,
1882 timeout,
1883 )
1884 }
1885
1886 fn custom_lsp_root_markers(&self) -> Vec<String> {
1889 self.config()
1890 .lsp_servers
1891 .iter()
1892 .flat_map(|s| s.root_markers.iter().cloned())
1893 .collect()
1894 }
1895
1896 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
1897 let custom_markers = self.custom_lsp_root_markers();
1898 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
1899 .iter()
1900 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
1901 .cloned()
1902 .map(|path| {
1903 let change_type = if path.exists() {
1904 FileChangeType::CHANGED
1905 } else {
1906 FileChangeType::DELETED
1907 };
1908 (path, change_type)
1909 })
1910 .collect();
1911
1912 self.notify_watched_config_events(&config_paths);
1913 }
1914
1915 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
1916 let paths = params
1917 .get("multi_file_write_paths")
1918 .and_then(|value| value.as_array())?
1919 .iter()
1920 .filter_map(|value| value.as_str())
1921 .map(PathBuf::from)
1922 .collect::<Vec<_>>();
1923
1924 (!paths.is_empty()).then_some(paths)
1925 }
1926
1927 fn watched_file_events_from_params(
1939 params: &serde_json::Value,
1940 extra_markers: &[String],
1941 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
1942 let events = params
1943 .get("multi_file_write_paths")
1944 .and_then(|value| value.as_array())?
1945 .iter()
1946 .filter_map(|entry| {
1947 let path = entry
1949 .get("path")
1950 .and_then(|value| value.as_str())
1951 .map(PathBuf::from)?;
1952
1953 if !is_config_file_path_with_custom(&path, extra_markers) {
1954 return None;
1955 }
1956
1957 let change_type = entry
1958 .get("type")
1959 .and_then(|value| value.as_str())
1960 .and_then(Self::parse_file_change_type)
1961 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
1962
1963 Some((path, change_type))
1964 })
1965 .collect::<Vec<_>>();
1966
1967 (!events.is_empty()).then_some(events)
1968 }
1969
1970 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
1971 match value {
1972 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
1973 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
1974 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
1975 _ => None,
1976 }
1977 }
1978
1979 fn change_type_from_current_state(path: &Path) -> FileChangeType {
1980 if path.exists() {
1981 FileChangeType::CHANGED
1982 } else {
1983 FileChangeType::DELETED
1984 }
1985 }
1986
1987 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
1988 if config_paths.is_empty() {
1989 return;
1990 }
1991
1992 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1993 let config = self.config();
1994 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
1995 crate::slog_warn!("watched-file sync error: {}", e);
1996 }
1997 }
1998 }
1999
2000 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
2001 let custom_markers = self.custom_lsp_root_markers();
2002 if !is_config_file_path_with_custom(file_path, &custom_markers) {
2003 return;
2004 }
2005
2006 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
2007 }
2008
2009 pub fn lsp_post_multi_file_write(
2014 &self,
2015 file_path: &Path,
2016 content: &str,
2017 file_paths: &[PathBuf],
2018 params: &serde_json::Value,
2019 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2020 self.notify_watched_config_files(file_paths);
2021
2022 let wants_diagnostics = params
2023 .get("diagnostics")
2024 .and_then(|v| v.as_bool())
2025 .unwrap_or(false);
2026
2027 if !wants_diagnostics {
2028 self.lsp_notify_file_changed(file_path, content);
2029 return None;
2030 }
2031
2032 let wait_ms = params
2033 .get("wait_ms")
2034 .and_then(|v| v.as_u64())
2035 .unwrap_or(3000)
2036 .min(10_000);
2037
2038 Some(self.lsp_notify_and_collect_diagnostics(
2039 file_path,
2040 content,
2041 std::time::Duration::from_millis(wait_ms),
2042 ))
2043 }
2044
2045 pub fn lsp_post_write(
2062 &self,
2063 file_path: &Path,
2064 content: &str,
2065 params: &serde_json::Value,
2066 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2067 let wants_diagnostics = params
2068 .get("diagnostics")
2069 .and_then(|v| v.as_bool())
2070 .unwrap_or(false);
2071
2072 let custom_markers = self.custom_lsp_root_markers();
2073
2074 if !wants_diagnostics {
2075 if let Some(file_paths) = Self::multi_file_write_paths(params) {
2076 self.notify_watched_config_files(&file_paths);
2077 } else if let Some(config_events) =
2078 Self::watched_file_events_from_params(params, &custom_markers)
2079 {
2080 self.notify_watched_config_events(&config_events);
2081 }
2082 self.lsp_notify_file_changed(file_path, content);
2083 return None;
2084 }
2085
2086 let wait_ms = params
2087 .get("wait_ms")
2088 .and_then(|v| v.as_u64())
2089 .unwrap_or(3000)
2090 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
2093 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
2094 }
2095
2096 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
2097 {
2098 self.notify_watched_config_events(&config_events);
2099 }
2100
2101 Some(self.lsp_notify_and_collect_diagnostics(
2102 file_path,
2103 content,
2104 std::time::Duration::from_millis(wait_ms),
2105 ))
2106 }
2107
2108 pub fn validate_path(
2117 &self,
2118 req_id: &str,
2119 path: &Path,
2120 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
2121 let config = self.config();
2122 if !config.restrict_to_project_root {
2124 return Ok(path.to_path_buf());
2125 }
2126 let root = match &config.project_root {
2127 Some(r) => r.clone(),
2128 None => return Ok(path.to_path_buf()), };
2130 drop(config);
2131
2132 let raw_root = root.clone();
2137 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
2138
2139 let path_for_resolution = if path.is_relative() {
2144 raw_root.join(path)
2145 } else {
2146 path.to_path_buf()
2147 };
2148 let resolved = match std::fs::canonicalize(&path_for_resolution) {
2149 Ok(resolved) => resolved,
2150 Err(_) => {
2151 let normalized = normalize_path(&path_for_resolution);
2152 reject_escaping_symlink(
2153 req_id,
2154 &path_for_resolution,
2155 &normalized,
2156 &resolved_root,
2157 &raw_root,
2158 )?;
2159 resolve_with_existing_ancestors(&normalized)
2160 }
2161 };
2162
2163 if !resolved.starts_with(&resolved_root) {
2164 return Err(path_error_response(req_id, path, &resolved_root));
2165 }
2166
2167 Ok(resolved)
2168 }
2169
2170 pub fn lsp_server_count(&self) -> usize {
2172 self.lsp_manager
2173 .try_borrow()
2174 .map(|lsp| lsp.server_count())
2175 .unwrap_or(0)
2176 }
2177
2178 pub fn symbol_cache_stats(&self) -> serde_json::Value {
2180 let entries = self
2181 .symbol_cache
2182 .read()
2183 .map(|cache| cache.len())
2184 .unwrap_or(0);
2185 serde_json::json!({
2186 "local_entries": entries,
2187 "warm_entries": 0,
2188 })
2189 }
2190}
2191
2192#[cfg(test)]
2193mod status_emitter_tests {
2194 use super::*;
2195 use crate::parser::TreeSitterProvider;
2196
2197 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
2198 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2199 let (tx, rx) = mpsc::channel();
2200 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
2201 let _ = tx.send(frame);
2202 }))));
2203 (ctx, rx)
2204 }
2205
2206 #[test]
2207 fn status_emitter_signal_triggers_push() {
2208 let (ctx, rx) = ctx_with_frame_rx();
2209 ctx.status_emitter().signal(ctx.build_status_snapshot());
2210 let frame = rx
2211 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2212 .expect("status_changed push");
2213 assert!(matches!(frame, PushFrame::StatusChanged(_)));
2214 }
2215
2216 #[test]
2217 fn status_emitter_debounces_burst() {
2218 let (ctx, rx) = ctx_with_frame_rx();
2219 for _ in 0..10 {
2220 ctx.status_emitter().signal(ctx.build_status_snapshot());
2221 }
2222 let frame = rx
2223 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2224 .expect("status_changed push");
2225 assert!(matches!(frame, PushFrame::StatusChanged(_)));
2226 assert!(rx.try_recv().is_err());
2227 }
2228
2229 #[test]
2230 fn status_emitter_separate_windows_separate_pushes() {
2231 let (ctx, rx) = ctx_with_frame_rx();
2232 ctx.status_emitter().signal(ctx.build_status_snapshot());
2233 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2234 .expect("first push");
2235 ctx.status_emitter().signal(ctx.build_status_snapshot());
2236 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2237 .expect("second push");
2238 }
2239
2240 #[test]
2241 fn status_emitter_no_signal_no_push() {
2242 let (_ctx, rx) = ctx_with_frame_rx();
2243 assert!(rx
2244 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
2245 .is_err());
2246 }
2247
2248 #[test]
2249 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
2250 let (ctx, rx) = ctx_with_frame_rx();
2251 drop(ctx);
2252 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
2253 }
2254}
2255
2256#[cfg(test)]
2257mod status_bar_tests {
2258 use super::*;
2259 use crate::parser::TreeSitterProvider;
2260
2261 fn ctx() -> AppContext {
2262 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
2263 }
2264
2265 #[test]
2266 fn status_bar_counts_none_until_tier2_populated() {
2267 let ctx = ctx();
2268 assert!(ctx.status_bar_counts().is_none());
2270
2271 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
2272 let counts = ctx.status_bar_counts().expect("populated");
2273 assert_eq!(counts.dead_code, 5);
2274 assert_eq!(counts.unused_exports, 3);
2275 assert_eq!(counts.duplicates, 7);
2276 assert_eq!(counts.todos, 2);
2277 assert!(!counts.tier2_stale);
2278 assert_eq!(counts.errors, 0);
2280 assert_eq!(counts.warnings, 0);
2281 }
2282
2283 #[test]
2284 fn partial_tier2_does_not_fabricate_zeros() {
2285 let ctx = ctx();
2286 ctx.update_status_bar_tier2(Some(5), None, None, None, true);
2290 assert!(
2291 ctx.status_bar_counts().is_none(),
2292 "bar must not surface until all three Tier-2 categories are real"
2293 );
2294
2295 ctx.update_status_bar_tier2(None, Some(3), None, None, true);
2297 assert!(ctx.status_bar_counts().is_none());
2298
2299 ctx.update_status_bar_tier2(None, None, Some(7), None, false);
2302 let counts = ctx.status_bar_counts().expect("all three real now");
2303 assert_eq!(counts.dead_code, 5);
2304 assert_eq!(counts.unused_exports, 3);
2305 assert_eq!(counts.duplicates, 7);
2306 }
2307
2308 #[test]
2309 fn update_with_none_todos_preserves_last_known_todos() {
2310 let ctx = ctx();
2311 ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
2312 ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
2314 let counts = ctx.status_bar_counts().expect("populated");
2315 assert_eq!(counts.todos, 9);
2316 assert_eq!(counts.dead_code, 2);
2317 }
2318
2319 #[test]
2320 fn update_with_none_count_preserves_last_known_count() {
2321 let ctx = ctx();
2322 ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
2323 ctx.update_status_bar_tier2(Some(11), None, None, None, false);
2326 let counts = ctx.status_bar_counts().expect("populated");
2327 assert_eq!(counts.dead_code, 11);
2328 assert_eq!(counts.unused_exports, 20);
2329 assert_eq!(counts.duplicates, 30);
2330 }
2331
2332 #[test]
2333 fn mark_stale_sets_flag_only_after_populate() {
2334 let ctx = ctx();
2335 ctx.mark_status_bar_tier2_stale();
2337 assert!(ctx.status_bar_counts().is_none());
2338
2339 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), Some(0), false);
2340 ctx.mark_status_bar_tier2_stale();
2341 assert!(ctx.status_bar_counts().expect("populated").tier2_stale);
2342
2343 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), None, false);
2345 assert!(!ctx.status_bar_counts().expect("populated").tier2_stale);
2346 }
2347
2348 #[test]
2353 fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
2354 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
2355 use crate::lsp::registry::ServerKind;
2356 use crate::lsp::roots::ServerKey;
2357
2358 let ctx = ctx();
2359 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); let file = std::path::PathBuf::from("/proj/gone.ts");
2362 {
2363 let mut lsp = ctx.lsp();
2364 lsp.diagnostics_store_mut_for_test().publish(
2365 ServerKey {
2366 kind: ServerKind::TypeScript,
2367 root: std::path::PathBuf::from("/proj"),
2368 },
2369 file.clone(),
2370 vec![StoredDiagnostic {
2371 file: file.clone(),
2372 line: 1,
2373 column: 1,
2374 end_line: 1,
2375 end_column: 2,
2376 severity: DiagnosticSeverity::Error,
2377 message: "boom".into(),
2378 code: None,
2379 source: None,
2380 }],
2381 );
2382 }
2383
2384 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
2386
2387 let removed = ctx.lsp_clear_diagnostics_for_file(&file);
2389 assert!(removed);
2390 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
2391 }
2392}
2393
2394#[cfg(test)]
2395mod harness_path_tests {
2396 use super::*;
2397 use crate::harness::Harness;
2398 use crate::parser::TreeSitterProvider;
2399
2400 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
2401 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2402 ctx.config_mut().storage_dir = Some(storage_dir);
2403 ctx.set_harness(harness);
2404 ctx
2405 }
2406
2407 #[test]
2408 fn harness_dir_resolves_correctly() {
2409 let storage = PathBuf::from("/tmp/cortexkit/aft");
2410 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2411
2412 assert_eq!(ctx.harness_dir(), storage.join("pi"));
2413 }
2414
2415 #[test]
2416 fn bash_tasks_dir_uses_hash_session() {
2417 let storage = PathBuf::from("/tmp/cortexkit/aft");
2418 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2419
2420 assert_eq!(
2421 ctx.bash_tasks_dir("ses_abc"),
2422 storage
2423 .join("opencode")
2424 .join("bash-tasks")
2425 .join(hash_session("ses_abc"))
2426 );
2427 }
2428
2429 #[test]
2430 fn backups_dir_includes_path_hash() {
2431 let storage = PathBuf::from("/tmp/cortexkit/aft");
2432 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2433
2434 assert_eq!(
2435 ctx.backups_dir("ses_abc", "pathhash"),
2436 storage
2437 .join("pi")
2438 .join("backups")
2439 .join(hash_session("ses_abc"))
2440 .join("pathhash")
2441 );
2442 }
2443
2444 #[test]
2445 fn filters_dir_under_harness() {
2446 let storage = PathBuf::from("/tmp/cortexkit/aft");
2447 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2448
2449 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
2450 }
2451
2452 #[test]
2453 fn trust_file_is_host_global() {
2454 let storage = PathBuf::from("/tmp/cortexkit/aft");
2455 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2456
2457 assert_eq!(
2458 ctx.trust_file(),
2459 storage.join("trusted-filter-projects.json")
2460 );
2461 }
2462
2463 #[test]
2464 fn same_session_different_harness_resolve_different_paths() {
2465 let storage = PathBuf::from("/tmp/cortexkit/aft");
2466 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2467 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
2468
2469 assert_ne!(
2470 opencode.bash_tasks_dir("ses_same"),
2471 pi.bash_tasks_dir("ses_same")
2472 );
2473 }
2474}
2475
2476#[cfg(test)]
2477mod gitignore_tests {
2478 use super::*;
2479 use std::fs;
2480 use std::path::Path;
2481 use tempfile::TempDir;
2482
2483 fn make_ctx_with_root(root: &Path) -> AppContext {
2484 let provider = Box::new(crate::parser::TreeSitterProvider::new());
2485 let config = Config {
2486 project_root: Some(root.to_path_buf()),
2487 ..Config::default()
2488 };
2489 AppContext::new(provider, config)
2490 }
2491
2492 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
2499 let Some(matcher) = ctx.gitignore() else {
2500 return false;
2501 };
2502 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
2503 if !canonical.starts_with(matcher.path()) {
2504 return false;
2505 }
2506 let is_dir = canonical.is_dir();
2507 matcher
2508 .matched_path_or_any_parents(&canonical, is_dir)
2509 .is_ignore()
2510 }
2511
2512 fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
2523 use std::sync::{Mutex, OnceLock};
2524 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2525 let _guard = LOCK
2526 .get_or_init(|| Mutex::new(()))
2527 .lock()
2528 .unwrap_or_else(|e| e.into_inner());
2529 let tmp = TempDir::new().unwrap();
2530 let prev = std::env::var_os("XDG_CONFIG_HOME");
2531 unsafe {
2533 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
2534 }
2535 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
2536 unsafe {
2537 match prev {
2538 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
2539 None => std::env::remove_var("XDG_CONFIG_HOME"),
2540 }
2541 }
2542 match result {
2543 Ok(r) => r,
2544 Err(p) => std::panic::resume_unwind(p),
2545 }
2546 }
2547
2548 #[test]
2549 fn rebuild_gitignore_returns_none_without_project_root() {
2550 let provider = Box::new(crate::parser::TreeSitterProvider::new());
2551 let ctx = AppContext::new(provider, Config::default());
2552 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2553 assert!(ctx.gitignore().is_none());
2554 }
2555
2556 #[test]
2557 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
2558 let tmp = TempDir::new().unwrap();
2559 let ctx = make_ctx_with_root(tmp.path());
2560 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2561 assert!(ctx.gitignore().is_none());
2562 }
2563
2564 #[test]
2565 fn matcher_filters_files_in_ignored_dist_dir() {
2566 let tmp = TempDir::new().unwrap();
2567 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
2568 fs::create_dir_all(tmp.path().join("dist")).unwrap();
2569 fs::create_dir_all(tmp.path().join("src")).unwrap();
2570 let dist_file = tmp.path().join("dist").join("bundle.js");
2571 let src_file = tmp.path().join("src").join("app.ts");
2572 fs::write(&dist_file, "x").unwrap();
2573 fs::write(&src_file, "y").unwrap();
2574
2575 let ctx = make_ctx_with_root(tmp.path());
2576 ctx.rebuild_gitignore();
2577
2578 assert!(ctx.gitignore().is_some());
2579 assert!(
2580 is_ignored(&ctx, &dist_file),
2581 "dist/bundle.js should be ignored"
2582 );
2583 assert!(
2584 !is_ignored(&ctx, &src_file),
2585 "src/app.ts should NOT be ignored"
2586 );
2587 }
2588
2589 #[test]
2590 fn matcher_handles_node_modules_and_target() {
2591 let tmp = TempDir::new().unwrap();
2592 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
2593 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
2594 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
2595 let nm_file = tmp.path().join("node_modules/foo/index.js");
2596 let target_file = tmp.path().join("target/debug/aft");
2597 fs::write(&nm_file, "x").unwrap();
2598 fs::write(&target_file, "x").unwrap();
2599
2600 let ctx = make_ctx_with_root(tmp.path());
2601 ctx.rebuild_gitignore();
2602
2603 assert!(is_ignored(&ctx, &nm_file));
2604 assert!(is_ignored(&ctx, &target_file));
2605 }
2606
2607 #[test]
2608 fn matcher_honors_negation_pattern() {
2609 let tmp = TempDir::new().unwrap();
2611 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
2612 let random_log = tmp.path().join("random.log");
2613 let important_log = tmp.path().join("important.log");
2614 fs::write(&random_log, "x").unwrap();
2615 fs::write(&important_log, "y").unwrap();
2616
2617 let ctx = make_ctx_with_root(tmp.path());
2618 ctx.rebuild_gitignore();
2619
2620 assert!(is_ignored(&ctx, &random_log));
2621 assert!(
2622 !is_ignored(&ctx, &important_log),
2623 "negation pattern should un-ignore important.log"
2624 );
2625 }
2626
2627 #[test]
2628 fn rebuild_picks_up_gitignore_changes() {
2629 let tmp = TempDir::new().unwrap();
2630 let ignore_path = tmp.path().join(".gitignore");
2631 fs::write(&ignore_path, "foo.txt\n").unwrap();
2632 let foo = tmp.path().join("foo.txt");
2633 let bar = tmp.path().join("bar.txt");
2634 fs::write(&foo, "").unwrap();
2635 fs::write(&bar, "").unwrap();
2636
2637 let ctx = make_ctx_with_root(tmp.path());
2638 ctx.rebuild_gitignore();
2639 assert!(is_ignored(&ctx, &foo));
2640 assert!(!is_ignored(&ctx, &bar));
2641
2642 fs::write(&ignore_path, "bar.txt\n").unwrap();
2644 ctx.rebuild_gitignore();
2645 assert!(!is_ignored(&ctx, &foo));
2646 assert!(is_ignored(&ctx, &bar));
2647 }
2648
2649 #[test]
2650 fn gitignore_loads_info_exclude_when_present() {
2651 let tmp = TempDir::new().unwrap();
2652 let info_dir = tmp.path().join(".git/info");
2653 fs::create_dir_all(&info_dir).unwrap();
2654 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
2655 let secrets = tmp.path().join("secrets.txt");
2656 let public = tmp.path().join("public.txt");
2657 fs::write(&secrets, "token").unwrap();
2658 fs::write(&public, "ok").unwrap();
2659
2660 let ctx = make_ctx_with_root(tmp.path());
2661 ctx.rebuild_gitignore();
2662
2663 assert!(is_ignored(&ctx, &secrets));
2664 assert!(!is_ignored(&ctx, &public));
2665 }
2666
2667 #[test]
2668 fn matcher_picks_up_nested_gitignore() {
2669 let tmp = TempDir::new().unwrap();
2670 fs::write(tmp.path().join(".gitignore"), "").unwrap();
2672 let sub = tmp.path().join("packages/foo");
2673 fs::create_dir_all(&sub).unwrap();
2674 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
2675 let generated_file = sub.join("generated").join("out.js");
2676 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
2677 fs::write(&generated_file, "x").unwrap();
2678
2679 let ctx = make_ctx_with_root(tmp.path());
2680 ctx.rebuild_gitignore();
2681
2682 assert!(
2683 is_ignored(&ctx, &generated_file),
2684 "nested gitignore in packages/foo/.gitignore should ignore generated/"
2685 );
2686 }
2687}