1use std::cell::{Ref, RefCell, RefMut};
2use std::io::{self, BufWriter};
3use std::path::{Component, Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::{mpsc, Arc, Mutex};
6use std::time::{Duration, Instant};
7
8use lsp_types::FileChangeType;
9use notify::RecommendedWatcher;
10use rusqlite::Connection;
11
12use crate::backup::hash_session;
13use crate::backup::BackupStore;
14use crate::bash_background::{BgCompletion, BgTaskRegistry};
15use crate::callgraph::CallGraph;
16use crate::checkpoint::CheckpointStore;
17use crate::config::Config;
18use crate::harness::Harness;
19use crate::language::LanguageProvider;
20use crate::lsp::manager::LspManager;
21use crate::lsp::registry::is_config_file_path_with_custom;
22use crate::parser::{SharedSymbolCache, SymbolCache};
23use crate::protocol::{
24 ConfigureWarningsFrame, ProgressFrame, PushFrame, StatusChangedFrame, StatusPayload,
25};
26
27pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
28pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
29pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
30const STATUS_DEBOUNCE_MS: u64 = 1_000;
31
32pub struct StatusEmitter {
33 latest: Arc<Mutex<Option<StatusPayload>>>,
34 notify: mpsc::Sender<()>,
35}
36
37impl StatusEmitter {
38 fn new(progress_sender: SharedProgressSender) -> Self {
39 let (notify, rx) = mpsc::channel();
40 let latest = Arc::new(Mutex::new(None));
41 let latest_for_thread = Arc::clone(&latest);
42 std::thread::spawn(move || {
43 status_debounce_loop(rx, latest_for_thread, progress_sender);
44 });
45 Self { latest, notify }
46 }
47
48 pub fn signal(&self, snapshot: StatusPayload) {
49 if let Ok(mut latest) = self.latest.lock() {
50 *latest = Some(snapshot);
51 }
52 let _ = self.notify.send(());
53 }
54}
55
56fn status_debounce_loop(
57 rx: mpsc::Receiver<()>,
58 latest: Arc<Mutex<Option<StatusPayload>>>,
59 progress_sender: SharedProgressSender,
60) {
61 while rx.recv().is_ok() {
62 let deadline = Instant::now() + Duration::from_millis(STATUS_DEBOUNCE_MS);
63 while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
64 match rx.recv_timeout(remaining) {
65 Ok(()) => continue,
66 Err(mpsc::RecvTimeoutError::Timeout) => break,
67 Err(mpsc::RecvTimeoutError::Disconnected) => return,
68 }
69 }
70
71 let snapshot = latest.lock().ok().and_then(|mut latest| latest.take());
72 let Some(snapshot) = snapshot else { continue };
73 let sender = progress_sender
74 .lock()
75 .ok()
76 .and_then(|sender| sender.clone());
77 if let Some(sender) = sender {
78 sender(PushFrame::StatusChanged(StatusChangedFrame::new(
79 None, snapshot,
80 )));
81 }
82 }
83}
84use crate::search_index::SearchIndex;
85use crate::semantic_index::SemanticIndex;
86
87#[derive(Debug, Clone)]
88pub enum SemanticIndexStatus {
89 Disabled,
90 Building {
91 stage: String,
93 files: Option<usize>,
94 entries_done: Option<usize>,
95 entries_total: Option<usize>,
96 },
97 Ready {
98 refreshing: Vec<PathBuf>,
101 },
102 Failed(String),
103}
104
105impl SemanticIndexStatus {
106 pub fn ready() -> Self {
107 Self::Ready {
108 refreshing: Vec::new(),
109 }
110 }
111
112 pub fn add_refreshing_file(&mut self, path: PathBuf) {
113 if let Self::Ready { refreshing } = self {
114 if !refreshing.iter().any(|existing| existing == &path) {
115 refreshing.push(path);
116 refreshing.sort();
117 }
118 }
119 }
120
121 pub fn remove_refreshing_file(&mut self, path: &Path) {
122 if let Self::Ready { refreshing } = self {
123 refreshing.retain(|existing| existing != path);
124 }
125 }
126
127 pub fn refreshing_count(&self) -> usize {
128 match self {
129 Self::Ready { refreshing } => refreshing.len(),
130 _ => 0,
131 }
132 }
133}
134
135pub enum SemanticIndexEvent {
136 Progress {
137 stage: String,
138 files: Option<usize>,
139 entries_done: Option<usize>,
140 entries_total: Option<usize>,
141 },
142 Ready(SemanticIndex),
143 Failed(String),
144}
145
146fn normalize_path(path: &Path) -> PathBuf {
150 let mut result = PathBuf::new();
151 for component in path.components() {
152 match component {
153 Component::ParentDir => {
154 if !result.pop() {
156 result.push(component);
157 }
158 }
159 Component::CurDir => {} _ => result.push(component),
161 }
162 }
163 result
164}
165
166fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
167 let mut existing = path.to_path_buf();
168 let mut tail_segments = Vec::new();
169
170 while !existing.exists() {
171 if let Some(name) = existing.file_name() {
172 tail_segments.push(name.to_owned());
173 } else {
174 break;
175 }
176
177 existing = match existing.parent() {
178 Some(parent) => parent.to_path_buf(),
179 None => break,
180 };
181 }
182
183 let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
184 for segment in tail_segments.into_iter().rev() {
185 resolved.push(segment);
186 }
187
188 resolved
189}
190
191fn path_error_response(
192 req_id: &str,
193 path: &Path,
194 resolved_root: &Path,
195) -> crate::protocol::Response {
196 crate::protocol::Response::error(
197 req_id,
198 "path_outside_root",
199 format!(
200 "path '{}' is outside the project root '{}'",
201 path.display(),
202 resolved_root.display()
203 ),
204 )
205}
206
207fn reject_escaping_symlink(
217 req_id: &str,
218 original_path: &Path,
219 candidate: &Path,
220 resolved_root: &Path,
221 raw_root: &Path,
222) -> Result<(), crate::protocol::Response> {
223 let mut current = PathBuf::new();
224
225 for component in candidate.components() {
226 current.push(component);
227
228 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
229 continue;
230 };
231
232 if !metadata.file_type().is_symlink() {
233 continue;
234 }
235
236 let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
245 if !inside_root {
246 continue;
247 }
248
249 iterative_follow_chain(req_id, original_path, ¤t, resolved_root)?;
250 }
251
252 Ok(())
253}
254
255fn iterative_follow_chain(
258 req_id: &str,
259 original_path: &Path,
260 start: &Path,
261 resolved_root: &Path,
262) -> Result<(), crate::protocol::Response> {
263 let mut link = start.to_path_buf();
264 let mut depth = 0usize;
265
266 loop {
267 if depth > 40 {
268 return Err(path_error_response(req_id, original_path, resolved_root));
269 }
270
271 let target = match std::fs::read_link(&link) {
272 Ok(t) => t,
273 Err(_) => {
274 return Err(path_error_response(req_id, original_path, resolved_root));
276 }
277 };
278
279 let resolved_target = if target.is_absolute() {
280 normalize_path(&target)
281 } else {
282 let parent = link.parent().unwrap_or_else(|| Path::new(""));
283 normalize_path(&parent.join(&target))
284 };
285
286 let canonical_target =
290 std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
291
292 if !canonical_target.starts_with(resolved_root)
293 && !resolved_target.starts_with(resolved_root)
294 {
295 return Err(path_error_response(req_id, original_path, resolved_root));
296 }
297
298 match std::fs::symlink_metadata(&resolved_target) {
300 Ok(meta) if meta.file_type().is_symlink() => {
301 link = resolved_target;
302 depth += 1;
303 }
304 _ => break, }
306 }
307
308 Ok(())
309}
310
311pub struct AppContext {
321 provider: Box<dyn LanguageProvider>,
322 backup: RefCell<BackupStore>,
323 checkpoint: RefCell<CheckpointStore>,
324 db: RefCell<Option<Arc<Mutex<Connection>>>>,
325 config: RefCell<Config>,
326 pub harness: RefCell<Option<Harness>>,
327 canonical_cache_root: RefCell<Option<PathBuf>>,
328 is_worktree_bridge: RefCell<bool>,
329 git_common_dir: RefCell<Option<PathBuf>>,
330 degraded_reasons: RefCell<Vec<String>>,
338 callgraph: RefCell<Option<CallGraph>>,
339 search_index: RefCell<Option<SearchIndex>>,
340 search_index_rx: RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>>,
341 symbol_cache: SharedSymbolCache,
342 semantic_index: RefCell<Option<SemanticIndex>>,
343 semantic_index_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
344 semantic_index_status: RefCell<SemanticIndexStatus>,
345 semantic_embedding_model: RefCell<Option<crate::semantic_index::EmbeddingModel>>,
346 watcher: RefCell<Option<RecommendedWatcher>>,
347 watcher_rx: RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>>,
348 lsp_manager: RefCell<LspManager>,
349 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
353 stdout_writer: SharedStdoutWriter,
354 progress_sender: SharedProgressSender,
355 configure_generation: AtomicU64,
356 configure_warnings_tx: mpsc::Sender<(u64, ConfigureWarningsFrame)>,
357 configure_warnings_rx: mpsc::Receiver<(u64, ConfigureWarningsFrame)>,
358 status_emitter: StatusEmitter,
359 bash_background: BgTaskRegistry,
360 filter_registry: crate::compress::SharedFilterRegistry,
367 filter_registry_loaded: std::sync::atomic::AtomicBool,
370 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
375 gitignore: RefCell<Option<Arc<ignore::gitignore::Gitignore>>>,
382}
383
384impl AppContext {
385 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
386 let bash_compress_enabled = config.experimental_bash_compress;
387 let progress_sender = Arc::new(Mutex::new(None));
388 let stdout_writer = Arc::new(Mutex::new(BufWriter::new(io::stdout())));
389 let (configure_warnings_tx, configure_warnings_rx) = mpsc::channel();
390 let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
391 let symbol_cache = provider
392 .as_any()
393 .downcast_ref::<crate::parser::TreeSitterProvider>()
394 .map(|provider| provider.symbol_cache())
395 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
396 let lsp_child_registry = crate::lsp::child_registry::LspChildRegistry::new();
397 let mut lsp_manager = LspManager::new();
398 lsp_manager.set_child_registry(lsp_child_registry.clone());
399 AppContext {
400 provider,
401 backup: RefCell::new(BackupStore::new()),
402 checkpoint: RefCell::new(CheckpointStore::new()),
403 db: RefCell::new(None),
404 config: RefCell::new(config),
405 harness: RefCell::new(None),
406 canonical_cache_root: RefCell::new(None),
407 is_worktree_bridge: RefCell::new(false),
408 git_common_dir: RefCell::new(None),
409 degraded_reasons: RefCell::new(Vec::new()),
410 callgraph: RefCell::new(None),
411 search_index: RefCell::new(None),
412 search_index_rx: RefCell::new(None),
413 symbol_cache,
414 semantic_index: RefCell::new(None),
415 semantic_index_rx: RefCell::new(None),
416 semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
417 semantic_embedding_model: RefCell::new(None),
418 watcher: RefCell::new(None),
419 watcher_rx: RefCell::new(None),
420 lsp_manager: RefCell::new(lsp_manager),
421 lsp_child_registry,
422 stdout_writer,
423 progress_sender: Arc::clone(&progress_sender),
424 configure_generation: AtomicU64::new(0),
425 configure_warnings_tx,
426 configure_warnings_rx,
427 status_emitter,
428 bash_background: BgTaskRegistry::new(progress_sender),
429 filter_registry: Arc::new(std::sync::RwLock::new(
430 crate::compress::toml_filter::FilterRegistry::default(),
431 )),
432 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
433 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
434 gitignore: RefCell::new(None),
435 }
436 }
437
438 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
441 self.gitignore.borrow().clone()
442 }
443
444 pub fn clear_gitignore(&self) {
465 *self.gitignore.borrow_mut() = None;
466 }
467
468 pub fn rebuild_gitignore(&self) {
469 use ignore::gitignore::GitignoreBuilder;
470 use std::path::Path;
471 let root_raw = match self.config().project_root.clone() {
472 Some(r) => r,
473 None => {
474 *self.gitignore.borrow_mut() = None;
475 return;
476 }
477 };
478 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
486 let mut builder = GitignoreBuilder::new(&root);
487 let root_ignore = Path::new(&root).join(".gitignore");
489 if root_ignore.exists() {
490 if let Some(err) = builder.add(&root_ignore) {
491 crate::slog_warn!(
492 "gitignore parse error in {}: {}",
493 root_ignore.display(),
494 err
495 );
496 }
497 }
498 let info_exclude = Path::new(&root).join(".git").join("info").join("exclude");
501 if info_exclude.exists() {
502 if let Some(err) = builder.add(&info_exclude) {
503 crate::slog_warn!(
504 "gitignore parse error in {}: {}",
505 info_exclude.display(),
506 err
507 );
508 }
509 }
510 let walker = ignore::WalkBuilder::new(&root)
516 .standard_filters(true)
517 .hidden(false)
520 .max_depth(Some(8))
521 .filter_entry(|entry| {
522 let name = entry.file_name().to_string_lossy();
523 !matches!(
524 name.as_ref(),
525 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
526 )
527 })
528 .build();
529 for entry in walker.flatten() {
530 if entry.file_name() == ".gitignore" && entry.path() != root_ignore {
531 if let Some(err) = builder.add(entry.path()) {
532 crate::slog_warn!(
533 "nested gitignore parse error in {}: {}",
534 entry.path().display(),
535 err
536 );
537 }
538 }
539 }
540 match builder.build() {
541 Ok(gi) => {
542 let count = gi.num_ignores();
543 if count > 0 {
544 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
545 *self.gitignore.borrow_mut() = Some(Arc::new(gi));
546 } else {
547 *self.gitignore.borrow_mut() = None;
548 }
549 }
550 Err(err) => {
551 crate::slog_warn!("gitignore matcher build failed: {}", err);
552 *self.gitignore.borrow_mut() = None;
553 }
554 }
555 }
556
557 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
560 Arc::clone(&self.bash_compress_flag)
561 }
562
563 pub fn sync_bash_compress_flag(&self) {
567 let value = self.config().experimental_bash_compress;
568 self.bash_compress_flag
569 .store(value, std::sync::atomic::Ordering::Relaxed);
570 }
571
572 pub fn set_bash_compress_enabled(&self, enabled: bool) {
573 self.config_mut().experimental_bash_compress = enabled;
574 self.bash_compress_flag
575 .store(enabled, std::sync::atomic::Ordering::Relaxed);
576 }
577
578 pub fn filter_registry(
582 &self,
583 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
584 self.ensure_filter_registry_loaded();
585 match self.filter_registry.read() {
586 Ok(g) => g,
587 Err(poisoned) => poisoned.into_inner(),
588 }
589 }
590
591 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
595 self.ensure_filter_registry_loaded();
596 Arc::clone(&self.filter_registry)
597 }
598
599 pub fn reset_filter_registry(&self) {
603 let new_registry = crate::compress::build_registry_for_context(self);
604 match self.filter_registry.write() {
605 Ok(mut slot) => *slot = new_registry,
606 Err(poisoned) => *poisoned.into_inner() = new_registry,
607 }
608 self.filter_registry_loaded
609 .store(true, std::sync::atomic::Ordering::Release);
610 }
611
612 fn ensure_filter_registry_loaded(&self) {
613 use std::sync::atomic::Ordering;
614 if self.filter_registry_loaded.load(Ordering::Acquire) {
615 return;
616 }
617 let new_registry = crate::compress::build_registry_for_context(self);
620 if let Ok(mut slot) = self.filter_registry.write() {
621 *slot = new_registry;
622 self.filter_registry_loaded.store(true, Ordering::Release);
623 }
624 }
625
626 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
629 self.lsp_child_registry.clone()
630 }
631
632 pub fn stdout_writer(&self) -> SharedStdoutWriter {
633 Arc::clone(&self.stdout_writer)
634 }
635
636 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
637 if let Ok(mut progress_sender) = self.progress_sender.lock() {
638 *progress_sender = sender;
639 }
640 }
641
642 pub fn emit_progress(&self, frame: ProgressFrame) {
643 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
644 return;
645 };
646 if let Some(sender) = progress_sender.as_ref() {
647 sender(PushFrame::Progress(frame));
648 }
649 }
650
651 pub fn status_emitter(&self) -> &StatusEmitter {
652 &self.status_emitter
653 }
654
655 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
663 self.progress_sender
664 .lock()
665 .ok()
666 .and_then(|sender| sender.clone())
667 }
668
669 pub fn advance_configure_generation(&self) -> u64 {
670 self.configure_generation
671 .fetch_add(1, Ordering::SeqCst)
672 .wrapping_add(1)
673 }
674
675 pub fn configure_generation(&self) -> u64 {
676 self.configure_generation.load(Ordering::SeqCst)
677 }
678
679 pub fn configure_warnings_sender(&self) -> mpsc::Sender<(u64, ConfigureWarningsFrame)> {
680 self.configure_warnings_tx.clone()
681 }
682
683 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
684 let mut warnings = Vec::new();
685 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
686 warnings.push(warning);
687 }
688 warnings
689 }
690
691 pub fn bash_background(&self) -> &BgTaskRegistry {
692 &self.bash_background
693 }
694
695 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
696 self.bash_background.drain_completions()
697 }
698
699 pub fn provider(&self) -> &dyn LanguageProvider {
701 self.provider.as_ref()
702 }
703
704 pub fn backup(&self) -> &RefCell<BackupStore> {
706 &self.backup
707 }
708
709 pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
711 &self.checkpoint
712 }
713
714 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
715 *self.db.borrow_mut() = Some(conn);
716 }
717
718 pub fn clear_db(&self) {
719 *self.db.borrow_mut() = None;
720 }
721
722 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
723 self.db.borrow().clone()
724 }
725
726 pub fn config(&self) -> Ref<'_, Config> {
728 self.config.borrow()
729 }
730
731 pub fn config_mut(&self) -> RefMut<'_, Config> {
733 self.config.borrow_mut()
734 }
735
736 pub fn set_harness(&self, harness: Harness) {
737 *self.harness.borrow_mut() = Some(harness);
738 self.bash_background.set_harness(harness);
739 }
740
741 pub fn harness_opt(&self) -> Option<Harness> {
742 *self.harness.borrow()
743 }
744
745 pub fn harness(&self) -> Harness {
746 self.harness_opt()
747 .expect("harness set by configure before any tool call")
748 }
749
750 pub fn storage_dir(&self) -> PathBuf {
751 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
752 }
753
754 pub fn harness_dir(&self) -> PathBuf {
755 self.storage_dir().join(self.harness().as_str())
756 }
757
758 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
759 self.harness_dir()
760 .join("bash-tasks")
761 .join(hash_session(session_id))
762 }
763
764 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
765 self.harness_dir()
766 .join("backups")
767 .join(hash_session(session_id))
768 .join(path_hash)
769 }
770
771 pub fn filters_dir(&self) -> PathBuf {
772 self.harness_dir().join("filters")
773 }
774
775 pub fn trust_file(&self) -> PathBuf {
777 self.storage_dir().join("trusted-filter-projects.json")
778 }
779
780 pub fn set_canonical_cache_root(&self, root: PathBuf) {
781 debug_assert!(root.is_absolute());
782 *self.canonical_cache_root.borrow_mut() = Some(root);
783 }
784
785 pub fn canonical_cache_root(&self) -> PathBuf {
786 self.canonical_cache_root
787 .borrow()
788 .clone()
789 .expect("canonical_cache_root accessed before handle_configure")
790 }
791
792 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
793 self.canonical_cache_root.borrow().clone()
794 }
795
796 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
797 *self.is_worktree_bridge.borrow_mut() = is_worktree_bridge;
798 *self.git_common_dir.borrow_mut() = git_common_dir;
799 }
800
801 pub fn is_worktree_bridge(&self) -> bool {
802 *self.is_worktree_bridge.borrow()
803 }
804
805 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
809 *self.degraded_reasons.borrow_mut() = reasons;
810 }
811
812 pub fn degraded_reasons(&self) -> Vec<String> {
816 self.degraded_reasons.borrow().clone()
817 }
818
819 pub fn is_degraded(&self) -> bool {
821 !self.degraded_reasons.borrow().is_empty()
822 }
823
824 pub fn cache_role(&self) -> &'static str {
825 if self.canonical_cache_root.borrow().is_none() {
826 "not_initialized"
827 } else if self.is_worktree_bridge() {
828 "worktree"
829 } else {
830 "main"
831 }
832 }
833
834 pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
836 &self.callgraph
837 }
838
839 pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
841 &self.search_index
842 }
843
844 pub fn search_index_rx(&self) -> &RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>> {
846 &self.search_index_rx
847 }
848
849 pub fn symbol_cache(&self) -> SharedSymbolCache {
851 Arc::clone(&self.symbol_cache)
852 }
853
854 pub fn reset_symbol_cache(&self) -> u64 {
856 self.symbol_cache
857 .write()
858 .map(|mut cache| cache.reset())
859 .unwrap_or(0)
860 }
861
862 pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
864 &self.semantic_index
865 }
866
867 pub fn semantic_index_rx(
869 &self,
870 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
871 &self.semantic_index_rx
872 }
873
874 pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
875 &self.semantic_index_status
876 }
877
878 pub fn semantic_embedding_model(
880 &self,
881 ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
882 &self.semantic_embedding_model
883 }
884
885 pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
887 &self.watcher
888 }
889
890 pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
892 &self.watcher_rx
893 }
894
895 pub fn lsp(&self) -> RefMut<'_, LspManager> {
897 self.lsp_manager.borrow_mut()
898 }
899
900 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
903 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
904 let config = self.config();
905 if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
906 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
907 }
908 }
909 }
910
911 pub fn lsp_notify_and_collect_diagnostics(
922 &self,
923 file_path: &Path,
924 content: &str,
925 timeout: std::time::Duration,
926 ) -> crate::lsp::manager::PostEditWaitOutcome {
927 let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
928 return crate::lsp::manager::PostEditWaitOutcome::default();
929 };
930
931 lsp.drain_events();
934
935 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
939
940 let config = self.config();
942 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
943 {
944 Ok(v) => v,
945 Err(e) => {
946 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
947 return crate::lsp::manager::PostEditWaitOutcome::default();
948 }
949 };
950
951 if expected_versions.is_empty() {
954 return crate::lsp::manager::PostEditWaitOutcome::default();
955 }
956
957 lsp.wait_for_post_edit_diagnostics(
958 file_path,
959 &config,
960 &expected_versions,
961 &pre_snapshot,
962 timeout,
963 )
964 }
965
966 fn custom_lsp_root_markers(&self) -> Vec<String> {
969 self.config()
970 .lsp_servers
971 .iter()
972 .flat_map(|s| s.root_markers.iter().cloned())
973 .collect()
974 }
975
976 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
977 let custom_markers = self.custom_lsp_root_markers();
978 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
979 .iter()
980 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
981 .cloned()
982 .map(|path| {
983 let change_type = if path.exists() {
984 FileChangeType::CHANGED
985 } else {
986 FileChangeType::DELETED
987 };
988 (path, change_type)
989 })
990 .collect();
991
992 self.notify_watched_config_events(&config_paths);
993 }
994
995 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
996 let paths = params
997 .get("multi_file_write_paths")
998 .and_then(|value| value.as_array())?
999 .iter()
1000 .filter_map(|value| value.as_str())
1001 .map(PathBuf::from)
1002 .collect::<Vec<_>>();
1003
1004 (!paths.is_empty()).then_some(paths)
1005 }
1006
1007 fn watched_file_events_from_params(
1019 params: &serde_json::Value,
1020 extra_markers: &[String],
1021 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
1022 let events = params
1023 .get("multi_file_write_paths")
1024 .and_then(|value| value.as_array())?
1025 .iter()
1026 .filter_map(|entry| {
1027 let path = entry
1029 .get("path")
1030 .and_then(|value| value.as_str())
1031 .map(PathBuf::from)?;
1032
1033 if !is_config_file_path_with_custom(&path, extra_markers) {
1034 return None;
1035 }
1036
1037 let change_type = entry
1038 .get("type")
1039 .and_then(|value| value.as_str())
1040 .and_then(Self::parse_file_change_type)
1041 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
1042
1043 Some((path, change_type))
1044 })
1045 .collect::<Vec<_>>();
1046
1047 (!events.is_empty()).then_some(events)
1048 }
1049
1050 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
1051 match value {
1052 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
1053 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
1054 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
1055 _ => None,
1056 }
1057 }
1058
1059 fn change_type_from_current_state(path: &Path) -> FileChangeType {
1060 if path.exists() {
1061 FileChangeType::CHANGED
1062 } else {
1063 FileChangeType::DELETED
1064 }
1065 }
1066
1067 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
1068 if config_paths.is_empty() {
1069 return;
1070 }
1071
1072 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1073 let config = self.config();
1074 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
1075 crate::slog_warn!("watched-file sync error: {}", e);
1076 }
1077 }
1078 }
1079
1080 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
1081 let custom_markers = self.custom_lsp_root_markers();
1082 if !is_config_file_path_with_custom(file_path, &custom_markers) {
1083 return;
1084 }
1085
1086 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
1087 }
1088
1089 pub fn lsp_post_multi_file_write(
1094 &self,
1095 file_path: &Path,
1096 content: &str,
1097 file_paths: &[PathBuf],
1098 params: &serde_json::Value,
1099 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
1100 self.notify_watched_config_files(file_paths);
1101
1102 let wants_diagnostics = params
1103 .get("diagnostics")
1104 .and_then(|v| v.as_bool())
1105 .unwrap_or(false);
1106
1107 if !wants_diagnostics {
1108 self.lsp_notify_file_changed(file_path, content);
1109 return None;
1110 }
1111
1112 let wait_ms = params
1113 .get("wait_ms")
1114 .and_then(|v| v.as_u64())
1115 .unwrap_or(3000)
1116 .min(10_000);
1117
1118 Some(self.lsp_notify_and_collect_diagnostics(
1119 file_path,
1120 content,
1121 std::time::Duration::from_millis(wait_ms),
1122 ))
1123 }
1124
1125 pub fn lsp_post_write(
1142 &self,
1143 file_path: &Path,
1144 content: &str,
1145 params: &serde_json::Value,
1146 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
1147 let wants_diagnostics = params
1148 .get("diagnostics")
1149 .and_then(|v| v.as_bool())
1150 .unwrap_or(false);
1151
1152 let custom_markers = self.custom_lsp_root_markers();
1153
1154 if !wants_diagnostics {
1155 if let Some(file_paths) = Self::multi_file_write_paths(params) {
1156 self.notify_watched_config_files(&file_paths);
1157 } else if let Some(config_events) =
1158 Self::watched_file_events_from_params(params, &custom_markers)
1159 {
1160 self.notify_watched_config_events(&config_events);
1161 }
1162 self.lsp_notify_file_changed(file_path, content);
1163 return None;
1164 }
1165
1166 let wait_ms = params
1167 .get("wait_ms")
1168 .and_then(|v| v.as_u64())
1169 .unwrap_or(3000)
1170 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
1173 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
1174 }
1175
1176 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
1177 {
1178 self.notify_watched_config_events(&config_events);
1179 }
1180
1181 Some(self.lsp_notify_and_collect_diagnostics(
1182 file_path,
1183 content,
1184 std::time::Duration::from_millis(wait_ms),
1185 ))
1186 }
1187
1188 pub fn validate_path(
1197 &self,
1198 req_id: &str,
1199 path: &Path,
1200 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
1201 let config = self.config();
1202 if !config.restrict_to_project_root {
1204 return Ok(path.to_path_buf());
1205 }
1206 let root = match &config.project_root {
1207 Some(r) => r.clone(),
1208 None => return Ok(path.to_path_buf()), };
1210 drop(config);
1211
1212 let raw_root = root.clone();
1217 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
1218
1219 let path_for_resolution = if path.is_relative() {
1224 raw_root.join(path)
1225 } else {
1226 path.to_path_buf()
1227 };
1228 let resolved = match std::fs::canonicalize(&path_for_resolution) {
1229 Ok(resolved) => resolved,
1230 Err(_) => {
1231 let normalized = normalize_path(&path_for_resolution);
1232 reject_escaping_symlink(
1233 req_id,
1234 &path_for_resolution,
1235 &normalized,
1236 &resolved_root,
1237 &raw_root,
1238 )?;
1239 resolve_with_existing_ancestors(&normalized)
1240 }
1241 };
1242
1243 if !resolved.starts_with(&resolved_root) {
1244 return Err(path_error_response(req_id, path, &resolved_root));
1245 }
1246
1247 Ok(resolved)
1248 }
1249
1250 pub fn lsp_server_count(&self) -> usize {
1252 self.lsp_manager
1253 .try_borrow()
1254 .map(|lsp| lsp.server_count())
1255 .unwrap_or(0)
1256 }
1257
1258 pub fn symbol_cache_stats(&self) -> serde_json::Value {
1260 let entries = self
1261 .symbol_cache
1262 .read()
1263 .map(|cache| cache.len())
1264 .unwrap_or(0);
1265 serde_json::json!({
1266 "local_entries": entries,
1267 "warm_entries": 0,
1268 })
1269 }
1270}
1271
1272#[cfg(test)]
1273mod status_emitter_tests {
1274 use super::*;
1275 use crate::parser::TreeSitterProvider;
1276
1277 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
1278 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
1279 let (tx, rx) = mpsc::channel();
1280 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
1281 let _ = tx.send(frame);
1282 }))));
1283 (ctx, rx)
1284 }
1285
1286 #[test]
1287 fn status_emitter_signal_triggers_push() {
1288 let (ctx, rx) = ctx_with_frame_rx();
1289 ctx.status_emitter().signal(ctx.build_status_snapshot());
1290 let frame = rx
1291 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1292 .expect("status_changed push");
1293 assert!(matches!(frame, PushFrame::StatusChanged(_)));
1294 }
1295
1296 #[test]
1297 fn status_emitter_debounces_burst() {
1298 let (ctx, rx) = ctx_with_frame_rx();
1299 for _ in 0..10 {
1300 ctx.status_emitter().signal(ctx.build_status_snapshot());
1301 }
1302 let frame = rx
1303 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1304 .expect("status_changed push");
1305 assert!(matches!(frame, PushFrame::StatusChanged(_)));
1306 assert!(rx.try_recv().is_err());
1307 }
1308
1309 #[test]
1310 fn status_emitter_separate_windows_separate_pushes() {
1311 let (ctx, rx) = ctx_with_frame_rx();
1312 ctx.status_emitter().signal(ctx.build_status_snapshot());
1313 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1314 .expect("first push");
1315 ctx.status_emitter().signal(ctx.build_status_snapshot());
1316 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1317 .expect("second push");
1318 }
1319
1320 #[test]
1321 fn status_emitter_no_signal_no_push() {
1322 let (_ctx, rx) = ctx_with_frame_rx();
1323 assert!(rx
1324 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
1325 .is_err());
1326 }
1327
1328 #[test]
1329 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
1330 let (ctx, rx) = ctx_with_frame_rx();
1331 drop(ctx);
1332 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
1333 }
1334}
1335
1336#[cfg(test)]
1337mod harness_path_tests {
1338 use super::*;
1339 use crate::harness::Harness;
1340 use crate::parser::TreeSitterProvider;
1341
1342 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
1343 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
1344 ctx.config_mut().storage_dir = Some(storage_dir);
1345 ctx.set_harness(harness);
1346 ctx
1347 }
1348
1349 #[test]
1350 fn harness_dir_resolves_correctly() {
1351 let storage = PathBuf::from("/tmp/cortexkit/aft");
1352 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
1353
1354 assert_eq!(ctx.harness_dir(), storage.join("pi"));
1355 }
1356
1357 #[test]
1358 fn bash_tasks_dir_uses_hash_session() {
1359 let storage = PathBuf::from("/tmp/cortexkit/aft");
1360 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
1361
1362 assert_eq!(
1363 ctx.bash_tasks_dir("ses_abc"),
1364 storage
1365 .join("opencode")
1366 .join("bash-tasks")
1367 .join(hash_session("ses_abc"))
1368 );
1369 }
1370
1371 #[test]
1372 fn backups_dir_includes_path_hash() {
1373 let storage = PathBuf::from("/tmp/cortexkit/aft");
1374 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
1375
1376 assert_eq!(
1377 ctx.backups_dir("ses_abc", "pathhash"),
1378 storage
1379 .join("pi")
1380 .join("backups")
1381 .join(hash_session("ses_abc"))
1382 .join("pathhash")
1383 );
1384 }
1385
1386 #[test]
1387 fn filters_dir_under_harness() {
1388 let storage = PathBuf::from("/tmp/cortexkit/aft");
1389 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
1390
1391 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
1392 }
1393
1394 #[test]
1395 fn trust_file_is_host_global() {
1396 let storage = PathBuf::from("/tmp/cortexkit/aft");
1397 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
1398
1399 assert_eq!(
1400 ctx.trust_file(),
1401 storage.join("trusted-filter-projects.json")
1402 );
1403 }
1404
1405 #[test]
1406 fn same_session_different_harness_resolve_different_paths() {
1407 let storage = PathBuf::from("/tmp/cortexkit/aft");
1408 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
1409 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
1410
1411 assert_ne!(
1412 opencode.bash_tasks_dir("ses_same"),
1413 pi.bash_tasks_dir("ses_same")
1414 );
1415 }
1416}
1417
1418#[cfg(test)]
1419mod gitignore_tests {
1420 use super::*;
1421 use std::fs;
1422 use std::path::Path;
1423 use tempfile::TempDir;
1424
1425 fn make_ctx_with_root(root: &Path) -> AppContext {
1426 let provider = Box::new(crate::parser::TreeSitterProvider::new());
1427 let config = Config {
1428 project_root: Some(root.to_path_buf()),
1429 ..Config::default()
1430 };
1431 AppContext::new(provider, config)
1432 }
1433
1434 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
1441 let Some(matcher) = ctx.gitignore() else {
1442 return false;
1443 };
1444 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1445 if !canonical.starts_with(matcher.path()) {
1446 return false;
1447 }
1448 let is_dir = canonical.is_dir();
1449 matcher
1450 .matched_path_or_any_parents(&canonical, is_dir)
1451 .is_ignore()
1452 }
1453
1454 #[test]
1455 fn rebuild_gitignore_returns_none_without_project_root() {
1456 let provider = Box::new(crate::parser::TreeSitterProvider::new());
1457 let ctx = AppContext::new(provider, Config::default());
1458 ctx.rebuild_gitignore();
1459 assert!(ctx.gitignore().is_none());
1460 }
1461
1462 #[test]
1463 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
1464 let tmp = TempDir::new().unwrap();
1465 let ctx = make_ctx_with_root(tmp.path());
1466 ctx.rebuild_gitignore();
1467 assert!(ctx.gitignore().is_none());
1468 }
1469
1470 #[test]
1471 fn matcher_filters_files_in_ignored_dist_dir() {
1472 let tmp = TempDir::new().unwrap();
1473 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
1474 fs::create_dir_all(tmp.path().join("dist")).unwrap();
1475 fs::create_dir_all(tmp.path().join("src")).unwrap();
1476 let dist_file = tmp.path().join("dist").join("bundle.js");
1477 let src_file = tmp.path().join("src").join("app.ts");
1478 fs::write(&dist_file, "x").unwrap();
1479 fs::write(&src_file, "y").unwrap();
1480
1481 let ctx = make_ctx_with_root(tmp.path());
1482 ctx.rebuild_gitignore();
1483
1484 assert!(ctx.gitignore().is_some());
1485 assert!(
1486 is_ignored(&ctx, &dist_file),
1487 "dist/bundle.js should be ignored"
1488 );
1489 assert!(
1490 !is_ignored(&ctx, &src_file),
1491 "src/app.ts should NOT be ignored"
1492 );
1493 }
1494
1495 #[test]
1496 fn matcher_handles_node_modules_and_target() {
1497 let tmp = TempDir::new().unwrap();
1498 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
1499 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
1500 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
1501 let nm_file = tmp.path().join("node_modules/foo/index.js");
1502 let target_file = tmp.path().join("target/debug/aft");
1503 fs::write(&nm_file, "x").unwrap();
1504 fs::write(&target_file, "x").unwrap();
1505
1506 let ctx = make_ctx_with_root(tmp.path());
1507 ctx.rebuild_gitignore();
1508
1509 assert!(is_ignored(&ctx, &nm_file));
1510 assert!(is_ignored(&ctx, &target_file));
1511 }
1512
1513 #[test]
1514 fn matcher_honors_negation_pattern() {
1515 let tmp = TempDir::new().unwrap();
1517 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
1518 let random_log = tmp.path().join("random.log");
1519 let important_log = tmp.path().join("important.log");
1520 fs::write(&random_log, "x").unwrap();
1521 fs::write(&important_log, "y").unwrap();
1522
1523 let ctx = make_ctx_with_root(tmp.path());
1524 ctx.rebuild_gitignore();
1525
1526 assert!(is_ignored(&ctx, &random_log));
1527 assert!(
1528 !is_ignored(&ctx, &important_log),
1529 "negation pattern should un-ignore important.log"
1530 );
1531 }
1532
1533 #[test]
1534 fn rebuild_picks_up_gitignore_changes() {
1535 let tmp = TempDir::new().unwrap();
1536 let ignore_path = tmp.path().join(".gitignore");
1537 fs::write(&ignore_path, "foo.txt\n").unwrap();
1538 let foo = tmp.path().join("foo.txt");
1539 let bar = tmp.path().join("bar.txt");
1540 fs::write(&foo, "").unwrap();
1541 fs::write(&bar, "").unwrap();
1542
1543 let ctx = make_ctx_with_root(tmp.path());
1544 ctx.rebuild_gitignore();
1545 assert!(is_ignored(&ctx, &foo));
1546 assert!(!is_ignored(&ctx, &bar));
1547
1548 fs::write(&ignore_path, "bar.txt\n").unwrap();
1550 ctx.rebuild_gitignore();
1551 assert!(!is_ignored(&ctx, &foo));
1552 assert!(is_ignored(&ctx, &bar));
1553 }
1554
1555 #[test]
1556 fn gitignore_loads_info_exclude_when_present() {
1557 let tmp = TempDir::new().unwrap();
1558 let info_dir = tmp.path().join(".git/info");
1559 fs::create_dir_all(&info_dir).unwrap();
1560 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
1561 let secrets = tmp.path().join("secrets.txt");
1562 let public = tmp.path().join("public.txt");
1563 fs::write(&secrets, "token").unwrap();
1564 fs::write(&public, "ok").unwrap();
1565
1566 let ctx = make_ctx_with_root(tmp.path());
1567 ctx.rebuild_gitignore();
1568
1569 assert!(is_ignored(&ctx, &secrets));
1570 assert!(!is_ignored(&ctx, &public));
1571 }
1572
1573 #[test]
1574 fn matcher_picks_up_nested_gitignore() {
1575 let tmp = TempDir::new().unwrap();
1576 fs::write(tmp.path().join(".gitignore"), "").unwrap();
1578 let sub = tmp.path().join("packages/foo");
1579 fs::create_dir_all(&sub).unwrap();
1580 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
1581 let generated_file = sub.join("generated").join("out.js");
1582 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
1583 fs::write(&generated_file, "x").unwrap();
1584
1585 let ctx = make_ctx_with_root(tmp.path());
1586 ctx.rebuild_gitignore();
1587
1588 assert!(
1589 is_ignored(&ctx, &generated_file),
1590 "nested gitignore in packages/foo/.gitignore should ignore generated/"
1591 );
1592 }
1593}