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) -> bool {
660 let mut tier2 = self.status_bar_tier2.borrow_mut();
661 if tier2.dead_code.is_some() && tier2.unused_exports.is_some() && tier2.duplicates.is_some()
663 {
664 let changed = !tier2.stale;
665 tier2.stale = true;
666 return changed;
667 }
668 false
669 }
670
671 pub fn update_status_bar_tier2(
677 &self,
678 dead_code: Option<usize>,
679 unused_exports: Option<usize>,
680 duplicates: Option<usize>,
681 todos: Option<usize>,
682 stale: bool,
683 ) {
684 let mut tier2 = self.status_bar_tier2.borrow_mut();
685 if let Some(dead_code) = dead_code {
686 tier2.dead_code = Some(dead_code);
687 }
688 if let Some(unused_exports) = unused_exports {
689 tier2.unused_exports = Some(unused_exports);
690 }
691 if let Some(duplicates) = duplicates {
692 tier2.duplicates = Some(duplicates);
693 }
694 if let Some(todos) = todos {
695 tier2.todos = Some(todos);
696 }
697 tier2.stale = stale;
698 }
699
700 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
703 self.gitignore.borrow().clone()
704 }
705
706 pub fn clear_gitignore(&self) {
728 *self.gitignore.borrow_mut() = None;
729 }
730
731 pub fn rebuild_gitignore(&self) {
732 use ignore::gitignore::GitignoreBuilder;
733 use std::path::Path;
734 let root_raw = match self.config().project_root.clone() {
735 Some(r) => r,
736 None => {
737 *self.gitignore.borrow_mut() = None;
738 return;
739 }
740 };
741 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
749 let mut builder = GitignoreBuilder::new(&root);
750 if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
755 if global_ignore.is_file() {
756 if let Some(err) = builder.add(&global_ignore) {
757 crate::slog_warn!(
758 "global gitignore parse error in {}: {}",
759 global_ignore.display(),
760 err
761 );
762 }
763 }
764 }
765 let root_ignore = Path::new(&root).join(".gitignore");
767 if root_ignore.exists() {
768 if let Some(err) = builder.add(&root_ignore) {
769 crate::slog_warn!(
770 "gitignore parse error in {}: {}",
771 root_ignore.display(),
772 err
773 );
774 }
775 }
776 let root_aftignore = Path::new(&root).join(".aftignore");
781 if root_aftignore.exists() {
782 if let Some(err) = builder.add(&root_aftignore) {
783 crate::slog_warn!(
784 "aftignore parse error in {}: {}",
785 root_aftignore.display(),
786 err
787 );
788 }
789 }
790 let info_exclude = self
795 .git_common_dir
796 .borrow()
797 .clone()
798 .unwrap_or_else(|| Path::new(&root).join(".git"))
799 .join("info")
800 .join("exclude");
801 if info_exclude.exists() {
802 if let Some(err) = builder.add(&info_exclude) {
803 crate::slog_warn!(
804 "gitignore parse error in {}: {}",
805 info_exclude.display(),
806 err
807 );
808 }
809 }
810 let walker = ignore::WalkBuilder::new(&root)
816 .standard_filters(true)
817 .hidden(false)
825 .filter_entry(|entry| {
826 let name = entry.file_name().to_string_lossy();
827 !matches!(
828 name.as_ref(),
829 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
830 )
831 })
832 .build();
833 for entry in walker.flatten() {
834 let file_name = entry.file_name();
835 let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
836 let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
837 if is_nested_gitignore || is_nested_aftignore {
838 if let Some(err) = builder.add(entry.path()) {
839 crate::slog_warn!(
840 "nested ignore parse error in {}: {}",
841 entry.path().display(),
842 err
843 );
844 }
845 }
846 }
847 match builder.build() {
848 Ok(gi) => {
849 let count = gi.num_ignores();
850 if count > 0 {
851 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
852 *self.gitignore.borrow_mut() = Some(Arc::new(gi));
853 } else {
854 *self.gitignore.borrow_mut() = None;
855 }
856 }
857 Err(err) => {
858 crate::slog_warn!("gitignore matcher build failed: {}", err);
859 *self.gitignore.borrow_mut() = None;
860 }
861 }
862 }
863
864 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
867 Arc::clone(&self.bash_compress_flag)
868 }
869
870 pub fn sync_bash_compress_flag(&self) {
874 let value = self.config().experimental_bash_compress;
875 self.bash_compress_flag
876 .store(value, std::sync::atomic::Ordering::Relaxed);
877 }
878
879 pub fn set_bash_compress_enabled(&self, enabled: bool) {
880 self.config_mut().experimental_bash_compress = enabled;
881 self.bash_compress_flag
882 .store(enabled, std::sync::atomic::Ordering::Relaxed);
883 }
884
885 pub fn filter_registry(
889 &self,
890 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
891 self.ensure_filter_registry_loaded();
892 match self.filter_registry.read() {
893 Ok(g) => g,
894 Err(poisoned) => poisoned.into_inner(),
895 }
896 }
897
898 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
902 self.ensure_filter_registry_loaded();
903 Arc::clone(&self.filter_registry)
904 }
905
906 pub fn reset_filter_registry(&self) {
910 let new_registry = crate::compress::build_registry_for_context(self);
911 match self.filter_registry.write() {
912 Ok(mut slot) => *slot = new_registry,
913 Err(poisoned) => *poisoned.into_inner() = new_registry,
914 }
915 self.filter_registry_loaded
916 .store(true, std::sync::atomic::Ordering::Release);
917 }
918
919 fn ensure_filter_registry_loaded(&self) {
920 use std::sync::atomic::Ordering;
921 if self.filter_registry_loaded.load(Ordering::Acquire) {
922 return;
923 }
924 let new_registry = crate::compress::build_registry_for_context(self);
927 if let Ok(mut slot) = self.filter_registry.write() {
928 *slot = new_registry;
929 self.filter_registry_loaded.store(true, Ordering::Release);
930 }
931 }
932
933 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
936 self.lsp_child_registry.clone()
937 }
938
939 pub fn stdout_writer(&self) -> SharedStdoutWriter {
940 Arc::clone(&self.stdout_writer)
941 }
942
943 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
944 if let Ok(mut progress_sender) = self.progress_sender.lock() {
945 *progress_sender = sender;
946 }
947 }
948
949 pub fn emit_progress(&self, frame: ProgressFrame) {
950 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
951 return;
952 };
953 if let Some(sender) = progress_sender.as_ref() {
954 sender(PushFrame::Progress(frame));
955 }
956 }
957
958 pub fn status_emitter(&self) -> &StatusEmitter {
959 &self.status_emitter
960 }
961
962 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
970 self.progress_sender
971 .lock()
972 .ok()
973 .and_then(|sender| sender.clone())
974 }
975
976 pub fn advance_configure_generation(&self) -> u64 {
977 self.configure_generation
978 .fetch_add(1, Ordering::SeqCst)
979 .wrapping_add(1)
980 }
981
982 pub fn configure_generation(&self) -> u64 {
983 self.configure_generation.load(Ordering::SeqCst)
984 }
985
986 pub fn configure_warnings_sender(&self) -> mpsc::Sender<(u64, ConfigureWarningsFrame)> {
987 self.configure_warnings_tx.clone()
988 }
989
990 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
991 let mut warnings = Vec::new();
992 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
993 warnings.push(warning);
994 }
995 warnings
996 }
997
998 pub fn bash_background(&self) -> &BgTaskRegistry {
999 &self.bash_background
1000 }
1001
1002 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
1003 self.bash_background.drain_completions()
1004 }
1005
1006 pub fn provider(&self) -> &dyn LanguageProvider {
1008 self.provider.as_ref()
1009 }
1010
1011 pub fn backup(&self) -> &RefCell<BackupStore> {
1013 &self.backup
1014 }
1015
1016 pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
1018 &self.checkpoint
1019 }
1020
1021 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
1022 *self.db.borrow_mut() = Some(conn);
1023 }
1024
1025 pub fn clear_db(&self) {
1026 *self.db.borrow_mut() = None;
1027 }
1028
1029 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
1030 self.db.borrow().clone()
1031 }
1032
1033 pub fn config(&self) -> Ref<'_, Config> {
1035 self.config.borrow()
1036 }
1037
1038 pub fn config_mut(&self) -> RefMut<'_, Config> {
1040 self.config.borrow_mut()
1041 }
1042
1043 pub fn set_harness(&self, harness: Harness) {
1044 *self.harness.borrow_mut() = Some(harness);
1045 self.bash_background.set_harness(harness);
1046 }
1047
1048 pub fn harness_opt(&self) -> Option<Harness> {
1049 *self.harness.borrow()
1050 }
1051
1052 pub fn harness(&self) -> Harness {
1053 self.harness_opt()
1054 .expect("harness set by configure before any tool call")
1055 }
1056
1057 pub fn storage_dir(&self) -> PathBuf {
1058 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
1059 }
1060
1061 pub fn harness_dir(&self) -> PathBuf {
1062 self.storage_dir().join(self.harness().as_str())
1063 }
1064
1065 pub fn inspect_dir(&self) -> PathBuf {
1066 self.harness_dir().join("inspect")
1067 }
1068
1069 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
1070 self.harness_dir()
1071 .join("bash-tasks")
1072 .join(hash_session(session_id))
1073 }
1074
1075 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
1076 self.harness_dir()
1077 .join("backups")
1078 .join(hash_session(session_id))
1079 .join(path_hash)
1080 }
1081
1082 pub fn filters_dir(&self) -> PathBuf {
1083 self.harness_dir().join("filters")
1084 }
1085
1086 pub fn trust_file(&self) -> PathBuf {
1088 self.storage_dir().join("trusted-filter-projects.json")
1089 }
1090
1091 pub fn set_canonical_cache_root(&self, root: PathBuf) {
1092 debug_assert!(root.is_absolute());
1093 *self.canonical_cache_root.borrow_mut() = Some(root);
1094 }
1095
1096 pub fn canonical_cache_root(&self) -> PathBuf {
1097 self.canonical_cache_root
1098 .borrow()
1099 .clone()
1100 .expect("canonical_cache_root accessed before handle_configure")
1101 }
1102
1103 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
1104 self.canonical_cache_root.borrow().clone()
1105 }
1106
1107 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
1108 *self.is_worktree_bridge.borrow_mut() = is_worktree_bridge;
1109 *self.git_common_dir.borrow_mut() = git_common_dir;
1110 }
1111
1112 pub fn is_worktree_bridge(&self) -> bool {
1113 *self.is_worktree_bridge.borrow()
1114 }
1115
1116 pub fn git_common_dir(&self) -> Option<PathBuf> {
1117 self.git_common_dir.borrow().clone()
1118 }
1119
1120 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
1124 *self.degraded_reasons.borrow_mut() = reasons;
1125 }
1126
1127 pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
1128 let reason = reason.into();
1129 let mut reasons = self.degraded_reasons.borrow_mut();
1130 if reasons.iter().any(|existing| existing == &reason) {
1131 return false;
1132 }
1133 reasons.push(reason);
1134 true
1135 }
1136
1137 pub fn degraded_reasons(&self) -> Vec<String> {
1141 self.degraded_reasons.borrow().clone()
1142 }
1143
1144 pub fn is_degraded(&self) -> bool {
1146 !self.degraded_reasons.borrow().is_empty()
1147 }
1148
1149 pub fn cache_role(&self) -> &'static str {
1150 if self.canonical_cache_root.borrow().is_none() {
1151 "not_initialized"
1152 } else if self.is_worktree_bridge() {
1153 "worktree"
1154 } else {
1155 "main"
1156 }
1157 }
1158
1159 pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
1161 &self.callgraph
1162 }
1163
1164 pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
1166 &self.search_index
1167 }
1168
1169 pub fn search_index_rx(&self) -> &RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>> {
1171 &self.search_index_rx
1172 }
1173
1174 pub fn add_pending_search_index_paths<I>(&self, paths: I)
1175 where
1176 I: IntoIterator<Item = PathBuf>,
1177 {
1178 self.pending_search_index_paths.borrow_mut().extend(paths);
1179 }
1180
1181 pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
1182 std::mem::take(&mut *self.pending_search_index_paths.borrow_mut())
1183 .into_iter()
1184 .collect()
1185 }
1186
1187 pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
1188 where
1189 I: IntoIterator<Item = PathBuf>,
1190 {
1191 self.pending_semantic_index_paths.borrow_mut().extend(paths);
1192 }
1193
1194 pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
1195 std::mem::take(&mut *self.pending_semantic_index_paths.borrow_mut())
1196 .into_iter()
1197 .collect()
1198 }
1199
1200 pub fn mark_pending_semantic_corpus_refresh(&self) {
1201 *self.pending_semantic_corpus_refresh.borrow_mut() = true;
1202 }
1203
1204 pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
1205 std::mem::take(&mut *self.pending_semantic_corpus_refresh.borrow_mut())
1206 }
1207
1208 pub fn clear_pending_index_updates(&self) {
1209 self.pending_search_index_paths.borrow_mut().clear();
1210 self.pending_semantic_index_paths.borrow_mut().clear();
1211 *self.pending_semantic_corpus_refresh.borrow_mut() = false;
1212 }
1213
1214 pub fn inspect_manager(&self) -> Arc<InspectManager> {
1215 Arc::clone(&self.inspect_manager)
1216 }
1217
1218 pub fn take_new_reuse_completions(&self) -> bool {
1223 let current = self.inspect_manager.reuse_completion_count();
1224 let previous = self
1225 .last_seen_reuse_completions
1226 .swap(current, Ordering::SeqCst);
1227 current != previous
1228 }
1229
1230 pub fn reset_tier2_refresh_scheduler(&self) {
1231 self.reset_tier2_refresh_scheduler_at(Instant::now());
1232 }
1233
1234 #[doc(hidden)]
1235 pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
1236 self.tier2_refresh_scheduler
1237 .borrow_mut()
1238 .reset_after_configure(now);
1239 }
1240
1241 pub fn request_tier2_refresh_pull(&self) -> bool {
1242 self.tier2_refresh_scheduler
1243 .borrow_mut()
1244 .request_pull(!self.is_worktree_bridge())
1245 }
1246
1247 pub fn tick_tier2_refresh_scheduler(
1248 &self,
1249 changed_path_count: usize,
1250 ) -> Option<Tier2TriggerReason> {
1251 self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
1252 }
1253
1254 #[doc(hidden)]
1255 pub fn tick_tier2_refresh_scheduler_at(
1256 &self,
1257 now: Instant,
1258 changed_path_count: usize,
1259 ) -> Option<Tier2TriggerReason> {
1260 let manager = self.inspect_manager();
1261 let can_write = !self.is_worktree_bridge();
1262 let in_flight = manager.tier2_any_in_flight();
1263 let decision = self.tier2_refresh_scheduler.borrow_mut().tick(
1264 now,
1265 changed_path_count,
1266 can_write,
1267 in_flight,
1268 );
1269
1270 if let Some(reason) = decision {
1271 self.start_tier2_refresh(reason, manager);
1272 }
1273
1274 decision
1275 }
1276
1277 pub fn note_tier2_refresh_started(&self) {
1278 self.note_tier2_refresh_started_at(Instant::now());
1279 }
1280
1281 #[doc(hidden)]
1282 pub fn note_tier2_refresh_started_at(&self, now: Instant) {
1283 self.tier2_refresh_scheduler
1284 .borrow_mut()
1285 .note_external_scan_started(now);
1286 }
1287
1288 pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
1289 self.tier2_refresh_scheduler
1290 .borrow()
1291 .last_trigger_reason()
1292 .map(Tier2TriggerReason::as_str)
1293 }
1294
1295 #[doc(hidden)]
1296 pub fn tier2_pull_demand_pending(&self) -> bool {
1297 self.tier2_refresh_scheduler.borrow().pull_demand_pending()
1298 }
1299
1300 fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
1301 if self.is_worktree_bridge() {
1302 return;
1303 }
1304 let Some(snapshot) = self.tier2_refresh_snapshot() else {
1305 return;
1306 };
1307 let categories = InspectCategory::active()
1308 .iter()
1309 .copied()
1310 .filter(|category| category.is_tier2())
1311 .collect::<Vec<_>>();
1312 let submission =
1313 manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
1314 if submission.has_new_work() {
1315 crate::slog_info!(
1316 "tier2 refresh scheduled: reason={}, categories={:?}",
1317 reason.as_str(),
1318 submission
1319 .newly_queued_categories
1320 .iter()
1321 .map(|category| category.as_str())
1322 .collect::<Vec<_>>()
1323 );
1324 }
1325 for error in submission.errors {
1326 crate::slog_warn!(
1327 "tier2 refresh schedule failed for {}: {}",
1328 error.category,
1329 error.message
1330 );
1331 }
1332 }
1333
1334 fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
1335 self.harness_opt()?;
1336 let config = self.config().clone();
1337 let project_root = config
1338 .project_root
1339 .clone()
1340 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1341 let project_root = std::fs::canonicalize(&project_root).unwrap_or(project_root);
1342 Some(InspectSnapshot::new(
1343 project_root,
1344 self.inspect_dir(),
1345 Arc::new(config),
1346 self.symbol_cache(),
1347 ))
1348 }
1349
1350 pub fn symbol_cache(&self) -> SharedSymbolCache {
1352 Arc::clone(&self.symbol_cache)
1353 }
1354
1355 pub fn reset_symbol_cache(&self) -> u64 {
1357 self.symbol_cache
1358 .write()
1359 .map(|mut cache| cache.reset())
1360 .unwrap_or(0)
1361 }
1362
1363 pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
1365 &self.semantic_index
1366 }
1367
1368 pub fn semantic_index_rx(
1370 &self,
1371 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
1372 &self.semantic_index_rx
1373 }
1374
1375 pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
1376 &self.semantic_index_status
1377 }
1378
1379 pub fn install_semantic_refresh_worker(
1380 &self,
1381 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
1382 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
1383 worker_slot: SemanticRefreshWorkerSlot,
1384 ) {
1385 self.clear_semantic_refresh_worker();
1386 *self.semantic_refresh_tx.borrow_mut() = Some(sender);
1387 *self.semantic_refresh_event_rx.borrow_mut() = Some(event_rx);
1388 *self.semantic_refresh_worker.borrow_mut() = Some(worker_slot);
1389 }
1390
1391 pub fn clear_semantic_refresh_worker(&self) {
1392 *self.semantic_refresh_tx.borrow_mut() = None;
1393 *self.semantic_refresh_event_rx.borrow_mut() = None;
1394 if let Some(worker_slot) = self.semantic_refresh_worker.borrow_mut().take() {
1395 if let Ok(mut handle) = worker_slot.lock() {
1396 drop(handle.take());
1397 }
1398 }
1399 }
1400
1401 pub fn semantic_refresh_sender(
1402 &self,
1403 ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
1404 self.semantic_refresh_tx.borrow().clone()
1405 }
1406
1407 pub fn semantic_refresh_event_rx(
1408 &self,
1409 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
1410 &self.semantic_refresh_event_rx
1411 }
1412
1413 pub fn semantic_embedding_model(
1415 &self,
1416 ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
1417 &self.semantic_embedding_model
1418 }
1419
1420 pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
1422 &self.watcher
1423 }
1424
1425 pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
1427 &self.watcher_rx
1428 }
1429
1430 pub fn lsp(&self) -> RefMut<'_, LspManager> {
1432 self.lsp_manager.borrow_mut()
1433 }
1434
1435 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
1438 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1439 let config = self.config();
1440 if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
1441 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1442 }
1443 }
1444 }
1445
1446 pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
1452 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1453 lsp.clear_diagnostics_for_file(file_path)
1454 } else {
1455 false
1456 }
1457 }
1458
1459 pub fn lsp_notify_and_collect_diagnostics(
1470 &self,
1471 file_path: &Path,
1472 content: &str,
1473 timeout: std::time::Duration,
1474 ) -> crate::lsp::manager::PostEditWaitOutcome {
1475 let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
1476 return crate::lsp::manager::PostEditWaitOutcome::default();
1477 };
1478
1479 lsp.drain_events();
1482
1483 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
1487
1488 let config = self.config();
1490 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
1491 {
1492 Ok(v) => v,
1493 Err(e) => {
1494 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1495 return crate::lsp::manager::PostEditWaitOutcome::default();
1496 }
1497 };
1498
1499 if expected_versions.is_empty() {
1502 return crate::lsp::manager::PostEditWaitOutcome::default();
1503 }
1504
1505 lsp.wait_for_post_edit_diagnostics(
1506 file_path,
1507 &config,
1508 &expected_versions,
1509 &pre_snapshot,
1510 timeout,
1511 )
1512 }
1513
1514 fn custom_lsp_root_markers(&self) -> Vec<String> {
1517 self.config()
1518 .lsp_servers
1519 .iter()
1520 .flat_map(|s| s.root_markers.iter().cloned())
1521 .collect()
1522 }
1523
1524 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
1525 let custom_markers = self.custom_lsp_root_markers();
1526 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
1527 .iter()
1528 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
1529 .cloned()
1530 .map(|path| {
1531 let change_type = if path.exists() {
1532 FileChangeType::CHANGED
1533 } else {
1534 FileChangeType::DELETED
1535 };
1536 (path, change_type)
1537 })
1538 .collect();
1539
1540 self.notify_watched_config_events(&config_paths);
1541 }
1542
1543 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
1544 let paths = params
1545 .get("multi_file_write_paths")
1546 .and_then(|value| value.as_array())?
1547 .iter()
1548 .filter_map(|value| value.as_str())
1549 .map(PathBuf::from)
1550 .collect::<Vec<_>>();
1551
1552 (!paths.is_empty()).then_some(paths)
1553 }
1554
1555 fn watched_file_events_from_params(
1567 params: &serde_json::Value,
1568 extra_markers: &[String],
1569 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
1570 let events = params
1571 .get("multi_file_write_paths")
1572 .and_then(|value| value.as_array())?
1573 .iter()
1574 .filter_map(|entry| {
1575 let path = entry
1577 .get("path")
1578 .and_then(|value| value.as_str())
1579 .map(PathBuf::from)?;
1580
1581 if !is_config_file_path_with_custom(&path, extra_markers) {
1582 return None;
1583 }
1584
1585 let change_type = entry
1586 .get("type")
1587 .and_then(|value| value.as_str())
1588 .and_then(Self::parse_file_change_type)
1589 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
1590
1591 Some((path, change_type))
1592 })
1593 .collect::<Vec<_>>();
1594
1595 (!events.is_empty()).then_some(events)
1596 }
1597
1598 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
1599 match value {
1600 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
1601 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
1602 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
1603 _ => None,
1604 }
1605 }
1606
1607 fn change_type_from_current_state(path: &Path) -> FileChangeType {
1608 if path.exists() {
1609 FileChangeType::CHANGED
1610 } else {
1611 FileChangeType::DELETED
1612 }
1613 }
1614
1615 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
1616 if config_paths.is_empty() {
1617 return;
1618 }
1619
1620 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1621 let config = self.config();
1622 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
1623 crate::slog_warn!("watched-file sync error: {}", e);
1624 }
1625 }
1626 }
1627
1628 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
1629 let custom_markers = self.custom_lsp_root_markers();
1630 if !is_config_file_path_with_custom(file_path, &custom_markers) {
1631 return;
1632 }
1633
1634 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
1635 }
1636
1637 pub fn lsp_post_multi_file_write(
1642 &self,
1643 file_path: &Path,
1644 content: &str,
1645 file_paths: &[PathBuf],
1646 params: &serde_json::Value,
1647 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
1648 self.notify_watched_config_files(file_paths);
1649
1650 let wants_diagnostics = params
1651 .get("diagnostics")
1652 .and_then(|v| v.as_bool())
1653 .unwrap_or(false);
1654
1655 if !wants_diagnostics {
1656 self.lsp_notify_file_changed(file_path, content);
1657 return None;
1658 }
1659
1660 let wait_ms = params
1661 .get("wait_ms")
1662 .and_then(|v| v.as_u64())
1663 .unwrap_or(3000)
1664 .min(10_000);
1665
1666 Some(self.lsp_notify_and_collect_diagnostics(
1667 file_path,
1668 content,
1669 std::time::Duration::from_millis(wait_ms),
1670 ))
1671 }
1672
1673 pub fn lsp_post_write(
1690 &self,
1691 file_path: &Path,
1692 content: &str,
1693 params: &serde_json::Value,
1694 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
1695 let wants_diagnostics = params
1696 .get("diagnostics")
1697 .and_then(|v| v.as_bool())
1698 .unwrap_or(false);
1699
1700 let custom_markers = self.custom_lsp_root_markers();
1701
1702 if !wants_diagnostics {
1703 if let Some(file_paths) = Self::multi_file_write_paths(params) {
1704 self.notify_watched_config_files(&file_paths);
1705 } else if let Some(config_events) =
1706 Self::watched_file_events_from_params(params, &custom_markers)
1707 {
1708 self.notify_watched_config_events(&config_events);
1709 }
1710 self.lsp_notify_file_changed(file_path, content);
1711 return None;
1712 }
1713
1714 let wait_ms = params
1715 .get("wait_ms")
1716 .and_then(|v| v.as_u64())
1717 .unwrap_or(3000)
1718 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
1721 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
1722 }
1723
1724 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
1725 {
1726 self.notify_watched_config_events(&config_events);
1727 }
1728
1729 Some(self.lsp_notify_and_collect_diagnostics(
1730 file_path,
1731 content,
1732 std::time::Duration::from_millis(wait_ms),
1733 ))
1734 }
1735
1736 pub fn validate_path(
1745 &self,
1746 req_id: &str,
1747 path: &Path,
1748 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
1749 let config = self.config();
1750 if !config.restrict_to_project_root {
1752 return Ok(path.to_path_buf());
1753 }
1754 let root = match &config.project_root {
1755 Some(r) => r.clone(),
1756 None => return Ok(path.to_path_buf()), };
1758 drop(config);
1759
1760 let raw_root = root.clone();
1765 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
1766
1767 let path_for_resolution = if path.is_relative() {
1772 raw_root.join(path)
1773 } else {
1774 path.to_path_buf()
1775 };
1776 let resolved = match std::fs::canonicalize(&path_for_resolution) {
1777 Ok(resolved) => resolved,
1778 Err(_) => {
1779 let normalized = normalize_path(&path_for_resolution);
1780 reject_escaping_symlink(
1781 req_id,
1782 &path_for_resolution,
1783 &normalized,
1784 &resolved_root,
1785 &raw_root,
1786 )?;
1787 resolve_with_existing_ancestors(&normalized)
1788 }
1789 };
1790
1791 if !resolved.starts_with(&resolved_root) {
1792 return Err(path_error_response(req_id, path, &resolved_root));
1793 }
1794
1795 Ok(resolved)
1796 }
1797
1798 pub fn lsp_server_count(&self) -> usize {
1800 self.lsp_manager
1801 .try_borrow()
1802 .map(|lsp| lsp.server_count())
1803 .unwrap_or(0)
1804 }
1805
1806 pub fn symbol_cache_stats(&self) -> serde_json::Value {
1808 let entries = self
1809 .symbol_cache
1810 .read()
1811 .map(|cache| cache.len())
1812 .unwrap_or(0);
1813 serde_json::json!({
1814 "local_entries": entries,
1815 "warm_entries": 0,
1816 })
1817 }
1818}
1819
1820#[cfg(test)]
1821mod status_emitter_tests {
1822 use super::*;
1823 use crate::parser::TreeSitterProvider;
1824
1825 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
1826 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
1827 let (tx, rx) = mpsc::channel();
1828 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
1829 let _ = tx.send(frame);
1830 }))));
1831 (ctx, rx)
1832 }
1833
1834 #[test]
1835 fn status_emitter_signal_triggers_push() {
1836 let (ctx, rx) = ctx_with_frame_rx();
1837 ctx.status_emitter().signal(ctx.build_status_snapshot());
1838 let frame = rx
1839 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1840 .expect("status_changed push");
1841 assert!(matches!(frame, PushFrame::StatusChanged(_)));
1842 }
1843
1844 #[test]
1845 fn status_emitter_debounces_burst() {
1846 let (ctx, rx) = ctx_with_frame_rx();
1847 for _ in 0..10 {
1848 ctx.status_emitter().signal(ctx.build_status_snapshot());
1849 }
1850 let frame = rx
1851 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1852 .expect("status_changed push");
1853 assert!(matches!(frame, PushFrame::StatusChanged(_)));
1854 assert!(rx.try_recv().is_err());
1855 }
1856
1857 #[test]
1858 fn status_emitter_separate_windows_separate_pushes() {
1859 let (ctx, rx) = ctx_with_frame_rx();
1860 ctx.status_emitter().signal(ctx.build_status_snapshot());
1861 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1862 .expect("first push");
1863 ctx.status_emitter().signal(ctx.build_status_snapshot());
1864 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1865 .expect("second push");
1866 }
1867
1868 #[test]
1869 fn status_emitter_no_signal_no_push() {
1870 let (_ctx, rx) = ctx_with_frame_rx();
1871 assert!(rx
1872 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
1873 .is_err());
1874 }
1875
1876 #[test]
1877 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
1878 let (ctx, rx) = ctx_with_frame_rx();
1879 drop(ctx);
1880 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
1881 }
1882}
1883
1884#[cfg(test)]
1885mod status_bar_tests {
1886 use super::*;
1887 use crate::parser::TreeSitterProvider;
1888
1889 fn ctx() -> AppContext {
1890 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
1891 }
1892
1893 #[test]
1894 fn status_bar_counts_none_until_tier2_populated() {
1895 let ctx = ctx();
1896 assert!(ctx.status_bar_counts().is_none());
1898
1899 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
1900 let counts = ctx.status_bar_counts().expect("populated");
1901 assert_eq!(counts.dead_code, 5);
1902 assert_eq!(counts.unused_exports, 3);
1903 assert_eq!(counts.duplicates, 7);
1904 assert_eq!(counts.todos, 2);
1905 assert!(!counts.tier2_stale);
1906 assert_eq!(counts.errors, 0);
1908 assert_eq!(counts.warnings, 0);
1909 }
1910
1911 #[test]
1912 fn partial_tier2_does_not_fabricate_zeros() {
1913 let ctx = ctx();
1914 ctx.update_status_bar_tier2(Some(5), None, None, None, true);
1918 assert!(
1919 ctx.status_bar_counts().is_none(),
1920 "bar must not surface until all three Tier-2 categories are real"
1921 );
1922
1923 ctx.update_status_bar_tier2(None, Some(3), None, None, true);
1925 assert!(ctx.status_bar_counts().is_none());
1926
1927 ctx.update_status_bar_tier2(None, None, Some(7), None, false);
1930 let counts = ctx.status_bar_counts().expect("all three real now");
1931 assert_eq!(counts.dead_code, 5);
1932 assert_eq!(counts.unused_exports, 3);
1933 assert_eq!(counts.duplicates, 7);
1934 }
1935
1936 #[test]
1937 fn update_with_none_todos_preserves_last_known_todos() {
1938 let ctx = ctx();
1939 ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
1940 ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
1942 let counts = ctx.status_bar_counts().expect("populated");
1943 assert_eq!(counts.todos, 9);
1944 assert_eq!(counts.dead_code, 2);
1945 }
1946
1947 #[test]
1948 fn update_with_none_count_preserves_last_known_count() {
1949 let ctx = ctx();
1950 ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
1951 ctx.update_status_bar_tier2(Some(11), None, None, None, false);
1954 let counts = ctx.status_bar_counts().expect("populated");
1955 assert_eq!(counts.dead_code, 11);
1956 assert_eq!(counts.unused_exports, 20);
1957 assert_eq!(counts.duplicates, 30);
1958 }
1959
1960 #[test]
1961 fn mark_stale_sets_flag_only_after_populate() {
1962 let ctx = ctx();
1963 ctx.mark_status_bar_tier2_stale();
1965 assert!(ctx.status_bar_counts().is_none());
1966
1967 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), Some(0), false);
1968 ctx.mark_status_bar_tier2_stale();
1969 assert!(ctx.status_bar_counts().expect("populated").tier2_stale);
1970
1971 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), None, false);
1973 assert!(!ctx.status_bar_counts().expect("populated").tier2_stale);
1974 }
1975
1976 #[test]
1981 fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
1982 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
1983 use crate::lsp::registry::ServerKind;
1984 use crate::lsp::roots::ServerKey;
1985
1986 let ctx = ctx();
1987 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); let file = std::path::PathBuf::from("/proj/gone.ts");
1990 {
1991 let mut lsp = ctx.lsp();
1992 lsp.diagnostics_store_mut_for_test().publish(
1993 ServerKey {
1994 kind: ServerKind::TypeScript,
1995 root: std::path::PathBuf::from("/proj"),
1996 },
1997 file.clone(),
1998 vec![StoredDiagnostic {
1999 file: file.clone(),
2000 line: 1,
2001 column: 1,
2002 end_line: 1,
2003 end_column: 2,
2004 severity: DiagnosticSeverity::Error,
2005 message: "boom".into(),
2006 code: None,
2007 source: None,
2008 }],
2009 );
2010 }
2011
2012 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
2014
2015 let removed = ctx.lsp_clear_diagnostics_for_file(&file);
2017 assert!(removed);
2018 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
2019 }
2020}
2021
2022#[cfg(test)]
2023mod harness_path_tests {
2024 use super::*;
2025 use crate::harness::Harness;
2026 use crate::parser::TreeSitterProvider;
2027
2028 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
2029 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
2030 ctx.config_mut().storage_dir = Some(storage_dir);
2031 ctx.set_harness(harness);
2032 ctx
2033 }
2034
2035 #[test]
2036 fn harness_dir_resolves_correctly() {
2037 let storage = PathBuf::from("/tmp/cortexkit/aft");
2038 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2039
2040 assert_eq!(ctx.harness_dir(), storage.join("pi"));
2041 }
2042
2043 #[test]
2044 fn bash_tasks_dir_uses_hash_session() {
2045 let storage = PathBuf::from("/tmp/cortexkit/aft");
2046 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2047
2048 assert_eq!(
2049 ctx.bash_tasks_dir("ses_abc"),
2050 storage
2051 .join("opencode")
2052 .join("bash-tasks")
2053 .join(hash_session("ses_abc"))
2054 );
2055 }
2056
2057 #[test]
2058 fn backups_dir_includes_path_hash() {
2059 let storage = PathBuf::from("/tmp/cortexkit/aft");
2060 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2061
2062 assert_eq!(
2063 ctx.backups_dir("ses_abc", "pathhash"),
2064 storage
2065 .join("pi")
2066 .join("backups")
2067 .join(hash_session("ses_abc"))
2068 .join("pathhash")
2069 );
2070 }
2071
2072 #[test]
2073 fn filters_dir_under_harness() {
2074 let storage = PathBuf::from("/tmp/cortexkit/aft");
2075 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2076
2077 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
2078 }
2079
2080 #[test]
2081 fn trust_file_is_host_global() {
2082 let storage = PathBuf::from("/tmp/cortexkit/aft");
2083 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
2084
2085 assert_eq!(
2086 ctx.trust_file(),
2087 storage.join("trusted-filter-projects.json")
2088 );
2089 }
2090
2091 #[test]
2092 fn same_session_different_harness_resolve_different_paths() {
2093 let storage = PathBuf::from("/tmp/cortexkit/aft");
2094 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
2095 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
2096
2097 assert_ne!(
2098 opencode.bash_tasks_dir("ses_same"),
2099 pi.bash_tasks_dir("ses_same")
2100 );
2101 }
2102}
2103
2104#[cfg(test)]
2105mod gitignore_tests {
2106 use super::*;
2107 use std::fs;
2108 use std::path::Path;
2109 use tempfile::TempDir;
2110
2111 fn make_ctx_with_root(root: &Path) -> AppContext {
2112 let provider = Box::new(crate::parser::TreeSitterProvider::new());
2113 let config = Config {
2114 project_root: Some(root.to_path_buf()),
2115 ..Config::default()
2116 };
2117 AppContext::new(provider, config)
2118 }
2119
2120 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
2127 let Some(matcher) = ctx.gitignore() else {
2128 return false;
2129 };
2130 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
2131 if !canonical.starts_with(matcher.path()) {
2132 return false;
2133 }
2134 let is_dir = canonical.is_dir();
2135 matcher
2136 .matched_path_or_any_parents(&canonical, is_dir)
2137 .is_ignore()
2138 }
2139
2140 fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
2151 use std::sync::{Mutex, OnceLock};
2152 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2153 let _guard = LOCK
2154 .get_or_init(|| Mutex::new(()))
2155 .lock()
2156 .unwrap_or_else(|e| e.into_inner());
2157 let tmp = TempDir::new().unwrap();
2158 let prev = std::env::var_os("XDG_CONFIG_HOME");
2159 unsafe {
2161 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
2162 }
2163 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
2164 unsafe {
2165 match prev {
2166 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
2167 None => std::env::remove_var("XDG_CONFIG_HOME"),
2168 }
2169 }
2170 match result {
2171 Ok(r) => r,
2172 Err(p) => std::panic::resume_unwind(p),
2173 }
2174 }
2175
2176 #[test]
2177 fn rebuild_gitignore_returns_none_without_project_root() {
2178 let provider = Box::new(crate::parser::TreeSitterProvider::new());
2179 let ctx = AppContext::new(provider, Config::default());
2180 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2181 assert!(ctx.gitignore().is_none());
2182 }
2183
2184 #[test]
2185 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
2186 let tmp = TempDir::new().unwrap();
2187 let ctx = make_ctx_with_root(tmp.path());
2188 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
2189 assert!(ctx.gitignore().is_none());
2190 }
2191
2192 #[test]
2193 fn matcher_filters_files_in_ignored_dist_dir() {
2194 let tmp = TempDir::new().unwrap();
2195 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
2196 fs::create_dir_all(tmp.path().join("dist")).unwrap();
2197 fs::create_dir_all(tmp.path().join("src")).unwrap();
2198 let dist_file = tmp.path().join("dist").join("bundle.js");
2199 let src_file = tmp.path().join("src").join("app.ts");
2200 fs::write(&dist_file, "x").unwrap();
2201 fs::write(&src_file, "y").unwrap();
2202
2203 let ctx = make_ctx_with_root(tmp.path());
2204 ctx.rebuild_gitignore();
2205
2206 assert!(ctx.gitignore().is_some());
2207 assert!(
2208 is_ignored(&ctx, &dist_file),
2209 "dist/bundle.js should be ignored"
2210 );
2211 assert!(
2212 !is_ignored(&ctx, &src_file),
2213 "src/app.ts should NOT be ignored"
2214 );
2215 }
2216
2217 #[test]
2218 fn matcher_handles_node_modules_and_target() {
2219 let tmp = TempDir::new().unwrap();
2220 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
2221 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
2222 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
2223 let nm_file = tmp.path().join("node_modules/foo/index.js");
2224 let target_file = tmp.path().join("target/debug/aft");
2225 fs::write(&nm_file, "x").unwrap();
2226 fs::write(&target_file, "x").unwrap();
2227
2228 let ctx = make_ctx_with_root(tmp.path());
2229 ctx.rebuild_gitignore();
2230
2231 assert!(is_ignored(&ctx, &nm_file));
2232 assert!(is_ignored(&ctx, &target_file));
2233 }
2234
2235 #[test]
2236 fn matcher_honors_negation_pattern() {
2237 let tmp = TempDir::new().unwrap();
2239 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
2240 let random_log = tmp.path().join("random.log");
2241 let important_log = tmp.path().join("important.log");
2242 fs::write(&random_log, "x").unwrap();
2243 fs::write(&important_log, "y").unwrap();
2244
2245 let ctx = make_ctx_with_root(tmp.path());
2246 ctx.rebuild_gitignore();
2247
2248 assert!(is_ignored(&ctx, &random_log));
2249 assert!(
2250 !is_ignored(&ctx, &important_log),
2251 "negation pattern should un-ignore important.log"
2252 );
2253 }
2254
2255 #[test]
2256 fn rebuild_picks_up_gitignore_changes() {
2257 let tmp = TempDir::new().unwrap();
2258 let ignore_path = tmp.path().join(".gitignore");
2259 fs::write(&ignore_path, "foo.txt\n").unwrap();
2260 let foo = tmp.path().join("foo.txt");
2261 let bar = tmp.path().join("bar.txt");
2262 fs::write(&foo, "").unwrap();
2263 fs::write(&bar, "").unwrap();
2264
2265 let ctx = make_ctx_with_root(tmp.path());
2266 ctx.rebuild_gitignore();
2267 assert!(is_ignored(&ctx, &foo));
2268 assert!(!is_ignored(&ctx, &bar));
2269
2270 fs::write(&ignore_path, "bar.txt\n").unwrap();
2272 ctx.rebuild_gitignore();
2273 assert!(!is_ignored(&ctx, &foo));
2274 assert!(is_ignored(&ctx, &bar));
2275 }
2276
2277 #[test]
2278 fn gitignore_loads_info_exclude_when_present() {
2279 let tmp = TempDir::new().unwrap();
2280 let info_dir = tmp.path().join(".git/info");
2281 fs::create_dir_all(&info_dir).unwrap();
2282 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
2283 let secrets = tmp.path().join("secrets.txt");
2284 let public = tmp.path().join("public.txt");
2285 fs::write(&secrets, "token").unwrap();
2286 fs::write(&public, "ok").unwrap();
2287
2288 let ctx = make_ctx_with_root(tmp.path());
2289 ctx.rebuild_gitignore();
2290
2291 assert!(is_ignored(&ctx, &secrets));
2292 assert!(!is_ignored(&ctx, &public));
2293 }
2294
2295 #[test]
2296 fn matcher_picks_up_nested_gitignore() {
2297 let tmp = TempDir::new().unwrap();
2298 fs::write(tmp.path().join(".gitignore"), "").unwrap();
2300 let sub = tmp.path().join("packages/foo");
2301 fs::create_dir_all(&sub).unwrap();
2302 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
2303 let generated_file = sub.join("generated").join("out.js");
2304 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
2305 fs::write(&generated_file, "x").unwrap();
2306
2307 let ctx = make_ctx_with_root(tmp.path());
2308 ctx.rebuild_gitignore();
2309
2310 assert!(
2311 is_ignored(&ctx, &generated_file),
2312 "nested gitignore in packages/foo/.gitignore should ignore generated/"
2313 );
2314 }
2315}