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::checkpoint::CheckpointStore;
18use crate::config::Config;
19use crate::harness::Harness;
20use crate::inspect::{
21 InspectCategory, InspectManager, InspectSnapshot, Tier2RefreshScheduler, Tier2TriggerReason,
22};
23use crate::language::LanguageProvider;
24use crate::lsp::manager::LspManager;
25use crate::lsp::registry::is_config_file_path_with_custom;
26use crate::parser::{SharedSymbolCache, SymbolCache};
27use crate::protocol::{
28 ConfigureWarningsFrame, ProgressFrame, PushFrame, StatusChangedFrame, StatusPayload,
29};
30
31pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
32pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
33pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
34const STATUS_DEBOUNCE_MS: u64 = 1_000;
35
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
44pub struct StatusBarCounts {
45 pub errors: usize,
46 pub warnings: usize,
47 pub dead_code: usize,
48 pub unused_exports: usize,
49 pub duplicates: usize,
50 pub todos: usize,
51 pub tier2_stale: bool,
52}
53
54#[derive(Debug, Clone, Default)]
63struct StatusBarTier2 {
64 dead_code: Option<usize>,
65 unused_exports: Option<usize>,
66 duplicates: Option<usize>,
67 todos: Option<usize>,
68 stale: bool,
69}
70
71pub struct StatusEmitter {
72 latest: Arc<Mutex<Option<StatusPayload>>>,
73 notify: mpsc::Sender<()>,
74}
75
76impl StatusEmitter {
77 fn new(progress_sender: SharedProgressSender) -> Self {
78 let (notify, rx) = mpsc::channel();
79 let latest = Arc::new(Mutex::new(None));
80 let latest_for_thread = Arc::clone(&latest);
81 std::thread::spawn(move || {
82 status_debounce_loop(rx, latest_for_thread, progress_sender);
83 });
84 Self { latest, notify }
85 }
86
87 pub fn signal(&self, snapshot: StatusPayload) {
88 if let Ok(mut latest) = self.latest.lock() {
89 *latest = Some(snapshot);
90 }
91 let _ = self.notify.send(());
92 }
93}
94
95fn status_debounce_loop(
96 rx: mpsc::Receiver<()>,
97 latest: Arc<Mutex<Option<StatusPayload>>>,
98 progress_sender: SharedProgressSender,
99) {
100 while rx.recv().is_ok() {
101 let deadline = Instant::now() + Duration::from_millis(STATUS_DEBOUNCE_MS);
102 while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
103 match rx.recv_timeout(remaining) {
104 Ok(()) => continue,
105 Err(mpsc::RecvTimeoutError::Timeout) => break,
106 Err(mpsc::RecvTimeoutError::Disconnected) => return,
107 }
108 }
109
110 let snapshot = latest.lock().ok().and_then(|mut latest| latest.take());
111 let Some(snapshot) = snapshot else { continue };
112 let sender = progress_sender
113 .lock()
114 .ok()
115 .and_then(|sender| sender.clone());
116 if let Some(sender) = sender {
117 sender(PushFrame::StatusChanged(StatusChangedFrame::new(
118 None, snapshot,
119 )));
120 }
121 }
122}
123use crate::cache_freshness::FileFreshness;
124use crate::search_index::SearchIndex;
125use crate::semantic_index::{EmbeddingEntry, SemanticIndex};
126
127#[derive(Debug, Default)]
131struct SemanticRefreshAccounting {
132 pending: usize,
133 in_flight: usize,
134}
135
136static SEMANTIC_REFRESH_ACCOUNTING: OnceLock<Mutex<BTreeMap<PathBuf, SemanticRefreshAccounting>>> =
137 OnceLock::new();
138
139fn semantic_refresh_accounting() -> &'static Mutex<BTreeMap<PathBuf, SemanticRefreshAccounting>> {
140 SEMANTIC_REFRESH_ACCOUNTING.get_or_init(|| Mutex::new(BTreeMap::new()))
141}
142
143fn clear_semantic_refresh_accounting() {
144 if let Some(accounting) = SEMANTIC_REFRESH_ACCOUNTING.get() {
145 if let Ok(mut accounting) = accounting.lock() {
146 accounting.clear();
147 }
148 }
149}
150
151fn ensure_refreshing_path(refreshing: &mut Vec<PathBuf>, path: PathBuf) {
152 if !refreshing.iter().any(|existing| existing == &path) {
153 refreshing.push(path);
154 refreshing.sort();
155 }
156}
157
158fn remove_refreshing_path(refreshing: &mut Vec<PathBuf>, path: &Path) {
159 refreshing.retain(|existing| existing != path);
160}
161
162#[derive(Debug, Clone)]
163pub enum SemanticIndexStatus {
164 Disabled,
165 Building {
166 stage: String,
168 files: Option<usize>,
169 entries_done: Option<usize>,
170 entries_total: Option<usize>,
171 },
172 Ready {
173 refreshing: Vec<PathBuf>,
176 },
177 Failed(String),
178}
179
180impl SemanticIndexStatus {
181 pub fn ready() -> Self {
182 clear_semantic_refresh_accounting();
183 Self::Ready {
184 refreshing: Vec::new(),
185 }
186 }
187
188 pub fn add_refreshing_file(&mut self, path: PathBuf) {
189 if let Self::Ready { refreshing } = self {
190 if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
191 let state = accounting.entry(path.clone()).or_default();
192 state.pending = state.pending.saturating_add(1);
193 }
194 ensure_refreshing_path(refreshing, path);
195 }
196 }
197
198 pub fn start_refreshing_file(&mut self, path: PathBuf) {
199 if let Self::Ready { refreshing } = self {
200 if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
201 let state = accounting.entry(path.clone()).or_default();
202 if state.pending == 0 {
203 state.pending = 1;
204 }
205 if state.in_flight == 0 {
206 state.in_flight = state.pending;
207 }
208 }
209 ensure_refreshing_path(refreshing, path);
210 }
211 }
212
213 pub fn cancel_refreshing_file(&mut self, path: &Path) {
214 self.finish_refreshing_file(path, false);
215 }
216
217 pub fn complete_refreshing_file(&mut self, path: &Path) {
218 self.finish_refreshing_file(path, true);
219 }
220
221 pub fn remove_refreshing_file(&mut self, path: &Path) {
222 self.complete_refreshing_file(path);
223 }
224
225 fn finish_refreshing_file(&mut self, path: &Path, complete_in_flight: bool) {
226 if let Self::Ready { refreshing } = self {
227 let mut keep_refreshing = false;
228 let mut accounting_checked = false;
229 if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
230 accounting_checked = true;
231 if let Some(state) = accounting.get_mut(path) {
232 let finished = if complete_in_flight {
233 state.in_flight.max(1)
234 } else {
235 1
236 };
237 state.pending = state.pending.saturating_sub(finished);
238 if complete_in_flight {
239 state.in_flight = 0;
240 } else {
241 state.in_flight = state.in_flight.min(state.pending);
242 }
243 keep_refreshing = state.pending > 0;
244 if !keep_refreshing {
245 accounting.remove(path);
246 }
247 }
248 }
249
250 if !accounting_checked || !keep_refreshing {
251 remove_refreshing_path(refreshing, path);
252 }
253 }
254 }
255
256 pub fn refreshing_count(&self) -> usize {
257 match self {
258 Self::Ready { refreshing } => refreshing.len(),
259 _ => 0,
260 }
261 }
262}
263
264pub enum SemanticIndexEvent {
265 Progress {
266 stage: String,
267 files: Option<usize>,
268 entries_done: Option<usize>,
269 entries_total: Option<usize>,
270 },
271 Ready(SemanticIndex),
272 Failed(String),
273}
274
275#[derive(Debug, Clone)]
276pub enum SemanticRefreshRequest {
277 Files { paths: Vec<PathBuf> },
278 Corpus { current_files: Vec<PathBuf> },
279}
280
281#[derive(Debug)]
282pub enum SemanticRefreshEvent {
283 Started {
284 paths: Vec<PathBuf>,
285 },
286 Completed {
287 added_entries: Vec<EmbeddingEntry>,
288 updated_metadata: Vec<(PathBuf, FileFreshness)>,
289 completed_paths: Vec<PathBuf>,
290 },
291 CorpusCompleted {
292 index: SemanticIndex,
293 changed: usize,
294 added: usize,
295 deleted: usize,
296 total_processed: usize,
297 },
298 Failed {
299 paths: Vec<PathBuf>,
300 error: String,
301 },
302 CorpusFailed {
303 error: String,
304 },
305}
306
307pub type SemanticRefreshWorkerSlot = Arc<Mutex<Option<std::thread::JoinHandle<()>>>>;
308
309fn normalize_path(path: &Path) -> PathBuf {
313 let mut result = PathBuf::new();
314 for component in path.components() {
315 match component {
316 Component::ParentDir => {
317 if !result.pop() {
319 result.push(component);
320 }
321 }
322 Component::CurDir => {} _ => result.push(component),
324 }
325 }
326 result
327}
328
329fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
330 let mut existing = path.to_path_buf();
331 let mut tail_segments = Vec::new();
332
333 while !existing.exists() {
334 if let Some(name) = existing.file_name() {
335 tail_segments.push(name.to_owned());
336 } else {
337 break;
338 }
339
340 existing = match existing.parent() {
341 Some(parent) => parent.to_path_buf(),
342 None => break,
343 };
344 }
345
346 let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
347 for segment in tail_segments.into_iter().rev() {
348 resolved.push(segment);
349 }
350
351 resolved
352}
353
354fn path_error_response(
355 req_id: &str,
356 path: &Path,
357 resolved_root: &Path,
358) -> crate::protocol::Response {
359 crate::protocol::Response::error(
360 req_id,
361 "path_outside_root",
362 format!(
363 "path '{}' is outside the project root '{}'",
364 path.display(),
365 resolved_root.display()
366 ),
367 )
368}
369
370fn reject_escaping_symlink(
380 req_id: &str,
381 original_path: &Path,
382 candidate: &Path,
383 resolved_root: &Path,
384 raw_root: &Path,
385) -> Result<(), crate::protocol::Response> {
386 let mut current = PathBuf::new();
387
388 for component in candidate.components() {
389 current.push(component);
390
391 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
392 continue;
393 };
394
395 if !metadata.file_type().is_symlink() {
396 continue;
397 }
398
399 let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
408 if !inside_root {
409 continue;
410 }
411
412 iterative_follow_chain(req_id, original_path, ¤t, resolved_root)?;
413 }
414
415 Ok(())
416}
417
418fn iterative_follow_chain(
421 req_id: &str,
422 original_path: &Path,
423 start: &Path,
424 resolved_root: &Path,
425) -> Result<(), crate::protocol::Response> {
426 let mut link = start.to_path_buf();
427 let mut depth = 0usize;
428
429 loop {
430 if depth > 40 {
431 return Err(path_error_response(req_id, original_path, resolved_root));
432 }
433
434 let target = match std::fs::read_link(&link) {
435 Ok(t) => t,
436 Err(_) => {
437 return Err(path_error_response(req_id, original_path, resolved_root));
439 }
440 };
441
442 let resolved_target = if target.is_absolute() {
443 normalize_path(&target)
444 } else {
445 let parent = link.parent().unwrap_or_else(|| Path::new(""));
446 normalize_path(&parent.join(&target))
447 };
448
449 let canonical_target =
453 std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
454
455 if !canonical_target.starts_with(resolved_root)
456 && !resolved_target.starts_with(resolved_root)
457 {
458 return Err(path_error_response(req_id, original_path, resolved_root));
459 }
460
461 match std::fs::symlink_metadata(&resolved_target) {
463 Ok(meta) if meta.file_type().is_symlink() => {
464 link = resolved_target;
465 depth += 1;
466 }
467 _ => break, }
469 }
470
471 Ok(())
472}
473
474pub struct AppContext {
484 provider: Box<dyn LanguageProvider>,
485 backup: RefCell<BackupStore>,
486 checkpoint: RefCell<CheckpointStore>,
487 db: RefCell<Option<Arc<Mutex<Connection>>>>,
488 config: RefCell<Config>,
489 pub harness: RefCell<Option<Harness>>,
490 canonical_cache_root: RefCell<Option<PathBuf>>,
491 is_worktree_bridge: RefCell<bool>,
492 git_common_dir: RefCell<Option<PathBuf>>,
493 degraded_reasons: RefCell<Vec<String>>,
501 callgraph: RefCell<Option<CallGraph>>,
502 search_index: RefCell<Option<SearchIndex>>,
503 search_index_rx: RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>>,
504 pending_search_index_paths: RefCell<BTreeSet<PathBuf>>,
505 symbol_cache: SharedSymbolCache,
506 inspect_manager: Arc<InspectManager>,
507 tier2_refresh_scheduler: RefCell<Tier2RefreshScheduler>,
508 semantic_index: RefCell<Option<SemanticIndex>>,
509 semantic_index_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
510 semantic_index_status: RefCell<SemanticIndexStatus>,
511 pending_semantic_index_paths: RefCell<BTreeSet<PathBuf>>,
512 pending_semantic_corpus_refresh: RefCell<bool>,
513 semantic_refresh_tx: RefCell<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>,
514 semantic_refresh_event_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
515 semantic_refresh_worker: RefCell<Option<SemanticRefreshWorkerSlot>>,
516 semantic_embedding_model: RefCell<Option<crate::semantic_index::EmbeddingModel>>,
517 watcher: RefCell<Option<RecommendedWatcher>>,
518 watcher_rx: RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>>,
519 lsp_manager: RefCell<LspManager>,
520 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
524 stdout_writer: SharedStdoutWriter,
525 progress_sender: SharedProgressSender,
526 configure_generation: AtomicU64,
527 last_seen_reuse_completions: AtomicU64,
531 configure_warnings_tx: mpsc::Sender<(u64, ConfigureWarningsFrame)>,
532 configure_warnings_rx: mpsc::Receiver<(u64, ConfigureWarningsFrame)>,
533 status_emitter: StatusEmitter,
534 bash_background: BgTaskRegistry,
535 filter_registry: crate::compress::SharedFilterRegistry,
542 filter_registry_loaded: std::sync::atomic::AtomicBool,
545 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
550 gitignore: RefCell<Option<Arc<ignore::gitignore::Gitignore>>>,
557 status_bar_tier2: RefCell<StatusBarTier2>,
561}
562
563impl AppContext {
564 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
565 let bash_compress_enabled = config.experimental_bash_compress;
566 let progress_sender = Arc::new(Mutex::new(None));
567 let stdout_writer = Arc::new(Mutex::new(BufWriter::new(io::stdout())));
568 let (configure_warnings_tx, configure_warnings_rx) = mpsc::channel();
569 let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
570 let symbol_cache = provider
571 .as_any()
572 .downcast_ref::<crate::parser::TreeSitterProvider>()
573 .map(|provider| provider.symbol_cache())
574 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
575 let lsp_child_registry = crate::lsp::child_registry::LspChildRegistry::new();
576 let mut lsp_manager = LspManager::new();
577 lsp_manager.set_child_registry(lsp_child_registry.clone());
578 AppContext {
579 provider,
580 backup: RefCell::new(BackupStore::new()),
581 checkpoint: RefCell::new(CheckpointStore::new()),
582 db: RefCell::new(None),
583 config: RefCell::new(config),
584 harness: RefCell::new(None),
585 canonical_cache_root: RefCell::new(None),
586 is_worktree_bridge: RefCell::new(false),
587 git_common_dir: RefCell::new(None),
588 degraded_reasons: RefCell::new(Vec::new()),
589 callgraph: RefCell::new(None),
590 search_index: RefCell::new(None),
591 search_index_rx: RefCell::new(None),
592 pending_search_index_paths: RefCell::new(BTreeSet::new()),
593 symbol_cache,
594 inspect_manager: Arc::new(InspectManager::new()),
595 tier2_refresh_scheduler: RefCell::new(Tier2RefreshScheduler::new()),
596 semantic_index: RefCell::new(None),
597 semantic_index_rx: RefCell::new(None),
598 semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
599 pending_semantic_index_paths: RefCell::new(BTreeSet::new()),
600 pending_semantic_corpus_refresh: RefCell::new(false),
601 semantic_refresh_tx: RefCell::new(None),
602 semantic_refresh_event_rx: RefCell::new(None),
603 semantic_refresh_worker: RefCell::new(None),
604 semantic_embedding_model: RefCell::new(None),
605 watcher: RefCell::new(None),
606 watcher_rx: RefCell::new(None),
607 lsp_manager: RefCell::new(lsp_manager),
608 lsp_child_registry,
609 stdout_writer,
610 progress_sender: Arc::clone(&progress_sender),
611 configure_generation: AtomicU64::new(0),
612 last_seen_reuse_completions: AtomicU64::new(0),
613 configure_warnings_tx,
614 configure_warnings_rx,
615 status_emitter,
616 bash_background: BgTaskRegistry::new(progress_sender),
617 filter_registry: Arc::new(std::sync::RwLock::new(
618 crate::compress::toml_filter::FilterRegistry::default(),
619 )),
620 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
621 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
622 gitignore: RefCell::new(None),
623 status_bar_tier2: RefCell::new(StatusBarTier2::default()),
624 }
625 }
626
627 pub fn status_bar_counts(&self) -> Option<StatusBarCounts> {
633 let tier2 = self.status_bar_tier2.borrow();
634 let (Some(dead_code), Some(unused_exports), Some(duplicates)) =
638 (tier2.dead_code, tier2.unused_exports, tier2.duplicates)
639 else {
640 return None;
641 };
642 let (errors, warnings) = self.lsp_manager.borrow().warm_error_warning_counts();
643 Some(StatusBarCounts {
644 errors,
645 warnings,
646 dead_code,
647 unused_exports,
648 duplicates,
649 todos: tier2.todos.unwrap_or(0),
650 tier2_stale: tier2.stale,
651 })
652 }
653
654 pub fn mark_status_bar_tier2_stale(&self) {
659 let mut tier2 = self.status_bar_tier2.borrow_mut();
660 if tier2.dead_code.is_some() && tier2.unused_exports.is_some() && tier2.duplicates.is_some()
662 {
663 tier2.stale = true;
664 }
665 }
666
667 pub fn update_status_bar_tier2(
673 &self,
674 dead_code: Option<usize>,
675 unused_exports: Option<usize>,
676 duplicates: Option<usize>,
677 todos: Option<usize>,
678 stale: bool,
679 ) {
680 let mut tier2 = self.status_bar_tier2.borrow_mut();
681 if let Some(dead_code) = dead_code {
682 tier2.dead_code = Some(dead_code);
683 }
684 if let Some(unused_exports) = unused_exports {
685 tier2.unused_exports = Some(unused_exports);
686 }
687 if let Some(duplicates) = duplicates {
688 tier2.duplicates = Some(duplicates);
689 }
690 if let Some(todos) = todos {
691 tier2.todos = Some(todos);
692 }
693 tier2.stale = stale;
694 }
695
696 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
699 self.gitignore.borrow().clone()
700 }
701
702 pub fn clear_gitignore(&self) {
724 *self.gitignore.borrow_mut() = None;
725 }
726
727 pub fn rebuild_gitignore(&self) {
728 use ignore::gitignore::GitignoreBuilder;
729 use std::path::Path;
730 let root_raw = match self.config().project_root.clone() {
731 Some(r) => r,
732 None => {
733 *self.gitignore.borrow_mut() = None;
734 return;
735 }
736 };
737 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
745 let mut builder = GitignoreBuilder::new(&root);
746 if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
751 if global_ignore.is_file() {
752 if let Some(err) = builder.add(&global_ignore) {
753 crate::slog_warn!(
754 "global gitignore parse error in {}: {}",
755 global_ignore.display(),
756 err
757 );
758 }
759 }
760 }
761 let root_ignore = Path::new(&root).join(".gitignore");
763 if root_ignore.exists() {
764 if let Some(err) = builder.add(&root_ignore) {
765 crate::slog_warn!(
766 "gitignore parse error in {}: {}",
767 root_ignore.display(),
768 err
769 );
770 }
771 }
772 let root_aftignore = Path::new(&root).join(".aftignore");
777 if root_aftignore.exists() {
778 if let Some(err) = builder.add(&root_aftignore) {
779 crate::slog_warn!(
780 "aftignore parse error in {}: {}",
781 root_aftignore.display(),
782 err
783 );
784 }
785 }
786 let info_exclude = self
791 .git_common_dir
792 .borrow()
793 .clone()
794 .unwrap_or_else(|| Path::new(&root).join(".git"))
795 .join("info")
796 .join("exclude");
797 if info_exclude.exists() {
798 if let Some(err) = builder.add(&info_exclude) {
799 crate::slog_warn!(
800 "gitignore parse error in {}: {}",
801 info_exclude.display(),
802 err
803 );
804 }
805 }
806 let walker = ignore::WalkBuilder::new(&root)
812 .standard_filters(true)
813 .hidden(false)
821 .filter_entry(|entry| {
822 let name = entry.file_name().to_string_lossy();
823 !matches!(
824 name.as_ref(),
825 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
826 )
827 })
828 .build();
829 for entry in walker.flatten() {
830 let file_name = entry.file_name();
831 let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
832 let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
833 if is_nested_gitignore || is_nested_aftignore {
834 if let Some(err) = builder.add(entry.path()) {
835 crate::slog_warn!(
836 "nested ignore parse error in {}: {}",
837 entry.path().display(),
838 err
839 );
840 }
841 }
842 }
843 match builder.build() {
844 Ok(gi) => {
845 let count = gi.num_ignores();
846 if count > 0 {
847 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
848 *self.gitignore.borrow_mut() = Some(Arc::new(gi));
849 } else {
850 *self.gitignore.borrow_mut() = None;
851 }
852 }
853 Err(err) => {
854 crate::slog_warn!("gitignore matcher build failed: {}", err);
855 *self.gitignore.borrow_mut() = None;
856 }
857 }
858 }
859
860 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
863 Arc::clone(&self.bash_compress_flag)
864 }
865
866 pub fn sync_bash_compress_flag(&self) {
870 let value = self.config().experimental_bash_compress;
871 self.bash_compress_flag
872 .store(value, std::sync::atomic::Ordering::Relaxed);
873 }
874
875 pub fn set_bash_compress_enabled(&self, enabled: bool) {
876 self.config_mut().experimental_bash_compress = enabled;
877 self.bash_compress_flag
878 .store(enabled, std::sync::atomic::Ordering::Relaxed);
879 }
880
881 pub fn filter_registry(
885 &self,
886 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
887 self.ensure_filter_registry_loaded();
888 match self.filter_registry.read() {
889 Ok(g) => g,
890 Err(poisoned) => poisoned.into_inner(),
891 }
892 }
893
894 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
898 self.ensure_filter_registry_loaded();
899 Arc::clone(&self.filter_registry)
900 }
901
902 pub fn reset_filter_registry(&self) {
906 let new_registry = crate::compress::build_registry_for_context(self);
907 match self.filter_registry.write() {
908 Ok(mut slot) => *slot = new_registry,
909 Err(poisoned) => *poisoned.into_inner() = new_registry,
910 }
911 self.filter_registry_loaded
912 .store(true, std::sync::atomic::Ordering::Release);
913 }
914
915 fn ensure_filter_registry_loaded(&self) {
916 use std::sync::atomic::Ordering;
917 if self.filter_registry_loaded.load(Ordering::Acquire) {
918 return;
919 }
920 let new_registry = crate::compress::build_registry_for_context(self);
923 if let Ok(mut slot) = self.filter_registry.write() {
924 *slot = new_registry;
925 self.filter_registry_loaded.store(true, Ordering::Release);
926 }
927 }
928
929 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
932 self.lsp_child_registry.clone()
933 }
934
935 pub fn stdout_writer(&self) -> SharedStdoutWriter {
936 Arc::clone(&self.stdout_writer)
937 }
938
939 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
940 if let Ok(mut progress_sender) = self.progress_sender.lock() {
941 *progress_sender = sender;
942 }
943 }
944
945 pub fn emit_progress(&self, frame: ProgressFrame) {
946 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
947 return;
948 };
949 if let Some(sender) = progress_sender.as_ref() {
950 sender(PushFrame::Progress(frame));
951 }
952 }
953
954 pub fn status_emitter(&self) -> &StatusEmitter {
955 &self.status_emitter
956 }
957
958 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
966 self.progress_sender
967 .lock()
968 .ok()
969 .and_then(|sender| sender.clone())
970 }
971
972 pub fn advance_configure_generation(&self) -> u64 {
973 self.configure_generation
974 .fetch_add(1, Ordering::SeqCst)
975 .wrapping_add(1)
976 }
977
978 pub fn configure_generation(&self) -> u64 {
979 self.configure_generation.load(Ordering::SeqCst)
980 }
981
982 pub fn configure_warnings_sender(&self) -> mpsc::Sender<(u64, ConfigureWarningsFrame)> {
983 self.configure_warnings_tx.clone()
984 }
985
986 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
987 let mut warnings = Vec::new();
988 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
989 warnings.push(warning);
990 }
991 warnings
992 }
993
994 pub fn bash_background(&self) -> &BgTaskRegistry {
995 &self.bash_background
996 }
997
998 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
999 self.bash_background.drain_completions()
1000 }
1001
1002 pub fn provider(&self) -> &dyn LanguageProvider {
1004 self.provider.as_ref()
1005 }
1006
1007 pub fn backup(&self) -> &RefCell<BackupStore> {
1009 &self.backup
1010 }
1011
1012 pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
1014 &self.checkpoint
1015 }
1016
1017 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
1018 *self.db.borrow_mut() = Some(conn);
1019 }
1020
1021 pub fn clear_db(&self) {
1022 *self.db.borrow_mut() = None;
1023 }
1024
1025 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
1026 self.db.borrow().clone()
1027 }
1028
1029 pub fn config(&self) -> Ref<'_, Config> {
1031 self.config.borrow()
1032 }
1033
1034 pub fn config_mut(&self) -> RefMut<'_, Config> {
1036 self.config.borrow_mut()
1037 }
1038
1039 pub fn set_harness(&self, harness: Harness) {
1040 *self.harness.borrow_mut() = Some(harness);
1041 self.bash_background.set_harness(harness);
1042 }
1043
1044 pub fn harness_opt(&self) -> Option<Harness> {
1045 *self.harness.borrow()
1046 }
1047
1048 pub fn harness(&self) -> Harness {
1049 self.harness_opt()
1050 .expect("harness set by configure before any tool call")
1051 }
1052
1053 pub fn storage_dir(&self) -> PathBuf {
1054 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
1055 }
1056
1057 pub fn harness_dir(&self) -> PathBuf {
1058 self.storage_dir().join(self.harness().as_str())
1059 }
1060
1061 pub fn inspect_dir(&self) -> PathBuf {
1062 self.harness_dir().join("inspect")
1063 }
1064
1065 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
1066 self.harness_dir()
1067 .join("bash-tasks")
1068 .join(hash_session(session_id))
1069 }
1070
1071 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
1072 self.harness_dir()
1073 .join("backups")
1074 .join(hash_session(session_id))
1075 .join(path_hash)
1076 }
1077
1078 pub fn filters_dir(&self) -> PathBuf {
1079 self.harness_dir().join("filters")
1080 }
1081
1082 pub fn trust_file(&self) -> PathBuf {
1084 self.storage_dir().join("trusted-filter-projects.json")
1085 }
1086
1087 pub fn set_canonical_cache_root(&self, root: PathBuf) {
1088 debug_assert!(root.is_absolute());
1089 *self.canonical_cache_root.borrow_mut() = Some(root);
1090 }
1091
1092 pub fn canonical_cache_root(&self) -> PathBuf {
1093 self.canonical_cache_root
1094 .borrow()
1095 .clone()
1096 .expect("canonical_cache_root accessed before handle_configure")
1097 }
1098
1099 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
1100 self.canonical_cache_root.borrow().clone()
1101 }
1102
1103 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
1104 *self.is_worktree_bridge.borrow_mut() = is_worktree_bridge;
1105 *self.git_common_dir.borrow_mut() = git_common_dir;
1106 }
1107
1108 pub fn is_worktree_bridge(&self) -> bool {
1109 *self.is_worktree_bridge.borrow()
1110 }
1111
1112 pub fn git_common_dir(&self) -> Option<PathBuf> {
1113 self.git_common_dir.borrow().clone()
1114 }
1115
1116 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
1120 *self.degraded_reasons.borrow_mut() = reasons;
1121 }
1122
1123 pub fn degraded_reasons(&self) -> Vec<String> {
1127 self.degraded_reasons.borrow().clone()
1128 }
1129
1130 pub fn is_degraded(&self) -> bool {
1132 !self.degraded_reasons.borrow().is_empty()
1133 }
1134
1135 pub fn cache_role(&self) -> &'static str {
1136 if self.canonical_cache_root.borrow().is_none() {
1137 "not_initialized"
1138 } else if self.is_worktree_bridge() {
1139 "worktree"
1140 } else {
1141 "main"
1142 }
1143 }
1144
1145 pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
1147 &self.callgraph
1148 }
1149
1150 pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
1152 &self.search_index
1153 }
1154
1155 pub fn search_index_rx(&self) -> &RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>> {
1157 &self.search_index_rx
1158 }
1159
1160 pub fn add_pending_search_index_paths<I>(&self, paths: I)
1161 where
1162 I: IntoIterator<Item = PathBuf>,
1163 {
1164 self.pending_search_index_paths.borrow_mut().extend(paths);
1165 }
1166
1167 pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
1168 std::mem::take(&mut *self.pending_search_index_paths.borrow_mut())
1169 .into_iter()
1170 .collect()
1171 }
1172
1173 pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
1174 where
1175 I: IntoIterator<Item = PathBuf>,
1176 {
1177 self.pending_semantic_index_paths.borrow_mut().extend(paths);
1178 }
1179
1180 pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
1181 std::mem::take(&mut *self.pending_semantic_index_paths.borrow_mut())
1182 .into_iter()
1183 .collect()
1184 }
1185
1186 pub fn mark_pending_semantic_corpus_refresh(&self) {
1187 *self.pending_semantic_corpus_refresh.borrow_mut() = true;
1188 }
1189
1190 pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
1191 std::mem::take(&mut *self.pending_semantic_corpus_refresh.borrow_mut())
1192 }
1193
1194 pub fn clear_pending_index_updates(&self) {
1195 self.pending_search_index_paths.borrow_mut().clear();
1196 self.pending_semantic_index_paths.borrow_mut().clear();
1197 *self.pending_semantic_corpus_refresh.borrow_mut() = false;
1198 }
1199
1200 pub fn inspect_manager(&self) -> Arc<InspectManager> {
1201 Arc::clone(&self.inspect_manager)
1202 }
1203
1204 pub fn take_new_reuse_completions(&self) -> bool {
1209 let current = self.inspect_manager.reuse_completion_count();
1210 let previous = self
1211 .last_seen_reuse_completions
1212 .swap(current, Ordering::SeqCst);
1213 current != previous
1214 }
1215
1216 pub fn reset_tier2_refresh_scheduler(&self) {
1217 self.reset_tier2_refresh_scheduler_at(Instant::now());
1218 }
1219
1220 #[doc(hidden)]
1221 pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
1222 self.tier2_refresh_scheduler
1223 .borrow_mut()
1224 .reset_after_configure(now);
1225 }
1226
1227 pub fn request_tier2_refresh_pull(&self) -> bool {
1228 self.tier2_refresh_scheduler
1229 .borrow_mut()
1230 .request_pull(!self.is_worktree_bridge())
1231 }
1232
1233 pub fn tick_tier2_refresh_scheduler(
1234 &self,
1235 changed_path_count: usize,
1236 ) -> Option<Tier2TriggerReason> {
1237 self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
1238 }
1239
1240 #[doc(hidden)]
1241 pub fn tick_tier2_refresh_scheduler_at(
1242 &self,
1243 now: Instant,
1244 changed_path_count: usize,
1245 ) -> Option<Tier2TriggerReason> {
1246 let manager = self.inspect_manager();
1247 let can_write = !self.is_worktree_bridge();
1248 let in_flight = manager.tier2_any_in_flight();
1249 let decision = self.tier2_refresh_scheduler.borrow_mut().tick(
1250 now,
1251 changed_path_count,
1252 can_write,
1253 in_flight,
1254 );
1255
1256 if let Some(reason) = decision {
1257 self.start_tier2_refresh(reason, manager);
1258 }
1259
1260 decision
1261 }
1262
1263 pub fn note_tier2_refresh_started(&self) {
1264 self.note_tier2_refresh_started_at(Instant::now());
1265 }
1266
1267 #[doc(hidden)]
1268 pub fn note_tier2_refresh_started_at(&self, now: Instant) {
1269 self.tier2_refresh_scheduler
1270 .borrow_mut()
1271 .note_external_scan_started(now);
1272 }
1273
1274 pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
1275 self.tier2_refresh_scheduler
1276 .borrow()
1277 .last_trigger_reason()
1278 .map(Tier2TriggerReason::as_str)
1279 }
1280
1281 #[doc(hidden)]
1282 pub fn tier2_pull_demand_pending(&self) -> bool {
1283 self.tier2_refresh_scheduler.borrow().pull_demand_pending()
1284 }
1285
1286 fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
1287 if self.is_worktree_bridge() {
1288 return;
1289 }
1290 let Some(snapshot) = self.tier2_refresh_snapshot() else {
1291 return;
1292 };
1293 let categories = InspectCategory::active()
1294 .iter()
1295 .copied()
1296 .filter(|category| category.is_tier2())
1297 .collect::<Vec<_>>();
1298 let submission =
1299 manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
1300 if submission.has_new_work() {
1301 crate::slog_info!(
1302 "tier2 refresh scheduled: reason={}, categories={:?}",
1303 reason.as_str(),
1304 submission
1305 .newly_queued_categories
1306 .iter()
1307 .map(|category| category.as_str())
1308 .collect::<Vec<_>>()
1309 );
1310 }
1311 for error in submission.errors {
1312 crate::slog_warn!(
1313 "tier2 refresh schedule failed for {}: {}",
1314 error.category,
1315 error.message
1316 );
1317 }
1318 }
1319
1320 fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
1321 self.harness_opt()?;
1322 let config = self.config().clone();
1323 let project_root = config
1324 .project_root
1325 .clone()
1326 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1327 let project_root = std::fs::canonicalize(&project_root).unwrap_or(project_root);
1328 Some(InspectSnapshot::new(
1329 project_root,
1330 self.inspect_dir(),
1331 Arc::new(config),
1332 self.symbol_cache(),
1333 ))
1334 }
1335
1336 pub fn symbol_cache(&self) -> SharedSymbolCache {
1338 Arc::clone(&self.symbol_cache)
1339 }
1340
1341 pub fn reset_symbol_cache(&self) -> u64 {
1343 self.symbol_cache
1344 .write()
1345 .map(|mut cache| cache.reset())
1346 .unwrap_or(0)
1347 }
1348
1349 pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
1351 &self.semantic_index
1352 }
1353
1354 pub fn semantic_index_rx(
1356 &self,
1357 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
1358 &self.semantic_index_rx
1359 }
1360
1361 pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
1362 &self.semantic_index_status
1363 }
1364
1365 pub fn install_semantic_refresh_worker(
1366 &self,
1367 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
1368 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
1369 worker_slot: SemanticRefreshWorkerSlot,
1370 ) {
1371 self.clear_semantic_refresh_worker();
1372 *self.semantic_refresh_tx.borrow_mut() = Some(sender);
1373 *self.semantic_refresh_event_rx.borrow_mut() = Some(event_rx);
1374 *self.semantic_refresh_worker.borrow_mut() = Some(worker_slot);
1375 }
1376
1377 pub fn clear_semantic_refresh_worker(&self) {
1378 *self.semantic_refresh_tx.borrow_mut() = None;
1379 *self.semantic_refresh_event_rx.borrow_mut() = None;
1380 if let Some(worker_slot) = self.semantic_refresh_worker.borrow_mut().take() {
1381 if let Ok(mut handle) = worker_slot.lock() {
1382 drop(handle.take());
1383 }
1384 }
1385 }
1386
1387 pub fn semantic_refresh_sender(
1388 &self,
1389 ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
1390 self.semantic_refresh_tx.borrow().clone()
1391 }
1392
1393 pub fn semantic_refresh_event_rx(
1394 &self,
1395 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
1396 &self.semantic_refresh_event_rx
1397 }
1398
1399 pub fn semantic_embedding_model(
1401 &self,
1402 ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
1403 &self.semantic_embedding_model
1404 }
1405
1406 pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
1408 &self.watcher
1409 }
1410
1411 pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
1413 &self.watcher_rx
1414 }
1415
1416 pub fn lsp(&self) -> RefMut<'_, LspManager> {
1418 self.lsp_manager.borrow_mut()
1419 }
1420
1421 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
1424 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1425 let config = self.config();
1426 if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
1427 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1428 }
1429 }
1430 }
1431
1432 pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
1438 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1439 lsp.clear_diagnostics_for_file(file_path)
1440 } else {
1441 false
1442 }
1443 }
1444
1445 pub fn lsp_notify_and_collect_diagnostics(
1456 &self,
1457 file_path: &Path,
1458 content: &str,
1459 timeout: std::time::Duration,
1460 ) -> crate::lsp::manager::PostEditWaitOutcome {
1461 let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
1462 return crate::lsp::manager::PostEditWaitOutcome::default();
1463 };
1464
1465 lsp.drain_events();
1468
1469 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
1473
1474 let config = self.config();
1476 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
1477 {
1478 Ok(v) => v,
1479 Err(e) => {
1480 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1481 return crate::lsp::manager::PostEditWaitOutcome::default();
1482 }
1483 };
1484
1485 if expected_versions.is_empty() {
1488 return crate::lsp::manager::PostEditWaitOutcome::default();
1489 }
1490
1491 lsp.wait_for_post_edit_diagnostics(
1492 file_path,
1493 &config,
1494 &expected_versions,
1495 &pre_snapshot,
1496 timeout,
1497 )
1498 }
1499
1500 fn custom_lsp_root_markers(&self) -> Vec<String> {
1503 self.config()
1504 .lsp_servers
1505 .iter()
1506 .flat_map(|s| s.root_markers.iter().cloned())
1507 .collect()
1508 }
1509
1510 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
1511 let custom_markers = self.custom_lsp_root_markers();
1512 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
1513 .iter()
1514 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
1515 .cloned()
1516 .map(|path| {
1517 let change_type = if path.exists() {
1518 FileChangeType::CHANGED
1519 } else {
1520 FileChangeType::DELETED
1521 };
1522 (path, change_type)
1523 })
1524 .collect();
1525
1526 self.notify_watched_config_events(&config_paths);
1527 }
1528
1529 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
1530 let paths = params
1531 .get("multi_file_write_paths")
1532 .and_then(|value| value.as_array())?
1533 .iter()
1534 .filter_map(|value| value.as_str())
1535 .map(PathBuf::from)
1536 .collect::<Vec<_>>();
1537
1538 (!paths.is_empty()).then_some(paths)
1539 }
1540
1541 fn watched_file_events_from_params(
1553 params: &serde_json::Value,
1554 extra_markers: &[String],
1555 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
1556 let events = params
1557 .get("multi_file_write_paths")
1558 .and_then(|value| value.as_array())?
1559 .iter()
1560 .filter_map(|entry| {
1561 let path = entry
1563 .get("path")
1564 .and_then(|value| value.as_str())
1565 .map(PathBuf::from)?;
1566
1567 if !is_config_file_path_with_custom(&path, extra_markers) {
1568 return None;
1569 }
1570
1571 let change_type = entry
1572 .get("type")
1573 .and_then(|value| value.as_str())
1574 .and_then(Self::parse_file_change_type)
1575 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
1576
1577 Some((path, change_type))
1578 })
1579 .collect::<Vec<_>>();
1580
1581 (!events.is_empty()).then_some(events)
1582 }
1583
1584 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
1585 match value {
1586 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
1587 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
1588 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
1589 _ => None,
1590 }
1591 }
1592
1593 fn change_type_from_current_state(path: &Path) -> FileChangeType {
1594 if path.exists() {
1595 FileChangeType::CHANGED
1596 } else {
1597 FileChangeType::DELETED
1598 }
1599 }
1600
1601 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
1602 if config_paths.is_empty() {
1603 return;
1604 }
1605
1606 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1607 let config = self.config();
1608 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
1609 crate::slog_warn!("watched-file sync error: {}", e);
1610 }
1611 }
1612 }
1613
1614 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
1615 let custom_markers = self.custom_lsp_root_markers();
1616 if !is_config_file_path_with_custom(file_path, &custom_markers) {
1617 return;
1618 }
1619
1620 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
1621 }
1622
1623 pub fn lsp_post_multi_file_write(
1628 &self,
1629 file_path: &Path,
1630 content: &str,
1631 file_paths: &[PathBuf],
1632 params: &serde_json::Value,
1633 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
1634 self.notify_watched_config_files(file_paths);
1635
1636 let wants_diagnostics = params
1637 .get("diagnostics")
1638 .and_then(|v| v.as_bool())
1639 .unwrap_or(false);
1640
1641 if !wants_diagnostics {
1642 self.lsp_notify_file_changed(file_path, content);
1643 return None;
1644 }
1645
1646 let wait_ms = params
1647 .get("wait_ms")
1648 .and_then(|v| v.as_u64())
1649 .unwrap_or(3000)
1650 .min(10_000);
1651
1652 Some(self.lsp_notify_and_collect_diagnostics(
1653 file_path,
1654 content,
1655 std::time::Duration::from_millis(wait_ms),
1656 ))
1657 }
1658
1659 pub fn lsp_post_write(
1676 &self,
1677 file_path: &Path,
1678 content: &str,
1679 params: &serde_json::Value,
1680 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
1681 let wants_diagnostics = params
1682 .get("diagnostics")
1683 .and_then(|v| v.as_bool())
1684 .unwrap_or(false);
1685
1686 let custom_markers = self.custom_lsp_root_markers();
1687
1688 if !wants_diagnostics {
1689 if let Some(file_paths) = Self::multi_file_write_paths(params) {
1690 self.notify_watched_config_files(&file_paths);
1691 } else if let Some(config_events) =
1692 Self::watched_file_events_from_params(params, &custom_markers)
1693 {
1694 self.notify_watched_config_events(&config_events);
1695 }
1696 self.lsp_notify_file_changed(file_path, content);
1697 return None;
1698 }
1699
1700 let wait_ms = params
1701 .get("wait_ms")
1702 .and_then(|v| v.as_u64())
1703 .unwrap_or(3000)
1704 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
1707 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
1708 }
1709
1710 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
1711 {
1712 self.notify_watched_config_events(&config_events);
1713 }
1714
1715 Some(self.lsp_notify_and_collect_diagnostics(
1716 file_path,
1717 content,
1718 std::time::Duration::from_millis(wait_ms),
1719 ))
1720 }
1721
1722 pub fn validate_path(
1731 &self,
1732 req_id: &str,
1733 path: &Path,
1734 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
1735 let config = self.config();
1736 if !config.restrict_to_project_root {
1738 return Ok(path.to_path_buf());
1739 }
1740 let root = match &config.project_root {
1741 Some(r) => r.clone(),
1742 None => return Ok(path.to_path_buf()), };
1744 drop(config);
1745
1746 let raw_root = root.clone();
1751 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
1752
1753 let path_for_resolution = if path.is_relative() {
1758 raw_root.join(path)
1759 } else {
1760 path.to_path_buf()
1761 };
1762 let resolved = match std::fs::canonicalize(&path_for_resolution) {
1763 Ok(resolved) => resolved,
1764 Err(_) => {
1765 let normalized = normalize_path(&path_for_resolution);
1766 reject_escaping_symlink(
1767 req_id,
1768 &path_for_resolution,
1769 &normalized,
1770 &resolved_root,
1771 &raw_root,
1772 )?;
1773 resolve_with_existing_ancestors(&normalized)
1774 }
1775 };
1776
1777 if !resolved.starts_with(&resolved_root) {
1778 return Err(path_error_response(req_id, path, &resolved_root));
1779 }
1780
1781 Ok(resolved)
1782 }
1783
1784 pub fn lsp_server_count(&self) -> usize {
1786 self.lsp_manager
1787 .try_borrow()
1788 .map(|lsp| lsp.server_count())
1789 .unwrap_or(0)
1790 }
1791
1792 pub fn symbol_cache_stats(&self) -> serde_json::Value {
1794 let entries = self
1795 .symbol_cache
1796 .read()
1797 .map(|cache| cache.len())
1798 .unwrap_or(0);
1799 serde_json::json!({
1800 "local_entries": entries,
1801 "warm_entries": 0,
1802 })
1803 }
1804}
1805
1806#[cfg(test)]
1807mod status_emitter_tests {
1808 use super::*;
1809 use crate::parser::TreeSitterProvider;
1810
1811 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
1812 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
1813 let (tx, rx) = mpsc::channel();
1814 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
1815 let _ = tx.send(frame);
1816 }))));
1817 (ctx, rx)
1818 }
1819
1820 #[test]
1821 fn status_emitter_signal_triggers_push() {
1822 let (ctx, rx) = ctx_with_frame_rx();
1823 ctx.status_emitter().signal(ctx.build_status_snapshot());
1824 let frame = rx
1825 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1826 .expect("status_changed push");
1827 assert!(matches!(frame, PushFrame::StatusChanged(_)));
1828 }
1829
1830 #[test]
1831 fn status_emitter_debounces_burst() {
1832 let (ctx, rx) = ctx_with_frame_rx();
1833 for _ in 0..10 {
1834 ctx.status_emitter().signal(ctx.build_status_snapshot());
1835 }
1836 let frame = rx
1837 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1838 .expect("status_changed push");
1839 assert!(matches!(frame, PushFrame::StatusChanged(_)));
1840 assert!(rx.try_recv().is_err());
1841 }
1842
1843 #[test]
1844 fn status_emitter_separate_windows_separate_pushes() {
1845 let (ctx, rx) = ctx_with_frame_rx();
1846 ctx.status_emitter().signal(ctx.build_status_snapshot());
1847 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1848 .expect("first push");
1849 ctx.status_emitter().signal(ctx.build_status_snapshot());
1850 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1851 .expect("second push");
1852 }
1853
1854 #[test]
1855 fn status_emitter_no_signal_no_push() {
1856 let (_ctx, rx) = ctx_with_frame_rx();
1857 assert!(rx
1858 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
1859 .is_err());
1860 }
1861
1862 #[test]
1863 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
1864 let (ctx, rx) = ctx_with_frame_rx();
1865 drop(ctx);
1866 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
1867 }
1868}
1869
1870#[cfg(test)]
1871mod status_bar_tests {
1872 use super::*;
1873 use crate::parser::TreeSitterProvider;
1874
1875 fn ctx() -> AppContext {
1876 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
1877 }
1878
1879 #[test]
1880 fn status_bar_counts_none_until_tier2_populated() {
1881 let ctx = ctx();
1882 assert!(ctx.status_bar_counts().is_none());
1884
1885 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
1886 let counts = ctx.status_bar_counts().expect("populated");
1887 assert_eq!(counts.dead_code, 5);
1888 assert_eq!(counts.unused_exports, 3);
1889 assert_eq!(counts.duplicates, 7);
1890 assert_eq!(counts.todos, 2);
1891 assert!(!counts.tier2_stale);
1892 assert_eq!(counts.errors, 0);
1894 assert_eq!(counts.warnings, 0);
1895 }
1896
1897 #[test]
1898 fn partial_tier2_does_not_fabricate_zeros() {
1899 let ctx = ctx();
1900 ctx.update_status_bar_tier2(Some(5), None, None, None, true);
1904 assert!(
1905 ctx.status_bar_counts().is_none(),
1906 "bar must not surface until all three Tier-2 categories are real"
1907 );
1908
1909 ctx.update_status_bar_tier2(None, Some(3), None, None, true);
1911 assert!(ctx.status_bar_counts().is_none());
1912
1913 ctx.update_status_bar_tier2(None, None, Some(7), None, false);
1916 let counts = ctx.status_bar_counts().expect("all three real now");
1917 assert_eq!(counts.dead_code, 5);
1918 assert_eq!(counts.unused_exports, 3);
1919 assert_eq!(counts.duplicates, 7);
1920 }
1921
1922 #[test]
1923 fn update_with_none_todos_preserves_last_known_todos() {
1924 let ctx = ctx();
1925 ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
1926 ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
1928 let counts = ctx.status_bar_counts().expect("populated");
1929 assert_eq!(counts.todos, 9);
1930 assert_eq!(counts.dead_code, 2);
1931 }
1932
1933 #[test]
1934 fn update_with_none_count_preserves_last_known_count() {
1935 let ctx = ctx();
1936 ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
1937 ctx.update_status_bar_tier2(Some(11), None, None, None, false);
1940 let counts = ctx.status_bar_counts().expect("populated");
1941 assert_eq!(counts.dead_code, 11);
1942 assert_eq!(counts.unused_exports, 20);
1943 assert_eq!(counts.duplicates, 30);
1944 }
1945
1946 #[test]
1947 fn mark_stale_sets_flag_only_after_populate() {
1948 let ctx = ctx();
1949 ctx.mark_status_bar_tier2_stale();
1951 assert!(ctx.status_bar_counts().is_none());
1952
1953 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), Some(0), false);
1954 ctx.mark_status_bar_tier2_stale();
1955 assert!(ctx.status_bar_counts().expect("populated").tier2_stale);
1956
1957 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), None, false);
1959 assert!(!ctx.status_bar_counts().expect("populated").tier2_stale);
1960 }
1961
1962 #[test]
1967 fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
1968 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
1969 use crate::lsp::registry::ServerKind;
1970 use crate::lsp::roots::ServerKey;
1971
1972 let ctx = ctx();
1973 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); let file = std::path::PathBuf::from("/proj/gone.ts");
1976 {
1977 let mut lsp = ctx.lsp();
1978 lsp.diagnostics_store_mut_for_test().publish(
1979 ServerKey {
1980 kind: ServerKind::TypeScript,
1981 root: std::path::PathBuf::from("/proj"),
1982 },
1983 file.clone(),
1984 vec![StoredDiagnostic {
1985 file: file.clone(),
1986 line: 1,
1987 column: 1,
1988 end_line: 1,
1989 end_column: 2,
1990 severity: DiagnosticSeverity::Error,
1991 message: "boom".into(),
1992 code: None,
1993 source: None,
1994 }],
1995 );
1996 }
1997
1998 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
2000
2001 let removed = ctx.lsp_clear_diagnostics_for_file(&file);
2003 assert!(removed);
2004 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
2005 }
2006}
2007
2008#[cfg(test)]
2009mod harness_path_tests {
2010 use super::*;
2011 use crate::harness::Harness;
2012 use crate::parser::TreeSitterProvider;
2013
2014 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
2015 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2016 ctx.config_mut().storage_dir = Some(storage_dir);
2017 ctx.set_harness(harness);
2018 ctx
2019 }
2020
2021 #[test]
2022 fn harness_dir_resolves_correctly() {
2023 let storage = PathBuf::from("/tmp/cortexkit/aft");
2024 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2025
2026 assert_eq!(ctx.harness_dir(), storage.join("pi"));
2027 }
2028
2029 #[test]
2030 fn bash_tasks_dir_uses_hash_session() {
2031 let storage = PathBuf::from("/tmp/cortexkit/aft");
2032 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2033
2034 assert_eq!(
2035 ctx.bash_tasks_dir("ses_abc"),
2036 storage
2037 .join("opencode")
2038 .join("bash-tasks")
2039 .join(hash_session("ses_abc"))
2040 );
2041 }
2042
2043 #[test]
2044 fn backups_dir_includes_path_hash() {
2045 let storage = PathBuf::from("/tmp/cortexkit/aft");
2046 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2047
2048 assert_eq!(
2049 ctx.backups_dir("ses_abc", "pathhash"),
2050 storage
2051 .join("pi")
2052 .join("backups")
2053 .join(hash_session("ses_abc"))
2054 .join("pathhash")
2055 );
2056 }
2057
2058 #[test]
2059 fn filters_dir_under_harness() {
2060 let storage = PathBuf::from("/tmp/cortexkit/aft");
2061 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2062
2063 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
2064 }
2065
2066 #[test]
2067 fn trust_file_is_host_global() {
2068 let storage = PathBuf::from("/tmp/cortexkit/aft");
2069 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2070
2071 assert_eq!(
2072 ctx.trust_file(),
2073 storage.join("trusted-filter-projects.json")
2074 );
2075 }
2076
2077 #[test]
2078 fn same_session_different_harness_resolve_different_paths() {
2079 let storage = PathBuf::from("/tmp/cortexkit/aft");
2080 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2081 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
2082
2083 assert_ne!(
2084 opencode.bash_tasks_dir("ses_same"),
2085 pi.bash_tasks_dir("ses_same")
2086 );
2087 }
2088}
2089
2090#[cfg(test)]
2091mod gitignore_tests {
2092 use super::*;
2093 use std::fs;
2094 use std::path::Path;
2095 use tempfile::TempDir;
2096
2097 fn make_ctx_with_root(root: &Path) -> AppContext {
2098 let provider = Box::new(crate::parser::TreeSitterProvider::new());
2099 let config = Config {
2100 project_root: Some(root.to_path_buf()),
2101 ..Config::default()
2102 };
2103 AppContext::new(provider, config)
2104 }
2105
2106 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
2113 let Some(matcher) = ctx.gitignore() else {
2114 return false;
2115 };
2116 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
2117 if !canonical.starts_with(matcher.path()) {
2118 return false;
2119 }
2120 let is_dir = canonical.is_dir();
2121 matcher
2122 .matched_path_or_any_parents(&canonical, is_dir)
2123 .is_ignore()
2124 }
2125
2126 fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
2137 use std::sync::{Mutex, OnceLock};
2138 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2139 let _guard = LOCK
2140 .get_or_init(|| Mutex::new(()))
2141 .lock()
2142 .unwrap_or_else(|e| e.into_inner());
2143 let tmp = TempDir::new().unwrap();
2144 let prev = std::env::var_os("XDG_CONFIG_HOME");
2145 unsafe {
2147 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
2148 }
2149 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
2150 unsafe {
2151 match prev {
2152 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
2153 None => std::env::remove_var("XDG_CONFIG_HOME"),
2154 }
2155 }
2156 match result {
2157 Ok(r) => r,
2158 Err(p) => std::panic::resume_unwind(p),
2159 }
2160 }
2161
2162 #[test]
2163 fn rebuild_gitignore_returns_none_without_project_root() {
2164 let provider = Box::new(crate::parser::TreeSitterProvider::new());
2165 let ctx = AppContext::new(provider, Config::default());
2166 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2167 assert!(ctx.gitignore().is_none());
2168 }
2169
2170 #[test]
2171 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
2172 let tmp = TempDir::new().unwrap();
2173 let ctx = make_ctx_with_root(tmp.path());
2174 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2175 assert!(ctx.gitignore().is_none());
2176 }
2177
2178 #[test]
2179 fn matcher_filters_files_in_ignored_dist_dir() {
2180 let tmp = TempDir::new().unwrap();
2181 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
2182 fs::create_dir_all(tmp.path().join("dist")).unwrap();
2183 fs::create_dir_all(tmp.path().join("src")).unwrap();
2184 let dist_file = tmp.path().join("dist").join("bundle.js");
2185 let src_file = tmp.path().join("src").join("app.ts");
2186 fs::write(&dist_file, "x").unwrap();
2187 fs::write(&src_file, "y").unwrap();
2188
2189 let ctx = make_ctx_with_root(tmp.path());
2190 ctx.rebuild_gitignore();
2191
2192 assert!(ctx.gitignore().is_some());
2193 assert!(
2194 is_ignored(&ctx, &dist_file),
2195 "dist/bundle.js should be ignored"
2196 );
2197 assert!(
2198 !is_ignored(&ctx, &src_file),
2199 "src/app.ts should NOT be ignored"
2200 );
2201 }
2202
2203 #[test]
2204 fn matcher_handles_node_modules_and_target() {
2205 let tmp = TempDir::new().unwrap();
2206 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
2207 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
2208 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
2209 let nm_file = tmp.path().join("node_modules/foo/index.js");
2210 let target_file = tmp.path().join("target/debug/aft");
2211 fs::write(&nm_file, "x").unwrap();
2212 fs::write(&target_file, "x").unwrap();
2213
2214 let ctx = make_ctx_with_root(tmp.path());
2215 ctx.rebuild_gitignore();
2216
2217 assert!(is_ignored(&ctx, &nm_file));
2218 assert!(is_ignored(&ctx, &target_file));
2219 }
2220
2221 #[test]
2222 fn matcher_honors_negation_pattern() {
2223 let tmp = TempDir::new().unwrap();
2225 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
2226 let random_log = tmp.path().join("random.log");
2227 let important_log = tmp.path().join("important.log");
2228 fs::write(&random_log, "x").unwrap();
2229 fs::write(&important_log, "y").unwrap();
2230
2231 let ctx = make_ctx_with_root(tmp.path());
2232 ctx.rebuild_gitignore();
2233
2234 assert!(is_ignored(&ctx, &random_log));
2235 assert!(
2236 !is_ignored(&ctx, &important_log),
2237 "negation pattern should un-ignore important.log"
2238 );
2239 }
2240
2241 #[test]
2242 fn rebuild_picks_up_gitignore_changes() {
2243 let tmp = TempDir::new().unwrap();
2244 let ignore_path = tmp.path().join(".gitignore");
2245 fs::write(&ignore_path, "foo.txt\n").unwrap();
2246 let foo = tmp.path().join("foo.txt");
2247 let bar = tmp.path().join("bar.txt");
2248 fs::write(&foo, "").unwrap();
2249 fs::write(&bar, "").unwrap();
2250
2251 let ctx = make_ctx_with_root(tmp.path());
2252 ctx.rebuild_gitignore();
2253 assert!(is_ignored(&ctx, &foo));
2254 assert!(!is_ignored(&ctx, &bar));
2255
2256 fs::write(&ignore_path, "bar.txt\n").unwrap();
2258 ctx.rebuild_gitignore();
2259 assert!(!is_ignored(&ctx, &foo));
2260 assert!(is_ignored(&ctx, &bar));
2261 }
2262
2263 #[test]
2264 fn gitignore_loads_info_exclude_when_present() {
2265 let tmp = TempDir::new().unwrap();
2266 let info_dir = tmp.path().join(".git/info");
2267 fs::create_dir_all(&info_dir).unwrap();
2268 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
2269 let secrets = tmp.path().join("secrets.txt");
2270 let public = tmp.path().join("public.txt");
2271 fs::write(&secrets, "token").unwrap();
2272 fs::write(&public, "ok").unwrap();
2273
2274 let ctx = make_ctx_with_root(tmp.path());
2275 ctx.rebuild_gitignore();
2276
2277 assert!(is_ignored(&ctx, &secrets));
2278 assert!(!is_ignored(&ctx, &public));
2279 }
2280
2281 #[test]
2282 fn matcher_picks_up_nested_gitignore() {
2283 let tmp = TempDir::new().unwrap();
2284 fs::write(tmp.path().join(".gitignore"), "").unwrap();
2286 let sub = tmp.path().join("packages/foo");
2287 fs::create_dir_all(&sub).unwrap();
2288 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
2289 let generated_file = sub.join("generated").join("out.js");
2290 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
2291 fs::write(&generated_file, "x").unwrap();
2292
2293 let ctx = make_ctx_with_root(tmp.path());
2294 ctx.rebuild_gitignore();
2295
2296 assert!(
2297 is_ignored(&ctx, &generated_file),
2298 "nested gitignore in packages/foo/.gitignore should ignore generated/"
2299 );
2300 }
2301}