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,
92 files: Option<usize>,
93 entries_done: Option<usize>,
94 entries_total: Option<usize>,
95 },
96 Ready,
97 Failed(String),
98}
99
100pub enum SemanticIndexEvent {
101 Progress {
102 stage: String,
103 files: Option<usize>,
104 entries_done: Option<usize>,
105 entries_total: Option<usize>,
106 },
107 Ready(SemanticIndex),
108 Failed(String),
109}
110
111fn normalize_path(path: &Path) -> PathBuf {
115 let mut result = PathBuf::new();
116 for component in path.components() {
117 match component {
118 Component::ParentDir => {
119 if !result.pop() {
121 result.push(component);
122 }
123 }
124 Component::CurDir => {} _ => result.push(component),
126 }
127 }
128 result
129}
130
131fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
132 let mut existing = path.to_path_buf();
133 let mut tail_segments = Vec::new();
134
135 while !existing.exists() {
136 if let Some(name) = existing.file_name() {
137 tail_segments.push(name.to_owned());
138 } else {
139 break;
140 }
141
142 existing = match existing.parent() {
143 Some(parent) => parent.to_path_buf(),
144 None => break,
145 };
146 }
147
148 let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
149 for segment in tail_segments.into_iter().rev() {
150 resolved.push(segment);
151 }
152
153 resolved
154}
155
156fn path_error_response(
157 req_id: &str,
158 path: &Path,
159 resolved_root: &Path,
160) -> crate::protocol::Response {
161 crate::protocol::Response::error(
162 req_id,
163 "path_outside_root",
164 format!(
165 "path '{}' is outside the project root '{}'",
166 path.display(),
167 resolved_root.display()
168 ),
169 )
170}
171
172fn reject_escaping_symlink(
182 req_id: &str,
183 original_path: &Path,
184 candidate: &Path,
185 resolved_root: &Path,
186 raw_root: &Path,
187) -> Result<(), crate::protocol::Response> {
188 let mut current = PathBuf::new();
189
190 for component in candidate.components() {
191 current.push(component);
192
193 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
194 continue;
195 };
196
197 if !metadata.file_type().is_symlink() {
198 continue;
199 }
200
201 let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
210 if !inside_root {
211 continue;
212 }
213
214 iterative_follow_chain(req_id, original_path, ¤t, resolved_root)?;
215 }
216
217 Ok(())
218}
219
220fn iterative_follow_chain(
223 req_id: &str,
224 original_path: &Path,
225 start: &Path,
226 resolved_root: &Path,
227) -> Result<(), crate::protocol::Response> {
228 let mut link = start.to_path_buf();
229 let mut depth = 0usize;
230
231 loop {
232 if depth > 40 {
233 return Err(path_error_response(req_id, original_path, resolved_root));
234 }
235
236 let target = match std::fs::read_link(&link) {
237 Ok(t) => t,
238 Err(_) => {
239 return Err(path_error_response(req_id, original_path, resolved_root));
241 }
242 };
243
244 let resolved_target = if target.is_absolute() {
245 normalize_path(&target)
246 } else {
247 let parent = link.parent().unwrap_or_else(|| Path::new(""));
248 normalize_path(&parent.join(&target))
249 };
250
251 let canonical_target =
255 std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
256
257 if !canonical_target.starts_with(resolved_root)
258 && !resolved_target.starts_with(resolved_root)
259 {
260 return Err(path_error_response(req_id, original_path, resolved_root));
261 }
262
263 match std::fs::symlink_metadata(&resolved_target) {
265 Ok(meta) if meta.file_type().is_symlink() => {
266 link = resolved_target;
267 depth += 1;
268 }
269 _ => break, }
271 }
272
273 Ok(())
274}
275
276pub struct AppContext {
286 provider: Box<dyn LanguageProvider>,
287 backup: RefCell<BackupStore>,
288 checkpoint: RefCell<CheckpointStore>,
289 db: RefCell<Option<Arc<Mutex<Connection>>>>,
290 config: RefCell<Config>,
291 pub harness: RefCell<Option<Harness>>,
292 canonical_cache_root: RefCell<Option<PathBuf>>,
293 is_worktree_bridge: RefCell<bool>,
294 git_common_dir: RefCell<Option<PathBuf>>,
295 degraded_reasons: RefCell<Vec<String>>,
303 callgraph: RefCell<Option<CallGraph>>,
304 search_index: RefCell<Option<SearchIndex>>,
305 search_index_rx: RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>>,
306 symbol_cache: SharedSymbolCache,
307 semantic_index: RefCell<Option<SemanticIndex>>,
308 semantic_index_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
309 semantic_index_status: RefCell<SemanticIndexStatus>,
310 semantic_embedding_model: RefCell<Option<crate::semantic_index::EmbeddingModel>>,
311 watcher: RefCell<Option<RecommendedWatcher>>,
312 watcher_rx: RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>>,
313 lsp_manager: RefCell<LspManager>,
314 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
318 stdout_writer: SharedStdoutWriter,
319 progress_sender: SharedProgressSender,
320 configure_generation: AtomicU64,
321 configure_warnings_tx: mpsc::Sender<(u64, ConfigureWarningsFrame)>,
322 configure_warnings_rx: mpsc::Receiver<(u64, ConfigureWarningsFrame)>,
323 status_emitter: StatusEmitter,
324 bash_background: BgTaskRegistry,
325 filter_registry: crate::compress::SharedFilterRegistry,
332 filter_registry_loaded: std::sync::atomic::AtomicBool,
335 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
340 gitignore: RefCell<Option<Arc<ignore::gitignore::Gitignore>>>,
347}
348
349impl AppContext {
350 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
351 let bash_compress_enabled = config.experimental_bash_compress;
352 let progress_sender = Arc::new(Mutex::new(None));
353 let stdout_writer = Arc::new(Mutex::new(BufWriter::new(io::stdout())));
354 let (configure_warnings_tx, configure_warnings_rx) = mpsc::channel();
355 let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
356 let symbol_cache = provider
357 .as_any()
358 .downcast_ref::<crate::parser::TreeSitterProvider>()
359 .map(|provider| provider.symbol_cache())
360 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
361 let lsp_child_registry = crate::lsp::child_registry::LspChildRegistry::new();
362 let mut lsp_manager = LspManager::new();
363 lsp_manager.set_child_registry(lsp_child_registry.clone());
364 AppContext {
365 provider,
366 backup: RefCell::new(BackupStore::new()),
367 checkpoint: RefCell::new(CheckpointStore::new()),
368 db: RefCell::new(None),
369 config: RefCell::new(config),
370 harness: RefCell::new(None),
371 canonical_cache_root: RefCell::new(None),
372 is_worktree_bridge: RefCell::new(false),
373 git_common_dir: RefCell::new(None),
374 degraded_reasons: RefCell::new(Vec::new()),
375 callgraph: RefCell::new(None),
376 search_index: RefCell::new(None),
377 search_index_rx: RefCell::new(None),
378 symbol_cache,
379 semantic_index: RefCell::new(None),
380 semantic_index_rx: RefCell::new(None),
381 semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
382 semantic_embedding_model: RefCell::new(None),
383 watcher: RefCell::new(None),
384 watcher_rx: RefCell::new(None),
385 lsp_manager: RefCell::new(lsp_manager),
386 lsp_child_registry,
387 stdout_writer,
388 progress_sender: Arc::clone(&progress_sender),
389 configure_generation: AtomicU64::new(0),
390 configure_warnings_tx,
391 configure_warnings_rx,
392 status_emitter,
393 bash_background: BgTaskRegistry::new(progress_sender),
394 filter_registry: Arc::new(std::sync::RwLock::new(
395 crate::compress::toml_filter::FilterRegistry::default(),
396 )),
397 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
398 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
399 gitignore: RefCell::new(None),
400 }
401 }
402
403 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
406 self.gitignore.borrow().clone()
407 }
408
409 pub fn clear_gitignore(&self) {
430 *self.gitignore.borrow_mut() = None;
431 }
432
433 pub fn rebuild_gitignore(&self) {
434 use ignore::gitignore::GitignoreBuilder;
435 use std::path::Path;
436 let root_raw = match self.config().project_root.clone() {
437 Some(r) => r,
438 None => {
439 *self.gitignore.borrow_mut() = None;
440 return;
441 }
442 };
443 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
451 let mut builder = GitignoreBuilder::new(&root);
452 let root_ignore = Path::new(&root).join(".gitignore");
454 if root_ignore.exists() {
455 if let Some(err) = builder.add(&root_ignore) {
456 crate::slog_warn!(
457 "gitignore parse error in {}: {}",
458 root_ignore.display(),
459 err
460 );
461 }
462 }
463 let info_exclude = Path::new(&root).join(".git").join("info").join("exclude");
466 if info_exclude.exists() {
467 if let Some(err) = builder.add(&info_exclude) {
468 crate::slog_warn!(
469 "gitignore parse error in {}: {}",
470 info_exclude.display(),
471 err
472 );
473 }
474 }
475 let walker = ignore::WalkBuilder::new(&root)
481 .standard_filters(true)
482 .hidden(false)
485 .max_depth(Some(8))
486 .filter_entry(|entry| {
487 let name = entry.file_name().to_string_lossy();
488 !matches!(
489 name.as_ref(),
490 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
491 )
492 })
493 .build();
494 for entry in walker.flatten() {
495 if entry.file_name() == ".gitignore" && entry.path() != root_ignore {
496 if let Some(err) = builder.add(entry.path()) {
497 crate::slog_warn!(
498 "nested gitignore parse error in {}: {}",
499 entry.path().display(),
500 err
501 );
502 }
503 }
504 }
505 match builder.build() {
506 Ok(gi) => {
507 let count = gi.num_ignores();
508 if count > 0 {
509 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
510 *self.gitignore.borrow_mut() = Some(Arc::new(gi));
511 } else {
512 *self.gitignore.borrow_mut() = None;
513 }
514 }
515 Err(err) => {
516 crate::slog_warn!("gitignore matcher build failed: {}", err);
517 *self.gitignore.borrow_mut() = None;
518 }
519 }
520 }
521
522 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
525 Arc::clone(&self.bash_compress_flag)
526 }
527
528 pub fn sync_bash_compress_flag(&self) {
532 let value = self.config().experimental_bash_compress;
533 self.bash_compress_flag
534 .store(value, std::sync::atomic::Ordering::Relaxed);
535 }
536
537 pub fn set_bash_compress_enabled(&self, enabled: bool) {
538 self.config_mut().experimental_bash_compress = enabled;
539 self.bash_compress_flag
540 .store(enabled, std::sync::atomic::Ordering::Relaxed);
541 }
542
543 pub fn filter_registry(
547 &self,
548 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
549 self.ensure_filter_registry_loaded();
550 match self.filter_registry.read() {
551 Ok(g) => g,
552 Err(poisoned) => poisoned.into_inner(),
553 }
554 }
555
556 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
560 self.ensure_filter_registry_loaded();
561 Arc::clone(&self.filter_registry)
562 }
563
564 pub fn reset_filter_registry(&self) {
568 let new_registry = crate::compress::build_registry_for_context(self);
569 match self.filter_registry.write() {
570 Ok(mut slot) => *slot = new_registry,
571 Err(poisoned) => *poisoned.into_inner() = new_registry,
572 }
573 self.filter_registry_loaded
574 .store(true, std::sync::atomic::Ordering::Release);
575 }
576
577 fn ensure_filter_registry_loaded(&self) {
578 use std::sync::atomic::Ordering;
579 if self.filter_registry_loaded.load(Ordering::Acquire) {
580 return;
581 }
582 let new_registry = crate::compress::build_registry_for_context(self);
585 if let Ok(mut slot) = self.filter_registry.write() {
586 *slot = new_registry;
587 self.filter_registry_loaded.store(true, Ordering::Release);
588 }
589 }
590
591 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
594 self.lsp_child_registry.clone()
595 }
596
597 pub fn stdout_writer(&self) -> SharedStdoutWriter {
598 Arc::clone(&self.stdout_writer)
599 }
600
601 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
602 if let Ok(mut progress_sender) = self.progress_sender.lock() {
603 *progress_sender = sender;
604 }
605 }
606
607 pub fn emit_progress(&self, frame: ProgressFrame) {
608 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
609 return;
610 };
611 if let Some(sender) = progress_sender.as_ref() {
612 sender(PushFrame::Progress(frame));
613 }
614 }
615
616 pub fn status_emitter(&self) -> &StatusEmitter {
617 &self.status_emitter
618 }
619
620 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
628 self.progress_sender
629 .lock()
630 .ok()
631 .and_then(|sender| sender.clone())
632 }
633
634 pub fn advance_configure_generation(&self) -> u64 {
635 self.configure_generation
636 .fetch_add(1, Ordering::SeqCst)
637 .wrapping_add(1)
638 }
639
640 pub fn configure_generation(&self) -> u64 {
641 self.configure_generation.load(Ordering::SeqCst)
642 }
643
644 pub fn configure_warnings_sender(&self) -> mpsc::Sender<(u64, ConfigureWarningsFrame)> {
645 self.configure_warnings_tx.clone()
646 }
647
648 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
649 let mut warnings = Vec::new();
650 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
651 warnings.push(warning);
652 }
653 warnings
654 }
655
656 pub fn bash_background(&self) -> &BgTaskRegistry {
657 &self.bash_background
658 }
659
660 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
661 self.bash_background.drain_completions()
662 }
663
664 pub fn provider(&self) -> &dyn LanguageProvider {
666 self.provider.as_ref()
667 }
668
669 pub fn backup(&self) -> &RefCell<BackupStore> {
671 &self.backup
672 }
673
674 pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
676 &self.checkpoint
677 }
678
679 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
680 *self.db.borrow_mut() = Some(conn);
681 }
682
683 pub fn clear_db(&self) {
684 *self.db.borrow_mut() = None;
685 }
686
687 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
688 self.db.borrow().clone()
689 }
690
691 pub fn config(&self) -> Ref<'_, Config> {
693 self.config.borrow()
694 }
695
696 pub fn config_mut(&self) -> RefMut<'_, Config> {
698 self.config.borrow_mut()
699 }
700
701 pub fn set_harness(&self, harness: Harness) {
702 *self.harness.borrow_mut() = Some(harness);
703 self.bash_background.set_harness(harness);
704 }
705
706 pub fn harness_opt(&self) -> Option<Harness> {
707 *self.harness.borrow()
708 }
709
710 pub fn harness(&self) -> Harness {
711 self.harness_opt()
712 .expect("harness set by configure before any tool call")
713 }
714
715 pub fn storage_dir(&self) -> PathBuf {
716 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
717 }
718
719 pub fn harness_dir(&self) -> PathBuf {
720 self.storage_dir().join(self.harness().as_str())
721 }
722
723 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
724 self.harness_dir()
725 .join("bash-tasks")
726 .join(hash_session(session_id))
727 }
728
729 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
730 self.harness_dir()
731 .join("backups")
732 .join(hash_session(session_id))
733 .join(path_hash)
734 }
735
736 pub fn filters_dir(&self) -> PathBuf {
737 self.harness_dir().join("filters")
738 }
739
740 pub fn trust_file(&self) -> PathBuf {
742 self.storage_dir().join("trusted-filter-projects.json")
743 }
744
745 pub fn set_canonical_cache_root(&self, root: PathBuf) {
746 debug_assert!(root.is_absolute());
747 *self.canonical_cache_root.borrow_mut() = Some(root);
748 }
749
750 pub fn canonical_cache_root(&self) -> PathBuf {
751 self.canonical_cache_root
752 .borrow()
753 .clone()
754 .expect("canonical_cache_root accessed before handle_configure")
755 }
756
757 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
758 self.canonical_cache_root.borrow().clone()
759 }
760
761 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
762 *self.is_worktree_bridge.borrow_mut() = is_worktree_bridge;
763 *self.git_common_dir.borrow_mut() = git_common_dir;
764 }
765
766 pub fn is_worktree_bridge(&self) -> bool {
767 *self.is_worktree_bridge.borrow()
768 }
769
770 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
774 *self.degraded_reasons.borrow_mut() = reasons;
775 }
776
777 pub fn degraded_reasons(&self) -> Vec<String> {
781 self.degraded_reasons.borrow().clone()
782 }
783
784 pub fn is_degraded(&self) -> bool {
786 !self.degraded_reasons.borrow().is_empty()
787 }
788
789 pub fn cache_role(&self) -> &'static str {
790 if self.canonical_cache_root.borrow().is_none() {
791 "not_initialized"
792 } else if self.is_worktree_bridge() {
793 "worktree"
794 } else {
795 "main"
796 }
797 }
798
799 pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
801 &self.callgraph
802 }
803
804 pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
806 &self.search_index
807 }
808
809 pub fn search_index_rx(&self) -> &RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>> {
811 &self.search_index_rx
812 }
813
814 pub fn symbol_cache(&self) -> SharedSymbolCache {
816 Arc::clone(&self.symbol_cache)
817 }
818
819 pub fn reset_symbol_cache(&self) -> u64 {
821 self.symbol_cache
822 .write()
823 .map(|mut cache| cache.reset())
824 .unwrap_or(0)
825 }
826
827 pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
829 &self.semantic_index
830 }
831
832 pub fn semantic_index_rx(
834 &self,
835 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
836 &self.semantic_index_rx
837 }
838
839 pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
840 &self.semantic_index_status
841 }
842
843 pub fn semantic_embedding_model(
845 &self,
846 ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
847 &self.semantic_embedding_model
848 }
849
850 pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
852 &self.watcher
853 }
854
855 pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
857 &self.watcher_rx
858 }
859
860 pub fn lsp(&self) -> RefMut<'_, LspManager> {
862 self.lsp_manager.borrow_mut()
863 }
864
865 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
868 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
869 let config = self.config();
870 if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
871 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
872 }
873 }
874 }
875
876 pub fn lsp_notify_and_collect_diagnostics(
887 &self,
888 file_path: &Path,
889 content: &str,
890 timeout: std::time::Duration,
891 ) -> crate::lsp::manager::PostEditWaitOutcome {
892 let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
893 return crate::lsp::manager::PostEditWaitOutcome::default();
894 };
895
896 lsp.drain_events();
899
900 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
904
905 let config = self.config();
907 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
908 {
909 Ok(v) => v,
910 Err(e) => {
911 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
912 return crate::lsp::manager::PostEditWaitOutcome::default();
913 }
914 };
915
916 if expected_versions.is_empty() {
919 return crate::lsp::manager::PostEditWaitOutcome::default();
920 }
921
922 lsp.wait_for_post_edit_diagnostics(
923 file_path,
924 &config,
925 &expected_versions,
926 &pre_snapshot,
927 timeout,
928 )
929 }
930
931 fn custom_lsp_root_markers(&self) -> Vec<String> {
934 self.config()
935 .lsp_servers
936 .iter()
937 .flat_map(|s| s.root_markers.iter().cloned())
938 .collect()
939 }
940
941 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
942 let custom_markers = self.custom_lsp_root_markers();
943 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
944 .iter()
945 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
946 .cloned()
947 .map(|path| {
948 let change_type = if path.exists() {
949 FileChangeType::CHANGED
950 } else {
951 FileChangeType::DELETED
952 };
953 (path, change_type)
954 })
955 .collect();
956
957 self.notify_watched_config_events(&config_paths);
958 }
959
960 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
961 let paths = params
962 .get("multi_file_write_paths")
963 .and_then(|value| value.as_array())?
964 .iter()
965 .filter_map(|value| value.as_str())
966 .map(PathBuf::from)
967 .collect::<Vec<_>>();
968
969 (!paths.is_empty()).then_some(paths)
970 }
971
972 fn watched_file_events_from_params(
984 params: &serde_json::Value,
985 extra_markers: &[String],
986 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
987 let events = params
988 .get("multi_file_write_paths")
989 .and_then(|value| value.as_array())?
990 .iter()
991 .filter_map(|entry| {
992 let path = entry
994 .get("path")
995 .and_then(|value| value.as_str())
996 .map(PathBuf::from)?;
997
998 if !is_config_file_path_with_custom(&path, extra_markers) {
999 return None;
1000 }
1001
1002 let change_type = entry
1003 .get("type")
1004 .and_then(|value| value.as_str())
1005 .and_then(Self::parse_file_change_type)
1006 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
1007
1008 Some((path, change_type))
1009 })
1010 .collect::<Vec<_>>();
1011
1012 (!events.is_empty()).then_some(events)
1013 }
1014
1015 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
1016 match value {
1017 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
1018 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
1019 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
1020 _ => None,
1021 }
1022 }
1023
1024 fn change_type_from_current_state(path: &Path) -> FileChangeType {
1025 if path.exists() {
1026 FileChangeType::CHANGED
1027 } else {
1028 FileChangeType::DELETED
1029 }
1030 }
1031
1032 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
1033 if config_paths.is_empty() {
1034 return;
1035 }
1036
1037 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1038 let config = self.config();
1039 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
1040 crate::slog_warn!("watched-file sync error: {}", e);
1041 }
1042 }
1043 }
1044
1045 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
1046 let custom_markers = self.custom_lsp_root_markers();
1047 if !is_config_file_path_with_custom(file_path, &custom_markers) {
1048 return;
1049 }
1050
1051 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
1052 }
1053
1054 pub fn lsp_post_multi_file_write(
1059 &self,
1060 file_path: &Path,
1061 content: &str,
1062 file_paths: &[PathBuf],
1063 params: &serde_json::Value,
1064 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
1065 self.notify_watched_config_files(file_paths);
1066
1067 let wants_diagnostics = params
1068 .get("diagnostics")
1069 .and_then(|v| v.as_bool())
1070 .unwrap_or(false);
1071
1072 if !wants_diagnostics {
1073 self.lsp_notify_file_changed(file_path, content);
1074 return None;
1075 }
1076
1077 let wait_ms = params
1078 .get("wait_ms")
1079 .and_then(|v| v.as_u64())
1080 .unwrap_or(3000)
1081 .min(10_000);
1082
1083 Some(self.lsp_notify_and_collect_diagnostics(
1084 file_path,
1085 content,
1086 std::time::Duration::from_millis(wait_ms),
1087 ))
1088 }
1089
1090 pub fn lsp_post_write(
1107 &self,
1108 file_path: &Path,
1109 content: &str,
1110 params: &serde_json::Value,
1111 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
1112 let wants_diagnostics = params
1113 .get("diagnostics")
1114 .and_then(|v| v.as_bool())
1115 .unwrap_or(false);
1116
1117 let custom_markers = self.custom_lsp_root_markers();
1118
1119 if !wants_diagnostics {
1120 if let Some(file_paths) = Self::multi_file_write_paths(params) {
1121 self.notify_watched_config_files(&file_paths);
1122 } else if let Some(config_events) =
1123 Self::watched_file_events_from_params(params, &custom_markers)
1124 {
1125 self.notify_watched_config_events(&config_events);
1126 }
1127 self.lsp_notify_file_changed(file_path, content);
1128 return None;
1129 }
1130
1131 let wait_ms = params
1132 .get("wait_ms")
1133 .and_then(|v| v.as_u64())
1134 .unwrap_or(3000)
1135 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
1138 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
1139 }
1140
1141 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
1142 {
1143 self.notify_watched_config_events(&config_events);
1144 }
1145
1146 Some(self.lsp_notify_and_collect_diagnostics(
1147 file_path,
1148 content,
1149 std::time::Duration::from_millis(wait_ms),
1150 ))
1151 }
1152
1153 pub fn validate_path(
1162 &self,
1163 req_id: &str,
1164 path: &Path,
1165 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
1166 let config = self.config();
1167 if !config.restrict_to_project_root {
1169 return Ok(path.to_path_buf());
1170 }
1171 let root = match &config.project_root {
1172 Some(r) => r.clone(),
1173 None => return Ok(path.to_path_buf()), };
1175 drop(config);
1176
1177 let raw_root = root.clone();
1182 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
1183
1184 let path_for_resolution = if path.is_relative() {
1189 raw_root.join(path)
1190 } else {
1191 path.to_path_buf()
1192 };
1193 let resolved = match std::fs::canonicalize(&path_for_resolution) {
1194 Ok(resolved) => resolved,
1195 Err(_) => {
1196 let normalized = normalize_path(&path_for_resolution);
1197 reject_escaping_symlink(
1198 req_id,
1199 &path_for_resolution,
1200 &normalized,
1201 &resolved_root,
1202 &raw_root,
1203 )?;
1204 resolve_with_existing_ancestors(&normalized)
1205 }
1206 };
1207
1208 if !resolved.starts_with(&resolved_root) {
1209 return Err(path_error_response(req_id, path, &resolved_root));
1210 }
1211
1212 Ok(resolved)
1213 }
1214
1215 pub fn lsp_server_count(&self) -> usize {
1217 self.lsp_manager
1218 .try_borrow()
1219 .map(|lsp| lsp.server_count())
1220 .unwrap_or(0)
1221 }
1222
1223 pub fn symbol_cache_stats(&self) -> serde_json::Value {
1225 let entries = self
1226 .symbol_cache
1227 .read()
1228 .map(|cache| cache.len())
1229 .unwrap_or(0);
1230 serde_json::json!({
1231 "local_entries": entries,
1232 "warm_entries": 0,
1233 })
1234 }
1235}
1236
1237#[cfg(test)]
1238mod status_emitter_tests {
1239 use super::*;
1240 use crate::parser::TreeSitterProvider;
1241
1242 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
1243 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
1244 let (tx, rx) = mpsc::channel();
1245 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
1246 let _ = tx.send(frame);
1247 }))));
1248 (ctx, rx)
1249 }
1250
1251 #[test]
1252 fn status_emitter_signal_triggers_push() {
1253 let (ctx, rx) = ctx_with_frame_rx();
1254 ctx.status_emitter().signal(ctx.build_status_snapshot());
1255 let frame = rx
1256 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1257 .expect("status_changed push");
1258 assert!(matches!(frame, PushFrame::StatusChanged(_)));
1259 }
1260
1261 #[test]
1262 fn status_emitter_debounces_burst() {
1263 let (ctx, rx) = ctx_with_frame_rx();
1264 for _ in 0..10 {
1265 ctx.status_emitter().signal(ctx.build_status_snapshot());
1266 }
1267 let frame = rx
1268 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1269 .expect("status_changed push");
1270 assert!(matches!(frame, PushFrame::StatusChanged(_)));
1271 assert!(rx.try_recv().is_err());
1272 }
1273
1274 #[test]
1275 fn status_emitter_separate_windows_separate_pushes() {
1276 let (ctx, rx) = ctx_with_frame_rx();
1277 ctx.status_emitter().signal(ctx.build_status_snapshot());
1278 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1279 .expect("first push");
1280 ctx.status_emitter().signal(ctx.build_status_snapshot());
1281 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1282 .expect("second push");
1283 }
1284
1285 #[test]
1286 fn status_emitter_no_signal_no_push() {
1287 let (_ctx, rx) = ctx_with_frame_rx();
1288 assert!(rx
1289 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
1290 .is_err());
1291 }
1292
1293 #[test]
1294 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
1295 let (ctx, rx) = ctx_with_frame_rx();
1296 drop(ctx);
1297 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
1298 }
1299}
1300
1301#[cfg(test)]
1302mod harness_path_tests {
1303 use super::*;
1304 use crate::harness::Harness;
1305 use crate::parser::TreeSitterProvider;
1306
1307 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
1308 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
1309 ctx.config_mut().storage_dir = Some(storage_dir);
1310 ctx.set_harness(harness);
1311 ctx
1312 }
1313
1314 #[test]
1315 fn harness_dir_resolves_correctly() {
1316 let storage = PathBuf::from("/tmp/cortexkit/aft");
1317 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
1318
1319 assert_eq!(ctx.harness_dir(), storage.join("pi"));
1320 }
1321
1322 #[test]
1323 fn bash_tasks_dir_uses_hash_session() {
1324 let storage = PathBuf::from("/tmp/cortexkit/aft");
1325 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
1326
1327 assert_eq!(
1328 ctx.bash_tasks_dir("ses_abc"),
1329 storage
1330 .join("opencode")
1331 .join("bash-tasks")
1332 .join(hash_session("ses_abc"))
1333 );
1334 }
1335
1336 #[test]
1337 fn backups_dir_includes_path_hash() {
1338 let storage = PathBuf::from("/tmp/cortexkit/aft");
1339 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
1340
1341 assert_eq!(
1342 ctx.backups_dir("ses_abc", "pathhash"),
1343 storage
1344 .join("pi")
1345 .join("backups")
1346 .join(hash_session("ses_abc"))
1347 .join("pathhash")
1348 );
1349 }
1350
1351 #[test]
1352 fn filters_dir_under_harness() {
1353 let storage = PathBuf::from("/tmp/cortexkit/aft");
1354 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
1355
1356 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
1357 }
1358
1359 #[test]
1360 fn trust_file_is_host_global() {
1361 let storage = PathBuf::from("/tmp/cortexkit/aft");
1362 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
1363
1364 assert_eq!(
1365 ctx.trust_file(),
1366 storage.join("trusted-filter-projects.json")
1367 );
1368 }
1369
1370 #[test]
1371 fn same_session_different_harness_resolve_different_paths() {
1372 let storage = PathBuf::from("/tmp/cortexkit/aft");
1373 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
1374 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
1375
1376 assert_ne!(
1377 opencode.bash_tasks_dir("ses_same"),
1378 pi.bash_tasks_dir("ses_same")
1379 );
1380 }
1381}
1382
1383#[cfg(test)]
1384mod gitignore_tests {
1385 use super::*;
1386 use std::fs;
1387 use std::path::Path;
1388 use tempfile::TempDir;
1389
1390 fn make_ctx_with_root(root: &Path) -> AppContext {
1391 let provider = Box::new(crate::parser::TreeSitterProvider::new());
1392 let config = Config {
1393 project_root: Some(root.to_path_buf()),
1394 ..Config::default()
1395 };
1396 AppContext::new(provider, config)
1397 }
1398
1399 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
1406 let Some(matcher) = ctx.gitignore() else {
1407 return false;
1408 };
1409 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1410 if !canonical.starts_with(matcher.path()) {
1411 return false;
1412 }
1413 let is_dir = canonical.is_dir();
1414 matcher
1415 .matched_path_or_any_parents(&canonical, is_dir)
1416 .is_ignore()
1417 }
1418
1419 #[test]
1420 fn rebuild_gitignore_returns_none_without_project_root() {
1421 let provider = Box::new(crate::parser::TreeSitterProvider::new());
1422 let ctx = AppContext::new(provider, Config::default());
1423 ctx.rebuild_gitignore();
1424 assert!(ctx.gitignore().is_none());
1425 }
1426
1427 #[test]
1428 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
1429 let tmp = TempDir::new().unwrap();
1430 let ctx = make_ctx_with_root(tmp.path());
1431 ctx.rebuild_gitignore();
1432 assert!(ctx.gitignore().is_none());
1433 }
1434
1435 #[test]
1436 fn matcher_filters_files_in_ignored_dist_dir() {
1437 let tmp = TempDir::new().unwrap();
1438 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
1439 fs::create_dir_all(tmp.path().join("dist")).unwrap();
1440 fs::create_dir_all(tmp.path().join("src")).unwrap();
1441 let dist_file = tmp.path().join("dist").join("bundle.js");
1442 let src_file = tmp.path().join("src").join("app.ts");
1443 fs::write(&dist_file, "x").unwrap();
1444 fs::write(&src_file, "y").unwrap();
1445
1446 let ctx = make_ctx_with_root(tmp.path());
1447 ctx.rebuild_gitignore();
1448
1449 assert!(ctx.gitignore().is_some());
1450 assert!(
1451 is_ignored(&ctx, &dist_file),
1452 "dist/bundle.js should be ignored"
1453 );
1454 assert!(
1455 !is_ignored(&ctx, &src_file),
1456 "src/app.ts should NOT be ignored"
1457 );
1458 }
1459
1460 #[test]
1461 fn matcher_handles_node_modules_and_target() {
1462 let tmp = TempDir::new().unwrap();
1463 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
1464 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
1465 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
1466 let nm_file = tmp.path().join("node_modules/foo/index.js");
1467 let target_file = tmp.path().join("target/debug/aft");
1468 fs::write(&nm_file, "x").unwrap();
1469 fs::write(&target_file, "x").unwrap();
1470
1471 let ctx = make_ctx_with_root(tmp.path());
1472 ctx.rebuild_gitignore();
1473
1474 assert!(is_ignored(&ctx, &nm_file));
1475 assert!(is_ignored(&ctx, &target_file));
1476 }
1477
1478 #[test]
1479 fn matcher_honors_negation_pattern() {
1480 let tmp = TempDir::new().unwrap();
1482 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
1483 let random_log = tmp.path().join("random.log");
1484 let important_log = tmp.path().join("important.log");
1485 fs::write(&random_log, "x").unwrap();
1486 fs::write(&important_log, "y").unwrap();
1487
1488 let ctx = make_ctx_with_root(tmp.path());
1489 ctx.rebuild_gitignore();
1490
1491 assert!(is_ignored(&ctx, &random_log));
1492 assert!(
1493 !is_ignored(&ctx, &important_log),
1494 "negation pattern should un-ignore important.log"
1495 );
1496 }
1497
1498 #[test]
1499 fn rebuild_picks_up_gitignore_changes() {
1500 let tmp = TempDir::new().unwrap();
1501 let ignore_path = tmp.path().join(".gitignore");
1502 fs::write(&ignore_path, "foo.txt\n").unwrap();
1503 let foo = tmp.path().join("foo.txt");
1504 let bar = tmp.path().join("bar.txt");
1505 fs::write(&foo, "").unwrap();
1506 fs::write(&bar, "").unwrap();
1507
1508 let ctx = make_ctx_with_root(tmp.path());
1509 ctx.rebuild_gitignore();
1510 assert!(is_ignored(&ctx, &foo));
1511 assert!(!is_ignored(&ctx, &bar));
1512
1513 fs::write(&ignore_path, "bar.txt\n").unwrap();
1515 ctx.rebuild_gitignore();
1516 assert!(!is_ignored(&ctx, &foo));
1517 assert!(is_ignored(&ctx, &bar));
1518 }
1519
1520 #[test]
1521 fn gitignore_loads_info_exclude_when_present() {
1522 let tmp = TempDir::new().unwrap();
1523 let info_dir = tmp.path().join(".git/info");
1524 fs::create_dir_all(&info_dir).unwrap();
1525 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
1526 let secrets = tmp.path().join("secrets.txt");
1527 let public = tmp.path().join("public.txt");
1528 fs::write(&secrets, "token").unwrap();
1529 fs::write(&public, "ok").unwrap();
1530
1531 let ctx = make_ctx_with_root(tmp.path());
1532 ctx.rebuild_gitignore();
1533
1534 assert!(is_ignored(&ctx, &secrets));
1535 assert!(!is_ignored(&ctx, &public));
1536 }
1537
1538 #[test]
1539 fn matcher_picks_up_nested_gitignore() {
1540 let tmp = TempDir::new().unwrap();
1541 fs::write(tmp.path().join(".gitignore"), "").unwrap();
1543 let sub = tmp.path().join("packages/foo");
1544 fs::create_dir_all(&sub).unwrap();
1545 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
1546 let generated_file = sub.join("generated").join("out.js");
1547 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
1548 fs::write(&generated_file, "x").unwrap();
1549
1550 let ctx = make_ctx_with_root(tmp.path());
1551 ctx.rebuild_gitignore();
1552
1553 assert!(
1554 is_ignored(&ctx, &generated_file),
1555 "nested gitignore in packages/foo/.gitignore should ignore generated/"
1556 );
1557 }
1558}