1use std::cell::{Ref, RefCell, RefMut};
2use std::io::{self, BufWriter};
3use std::path::{Component, Path, PathBuf};
4use std::sync::{mpsc, Arc, Mutex};
5use std::time::{Duration, Instant};
6
7use lsp_types::FileChangeType;
8use notify::RecommendedWatcher;
9use rusqlite::Connection;
10
11use crate::backup::hash_session;
12use crate::backup::BackupStore;
13use crate::bash_background::{BgCompletion, BgTaskRegistry};
14use crate::callgraph::CallGraph;
15use crate::checkpoint::CheckpointStore;
16use crate::config::Config;
17use crate::harness::Harness;
18use crate::language::LanguageProvider;
19use crate::lsp::manager::LspManager;
20use crate::lsp::registry::is_config_file_path_with_custom;
21use crate::parser::{SharedSymbolCache, SymbolCache};
22use crate::protocol::{ProgressFrame, PushFrame, StatusChangedFrame, StatusPayload};
23
24pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
25pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
26pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
27const STATUS_DEBOUNCE_MS: u64 = 1_000;
28
29pub struct StatusEmitter {
30 latest: Arc<Mutex<Option<StatusPayload>>>,
31 notify: mpsc::Sender<()>,
32}
33
34impl StatusEmitter {
35 fn new(progress_sender: SharedProgressSender) -> Self {
36 let (notify, rx) = mpsc::channel();
37 let latest = Arc::new(Mutex::new(None));
38 let latest_for_thread = Arc::clone(&latest);
39 std::thread::spawn(move || {
40 status_debounce_loop(rx, latest_for_thread, progress_sender);
41 });
42 Self { latest, notify }
43 }
44
45 pub fn signal(&self, snapshot: StatusPayload) {
46 if let Ok(mut latest) = self.latest.lock() {
47 *latest = Some(snapshot);
48 }
49 let _ = self.notify.send(());
50 }
51}
52
53fn status_debounce_loop(
54 rx: mpsc::Receiver<()>,
55 latest: Arc<Mutex<Option<StatusPayload>>>,
56 progress_sender: SharedProgressSender,
57) {
58 while rx.recv().is_ok() {
59 let deadline = Instant::now() + Duration::from_millis(STATUS_DEBOUNCE_MS);
60 while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
61 match rx.recv_timeout(remaining) {
62 Ok(()) => continue,
63 Err(mpsc::RecvTimeoutError::Timeout) => break,
64 Err(mpsc::RecvTimeoutError::Disconnected) => return,
65 }
66 }
67
68 let snapshot = latest.lock().ok().and_then(|mut latest| latest.take());
69 let Some(snapshot) = snapshot else { continue };
70 let sender = progress_sender
71 .lock()
72 .ok()
73 .and_then(|sender| sender.clone());
74 if let Some(sender) = sender {
75 sender(PushFrame::StatusChanged(StatusChangedFrame::new(
76 None, snapshot,
77 )));
78 }
79 }
80}
81use crate::search_index::SearchIndex;
82use crate::semantic_index::SemanticIndex;
83
84#[derive(Debug, Clone)]
85pub enum SemanticIndexStatus {
86 Disabled,
87 Building {
88 stage: String,
89 files: Option<usize>,
90 entries_done: Option<usize>,
91 entries_total: Option<usize>,
92 },
93 Ready,
94 Failed(String),
95}
96
97pub enum SemanticIndexEvent {
98 Progress {
99 stage: String,
100 files: Option<usize>,
101 entries_done: Option<usize>,
102 entries_total: Option<usize>,
103 },
104 Ready(SemanticIndex),
105 Failed(String),
106}
107
108fn normalize_path(path: &Path) -> PathBuf {
112 let mut result = PathBuf::new();
113 for component in path.components() {
114 match component {
115 Component::ParentDir => {
116 if !result.pop() {
118 result.push(component);
119 }
120 }
121 Component::CurDir => {} _ => result.push(component),
123 }
124 }
125 result
126}
127
128fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
129 let mut existing = path.to_path_buf();
130 let mut tail_segments = Vec::new();
131
132 while !existing.exists() {
133 if let Some(name) = existing.file_name() {
134 tail_segments.push(name.to_owned());
135 } else {
136 break;
137 }
138
139 existing = match existing.parent() {
140 Some(parent) => parent.to_path_buf(),
141 None => break,
142 };
143 }
144
145 let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
146 for segment in tail_segments.into_iter().rev() {
147 resolved.push(segment);
148 }
149
150 resolved
151}
152
153fn path_error_response(
154 req_id: &str,
155 path: &Path,
156 resolved_root: &Path,
157) -> crate::protocol::Response {
158 crate::protocol::Response::error(
159 req_id,
160 "path_outside_root",
161 format!(
162 "path '{}' is outside the project root '{}'",
163 path.display(),
164 resolved_root.display()
165 ),
166 )
167}
168
169fn reject_escaping_symlink(
179 req_id: &str,
180 original_path: &Path,
181 candidate: &Path,
182 resolved_root: &Path,
183 raw_root: &Path,
184) -> Result<(), crate::protocol::Response> {
185 let mut current = PathBuf::new();
186
187 for component in candidate.components() {
188 current.push(component);
189
190 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
191 continue;
192 };
193
194 if !metadata.file_type().is_symlink() {
195 continue;
196 }
197
198 let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
207 if !inside_root {
208 continue;
209 }
210
211 iterative_follow_chain(req_id, original_path, ¤t, resolved_root)?;
212 }
213
214 Ok(())
215}
216
217fn iterative_follow_chain(
220 req_id: &str,
221 original_path: &Path,
222 start: &Path,
223 resolved_root: &Path,
224) -> Result<(), crate::protocol::Response> {
225 let mut link = start.to_path_buf();
226 let mut depth = 0usize;
227
228 loop {
229 if depth > 40 {
230 return Err(path_error_response(req_id, original_path, resolved_root));
231 }
232
233 let target = match std::fs::read_link(&link) {
234 Ok(t) => t,
235 Err(_) => {
236 return Err(path_error_response(req_id, original_path, resolved_root));
238 }
239 };
240
241 let resolved_target = if target.is_absolute() {
242 normalize_path(&target)
243 } else {
244 let parent = link.parent().unwrap_or_else(|| Path::new(""));
245 normalize_path(&parent.join(&target))
246 };
247
248 let canonical_target =
252 std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
253
254 if !canonical_target.starts_with(resolved_root)
255 && !resolved_target.starts_with(resolved_root)
256 {
257 return Err(path_error_response(req_id, original_path, resolved_root));
258 }
259
260 match std::fs::symlink_metadata(&resolved_target) {
262 Ok(meta) if meta.file_type().is_symlink() => {
263 link = resolved_target;
264 depth += 1;
265 }
266 _ => break, }
268 }
269
270 Ok(())
271}
272
273pub struct AppContext {
283 provider: Box<dyn LanguageProvider>,
284 backup: RefCell<BackupStore>,
285 checkpoint: RefCell<CheckpointStore>,
286 db: RefCell<Option<Arc<Mutex<Connection>>>>,
287 config: RefCell<Config>,
288 pub harness: RefCell<Option<Harness>>,
289 canonical_cache_root: RefCell<Option<PathBuf>>,
290 is_worktree_bridge: RefCell<bool>,
291 git_common_dir: RefCell<Option<PathBuf>>,
292 degraded_reasons: RefCell<Vec<String>>,
300 callgraph: RefCell<Option<CallGraph>>,
301 search_index: RefCell<Option<SearchIndex>>,
302 search_index_rx: RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>>,
303 symbol_cache: SharedSymbolCache,
304 semantic_index: RefCell<Option<SemanticIndex>>,
305 semantic_index_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
306 semantic_index_status: RefCell<SemanticIndexStatus>,
307 semantic_embedding_model: RefCell<Option<crate::semantic_index::EmbeddingModel>>,
308 watcher: RefCell<Option<RecommendedWatcher>>,
309 watcher_rx: RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>>,
310 lsp_manager: RefCell<LspManager>,
311 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
315 stdout_writer: SharedStdoutWriter,
316 progress_sender: SharedProgressSender,
317 status_emitter: StatusEmitter,
318 bash_background: BgTaskRegistry,
319 filter_registry: crate::compress::SharedFilterRegistry,
326 filter_registry_loaded: std::sync::atomic::AtomicBool,
329 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
334 gitignore: RefCell<Option<Arc<ignore::gitignore::Gitignore>>>,
341}
342
343impl AppContext {
344 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
345 let bash_compress_enabled = config.experimental_bash_compress;
346 let progress_sender = Arc::new(Mutex::new(None));
347 let stdout_writer = Arc::new(Mutex::new(BufWriter::new(io::stdout())));
348 let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
349 let symbol_cache = provider
350 .as_any()
351 .downcast_ref::<crate::parser::TreeSitterProvider>()
352 .map(|provider| provider.symbol_cache())
353 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
354 let lsp_child_registry = crate::lsp::child_registry::LspChildRegistry::new();
355 let mut lsp_manager = LspManager::new();
356 lsp_manager.set_child_registry(lsp_child_registry.clone());
357 AppContext {
358 provider,
359 backup: RefCell::new(BackupStore::new()),
360 checkpoint: RefCell::new(CheckpointStore::new()),
361 db: RefCell::new(None),
362 config: RefCell::new(config),
363 harness: RefCell::new(None),
364 canonical_cache_root: RefCell::new(None),
365 is_worktree_bridge: RefCell::new(false),
366 git_common_dir: RefCell::new(None),
367 degraded_reasons: RefCell::new(Vec::new()),
368 callgraph: RefCell::new(None),
369 search_index: RefCell::new(None),
370 search_index_rx: RefCell::new(None),
371 symbol_cache,
372 semantic_index: RefCell::new(None),
373 semantic_index_rx: RefCell::new(None),
374 semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
375 semantic_embedding_model: RefCell::new(None),
376 watcher: RefCell::new(None),
377 watcher_rx: RefCell::new(None),
378 lsp_manager: RefCell::new(lsp_manager),
379 lsp_child_registry,
380 stdout_writer,
381 progress_sender: Arc::clone(&progress_sender),
382 status_emitter,
383 bash_background: BgTaskRegistry::new(progress_sender),
384 filter_registry: Arc::new(std::sync::RwLock::new(
385 crate::compress::toml_filter::FilterRegistry::default(),
386 )),
387 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
388 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
389 gitignore: RefCell::new(None),
390 }
391 }
392
393 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
396 self.gitignore.borrow().clone()
397 }
398
399 pub fn clear_gitignore(&self) {
420 *self.gitignore.borrow_mut() = None;
421 }
422
423 pub fn rebuild_gitignore(&self) {
424 use ignore::gitignore::GitignoreBuilder;
425 use std::path::Path;
426 let root_raw = match self.config().project_root.clone() {
427 Some(r) => r,
428 None => {
429 *self.gitignore.borrow_mut() = None;
430 return;
431 }
432 };
433 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
441 let mut builder = GitignoreBuilder::new(&root);
442 let root_ignore = Path::new(&root).join(".gitignore");
444 if root_ignore.exists() {
445 if let Some(err) = builder.add(&root_ignore) {
446 crate::slog_warn!(
447 "gitignore parse error in {}: {}",
448 root_ignore.display(),
449 err
450 );
451 }
452 }
453 let info_exclude = Path::new(&root).join(".git").join("info").join("exclude");
456 if info_exclude.exists() {
457 if let Some(err) = builder.add(&info_exclude) {
458 crate::slog_warn!(
459 "gitignore parse error in {}: {}",
460 info_exclude.display(),
461 err
462 );
463 }
464 }
465 let walker = ignore::WalkBuilder::new(&root)
471 .standard_filters(true)
472 .hidden(false)
475 .max_depth(Some(8))
476 .filter_entry(|entry| {
477 let name = entry.file_name().to_string_lossy();
478 !matches!(
479 name.as_ref(),
480 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
481 )
482 })
483 .build();
484 for entry in walker.flatten() {
485 if entry.file_name() == ".gitignore" && entry.path() != root_ignore {
486 if let Some(err) = builder.add(entry.path()) {
487 crate::slog_warn!(
488 "nested gitignore parse error in {}: {}",
489 entry.path().display(),
490 err
491 );
492 }
493 }
494 }
495 match builder.build() {
496 Ok(gi) => {
497 let count = gi.num_ignores();
498 if count > 0 {
499 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
500 *self.gitignore.borrow_mut() = Some(Arc::new(gi));
501 } else {
502 *self.gitignore.borrow_mut() = None;
503 }
504 }
505 Err(err) => {
506 crate::slog_warn!("gitignore matcher build failed: {}", err);
507 *self.gitignore.borrow_mut() = None;
508 }
509 }
510 }
511
512 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
515 Arc::clone(&self.bash_compress_flag)
516 }
517
518 pub fn sync_bash_compress_flag(&self) {
522 let value = self.config().experimental_bash_compress;
523 self.bash_compress_flag
524 .store(value, std::sync::atomic::Ordering::Relaxed);
525 }
526
527 pub fn set_bash_compress_enabled(&self, enabled: bool) {
528 self.config_mut().experimental_bash_compress = enabled;
529 self.bash_compress_flag
530 .store(enabled, std::sync::atomic::Ordering::Relaxed);
531 }
532
533 pub fn filter_registry(
537 &self,
538 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
539 self.ensure_filter_registry_loaded();
540 match self.filter_registry.read() {
541 Ok(g) => g,
542 Err(poisoned) => poisoned.into_inner(),
543 }
544 }
545
546 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
550 self.ensure_filter_registry_loaded();
551 Arc::clone(&self.filter_registry)
552 }
553
554 pub fn reset_filter_registry(&self) {
558 let new_registry = crate::compress::build_registry_for_context(self);
559 match self.filter_registry.write() {
560 Ok(mut slot) => *slot = new_registry,
561 Err(poisoned) => *poisoned.into_inner() = new_registry,
562 }
563 self.filter_registry_loaded
564 .store(true, std::sync::atomic::Ordering::Release);
565 }
566
567 fn ensure_filter_registry_loaded(&self) {
568 use std::sync::atomic::Ordering;
569 if self.filter_registry_loaded.load(Ordering::Acquire) {
570 return;
571 }
572 let new_registry = crate::compress::build_registry_for_context(self);
575 if let Ok(mut slot) = self.filter_registry.write() {
576 *slot = new_registry;
577 self.filter_registry_loaded.store(true, Ordering::Release);
578 }
579 }
580
581 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
584 self.lsp_child_registry.clone()
585 }
586
587 pub fn stdout_writer(&self) -> SharedStdoutWriter {
588 Arc::clone(&self.stdout_writer)
589 }
590
591 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
592 if let Ok(mut progress_sender) = self.progress_sender.lock() {
593 *progress_sender = sender;
594 }
595 }
596
597 pub fn emit_progress(&self, frame: ProgressFrame) {
598 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
599 return;
600 };
601 if let Some(sender) = progress_sender.as_ref() {
602 sender(PushFrame::Progress(frame));
603 }
604 }
605
606 pub fn status_emitter(&self) -> &StatusEmitter {
607 &self.status_emitter
608 }
609
610 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
618 self.progress_sender
619 .lock()
620 .ok()
621 .and_then(|sender| sender.clone())
622 }
623
624 pub fn bash_background(&self) -> &BgTaskRegistry {
625 &self.bash_background
626 }
627
628 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
629 self.bash_background.drain_completions()
630 }
631
632 pub fn provider(&self) -> &dyn LanguageProvider {
634 self.provider.as_ref()
635 }
636
637 pub fn backup(&self) -> &RefCell<BackupStore> {
639 &self.backup
640 }
641
642 pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
644 &self.checkpoint
645 }
646
647 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
648 *self.db.borrow_mut() = Some(conn);
649 }
650
651 pub fn clear_db(&self) {
652 *self.db.borrow_mut() = None;
653 }
654
655 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
656 self.db.borrow().clone()
657 }
658
659 pub fn config(&self) -> Ref<'_, Config> {
661 self.config.borrow()
662 }
663
664 pub fn config_mut(&self) -> RefMut<'_, Config> {
666 self.config.borrow_mut()
667 }
668
669 pub fn set_harness(&self, harness: Harness) {
670 *self.harness.borrow_mut() = Some(harness);
671 self.bash_background.set_harness(harness);
672 }
673
674 pub fn harness(&self) -> Harness {
675 self.harness
676 .borrow()
677 .expect("harness set by configure before any tool call")
678 }
679
680 pub fn storage_dir(&self) -> PathBuf {
681 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
682 }
683
684 pub fn harness_dir(&self) -> PathBuf {
685 self.storage_dir().join(self.harness().as_str())
686 }
687
688 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
689 self.harness_dir()
690 .join("bash-tasks")
691 .join(hash_session(session_id))
692 }
693
694 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
695 self.harness_dir()
696 .join("backups")
697 .join(hash_session(session_id))
698 .join(path_hash)
699 }
700
701 pub fn filters_dir(&self) -> PathBuf {
702 self.harness_dir().join("filters")
703 }
704
705 pub fn trust_file(&self) -> PathBuf {
707 self.storage_dir().join("trusted-filter-projects.json")
708 }
709
710 pub fn set_canonical_cache_root(&self, root: PathBuf) {
711 debug_assert!(root.is_absolute());
712 *self.canonical_cache_root.borrow_mut() = Some(root);
713 }
714
715 pub fn canonical_cache_root(&self) -> PathBuf {
716 self.canonical_cache_root
717 .borrow()
718 .clone()
719 .expect("canonical_cache_root accessed before handle_configure")
720 }
721
722 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
723 self.canonical_cache_root.borrow().clone()
724 }
725
726 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
727 *self.is_worktree_bridge.borrow_mut() = is_worktree_bridge;
728 *self.git_common_dir.borrow_mut() = git_common_dir;
729 }
730
731 pub fn is_worktree_bridge(&self) -> bool {
732 *self.is_worktree_bridge.borrow()
733 }
734
735 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
739 *self.degraded_reasons.borrow_mut() = reasons;
740 }
741
742 pub fn degraded_reasons(&self) -> Vec<String> {
746 self.degraded_reasons.borrow().clone()
747 }
748
749 pub fn is_degraded(&self) -> bool {
751 !self.degraded_reasons.borrow().is_empty()
752 }
753
754 pub fn cache_role(&self) -> &'static str {
755 if self.canonical_cache_root.borrow().is_none() {
756 "not_initialized"
757 } else if self.is_worktree_bridge() {
758 "worktree"
759 } else {
760 "main"
761 }
762 }
763
764 pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
766 &self.callgraph
767 }
768
769 pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
771 &self.search_index
772 }
773
774 pub fn search_index_rx(&self) -> &RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>> {
776 &self.search_index_rx
777 }
778
779 pub fn symbol_cache(&self) -> SharedSymbolCache {
781 Arc::clone(&self.symbol_cache)
782 }
783
784 pub fn reset_symbol_cache(&self) -> u64 {
786 self.symbol_cache
787 .write()
788 .map(|mut cache| cache.reset())
789 .unwrap_or(0)
790 }
791
792 pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
794 &self.semantic_index
795 }
796
797 pub fn semantic_index_rx(
799 &self,
800 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
801 &self.semantic_index_rx
802 }
803
804 pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
805 &self.semantic_index_status
806 }
807
808 pub fn semantic_embedding_model(
810 &self,
811 ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
812 &self.semantic_embedding_model
813 }
814
815 pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
817 &self.watcher
818 }
819
820 pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
822 &self.watcher_rx
823 }
824
825 pub fn lsp(&self) -> RefMut<'_, LspManager> {
827 self.lsp_manager.borrow_mut()
828 }
829
830 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
833 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
834 let config = self.config();
835 if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
836 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
837 }
838 }
839 }
840
841 pub fn lsp_notify_and_collect_diagnostics(
852 &self,
853 file_path: &Path,
854 content: &str,
855 timeout: std::time::Duration,
856 ) -> crate::lsp::manager::PostEditWaitOutcome {
857 let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
858 return crate::lsp::manager::PostEditWaitOutcome::default();
859 };
860
861 lsp.drain_events();
864
865 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
869
870 let config = self.config();
872 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
873 {
874 Ok(v) => v,
875 Err(e) => {
876 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
877 return crate::lsp::manager::PostEditWaitOutcome::default();
878 }
879 };
880
881 if expected_versions.is_empty() {
884 return crate::lsp::manager::PostEditWaitOutcome::default();
885 }
886
887 lsp.wait_for_post_edit_diagnostics(
888 file_path,
889 &config,
890 &expected_versions,
891 &pre_snapshot,
892 timeout,
893 )
894 }
895
896 fn custom_lsp_root_markers(&self) -> Vec<String> {
899 self.config()
900 .lsp_servers
901 .iter()
902 .flat_map(|s| s.root_markers.iter().cloned())
903 .collect()
904 }
905
906 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
907 let custom_markers = self.custom_lsp_root_markers();
908 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
909 .iter()
910 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
911 .cloned()
912 .map(|path| {
913 let change_type = if path.exists() {
914 FileChangeType::CHANGED
915 } else {
916 FileChangeType::DELETED
917 };
918 (path, change_type)
919 })
920 .collect();
921
922 self.notify_watched_config_events(&config_paths);
923 }
924
925 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
926 let paths = params
927 .get("multi_file_write_paths")
928 .and_then(|value| value.as_array())?
929 .iter()
930 .filter_map(|value| value.as_str())
931 .map(PathBuf::from)
932 .collect::<Vec<_>>();
933
934 (!paths.is_empty()).then_some(paths)
935 }
936
937 fn watched_file_events_from_params(
949 params: &serde_json::Value,
950 extra_markers: &[String],
951 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
952 let events = params
953 .get("multi_file_write_paths")
954 .and_then(|value| value.as_array())?
955 .iter()
956 .filter_map(|entry| {
957 let path = entry
959 .get("path")
960 .and_then(|value| value.as_str())
961 .map(PathBuf::from)?;
962
963 if !is_config_file_path_with_custom(&path, extra_markers) {
964 return None;
965 }
966
967 let change_type = entry
968 .get("type")
969 .and_then(|value| value.as_str())
970 .and_then(Self::parse_file_change_type)
971 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
972
973 Some((path, change_type))
974 })
975 .collect::<Vec<_>>();
976
977 (!events.is_empty()).then_some(events)
978 }
979
980 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
981 match value {
982 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
983 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
984 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
985 _ => None,
986 }
987 }
988
989 fn change_type_from_current_state(path: &Path) -> FileChangeType {
990 if path.exists() {
991 FileChangeType::CHANGED
992 } else {
993 FileChangeType::DELETED
994 }
995 }
996
997 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
998 if config_paths.is_empty() {
999 return;
1000 }
1001
1002 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1003 let config = self.config();
1004 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
1005 crate::slog_warn!("watched-file sync error: {}", e);
1006 }
1007 }
1008 }
1009
1010 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
1011 let custom_markers = self.custom_lsp_root_markers();
1012 if !is_config_file_path_with_custom(file_path, &custom_markers) {
1013 return;
1014 }
1015
1016 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
1017 }
1018
1019 pub fn lsp_post_multi_file_write(
1024 &self,
1025 file_path: &Path,
1026 content: &str,
1027 file_paths: &[PathBuf],
1028 params: &serde_json::Value,
1029 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
1030 self.notify_watched_config_files(file_paths);
1031
1032 let wants_diagnostics = params
1033 .get("diagnostics")
1034 .and_then(|v| v.as_bool())
1035 .unwrap_or(false);
1036
1037 if !wants_diagnostics {
1038 self.lsp_notify_file_changed(file_path, content);
1039 return None;
1040 }
1041
1042 let wait_ms = params
1043 .get("wait_ms")
1044 .and_then(|v| v.as_u64())
1045 .unwrap_or(3000)
1046 .min(10_000);
1047
1048 Some(self.lsp_notify_and_collect_diagnostics(
1049 file_path,
1050 content,
1051 std::time::Duration::from_millis(wait_ms),
1052 ))
1053 }
1054
1055 pub fn lsp_post_write(
1072 &self,
1073 file_path: &Path,
1074 content: &str,
1075 params: &serde_json::Value,
1076 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
1077 let wants_diagnostics = params
1078 .get("diagnostics")
1079 .and_then(|v| v.as_bool())
1080 .unwrap_or(false);
1081
1082 let custom_markers = self.custom_lsp_root_markers();
1083
1084 if !wants_diagnostics {
1085 if let Some(file_paths) = Self::multi_file_write_paths(params) {
1086 self.notify_watched_config_files(&file_paths);
1087 } else if let Some(config_events) =
1088 Self::watched_file_events_from_params(params, &custom_markers)
1089 {
1090 self.notify_watched_config_events(&config_events);
1091 }
1092 self.lsp_notify_file_changed(file_path, content);
1093 return None;
1094 }
1095
1096 let wait_ms = params
1097 .get("wait_ms")
1098 .and_then(|v| v.as_u64())
1099 .unwrap_or(3000)
1100 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
1103 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
1104 }
1105
1106 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
1107 {
1108 self.notify_watched_config_events(&config_events);
1109 }
1110
1111 Some(self.lsp_notify_and_collect_diagnostics(
1112 file_path,
1113 content,
1114 std::time::Duration::from_millis(wait_ms),
1115 ))
1116 }
1117
1118 pub fn validate_path(
1127 &self,
1128 req_id: &str,
1129 path: &Path,
1130 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
1131 let config = self.config();
1132 if !config.restrict_to_project_root {
1134 return Ok(path.to_path_buf());
1135 }
1136 let root = match &config.project_root {
1137 Some(r) => r.clone(),
1138 None => return Ok(path.to_path_buf()), };
1140 drop(config);
1141
1142 let raw_root = root.clone();
1147 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
1148
1149 let path_for_resolution = if path.is_relative() {
1154 raw_root.join(path)
1155 } else {
1156 path.to_path_buf()
1157 };
1158 let resolved = match std::fs::canonicalize(&path_for_resolution) {
1159 Ok(resolved) => resolved,
1160 Err(_) => {
1161 let normalized = normalize_path(&path_for_resolution);
1162 reject_escaping_symlink(
1163 req_id,
1164 &path_for_resolution,
1165 &normalized,
1166 &resolved_root,
1167 &raw_root,
1168 )?;
1169 resolve_with_existing_ancestors(&normalized)
1170 }
1171 };
1172
1173 if !resolved.starts_with(&resolved_root) {
1174 return Err(path_error_response(req_id, path, &resolved_root));
1175 }
1176
1177 Ok(resolved)
1178 }
1179
1180 pub fn lsp_server_count(&self) -> usize {
1182 self.lsp_manager
1183 .try_borrow()
1184 .map(|lsp| lsp.server_count())
1185 .unwrap_or(0)
1186 }
1187
1188 pub fn symbol_cache_stats(&self) -> serde_json::Value {
1190 let entries = self
1191 .symbol_cache
1192 .read()
1193 .map(|cache| cache.len())
1194 .unwrap_or(0);
1195 serde_json::json!({
1196 "local_entries": entries,
1197 "warm_entries": 0,
1198 })
1199 }
1200}
1201
1202#[cfg(test)]
1203mod status_emitter_tests {
1204 use super::*;
1205 use crate::parser::TreeSitterProvider;
1206
1207 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
1208 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
1209 let (tx, rx) = mpsc::channel();
1210 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
1211 let _ = tx.send(frame);
1212 }))));
1213 (ctx, rx)
1214 }
1215
1216 #[test]
1217 fn status_emitter_signal_triggers_push() {
1218 let (ctx, rx) = ctx_with_frame_rx();
1219 ctx.status_emitter().signal(ctx.build_status_snapshot());
1220 let frame = rx
1221 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1222 .expect("status_changed push");
1223 assert!(matches!(frame, PushFrame::StatusChanged(_)));
1224 }
1225
1226 #[test]
1227 fn status_emitter_debounces_burst() {
1228 let (ctx, rx) = ctx_with_frame_rx();
1229 for _ in 0..10 {
1230 ctx.status_emitter().signal(ctx.build_status_snapshot());
1231 }
1232 let frame = rx
1233 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1234 .expect("status_changed push");
1235 assert!(matches!(frame, PushFrame::StatusChanged(_)));
1236 assert!(rx.try_recv().is_err());
1237 }
1238
1239 #[test]
1240 fn status_emitter_separate_windows_separate_pushes() {
1241 let (ctx, rx) = ctx_with_frame_rx();
1242 ctx.status_emitter().signal(ctx.build_status_snapshot());
1243 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1244 .expect("first push");
1245 ctx.status_emitter().signal(ctx.build_status_snapshot());
1246 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1247 .expect("second push");
1248 }
1249
1250 #[test]
1251 fn status_emitter_no_signal_no_push() {
1252 let (_ctx, rx) = ctx_with_frame_rx();
1253 assert!(rx
1254 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
1255 .is_err());
1256 }
1257
1258 #[test]
1259 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
1260 let (ctx, rx) = ctx_with_frame_rx();
1261 drop(ctx);
1262 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
1263 }
1264}
1265
1266#[cfg(test)]
1267mod harness_path_tests {
1268 use super::*;
1269 use crate::harness::Harness;
1270 use crate::parser::TreeSitterProvider;
1271
1272 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
1273 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
1274 ctx.config_mut().storage_dir = Some(storage_dir);
1275 ctx.set_harness(harness);
1276 ctx
1277 }
1278
1279 #[test]
1280 fn harness_dir_resolves_correctly() {
1281 let storage = PathBuf::from("/tmp/cortexkit/aft");
1282 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
1283
1284 assert_eq!(ctx.harness_dir(), storage.join("pi"));
1285 }
1286
1287 #[test]
1288 fn bash_tasks_dir_uses_hash_session() {
1289 let storage = PathBuf::from("/tmp/cortexkit/aft");
1290 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
1291
1292 assert_eq!(
1293 ctx.bash_tasks_dir("ses_abc"),
1294 storage
1295 .join("opencode")
1296 .join("bash-tasks")
1297 .join(hash_session("ses_abc"))
1298 );
1299 }
1300
1301 #[test]
1302 fn backups_dir_includes_path_hash() {
1303 let storage = PathBuf::from("/tmp/cortexkit/aft");
1304 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
1305
1306 assert_eq!(
1307 ctx.backups_dir("ses_abc", "pathhash"),
1308 storage
1309 .join("pi")
1310 .join("backups")
1311 .join(hash_session("ses_abc"))
1312 .join("pathhash")
1313 );
1314 }
1315
1316 #[test]
1317 fn filters_dir_under_harness() {
1318 let storage = PathBuf::from("/tmp/cortexkit/aft");
1319 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
1320
1321 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
1322 }
1323
1324 #[test]
1325 fn trust_file_is_host_global() {
1326 let storage = PathBuf::from("/tmp/cortexkit/aft");
1327 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
1328
1329 assert_eq!(
1330 ctx.trust_file(),
1331 storage.join("trusted-filter-projects.json")
1332 );
1333 }
1334
1335 #[test]
1336 fn same_session_different_harness_resolve_different_paths() {
1337 let storage = PathBuf::from("/tmp/cortexkit/aft");
1338 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
1339 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
1340
1341 assert_ne!(
1342 opencode.bash_tasks_dir("ses_same"),
1343 pi.bash_tasks_dir("ses_same")
1344 );
1345 }
1346}
1347
1348#[cfg(test)]
1349mod gitignore_tests {
1350 use super::*;
1351 use std::fs;
1352 use std::path::Path;
1353 use tempfile::TempDir;
1354
1355 fn make_ctx_with_root(root: &Path) -> AppContext {
1356 let provider = Box::new(crate::parser::TreeSitterProvider::new());
1357 let config = Config {
1358 project_root: Some(root.to_path_buf()),
1359 ..Config::default()
1360 };
1361 AppContext::new(provider, config)
1362 }
1363
1364 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
1371 let Some(matcher) = ctx.gitignore() else {
1372 return false;
1373 };
1374 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1375 if !canonical.starts_with(matcher.path()) {
1376 return false;
1377 }
1378 let is_dir = canonical.is_dir();
1379 matcher
1380 .matched_path_or_any_parents(&canonical, is_dir)
1381 .is_ignore()
1382 }
1383
1384 #[test]
1385 fn rebuild_gitignore_returns_none_without_project_root() {
1386 let provider = Box::new(crate::parser::TreeSitterProvider::new());
1387 let ctx = AppContext::new(provider, Config::default());
1388 ctx.rebuild_gitignore();
1389 assert!(ctx.gitignore().is_none());
1390 }
1391
1392 #[test]
1393 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
1394 let tmp = TempDir::new().unwrap();
1395 let ctx = make_ctx_with_root(tmp.path());
1396 ctx.rebuild_gitignore();
1397 assert!(ctx.gitignore().is_none());
1398 }
1399
1400 #[test]
1401 fn matcher_filters_files_in_ignored_dist_dir() {
1402 let tmp = TempDir::new().unwrap();
1403 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
1404 fs::create_dir_all(tmp.path().join("dist")).unwrap();
1405 fs::create_dir_all(tmp.path().join("src")).unwrap();
1406 let dist_file = tmp.path().join("dist").join("bundle.js");
1407 let src_file = tmp.path().join("src").join("app.ts");
1408 fs::write(&dist_file, "x").unwrap();
1409 fs::write(&src_file, "y").unwrap();
1410
1411 let ctx = make_ctx_with_root(tmp.path());
1412 ctx.rebuild_gitignore();
1413
1414 assert!(ctx.gitignore().is_some());
1415 assert!(
1416 is_ignored(&ctx, &dist_file),
1417 "dist/bundle.js should be ignored"
1418 );
1419 assert!(
1420 !is_ignored(&ctx, &src_file),
1421 "src/app.ts should NOT be ignored"
1422 );
1423 }
1424
1425 #[test]
1426 fn matcher_handles_node_modules_and_target() {
1427 let tmp = TempDir::new().unwrap();
1428 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
1429 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
1430 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
1431 let nm_file = tmp.path().join("node_modules/foo/index.js");
1432 let target_file = tmp.path().join("target/debug/aft");
1433 fs::write(&nm_file, "x").unwrap();
1434 fs::write(&target_file, "x").unwrap();
1435
1436 let ctx = make_ctx_with_root(tmp.path());
1437 ctx.rebuild_gitignore();
1438
1439 assert!(is_ignored(&ctx, &nm_file));
1440 assert!(is_ignored(&ctx, &target_file));
1441 }
1442
1443 #[test]
1444 fn matcher_honors_negation_pattern() {
1445 let tmp = TempDir::new().unwrap();
1447 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
1448 let random_log = tmp.path().join("random.log");
1449 let important_log = tmp.path().join("important.log");
1450 fs::write(&random_log, "x").unwrap();
1451 fs::write(&important_log, "y").unwrap();
1452
1453 let ctx = make_ctx_with_root(tmp.path());
1454 ctx.rebuild_gitignore();
1455
1456 assert!(is_ignored(&ctx, &random_log));
1457 assert!(
1458 !is_ignored(&ctx, &important_log),
1459 "negation pattern should un-ignore important.log"
1460 );
1461 }
1462
1463 #[test]
1464 fn rebuild_picks_up_gitignore_changes() {
1465 let tmp = TempDir::new().unwrap();
1466 let ignore_path = tmp.path().join(".gitignore");
1467 fs::write(&ignore_path, "foo.txt\n").unwrap();
1468 let foo = tmp.path().join("foo.txt");
1469 let bar = tmp.path().join("bar.txt");
1470 fs::write(&foo, "").unwrap();
1471 fs::write(&bar, "").unwrap();
1472
1473 let ctx = make_ctx_with_root(tmp.path());
1474 ctx.rebuild_gitignore();
1475 assert!(is_ignored(&ctx, &foo));
1476 assert!(!is_ignored(&ctx, &bar));
1477
1478 fs::write(&ignore_path, "bar.txt\n").unwrap();
1480 ctx.rebuild_gitignore();
1481 assert!(!is_ignored(&ctx, &foo));
1482 assert!(is_ignored(&ctx, &bar));
1483 }
1484
1485 #[test]
1486 fn gitignore_loads_info_exclude_when_present() {
1487 let tmp = TempDir::new().unwrap();
1488 let info_dir = tmp.path().join(".git/info");
1489 fs::create_dir_all(&info_dir).unwrap();
1490 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
1491 let secrets = tmp.path().join("secrets.txt");
1492 let public = tmp.path().join("public.txt");
1493 fs::write(&secrets, "token").unwrap();
1494 fs::write(&public, "ok").unwrap();
1495
1496 let ctx = make_ctx_with_root(tmp.path());
1497 ctx.rebuild_gitignore();
1498
1499 assert!(is_ignored(&ctx, &secrets));
1500 assert!(!is_ignored(&ctx, &public));
1501 }
1502
1503 #[test]
1504 fn matcher_picks_up_nested_gitignore() {
1505 let tmp = TempDir::new().unwrap();
1506 fs::write(tmp.path().join(".gitignore"), "").unwrap();
1508 let sub = tmp.path().join("packages/foo");
1509 fs::create_dir_all(&sub).unwrap();
1510 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
1511 let generated_file = sub.join("generated").join("out.js");
1512 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
1513 fs::write(&generated_file, "x").unwrap();
1514
1515 let ctx = make_ctx_with_root(tmp.path());
1516 ctx.rebuild_gitignore();
1517
1518 assert!(
1519 is_ignored(&ctx, &generated_file),
1520 "nested gitignore in packages/foo/.gitignore should ignore generated/"
1521 );
1522 }
1523}