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 AppContext {
621 provider,
622 backup: RefCell::new(BackupStore::new()),
623 checkpoint: RefCell::new(CheckpointStore::new()),
624 db: RefCell::new(None),
625 config: RefCell::new(config),
626 harness: RefCell::new(None),
627 canonical_cache_root: RefCell::new(None),
628 is_worktree_bridge: RefCell::new(false),
629 git_common_dir: RefCell::new(None),
630 degraded_reasons: RefCell::new(Vec::new()),
631 callgraph: RefCell::new(None),
632 callgraph_store: RefCell::new(None),
633 callgraph_store_force_rebuild: RefCell::new(false),
634 callgraph_store_rx: RefCell::new(None),
635 pending_callgraph_store_paths: RefCell::new(BTreeSet::new()),
636 search_index: RefCell::new(None),
637 search_index_rx: RefCell::new(None),
638 pending_search_index_paths: RefCell::new(BTreeSet::new()),
639 symbol_cache,
640 inspect_manager: Arc::new(InspectManager::new()),
641 tier2_refresh_scheduler: RefCell::new(Tier2RefreshScheduler::new()),
642 semantic_index: RefCell::new(None),
643 semantic_index_rx: RefCell::new(None),
644 semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
645 pending_semantic_index_paths: RefCell::new(BTreeSet::new()),
646 pending_semantic_corpus_refresh: RefCell::new(false),
647 semantic_refresh_tx: RefCell::new(None),
648 semantic_refresh_event_rx: RefCell::new(None),
649 semantic_refresh_worker: RefCell::new(None),
650 semantic_embedding_model: RefCell::new(None),
651 watcher: RefCell::new(None),
652 watcher_rx: RefCell::new(None),
653 lsp_manager: RefCell::new(lsp_manager),
654 lsp_child_registry,
655 stdout_writer,
656 progress_sender: Arc::clone(&progress_sender),
657 configure_generation: AtomicU64::new(0),
658 last_seen_reuse_completions: AtomicU64::new(0),
659 configure_warnings_tx,
660 configure_warnings_rx,
661 status_emitter,
662 bash_background: BgTaskRegistry::new(progress_sender),
663 filter_registry: Arc::new(std::sync::RwLock::new(
664 crate::compress::toml_filter::FilterRegistry::default(),
665 )),
666 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
667 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
668 gitignore: RefCell::new(None),
669 status_bar_tier2: RefCell::new(StatusBarTier2::default()),
670 tsconfig_membership: RefCell::new(
671 crate::lsp::tsconfig_membership::TsconfigMembershipCache::new(),
672 ),
673 }
674 }
675
676 pub fn status_bar_counts(&self) -> Option<StatusBarCounts> {
682 let tier2 = self.status_bar_tier2.borrow();
683 let (Some(dead_code), Some(unused_exports), Some(duplicates)) =
687 (tier2.dead_code, tier2.unused_exports, tier2.duplicates)
688 else {
689 return None;
690 };
691 let (errors, warnings) = self.status_bar_error_warning_counts();
692 Some(StatusBarCounts {
693 errors,
694 warnings,
695 dead_code,
696 unused_exports,
697 duplicates,
698 todos: tier2.todos.unwrap_or(0),
699 tier2_stale: tier2.stale,
700 })
701 }
702
703 fn status_bar_error_warning_counts(&self) -> (usize, usize) {
709 let Some(root) = self.canonical_cache_root_opt() else {
710 return self.lsp_manager.borrow().warm_error_warning_counts();
713 };
714 let mut membership = self.tsconfig_membership.borrow_mut();
715 self.lsp_manager
716 .borrow()
717 .filtered_error_warning_counts(|file| {
718 file.starts_with(&root) && !membership.should_skip_diagnostics(file)
719 })
720 }
721
722 pub fn clear_tsconfig_membership_cache(&self) {
726 self.tsconfig_membership.borrow_mut().clear();
727 }
728
729 pub fn mark_status_bar_tier2_stale(&self) -> bool {
735 let mut tier2 = self.status_bar_tier2.borrow_mut();
736 if tier2.dead_code.is_some() && tier2.unused_exports.is_some() && tier2.duplicates.is_some()
738 {
739 let changed = !tier2.stale;
740 tier2.stale = true;
741 return changed;
742 }
743 false
744 }
745
746 pub fn update_status_bar_tier2(
752 &self,
753 dead_code: Option<usize>,
754 unused_exports: Option<usize>,
755 duplicates: Option<usize>,
756 todos: Option<usize>,
757 stale: bool,
758 ) {
759 let mut tier2 = self.status_bar_tier2.borrow_mut();
760 if let Some(dead_code) = dead_code {
761 tier2.dead_code = Some(dead_code);
762 }
763 if let Some(unused_exports) = unused_exports {
764 tier2.unused_exports = Some(unused_exports);
765 }
766 if let Some(duplicates) = duplicates {
767 tier2.duplicates = Some(duplicates);
768 }
769 if let Some(todos) = todos {
770 tier2.todos = Some(todos);
771 }
772 tier2.stale = stale;
773 }
774
775 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
778 self.gitignore.borrow().clone()
779 }
780
781 pub fn clear_gitignore(&self) {
803 *self.gitignore.borrow_mut() = None;
804 }
805
806 pub fn rebuild_gitignore(&self) {
807 use ignore::gitignore::GitignoreBuilder;
808 use std::path::Path;
809 let root_raw = match self.config().project_root.clone() {
810 Some(r) => r,
811 None => {
812 *self.gitignore.borrow_mut() = None;
813 return;
814 }
815 };
816 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
824 let mut builder = GitignoreBuilder::new(&root);
825 if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
830 if global_ignore.is_file() {
831 if let Some(err) = builder.add(&global_ignore) {
832 crate::slog_warn!(
833 "global gitignore parse error in {}: {}",
834 global_ignore.display(),
835 err
836 );
837 }
838 }
839 }
840 let root_ignore = Path::new(&root).join(".gitignore");
842 if root_ignore.exists() {
843 if let Some(err) = builder.add(&root_ignore) {
844 crate::slog_warn!(
845 "gitignore parse error in {}: {}",
846 root_ignore.display(),
847 err
848 );
849 }
850 }
851 let root_aftignore = Path::new(&root).join(".aftignore");
856 if root_aftignore.exists() {
857 if let Some(err) = builder.add(&root_aftignore) {
858 crate::slog_warn!(
859 "aftignore parse error in {}: {}",
860 root_aftignore.display(),
861 err
862 );
863 }
864 }
865 let info_exclude = self
870 .git_common_dir
871 .borrow()
872 .clone()
873 .unwrap_or_else(|| Path::new(&root).join(".git"))
874 .join("info")
875 .join("exclude");
876 if info_exclude.exists() {
877 if let Some(err) = builder.add(&info_exclude) {
878 crate::slog_warn!(
879 "gitignore parse error in {}: {}",
880 info_exclude.display(),
881 err
882 );
883 }
884 }
885 let walker = ignore::WalkBuilder::new(&root)
891 .standard_filters(true)
892 .hidden(false)
900 .filter_entry(|entry| {
901 let name = entry.file_name().to_string_lossy();
902 !matches!(
903 name.as_ref(),
904 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
905 )
906 })
907 .build();
908 for entry in walker.flatten() {
909 let file_name = entry.file_name();
910 let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
911 let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
912 if is_nested_gitignore || is_nested_aftignore {
913 if let Some(err) = builder.add(entry.path()) {
914 crate::slog_warn!(
915 "nested ignore parse error in {}: {}",
916 entry.path().display(),
917 err
918 );
919 }
920 }
921 }
922 match builder.build() {
923 Ok(gi) => {
924 let count = gi.num_ignores();
925 if count > 0 {
926 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
927 *self.gitignore.borrow_mut() = Some(Arc::new(gi));
928 } else {
929 *self.gitignore.borrow_mut() = None;
930 }
931 }
932 Err(err) => {
933 crate::slog_warn!("gitignore matcher build failed: {}", err);
934 *self.gitignore.borrow_mut() = None;
935 }
936 }
937 }
938
939 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
942 Arc::clone(&self.bash_compress_flag)
943 }
944
945 pub fn sync_bash_compress_flag(&self) {
949 let value = self.config().experimental_bash_compress;
950 self.bash_compress_flag
951 .store(value, std::sync::atomic::Ordering::Relaxed);
952 }
953
954 pub fn set_bash_compress_enabled(&self, enabled: bool) {
955 self.config_mut().experimental_bash_compress = enabled;
956 self.bash_compress_flag
957 .store(enabled, std::sync::atomic::Ordering::Relaxed);
958 }
959
960 pub fn filter_registry(
964 &self,
965 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
966 self.ensure_filter_registry_loaded();
967 match self.filter_registry.read() {
968 Ok(g) => g,
969 Err(poisoned) => poisoned.into_inner(),
970 }
971 }
972
973 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
977 self.ensure_filter_registry_loaded();
978 Arc::clone(&self.filter_registry)
979 }
980
981 pub fn reset_filter_registry(&self) {
985 let new_registry = crate::compress::build_registry_for_context(self);
986 match self.filter_registry.write() {
987 Ok(mut slot) => *slot = new_registry,
988 Err(poisoned) => *poisoned.into_inner() = new_registry,
989 }
990 self.filter_registry_loaded
991 .store(true, std::sync::atomic::Ordering::Release);
992 }
993
994 fn ensure_filter_registry_loaded(&self) {
995 use std::sync::atomic::Ordering;
996 if self.filter_registry_loaded.load(Ordering::Acquire) {
997 return;
998 }
999 let new_registry = crate::compress::build_registry_for_context(self);
1002 if let Ok(mut slot) = self.filter_registry.write() {
1003 *slot = new_registry;
1004 self.filter_registry_loaded.store(true, Ordering::Release);
1005 }
1006 }
1007
1008 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
1011 self.lsp_child_registry.clone()
1012 }
1013
1014 pub fn stdout_writer(&self) -> SharedStdoutWriter {
1015 Arc::clone(&self.stdout_writer)
1016 }
1017
1018 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
1019 if let Ok(mut progress_sender) = self.progress_sender.lock() {
1020 *progress_sender = sender;
1021 }
1022 }
1023
1024 pub fn emit_progress(&self, frame: ProgressFrame) {
1025 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
1026 return;
1027 };
1028 if let Some(sender) = progress_sender.as_ref() {
1029 sender(PushFrame::Progress(frame));
1030 }
1031 }
1032
1033 pub fn status_emitter(&self) -> &StatusEmitter {
1034 &self.status_emitter
1035 }
1036
1037 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
1045 self.progress_sender
1046 .lock()
1047 .ok()
1048 .and_then(|sender| sender.clone())
1049 }
1050
1051 pub fn advance_configure_generation(&self) -> u64 {
1052 self.configure_generation
1053 .fetch_add(1, Ordering::SeqCst)
1054 .wrapping_add(1)
1055 }
1056
1057 pub fn configure_generation(&self) -> u64 {
1058 self.configure_generation.load(Ordering::SeqCst)
1059 }
1060
1061 pub fn configure_warnings_sender(&self) -> mpsc::Sender<(u64, ConfigureWarningsFrame)> {
1062 self.configure_warnings_tx.clone()
1063 }
1064
1065 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
1066 let mut warnings = Vec::new();
1067 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
1068 warnings.push(warning);
1069 }
1070 warnings
1071 }
1072
1073 pub fn bash_background(&self) -> &BgTaskRegistry {
1074 &self.bash_background
1075 }
1076
1077 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
1078 self.bash_background.drain_completions()
1079 }
1080
1081 pub fn provider(&self) -> &dyn LanguageProvider {
1083 self.provider.as_ref()
1084 }
1085
1086 pub fn backup(&self) -> &RefCell<BackupStore> {
1088 &self.backup
1089 }
1090
1091 pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
1093 &self.checkpoint
1094 }
1095
1096 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
1097 *self.db.borrow_mut() = Some(conn);
1098 }
1099
1100 pub fn clear_db(&self) {
1101 *self.db.borrow_mut() = None;
1102 }
1103
1104 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
1105 self.db.borrow().clone()
1106 }
1107
1108 pub fn config(&self) -> Ref<'_, Config> {
1110 self.config.borrow()
1111 }
1112
1113 pub fn config_mut(&self) -> RefMut<'_, Config> {
1115 self.config.borrow_mut()
1116 }
1117
1118 pub fn set_harness(&self, harness: Harness) {
1119 *self.harness.borrow_mut() = Some(harness);
1120 self.bash_background.set_harness(harness);
1121 }
1122
1123 pub fn harness_opt(&self) -> Option<Harness> {
1124 *self.harness.borrow()
1125 }
1126
1127 pub fn harness(&self) -> Harness {
1128 self.harness_opt()
1129 .expect("harness set by configure before any tool call")
1130 }
1131
1132 pub fn storage_dir(&self) -> PathBuf {
1133 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
1134 }
1135
1136 pub fn harness_dir(&self) -> PathBuf {
1137 self.storage_dir().join(self.harness().as_str())
1138 }
1139
1140 pub fn inspect_dir(&self) -> PathBuf {
1141 self.harness_dir().join("inspect")
1142 }
1143
1144 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
1145 self.harness_dir()
1146 .join("bash-tasks")
1147 .join(hash_session(session_id))
1148 }
1149
1150 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
1151 self.harness_dir()
1152 .join("backups")
1153 .join(hash_session(session_id))
1154 .join(path_hash)
1155 }
1156
1157 pub fn filters_dir(&self) -> PathBuf {
1158 self.harness_dir().join("filters")
1159 }
1160
1161 pub fn trust_file(&self) -> PathBuf {
1163 self.storage_dir().join("trusted-filter-projects.json")
1164 }
1165
1166 pub fn set_canonical_cache_root(&self, root: PathBuf) {
1167 debug_assert!(root.is_absolute());
1168 *self.canonical_cache_root.borrow_mut() = Some(root);
1169 }
1170
1171 pub fn canonical_cache_root(&self) -> PathBuf {
1172 self.canonical_cache_root
1173 .borrow()
1174 .clone()
1175 .expect("canonical_cache_root accessed before handle_configure")
1176 }
1177
1178 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
1179 self.canonical_cache_root.borrow().clone()
1180 }
1181
1182 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
1183 *self.is_worktree_bridge.borrow_mut() = is_worktree_bridge;
1184 *self.git_common_dir.borrow_mut() = git_common_dir;
1185 }
1186
1187 pub fn is_worktree_bridge(&self) -> bool {
1188 *self.is_worktree_bridge.borrow()
1189 }
1190
1191 pub fn git_common_dir(&self) -> Option<PathBuf> {
1192 self.git_common_dir.borrow().clone()
1193 }
1194
1195 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
1199 *self.degraded_reasons.borrow_mut() = reasons;
1200 }
1201
1202 pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
1203 let reason = reason.into();
1204 let mut reasons = self.degraded_reasons.borrow_mut();
1205 if reasons.iter().any(|existing| existing == &reason) {
1206 return false;
1207 }
1208 reasons.push(reason);
1209 true
1210 }
1211
1212 pub fn degraded_reasons(&self) -> Vec<String> {
1216 self.degraded_reasons.borrow().clone()
1217 }
1218
1219 pub fn is_degraded(&self) -> bool {
1221 !self.degraded_reasons.borrow().is_empty()
1222 }
1223
1224 pub fn cache_role(&self) -> &'static str {
1225 if self.canonical_cache_root.borrow().is_none() {
1226 "not_initialized"
1227 } else if self.is_worktree_bridge() {
1228 "worktree"
1229 } else {
1230 "main"
1231 }
1232 }
1233
1234 pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
1236 &self.callgraph
1237 }
1238
1239 pub fn callgraph_store(&self) -> &RefCell<Option<CallGraphStore>> {
1241 &self.callgraph_store
1242 }
1243
1244 pub fn mark_callgraph_store_force_rebuild(&self) {
1245 *self.callgraph_store_force_rebuild.borrow_mut() = true;
1246 }
1247
1248 fn take_callgraph_store_force_rebuild(&self) -> bool {
1249 let force = *self.callgraph_store_force_rebuild.borrow();
1250 *self.callgraph_store_force_rebuild.borrow_mut() = false;
1251 force
1252 }
1253
1254 pub fn callgraph_store_dir(&self) -> PathBuf {
1255 match self.harness_opt() {
1256 Some(harness) => self.storage_dir().join(harness.as_str()).join("callgraph"),
1257 None => self.storage_dir().join("callgraph"),
1258 }
1259 }
1260
1261 pub fn ensure_callgraph_store(
1262 &self,
1263 ) -> Result<Option<RefMut<'_, CallGraphStore>>, CallGraphStoreError> {
1264 self.ensure_callgraph_store_with_flag(true)
1265 }
1266
1267 fn ensure_callgraph_store_with_flag(
1268 &self,
1269 respect_config_flag: bool,
1270 ) -> Result<Option<RefMut<'_, CallGraphStore>>, CallGraphStoreError> {
1271 if respect_config_flag && !self.config().callgraph_store {
1272 return Ok(None);
1273 }
1274 if self.callgraph_store.borrow().is_none() {
1275 let Some(project_root) = self.callgraph_project_root() else {
1276 return Ok(None);
1277 };
1278 let callgraph_dir = self.callgraph_store_dir();
1279 let force_rebuild = self.take_callgraph_store_force_rebuild();
1280 let store = if self.is_worktree_bridge() {
1281 CallGraphStore::open_readonly(callgraph_dir, project_root)?
1282 } else if force_rebuild {
1283 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1284 let (store, _stats) =
1285 CallGraphStore::cold_build_with_lease(callgraph_dir, project_root, &files)?;
1286 Some(store)
1287 } else if CallGraphStore::needs_cold_build(&callgraph_dir, &project_root)? {
1288 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1289 let (store, _stats) =
1290 CallGraphStore::ensure_built_with_lease(callgraph_dir, project_root, &files)?;
1291 Some(store)
1292 } else {
1293 Some(CallGraphStore::open(callgraph_dir, project_root)?)
1294 };
1295 *self.callgraph_store.borrow_mut() = store;
1296 }
1297 let borrow = self.callgraph_store.borrow_mut();
1298 Ok(RefMut::filter_map(borrow, Option::as_mut).ok())
1299 }
1300
1301 fn callgraph_project_root(&self) -> Option<PathBuf> {
1304 self.canonical_cache_root_opt().or_else(|| {
1305 self.config()
1306 .project_root
1307 .clone()
1308 .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
1309 })
1310 }
1311
1312 pub fn revalidate_callgraph_store_generation(&self) {
1330 if self.callgraph_store_rx.borrow().is_some() {
1333 return;
1334 }
1335 let superseded = self
1336 .callgraph_store
1337 .borrow()
1338 .as_ref()
1339 .is_some_and(|store| !store.is_current());
1340 if superseded {
1341 *self.callgraph_store.borrow_mut() = None;
1342 }
1343 }
1344
1345 pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess<'_> {
1346 self.revalidate_callgraph_store_generation();
1350 if self.callgraph_store.borrow().is_some() {
1351 let borrow = self.callgraph_store.borrow_mut();
1352 return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1353 Some(store) => CallgraphStoreAccess::Ready(store),
1354 None => CallgraphStoreAccess::Unavailable,
1355 };
1356 }
1357
1358 if self.callgraph_store_rx.borrow().is_some() {
1360 return CallgraphStoreAccess::Building;
1361 }
1362
1363 let Some(project_root) = self.callgraph_project_root() else {
1364 return CallgraphStoreAccess::Unavailable;
1365 };
1366 let callgraph_dir = self.callgraph_store_dir();
1367
1368 if self.is_worktree_bridge() {
1371 match CallGraphStore::open_readonly(callgraph_dir, project_root) {
1372 Ok(Some(store)) => {
1373 *self.callgraph_store.borrow_mut() = Some(store);
1374 let borrow = self.callgraph_store.borrow_mut();
1375 return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1376 Some(store) => CallgraphStoreAccess::Ready(store),
1377 None => CallgraphStoreAccess::Unavailable,
1378 };
1379 }
1380 Ok(None) | Err(_) => return CallgraphStoreAccess::Unavailable,
1381 }
1382 }
1383
1384 let force_rebuild = *self.callgraph_store_force_rebuild.borrow();
1385 if !force_rebuild {
1388 match CallGraphStore::needs_cold_build(&callgraph_dir, &project_root) {
1389 Ok(false) => match CallGraphStore::open(callgraph_dir, project_root) {
1390 Ok(store) => {
1391 *self.callgraph_store.borrow_mut() = Some(store);
1392 let borrow = self.callgraph_store.borrow_mut();
1393 return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1394 Some(store) => CallgraphStoreAccess::Ready(store),
1395 None => CallgraphStoreAccess::Unavailable,
1396 };
1397 }
1398 Err(error) => return CallgraphStoreAccess::Error(error),
1399 },
1400 Ok(true) => {}
1401 Err(error) => return CallgraphStoreAccess::Error(error),
1402 }
1403 }
1404
1405 self.spawn_callgraph_store_cold_build(project_root, callgraph_dir, force_rebuild);
1413
1414 let wait = callgraph_build_wait_window();
1415 if !wait.is_zero() {
1416 let received = {
1417 let rx_ref = self.callgraph_store_rx.borrow();
1418 let Some(rx) = rx_ref.as_ref() else {
1419 return CallgraphStoreAccess::Building;
1420 };
1421 rx.recv_timeout(wait)
1422 };
1423 match received {
1424 Ok(store) => {
1425 let pending = self.take_pending_callgraph_store_paths();
1429 if !pending.is_empty() {
1430 if let Err(error) = store.refresh_files(&pending) {
1431 crate::slog_warn!(
1432 "callgraph store inline post-build refresh failed: {}",
1433 error
1434 );
1435 let _ = store.mark_files_stale(&pending);
1436 }
1437 }
1438 *self.callgraph_store.borrow_mut() = Some(store);
1439 *self.callgraph_store_rx.borrow_mut() = None;
1440 let borrow = self.callgraph_store.borrow_mut();
1441 return match RefMut::filter_map(borrow, Option::as_mut).ok() {
1442 Some(store) => CallgraphStoreAccess::Ready(store),
1443 None => CallgraphStoreAccess::Unavailable,
1444 };
1445 }
1446 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1447 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
1448 *self.callgraph_store_rx.borrow_mut() = None;
1451 }
1452 }
1453 }
1454 CallgraphStoreAccess::Building
1455 }
1456
1457 fn spawn_callgraph_store_cold_build(
1462 &self,
1463 project_root: PathBuf,
1464 callgraph_dir: PathBuf,
1465 force_rebuild: bool,
1466 ) {
1467 if force_rebuild {
1468 self.take_callgraph_store_force_rebuild();
1471 }
1472 let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStore>();
1473 *self.callgraph_store_rx.borrow_mut() = Some(rx);
1474 let session_id = crate::log_ctx::current_session();
1475 std::thread::spawn(move || {
1476 crate::log_ctx::with_session(session_id, || {
1477 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1478 let built = if force_rebuild {
1479 CallGraphStore::cold_build_with_lease(callgraph_dir, project_root, &files)
1480 .map(|(store, _)| store)
1481 } else {
1482 CallGraphStore::ensure_built_with_lease(callgraph_dir, project_root, &files)
1483 .map(|(store, _)| store)
1484 };
1485 match built {
1486 Ok(store) => {
1487 let _ = tx.send(store);
1488 }
1489 Err(error) => {
1490 crate::slog_warn!("callgraph store cold build failed: {}", error);
1491 }
1494 }
1495 });
1496 });
1497 }
1498
1499 pub fn callgraph_store_rx(
1502 &self,
1503 ) -> &RefCell<Option<crossbeam_channel::Receiver<CallGraphStore>>> {
1504 &self.callgraph_store_rx
1505 }
1506
1507 pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
1510 where
1511 I: IntoIterator<Item = PathBuf>,
1512 {
1513 self.pending_callgraph_store_paths
1514 .borrow_mut()
1515 .extend(paths);
1516 }
1517
1518 pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
1520 std::mem::take(&mut *self.pending_callgraph_store_paths.borrow_mut())
1521 .into_iter()
1522 .collect()
1523 }
1524
1525 pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
1527 &self.search_index
1528 }
1529
1530 pub fn search_index_rx(&self) -> &RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>> {
1532 &self.search_index_rx
1533 }
1534
1535 pub fn add_pending_search_index_paths<I>(&self, paths: I)
1536 where
1537 I: IntoIterator<Item = PathBuf>,
1538 {
1539 self.pending_search_index_paths.borrow_mut().extend(paths);
1540 }
1541
1542 pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
1543 std::mem::take(&mut *self.pending_search_index_paths.borrow_mut())
1544 .into_iter()
1545 .collect()
1546 }
1547
1548 pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
1549 where
1550 I: IntoIterator<Item = PathBuf>,
1551 {
1552 self.pending_semantic_index_paths.borrow_mut().extend(paths);
1553 }
1554
1555 pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
1556 std::mem::take(&mut *self.pending_semantic_index_paths.borrow_mut())
1557 .into_iter()
1558 .collect()
1559 }
1560
1561 pub fn mark_pending_semantic_corpus_refresh(&self) {
1562 *self.pending_semantic_corpus_refresh.borrow_mut() = true;
1563 }
1564
1565 pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
1566 std::mem::take(&mut *self.pending_semantic_corpus_refresh.borrow_mut())
1567 }
1568
1569 pub fn clear_pending_index_updates(&self) {
1570 self.pending_search_index_paths.borrow_mut().clear();
1571 self.pending_callgraph_store_paths.borrow_mut().clear();
1572 self.pending_semantic_index_paths.borrow_mut().clear();
1573 *self.pending_semantic_corpus_refresh.borrow_mut() = false;
1574 }
1575
1576 pub fn inspect_manager(&self) -> Arc<InspectManager> {
1577 Arc::clone(&self.inspect_manager)
1578 }
1579
1580 pub fn take_new_reuse_completions(&self) -> bool {
1585 let current = self.inspect_manager.reuse_completion_count();
1586 let previous = self
1587 .last_seen_reuse_completions
1588 .swap(current, Ordering::SeqCst);
1589 current != previous
1590 }
1591
1592 pub fn reset_tier2_refresh_scheduler(&self) {
1593 self.reset_tier2_refresh_scheduler_at(Instant::now());
1594 }
1595
1596 #[doc(hidden)]
1597 pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
1598 self.tier2_refresh_scheduler
1599 .borrow_mut()
1600 .reset_after_configure(now);
1601 }
1602
1603 pub fn request_tier2_refresh_pull(&self) -> bool {
1604 self.tier2_refresh_scheduler
1605 .borrow_mut()
1606 .request_pull(!self.is_worktree_bridge())
1607 }
1608
1609 pub fn tick_tier2_refresh_scheduler(
1610 &self,
1611 changed_path_count: usize,
1612 ) -> Option<Tier2TriggerReason> {
1613 self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
1614 }
1615
1616 #[doc(hidden)]
1617 pub fn tick_tier2_refresh_scheduler_at(
1618 &self,
1619 now: Instant,
1620 changed_path_count: usize,
1621 ) -> Option<Tier2TriggerReason> {
1622 let manager = self.inspect_manager();
1623 let can_write = !self.is_worktree_bridge();
1624 let in_flight = manager.tier2_any_in_flight();
1625 let decision = self.tier2_refresh_scheduler.borrow_mut().tick(
1626 now,
1627 changed_path_count,
1628 can_write,
1629 in_flight,
1630 );
1631
1632 if let Some(reason) = decision {
1633 self.start_tier2_refresh(reason, manager);
1634 }
1635
1636 decision
1637 }
1638
1639 pub fn note_tier2_refresh_started(&self) {
1640 self.note_tier2_refresh_started_at(Instant::now());
1641 }
1642
1643 #[doc(hidden)]
1644 pub fn note_tier2_refresh_started_at(&self, now: Instant) {
1645 self.tier2_refresh_scheduler
1646 .borrow_mut()
1647 .note_external_scan_started(now);
1648 }
1649
1650 pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
1651 self.tier2_refresh_scheduler
1652 .borrow()
1653 .last_trigger_reason()
1654 .map(Tier2TriggerReason::as_str)
1655 }
1656
1657 #[doc(hidden)]
1658 pub fn tier2_pull_demand_pending(&self) -> bool {
1659 self.tier2_refresh_scheduler.borrow().pull_demand_pending()
1660 }
1661
1662 fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
1663 if self.is_worktree_bridge()
1664 || self
1665 .degraded_reasons
1666 .borrow()
1667 .iter()
1668 .any(|r| r == "home_root")
1669 || !self.config().inspect.enabled
1670 {
1671 return;
1672 }
1673 let Some(snapshot) = self.tier2_refresh_snapshot() else {
1674 return;
1675 };
1676 let categories = InspectCategory::active()
1677 .iter()
1678 .copied()
1679 .filter(|category| category.is_tier2())
1680 .collect::<Vec<_>>();
1681 let submission =
1682 manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
1683 if submission.has_new_work() {
1684 crate::slog_info!(
1685 "tier2 refresh scheduled: reason={}, categories={:?}",
1686 reason.as_str(),
1687 submission
1688 .newly_queued_categories
1689 .iter()
1690 .map(|category| category.as_str())
1691 .collect::<Vec<_>>()
1692 );
1693 }
1694 for error in submission.errors {
1695 crate::slog_warn!(
1696 "tier2 refresh schedule failed for {}: {}",
1697 error.category,
1698 error.message
1699 );
1700 }
1701 }
1702
1703 fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
1704 self.harness_opt()?;
1705 let config = self.config().clone();
1706 let project_root = config
1707 .project_root
1708 .clone()
1709 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1710 let project_root = std::fs::canonicalize(&project_root).unwrap_or(project_root);
1711 Some(InspectSnapshot::new(
1712 project_root,
1713 self.inspect_dir(),
1714 Arc::new(config),
1715 self.symbol_cache(),
1716 ))
1717 }
1718
1719 pub fn symbol_cache(&self) -> SharedSymbolCache {
1721 Arc::clone(&self.symbol_cache)
1722 }
1723
1724 pub fn reset_symbol_cache(&self) -> u64 {
1726 self.symbol_cache
1727 .write()
1728 .map(|mut cache| cache.reset())
1729 .unwrap_or(0)
1730 }
1731
1732 pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
1734 &self.semantic_index
1735 }
1736
1737 pub fn semantic_index_rx(
1739 &self,
1740 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
1741 &self.semantic_index_rx
1742 }
1743
1744 pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
1745 &self.semantic_index_status
1746 }
1747
1748 pub fn install_semantic_refresh_worker(
1749 &self,
1750 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
1751 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
1752 worker_slot: SemanticRefreshWorkerSlot,
1753 ) {
1754 self.clear_semantic_refresh_worker();
1755 *self.semantic_refresh_tx.borrow_mut() = Some(sender);
1756 *self.semantic_refresh_event_rx.borrow_mut() = Some(event_rx);
1757 *self.semantic_refresh_worker.borrow_mut() = Some(worker_slot);
1758 }
1759
1760 pub fn clear_semantic_refresh_worker(&self) {
1761 *self.semantic_refresh_tx.borrow_mut() = None;
1762 *self.semantic_refresh_event_rx.borrow_mut() = None;
1763 if let Some(worker_slot) = self.semantic_refresh_worker.borrow_mut().take() {
1764 if let Ok(mut handle) = worker_slot.lock() {
1765 drop(handle.take());
1766 }
1767 }
1768 }
1769
1770 pub fn semantic_refresh_sender(
1771 &self,
1772 ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
1773 self.semantic_refresh_tx.borrow().clone()
1774 }
1775
1776 pub fn semantic_refresh_event_rx(
1777 &self,
1778 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
1779 &self.semantic_refresh_event_rx
1780 }
1781
1782 pub fn semantic_embedding_model(
1784 &self,
1785 ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
1786 &self.semantic_embedding_model
1787 }
1788
1789 pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
1791 &self.watcher
1792 }
1793
1794 pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
1796 &self.watcher_rx
1797 }
1798
1799 pub fn lsp(&self) -> RefMut<'_, LspManager> {
1801 self.lsp_manager.borrow_mut()
1802 }
1803
1804 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
1807 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1808 let config = self.config();
1809 if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
1810 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1811 }
1812 }
1813 }
1814
1815 pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
1821 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1822 lsp.clear_diagnostics_for_file(file_path)
1823 } else {
1824 false
1825 }
1826 }
1827
1828 pub fn lsp_notify_and_collect_diagnostics(
1839 &self,
1840 file_path: &Path,
1841 content: &str,
1842 timeout: std::time::Duration,
1843 ) -> crate::lsp::manager::PostEditWaitOutcome {
1844 let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
1845 return crate::lsp::manager::PostEditWaitOutcome::default();
1846 };
1847
1848 lsp.drain_events();
1851
1852 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
1856
1857 let config = self.config();
1859 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
1860 {
1861 Ok(v) => v,
1862 Err(e) => {
1863 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1864 return crate::lsp::manager::PostEditWaitOutcome::default();
1865 }
1866 };
1867
1868 if expected_versions.is_empty() {
1871 return crate::lsp::manager::PostEditWaitOutcome::default();
1872 }
1873
1874 lsp.wait_for_post_edit_diagnostics(
1875 file_path,
1876 &config,
1877 &expected_versions,
1878 &pre_snapshot,
1879 timeout,
1880 )
1881 }
1882
1883 fn custom_lsp_root_markers(&self) -> Vec<String> {
1886 self.config()
1887 .lsp_servers
1888 .iter()
1889 .flat_map(|s| s.root_markers.iter().cloned())
1890 .collect()
1891 }
1892
1893 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
1894 let custom_markers = self.custom_lsp_root_markers();
1895 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
1896 .iter()
1897 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
1898 .cloned()
1899 .map(|path| {
1900 let change_type = if path.exists() {
1901 FileChangeType::CHANGED
1902 } else {
1903 FileChangeType::DELETED
1904 };
1905 (path, change_type)
1906 })
1907 .collect();
1908
1909 self.notify_watched_config_events(&config_paths);
1910 }
1911
1912 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
1913 let paths = params
1914 .get("multi_file_write_paths")
1915 .and_then(|value| value.as_array())?
1916 .iter()
1917 .filter_map(|value| value.as_str())
1918 .map(PathBuf::from)
1919 .collect::<Vec<_>>();
1920
1921 (!paths.is_empty()).then_some(paths)
1922 }
1923
1924 fn watched_file_events_from_params(
1936 params: &serde_json::Value,
1937 extra_markers: &[String],
1938 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
1939 let events = params
1940 .get("multi_file_write_paths")
1941 .and_then(|value| value.as_array())?
1942 .iter()
1943 .filter_map(|entry| {
1944 let path = entry
1946 .get("path")
1947 .and_then(|value| value.as_str())
1948 .map(PathBuf::from)?;
1949
1950 if !is_config_file_path_with_custom(&path, extra_markers) {
1951 return None;
1952 }
1953
1954 let change_type = entry
1955 .get("type")
1956 .and_then(|value| value.as_str())
1957 .and_then(Self::parse_file_change_type)
1958 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
1959
1960 Some((path, change_type))
1961 })
1962 .collect::<Vec<_>>();
1963
1964 (!events.is_empty()).then_some(events)
1965 }
1966
1967 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
1968 match value {
1969 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
1970 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
1971 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
1972 _ => None,
1973 }
1974 }
1975
1976 fn change_type_from_current_state(path: &Path) -> FileChangeType {
1977 if path.exists() {
1978 FileChangeType::CHANGED
1979 } else {
1980 FileChangeType::DELETED
1981 }
1982 }
1983
1984 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
1985 if config_paths.is_empty() {
1986 return;
1987 }
1988
1989 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1990 let config = self.config();
1991 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
1992 crate::slog_warn!("watched-file sync error: {}", e);
1993 }
1994 }
1995 }
1996
1997 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
1998 let custom_markers = self.custom_lsp_root_markers();
1999 if !is_config_file_path_with_custom(file_path, &custom_markers) {
2000 return;
2001 }
2002
2003 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
2004 }
2005
2006 pub fn lsp_post_multi_file_write(
2011 &self,
2012 file_path: &Path,
2013 content: &str,
2014 file_paths: &[PathBuf],
2015 params: &serde_json::Value,
2016 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2017 self.notify_watched_config_files(file_paths);
2018
2019 let wants_diagnostics = params
2020 .get("diagnostics")
2021 .and_then(|v| v.as_bool())
2022 .unwrap_or(false);
2023
2024 if !wants_diagnostics {
2025 self.lsp_notify_file_changed(file_path, content);
2026 return None;
2027 }
2028
2029 let wait_ms = params
2030 .get("wait_ms")
2031 .and_then(|v| v.as_u64())
2032 .unwrap_or(3000)
2033 .min(10_000);
2034
2035 Some(self.lsp_notify_and_collect_diagnostics(
2036 file_path,
2037 content,
2038 std::time::Duration::from_millis(wait_ms),
2039 ))
2040 }
2041
2042 pub fn lsp_post_write(
2059 &self,
2060 file_path: &Path,
2061 content: &str,
2062 params: &serde_json::Value,
2063 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
2064 let wants_diagnostics = params
2065 .get("diagnostics")
2066 .and_then(|v| v.as_bool())
2067 .unwrap_or(false);
2068
2069 let custom_markers = self.custom_lsp_root_markers();
2070
2071 if !wants_diagnostics {
2072 if let Some(file_paths) = Self::multi_file_write_paths(params) {
2073 self.notify_watched_config_files(&file_paths);
2074 } else if let Some(config_events) =
2075 Self::watched_file_events_from_params(params, &custom_markers)
2076 {
2077 self.notify_watched_config_events(&config_events);
2078 }
2079 self.lsp_notify_file_changed(file_path, content);
2080 return None;
2081 }
2082
2083 let wait_ms = params
2084 .get("wait_ms")
2085 .and_then(|v| v.as_u64())
2086 .unwrap_or(3000)
2087 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
2090 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
2091 }
2092
2093 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
2094 {
2095 self.notify_watched_config_events(&config_events);
2096 }
2097
2098 Some(self.lsp_notify_and_collect_diagnostics(
2099 file_path,
2100 content,
2101 std::time::Duration::from_millis(wait_ms),
2102 ))
2103 }
2104
2105 pub fn validate_path(
2114 &self,
2115 req_id: &str,
2116 path: &Path,
2117 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
2118 let config = self.config();
2119 if !config.restrict_to_project_root {
2121 return Ok(path.to_path_buf());
2122 }
2123 let root = match &config.project_root {
2124 Some(r) => r.clone(),
2125 None => return Ok(path.to_path_buf()), };
2127 drop(config);
2128
2129 let raw_root = root.clone();
2134 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
2135
2136 let path_for_resolution = if path.is_relative() {
2141 raw_root.join(path)
2142 } else {
2143 path.to_path_buf()
2144 };
2145 let resolved = match std::fs::canonicalize(&path_for_resolution) {
2146 Ok(resolved) => resolved,
2147 Err(_) => {
2148 let normalized = normalize_path(&path_for_resolution);
2149 reject_escaping_symlink(
2150 req_id,
2151 &path_for_resolution,
2152 &normalized,
2153 &resolved_root,
2154 &raw_root,
2155 )?;
2156 resolve_with_existing_ancestors(&normalized)
2157 }
2158 };
2159
2160 if !resolved.starts_with(&resolved_root) {
2161 return Err(path_error_response(req_id, path, &resolved_root));
2162 }
2163
2164 Ok(resolved)
2165 }
2166
2167 pub fn lsp_server_count(&self) -> usize {
2169 self.lsp_manager
2170 .try_borrow()
2171 .map(|lsp| lsp.server_count())
2172 .unwrap_or(0)
2173 }
2174
2175 pub fn symbol_cache_stats(&self) -> serde_json::Value {
2177 let entries = self
2178 .symbol_cache
2179 .read()
2180 .map(|cache| cache.len())
2181 .unwrap_or(0);
2182 serde_json::json!({
2183 "local_entries": entries,
2184 "warm_entries": 0,
2185 })
2186 }
2187}
2188
2189#[cfg(test)]
2190mod status_emitter_tests {
2191 use super::*;
2192 use crate::parser::TreeSitterProvider;
2193
2194 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
2195 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2196 let (tx, rx) = mpsc::channel();
2197 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
2198 let _ = tx.send(frame);
2199 }))));
2200 (ctx, rx)
2201 }
2202
2203 #[test]
2204 fn status_emitter_signal_triggers_push() {
2205 let (ctx, rx) = ctx_with_frame_rx();
2206 ctx.status_emitter().signal(ctx.build_status_snapshot());
2207 let frame = rx
2208 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2209 .expect("status_changed push");
2210 assert!(matches!(frame, PushFrame::StatusChanged(_)));
2211 }
2212
2213 #[test]
2214 fn status_emitter_debounces_burst() {
2215 let (ctx, rx) = ctx_with_frame_rx();
2216 for _ in 0..10 {
2217 ctx.status_emitter().signal(ctx.build_status_snapshot());
2218 }
2219 let frame = rx
2220 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2221 .expect("status_changed push");
2222 assert!(matches!(frame, PushFrame::StatusChanged(_)));
2223 assert!(rx.try_recv().is_err());
2224 }
2225
2226 #[test]
2227 fn status_emitter_separate_windows_separate_pushes() {
2228 let (ctx, rx) = ctx_with_frame_rx();
2229 ctx.status_emitter().signal(ctx.build_status_snapshot());
2230 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2231 .expect("first push");
2232 ctx.status_emitter().signal(ctx.build_status_snapshot());
2233 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
2234 .expect("second push");
2235 }
2236
2237 #[test]
2238 fn status_emitter_no_signal_no_push() {
2239 let (_ctx, rx) = ctx_with_frame_rx();
2240 assert!(rx
2241 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
2242 .is_err());
2243 }
2244
2245 #[test]
2246 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
2247 let (ctx, rx) = ctx_with_frame_rx();
2248 drop(ctx);
2249 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
2250 }
2251}
2252
2253#[cfg(test)]
2254mod status_bar_tests {
2255 use super::*;
2256 use crate::parser::TreeSitterProvider;
2257
2258 fn ctx() -> AppContext {
2259 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
2260 }
2261
2262 #[test]
2263 fn status_bar_counts_none_until_tier2_populated() {
2264 let ctx = ctx();
2265 assert!(ctx.status_bar_counts().is_none());
2267
2268 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
2269 let counts = ctx.status_bar_counts().expect("populated");
2270 assert_eq!(counts.dead_code, 5);
2271 assert_eq!(counts.unused_exports, 3);
2272 assert_eq!(counts.duplicates, 7);
2273 assert_eq!(counts.todos, 2);
2274 assert!(!counts.tier2_stale);
2275 assert_eq!(counts.errors, 0);
2277 assert_eq!(counts.warnings, 0);
2278 }
2279
2280 #[test]
2281 fn partial_tier2_does_not_fabricate_zeros() {
2282 let ctx = ctx();
2283 ctx.update_status_bar_tier2(Some(5), None, None, None, true);
2287 assert!(
2288 ctx.status_bar_counts().is_none(),
2289 "bar must not surface until all three Tier-2 categories are real"
2290 );
2291
2292 ctx.update_status_bar_tier2(None, Some(3), None, None, true);
2294 assert!(ctx.status_bar_counts().is_none());
2295
2296 ctx.update_status_bar_tier2(None, None, Some(7), None, false);
2299 let counts = ctx.status_bar_counts().expect("all three real now");
2300 assert_eq!(counts.dead_code, 5);
2301 assert_eq!(counts.unused_exports, 3);
2302 assert_eq!(counts.duplicates, 7);
2303 }
2304
2305 #[test]
2306 fn update_with_none_todos_preserves_last_known_todos() {
2307 let ctx = ctx();
2308 ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
2309 ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
2311 let counts = ctx.status_bar_counts().expect("populated");
2312 assert_eq!(counts.todos, 9);
2313 assert_eq!(counts.dead_code, 2);
2314 }
2315
2316 #[test]
2317 fn update_with_none_count_preserves_last_known_count() {
2318 let ctx = ctx();
2319 ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
2320 ctx.update_status_bar_tier2(Some(11), None, None, None, false);
2323 let counts = ctx.status_bar_counts().expect("populated");
2324 assert_eq!(counts.dead_code, 11);
2325 assert_eq!(counts.unused_exports, 20);
2326 assert_eq!(counts.duplicates, 30);
2327 }
2328
2329 #[test]
2330 fn mark_stale_sets_flag_only_after_populate() {
2331 let ctx = ctx();
2332 ctx.mark_status_bar_tier2_stale();
2334 assert!(ctx.status_bar_counts().is_none());
2335
2336 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), Some(0), false);
2337 ctx.mark_status_bar_tier2_stale();
2338 assert!(ctx.status_bar_counts().expect("populated").tier2_stale);
2339
2340 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), None, false);
2342 assert!(!ctx.status_bar_counts().expect("populated").tier2_stale);
2343 }
2344
2345 #[test]
2350 fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
2351 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
2352 use crate::lsp::registry::ServerKind;
2353 use crate::lsp::roots::ServerKey;
2354
2355 let ctx = ctx();
2356 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); let file = std::path::PathBuf::from("/proj/gone.ts");
2359 {
2360 let mut lsp = ctx.lsp();
2361 lsp.diagnostics_store_mut_for_test().publish(
2362 ServerKey {
2363 kind: ServerKind::TypeScript,
2364 root: std::path::PathBuf::from("/proj"),
2365 },
2366 file.clone(),
2367 vec![StoredDiagnostic {
2368 file: file.clone(),
2369 line: 1,
2370 column: 1,
2371 end_line: 1,
2372 end_column: 2,
2373 severity: DiagnosticSeverity::Error,
2374 message: "boom".into(),
2375 code: None,
2376 source: None,
2377 }],
2378 );
2379 }
2380
2381 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
2383
2384 let removed = ctx.lsp_clear_diagnostics_for_file(&file);
2386 assert!(removed);
2387 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
2388 }
2389}
2390
2391#[cfg(test)]
2392mod harness_path_tests {
2393 use super::*;
2394 use crate::harness::Harness;
2395 use crate::parser::TreeSitterProvider;
2396
2397 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
2398 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2399 ctx.config_mut().storage_dir = Some(storage_dir);
2400 ctx.set_harness(harness);
2401 ctx
2402 }
2403
2404 #[test]
2405 fn harness_dir_resolves_correctly() {
2406 let storage = PathBuf::from("/tmp/cortexkit/aft");
2407 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2408
2409 assert_eq!(ctx.harness_dir(), storage.join("pi"));
2410 }
2411
2412 #[test]
2413 fn bash_tasks_dir_uses_hash_session() {
2414 let storage = PathBuf::from("/tmp/cortexkit/aft");
2415 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2416
2417 assert_eq!(
2418 ctx.bash_tasks_dir("ses_abc"),
2419 storage
2420 .join("opencode")
2421 .join("bash-tasks")
2422 .join(hash_session("ses_abc"))
2423 );
2424 }
2425
2426 #[test]
2427 fn backups_dir_includes_path_hash() {
2428 let storage = PathBuf::from("/tmp/cortexkit/aft");
2429 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2430
2431 assert_eq!(
2432 ctx.backups_dir("ses_abc", "pathhash"),
2433 storage
2434 .join("pi")
2435 .join("backups")
2436 .join(hash_session("ses_abc"))
2437 .join("pathhash")
2438 );
2439 }
2440
2441 #[test]
2442 fn filters_dir_under_harness() {
2443 let storage = PathBuf::from("/tmp/cortexkit/aft");
2444 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2445
2446 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
2447 }
2448
2449 #[test]
2450 fn trust_file_is_host_global() {
2451 let storage = PathBuf::from("/tmp/cortexkit/aft");
2452 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2453
2454 assert_eq!(
2455 ctx.trust_file(),
2456 storage.join("trusted-filter-projects.json")
2457 );
2458 }
2459
2460 #[test]
2461 fn same_session_different_harness_resolve_different_paths() {
2462 let storage = PathBuf::from("/tmp/cortexkit/aft");
2463 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2464 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
2465
2466 assert_ne!(
2467 opencode.bash_tasks_dir("ses_same"),
2468 pi.bash_tasks_dir("ses_same")
2469 );
2470 }
2471}
2472
2473#[cfg(test)]
2474mod gitignore_tests {
2475 use super::*;
2476 use std::fs;
2477 use std::path::Path;
2478 use tempfile::TempDir;
2479
2480 fn make_ctx_with_root(root: &Path) -> AppContext {
2481 let provider = Box::new(crate::parser::TreeSitterProvider::new());
2482 let config = Config {
2483 project_root: Some(root.to_path_buf()),
2484 ..Config::default()
2485 };
2486 AppContext::new(provider, config)
2487 }
2488
2489 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
2496 let Some(matcher) = ctx.gitignore() else {
2497 return false;
2498 };
2499 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
2500 if !canonical.starts_with(matcher.path()) {
2501 return false;
2502 }
2503 let is_dir = canonical.is_dir();
2504 matcher
2505 .matched_path_or_any_parents(&canonical, is_dir)
2506 .is_ignore()
2507 }
2508
2509 fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
2520 use std::sync::{Mutex, OnceLock};
2521 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2522 let _guard = LOCK
2523 .get_or_init(|| Mutex::new(()))
2524 .lock()
2525 .unwrap_or_else(|e| e.into_inner());
2526 let tmp = TempDir::new().unwrap();
2527 let prev = std::env::var_os("XDG_CONFIG_HOME");
2528 unsafe {
2530 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
2531 }
2532 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
2533 unsafe {
2534 match prev {
2535 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
2536 None => std::env::remove_var("XDG_CONFIG_HOME"),
2537 }
2538 }
2539 match result {
2540 Ok(r) => r,
2541 Err(p) => std::panic::resume_unwind(p),
2542 }
2543 }
2544
2545 #[test]
2546 fn rebuild_gitignore_returns_none_without_project_root() {
2547 let provider = Box::new(crate::parser::TreeSitterProvider::new());
2548 let ctx = AppContext::new(provider, Config::default());
2549 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2550 assert!(ctx.gitignore().is_none());
2551 }
2552
2553 #[test]
2554 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
2555 let tmp = TempDir::new().unwrap();
2556 let ctx = make_ctx_with_root(tmp.path());
2557 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2558 assert!(ctx.gitignore().is_none());
2559 }
2560
2561 #[test]
2562 fn matcher_filters_files_in_ignored_dist_dir() {
2563 let tmp = TempDir::new().unwrap();
2564 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
2565 fs::create_dir_all(tmp.path().join("dist")).unwrap();
2566 fs::create_dir_all(tmp.path().join("src")).unwrap();
2567 let dist_file = tmp.path().join("dist").join("bundle.js");
2568 let src_file = tmp.path().join("src").join("app.ts");
2569 fs::write(&dist_file, "x").unwrap();
2570 fs::write(&src_file, "y").unwrap();
2571
2572 let ctx = make_ctx_with_root(tmp.path());
2573 ctx.rebuild_gitignore();
2574
2575 assert!(ctx.gitignore().is_some());
2576 assert!(
2577 is_ignored(&ctx, &dist_file),
2578 "dist/bundle.js should be ignored"
2579 );
2580 assert!(
2581 !is_ignored(&ctx, &src_file),
2582 "src/app.ts should NOT be ignored"
2583 );
2584 }
2585
2586 #[test]
2587 fn matcher_handles_node_modules_and_target() {
2588 let tmp = TempDir::new().unwrap();
2589 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
2590 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
2591 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
2592 let nm_file = tmp.path().join("node_modules/foo/index.js");
2593 let target_file = tmp.path().join("target/debug/aft");
2594 fs::write(&nm_file, "x").unwrap();
2595 fs::write(&target_file, "x").unwrap();
2596
2597 let ctx = make_ctx_with_root(tmp.path());
2598 ctx.rebuild_gitignore();
2599
2600 assert!(is_ignored(&ctx, &nm_file));
2601 assert!(is_ignored(&ctx, &target_file));
2602 }
2603
2604 #[test]
2605 fn matcher_honors_negation_pattern() {
2606 let tmp = TempDir::new().unwrap();
2608 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
2609 let random_log = tmp.path().join("random.log");
2610 let important_log = tmp.path().join("important.log");
2611 fs::write(&random_log, "x").unwrap();
2612 fs::write(&important_log, "y").unwrap();
2613
2614 let ctx = make_ctx_with_root(tmp.path());
2615 ctx.rebuild_gitignore();
2616
2617 assert!(is_ignored(&ctx, &random_log));
2618 assert!(
2619 !is_ignored(&ctx, &important_log),
2620 "negation pattern should un-ignore important.log"
2621 );
2622 }
2623
2624 #[test]
2625 fn rebuild_picks_up_gitignore_changes() {
2626 let tmp = TempDir::new().unwrap();
2627 let ignore_path = tmp.path().join(".gitignore");
2628 fs::write(&ignore_path, "foo.txt\n").unwrap();
2629 let foo = tmp.path().join("foo.txt");
2630 let bar = tmp.path().join("bar.txt");
2631 fs::write(&foo, "").unwrap();
2632 fs::write(&bar, "").unwrap();
2633
2634 let ctx = make_ctx_with_root(tmp.path());
2635 ctx.rebuild_gitignore();
2636 assert!(is_ignored(&ctx, &foo));
2637 assert!(!is_ignored(&ctx, &bar));
2638
2639 fs::write(&ignore_path, "bar.txt\n").unwrap();
2641 ctx.rebuild_gitignore();
2642 assert!(!is_ignored(&ctx, &foo));
2643 assert!(is_ignored(&ctx, &bar));
2644 }
2645
2646 #[test]
2647 fn gitignore_loads_info_exclude_when_present() {
2648 let tmp = TempDir::new().unwrap();
2649 let info_dir = tmp.path().join(".git/info");
2650 fs::create_dir_all(&info_dir).unwrap();
2651 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
2652 let secrets = tmp.path().join("secrets.txt");
2653 let public = tmp.path().join("public.txt");
2654 fs::write(&secrets, "token").unwrap();
2655 fs::write(&public, "ok").unwrap();
2656
2657 let ctx = make_ctx_with_root(tmp.path());
2658 ctx.rebuild_gitignore();
2659
2660 assert!(is_ignored(&ctx, &secrets));
2661 assert!(!is_ignored(&ctx, &public));
2662 }
2663
2664 #[test]
2665 fn matcher_picks_up_nested_gitignore() {
2666 let tmp = TempDir::new().unwrap();
2667 fs::write(tmp.path().join(".gitignore"), "").unwrap();
2669 let sub = tmp.path().join("packages/foo");
2670 fs::create_dir_all(&sub).unwrap();
2671 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
2672 let generated_file = sub.join("generated").join("out.js");
2673 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
2674 fs::write(&generated_file, "x").unwrap();
2675
2676 let ctx = make_ctx_with_root(tmp.path());
2677 ctx.rebuild_gitignore();
2678
2679 assert!(
2680 is_ignored(&ctx, &generated_file),
2681 "nested gitignore in packages/foo/.gitignore should ignore generated/"
2682 );
2683 }
2684}