1use std::cell::{Ref, RefCell, RefMut};
2use std::collections::{BTreeMap, BTreeSet};
3use std::io::{self, BufWriter};
4use std::path::{Component, Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{mpsc, Arc, Mutex, OnceLock};
7use std::time::{Duration, Instant};
8
9use lsp_types::FileChangeType;
10use notify::RecommendedWatcher;
11use rusqlite::Connection;
12
13use crate::backup::hash_session;
14use crate::backup::BackupStore;
15use crate::bash_background::{BgCompletion, BgTaskRegistry};
16use crate::callgraph::CallGraph;
17use crate::checkpoint::CheckpointStore;
18use crate::config::Config;
19use crate::harness::Harness;
20use crate::inspect::InspectManager;
21use crate::language::LanguageProvider;
22use crate::lsp::manager::LspManager;
23use crate::lsp::registry::is_config_file_path_with_custom;
24use crate::parser::{SharedSymbolCache, SymbolCache};
25use crate::protocol::{
26 ConfigureWarningsFrame, ProgressFrame, PushFrame, StatusChangedFrame, StatusPayload,
27};
28
29pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
30pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
31pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
32const STATUS_DEBOUNCE_MS: u64 = 1_000;
33
34pub struct StatusEmitter {
35 latest: Arc<Mutex<Option<StatusPayload>>>,
36 notify: mpsc::Sender<()>,
37}
38
39impl StatusEmitter {
40 fn new(progress_sender: SharedProgressSender) -> Self {
41 let (notify, rx) = mpsc::channel();
42 let latest = Arc::new(Mutex::new(None));
43 let latest_for_thread = Arc::clone(&latest);
44 std::thread::spawn(move || {
45 status_debounce_loop(rx, latest_for_thread, progress_sender);
46 });
47 Self { latest, notify }
48 }
49
50 pub fn signal(&self, snapshot: StatusPayload) {
51 if let Ok(mut latest) = self.latest.lock() {
52 *latest = Some(snapshot);
53 }
54 let _ = self.notify.send(());
55 }
56}
57
58fn status_debounce_loop(
59 rx: mpsc::Receiver<()>,
60 latest: Arc<Mutex<Option<StatusPayload>>>,
61 progress_sender: SharedProgressSender,
62) {
63 while rx.recv().is_ok() {
64 let deadline = Instant::now() + Duration::from_millis(STATUS_DEBOUNCE_MS);
65 while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
66 match rx.recv_timeout(remaining) {
67 Ok(()) => continue,
68 Err(mpsc::RecvTimeoutError::Timeout) => break,
69 Err(mpsc::RecvTimeoutError::Disconnected) => return,
70 }
71 }
72
73 let snapshot = latest.lock().ok().and_then(|mut latest| latest.take());
74 let Some(snapshot) = snapshot else { continue };
75 let sender = progress_sender
76 .lock()
77 .ok()
78 .and_then(|sender| sender.clone());
79 if let Some(sender) = sender {
80 sender(PushFrame::StatusChanged(StatusChangedFrame::new(
81 None, snapshot,
82 )));
83 }
84 }
85}
86use crate::cache_freshness::FileFreshness;
87use crate::search_index::SearchIndex;
88use crate::semantic_index::{EmbeddingEntry, SemanticIndex};
89
90#[derive(Debug, Default)]
94struct SemanticRefreshAccounting {
95 pending: usize,
96 in_flight: usize,
97}
98
99static SEMANTIC_REFRESH_ACCOUNTING: OnceLock<Mutex<BTreeMap<PathBuf, SemanticRefreshAccounting>>> =
100 OnceLock::new();
101
102fn semantic_refresh_accounting() -> &'static Mutex<BTreeMap<PathBuf, SemanticRefreshAccounting>> {
103 SEMANTIC_REFRESH_ACCOUNTING.get_or_init(|| Mutex::new(BTreeMap::new()))
104}
105
106fn clear_semantic_refresh_accounting() {
107 if let Some(accounting) = SEMANTIC_REFRESH_ACCOUNTING.get() {
108 if let Ok(mut accounting) = accounting.lock() {
109 accounting.clear();
110 }
111 }
112}
113
114fn ensure_refreshing_path(refreshing: &mut Vec<PathBuf>, path: PathBuf) {
115 if !refreshing.iter().any(|existing| existing == &path) {
116 refreshing.push(path);
117 refreshing.sort();
118 }
119}
120
121fn remove_refreshing_path(refreshing: &mut Vec<PathBuf>, path: &Path) {
122 refreshing.retain(|existing| existing != path);
123}
124
125#[derive(Debug, Clone)]
126pub enum SemanticIndexStatus {
127 Disabled,
128 Building {
129 stage: String,
131 files: Option<usize>,
132 entries_done: Option<usize>,
133 entries_total: Option<usize>,
134 },
135 Ready {
136 refreshing: Vec<PathBuf>,
139 },
140 Failed(String),
141}
142
143impl SemanticIndexStatus {
144 pub fn ready() -> Self {
145 clear_semantic_refresh_accounting();
146 Self::Ready {
147 refreshing: Vec::new(),
148 }
149 }
150
151 pub fn add_refreshing_file(&mut self, path: PathBuf) {
152 if let Self::Ready { refreshing } = self {
153 if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
154 let state = accounting.entry(path.clone()).or_default();
155 state.pending = state.pending.saturating_add(1);
156 }
157 ensure_refreshing_path(refreshing, path);
158 }
159 }
160
161 pub fn start_refreshing_file(&mut self, path: PathBuf) {
162 if let Self::Ready { refreshing } = self {
163 if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
164 let state = accounting.entry(path.clone()).or_default();
165 if state.pending == 0 {
166 state.pending = 1;
167 }
168 if state.in_flight == 0 {
169 state.in_flight = state.pending;
170 }
171 }
172 ensure_refreshing_path(refreshing, path);
173 }
174 }
175
176 pub fn cancel_refreshing_file(&mut self, path: &Path) {
177 self.finish_refreshing_file(path, false);
178 }
179
180 pub fn complete_refreshing_file(&mut self, path: &Path) {
181 self.finish_refreshing_file(path, true);
182 }
183
184 pub fn remove_refreshing_file(&mut self, path: &Path) {
185 self.complete_refreshing_file(path);
186 }
187
188 fn finish_refreshing_file(&mut self, path: &Path, complete_in_flight: bool) {
189 if let Self::Ready { refreshing } = self {
190 let mut keep_refreshing = false;
191 let mut accounting_checked = false;
192 if let Ok(mut accounting) = semantic_refresh_accounting().lock() {
193 accounting_checked = true;
194 if let Some(state) = accounting.get_mut(path) {
195 let finished = if complete_in_flight {
196 state.in_flight.max(1)
197 } else {
198 1
199 };
200 state.pending = state.pending.saturating_sub(finished);
201 if complete_in_flight {
202 state.in_flight = 0;
203 } else {
204 state.in_flight = state.in_flight.min(state.pending);
205 }
206 keep_refreshing = state.pending > 0;
207 if !keep_refreshing {
208 accounting.remove(path);
209 }
210 }
211 }
212
213 if !accounting_checked || !keep_refreshing {
214 remove_refreshing_path(refreshing, path);
215 }
216 }
217 }
218
219 pub fn refreshing_count(&self) -> usize {
220 match self {
221 Self::Ready { refreshing } => refreshing.len(),
222 _ => 0,
223 }
224 }
225}
226
227pub enum SemanticIndexEvent {
228 Progress {
229 stage: String,
230 files: Option<usize>,
231 entries_done: Option<usize>,
232 entries_total: Option<usize>,
233 },
234 Ready(SemanticIndex),
235 Failed(String),
236}
237
238#[derive(Debug, Clone)]
239pub enum SemanticRefreshRequest {
240 Files { paths: Vec<PathBuf> },
241 Corpus { current_files: Vec<PathBuf> },
242}
243
244#[derive(Debug)]
245pub enum SemanticRefreshEvent {
246 Started {
247 paths: Vec<PathBuf>,
248 },
249 Completed {
250 added_entries: Vec<EmbeddingEntry>,
251 updated_metadata: Vec<(PathBuf, FileFreshness)>,
252 completed_paths: Vec<PathBuf>,
253 },
254 CorpusCompleted {
255 index: SemanticIndex,
256 changed: usize,
257 added: usize,
258 deleted: usize,
259 total_processed: usize,
260 },
261 Failed {
262 paths: Vec<PathBuf>,
263 error: String,
264 },
265 CorpusFailed {
266 error: String,
267 },
268}
269
270pub type SemanticRefreshWorkerSlot = Arc<Mutex<Option<std::thread::JoinHandle<()>>>>;
271
272fn normalize_path(path: &Path) -> PathBuf {
276 let mut result = PathBuf::new();
277 for component in path.components() {
278 match component {
279 Component::ParentDir => {
280 if !result.pop() {
282 result.push(component);
283 }
284 }
285 Component::CurDir => {} _ => result.push(component),
287 }
288 }
289 result
290}
291
292fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
293 let mut existing = path.to_path_buf();
294 let mut tail_segments = Vec::new();
295
296 while !existing.exists() {
297 if let Some(name) = existing.file_name() {
298 tail_segments.push(name.to_owned());
299 } else {
300 break;
301 }
302
303 existing = match existing.parent() {
304 Some(parent) => parent.to_path_buf(),
305 None => break,
306 };
307 }
308
309 let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
310 for segment in tail_segments.into_iter().rev() {
311 resolved.push(segment);
312 }
313
314 resolved
315}
316
317fn path_error_response(
318 req_id: &str,
319 path: &Path,
320 resolved_root: &Path,
321) -> crate::protocol::Response {
322 crate::protocol::Response::error(
323 req_id,
324 "path_outside_root",
325 format!(
326 "path '{}' is outside the project root '{}'",
327 path.display(),
328 resolved_root.display()
329 ),
330 )
331}
332
333fn reject_escaping_symlink(
343 req_id: &str,
344 original_path: &Path,
345 candidate: &Path,
346 resolved_root: &Path,
347 raw_root: &Path,
348) -> Result<(), crate::protocol::Response> {
349 let mut current = PathBuf::new();
350
351 for component in candidate.components() {
352 current.push(component);
353
354 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
355 continue;
356 };
357
358 if !metadata.file_type().is_symlink() {
359 continue;
360 }
361
362 let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
371 if !inside_root {
372 continue;
373 }
374
375 iterative_follow_chain(req_id, original_path, ¤t, resolved_root)?;
376 }
377
378 Ok(())
379}
380
381fn iterative_follow_chain(
384 req_id: &str,
385 original_path: &Path,
386 start: &Path,
387 resolved_root: &Path,
388) -> Result<(), crate::protocol::Response> {
389 let mut link = start.to_path_buf();
390 let mut depth = 0usize;
391
392 loop {
393 if depth > 40 {
394 return Err(path_error_response(req_id, original_path, resolved_root));
395 }
396
397 let target = match std::fs::read_link(&link) {
398 Ok(t) => t,
399 Err(_) => {
400 return Err(path_error_response(req_id, original_path, resolved_root));
402 }
403 };
404
405 let resolved_target = if target.is_absolute() {
406 normalize_path(&target)
407 } else {
408 let parent = link.parent().unwrap_or_else(|| Path::new(""));
409 normalize_path(&parent.join(&target))
410 };
411
412 let canonical_target =
416 std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
417
418 if !canonical_target.starts_with(resolved_root)
419 && !resolved_target.starts_with(resolved_root)
420 {
421 return Err(path_error_response(req_id, original_path, resolved_root));
422 }
423
424 match std::fs::symlink_metadata(&resolved_target) {
426 Ok(meta) if meta.file_type().is_symlink() => {
427 link = resolved_target;
428 depth += 1;
429 }
430 _ => break, }
432 }
433
434 Ok(())
435}
436
437pub struct AppContext {
447 provider: Box<dyn LanguageProvider>,
448 backup: RefCell<BackupStore>,
449 checkpoint: RefCell<CheckpointStore>,
450 db: RefCell<Option<Arc<Mutex<Connection>>>>,
451 config: RefCell<Config>,
452 pub harness: RefCell<Option<Harness>>,
453 canonical_cache_root: RefCell<Option<PathBuf>>,
454 is_worktree_bridge: RefCell<bool>,
455 git_common_dir: RefCell<Option<PathBuf>>,
456 degraded_reasons: RefCell<Vec<String>>,
464 callgraph: RefCell<Option<CallGraph>>,
465 search_index: RefCell<Option<SearchIndex>>,
466 search_index_rx: RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>>,
467 pending_search_index_paths: RefCell<BTreeSet<PathBuf>>,
468 symbol_cache: SharedSymbolCache,
469 inspect_manager: Arc<InspectManager>,
470 semantic_index: RefCell<Option<SemanticIndex>>,
471 semantic_index_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
472 semantic_index_status: RefCell<SemanticIndexStatus>,
473 pending_semantic_index_paths: RefCell<BTreeSet<PathBuf>>,
474 pending_semantic_corpus_refresh: RefCell<bool>,
475 semantic_refresh_tx: RefCell<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>,
476 semantic_refresh_event_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
477 semantic_refresh_worker: RefCell<Option<SemanticRefreshWorkerSlot>>,
478 semantic_embedding_model: RefCell<Option<crate::semantic_index::EmbeddingModel>>,
479 watcher: RefCell<Option<RecommendedWatcher>>,
480 watcher_rx: RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>>,
481 lsp_manager: RefCell<LspManager>,
482 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
486 stdout_writer: SharedStdoutWriter,
487 progress_sender: SharedProgressSender,
488 configure_generation: AtomicU64,
489 configure_warnings_tx: mpsc::Sender<(u64, ConfigureWarningsFrame)>,
490 configure_warnings_rx: mpsc::Receiver<(u64, ConfigureWarningsFrame)>,
491 status_emitter: StatusEmitter,
492 bash_background: BgTaskRegistry,
493 filter_registry: crate::compress::SharedFilterRegistry,
500 filter_registry_loaded: std::sync::atomic::AtomicBool,
503 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
508 gitignore: RefCell<Option<Arc<ignore::gitignore::Gitignore>>>,
515}
516
517impl AppContext {
518 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
519 let bash_compress_enabled = config.experimental_bash_compress;
520 let progress_sender = Arc::new(Mutex::new(None));
521 let stdout_writer = Arc::new(Mutex::new(BufWriter::new(io::stdout())));
522 let (configure_warnings_tx, configure_warnings_rx) = mpsc::channel();
523 let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
524 let symbol_cache = provider
525 .as_any()
526 .downcast_ref::<crate::parser::TreeSitterProvider>()
527 .map(|provider| provider.symbol_cache())
528 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
529 let lsp_child_registry = crate::lsp::child_registry::LspChildRegistry::new();
530 let mut lsp_manager = LspManager::new();
531 lsp_manager.set_child_registry(lsp_child_registry.clone());
532 AppContext {
533 provider,
534 backup: RefCell::new(BackupStore::new()),
535 checkpoint: RefCell::new(CheckpointStore::new()),
536 db: RefCell::new(None),
537 config: RefCell::new(config),
538 harness: RefCell::new(None),
539 canonical_cache_root: RefCell::new(None),
540 is_worktree_bridge: RefCell::new(false),
541 git_common_dir: RefCell::new(None),
542 degraded_reasons: RefCell::new(Vec::new()),
543 callgraph: RefCell::new(None),
544 search_index: RefCell::new(None),
545 search_index_rx: RefCell::new(None),
546 pending_search_index_paths: RefCell::new(BTreeSet::new()),
547 symbol_cache,
548 inspect_manager: Arc::new(InspectManager::new()),
549 semantic_index: RefCell::new(None),
550 semantic_index_rx: RefCell::new(None),
551 semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
552 pending_semantic_index_paths: RefCell::new(BTreeSet::new()),
553 pending_semantic_corpus_refresh: RefCell::new(false),
554 semantic_refresh_tx: RefCell::new(None),
555 semantic_refresh_event_rx: RefCell::new(None),
556 semantic_refresh_worker: RefCell::new(None),
557 semantic_embedding_model: RefCell::new(None),
558 watcher: RefCell::new(None),
559 watcher_rx: RefCell::new(None),
560 lsp_manager: RefCell::new(lsp_manager),
561 lsp_child_registry,
562 stdout_writer,
563 progress_sender: Arc::clone(&progress_sender),
564 configure_generation: AtomicU64::new(0),
565 configure_warnings_tx,
566 configure_warnings_rx,
567 status_emitter,
568 bash_background: BgTaskRegistry::new(progress_sender),
569 filter_registry: Arc::new(std::sync::RwLock::new(
570 crate::compress::toml_filter::FilterRegistry::default(),
571 )),
572 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
573 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
574 gitignore: RefCell::new(None),
575 }
576 }
577
578 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
581 self.gitignore.borrow().clone()
582 }
583
584 pub fn clear_gitignore(&self) {
606 *self.gitignore.borrow_mut() = None;
607 }
608
609 pub fn rebuild_gitignore(&self) {
610 use ignore::gitignore::GitignoreBuilder;
611 use std::path::Path;
612 let root_raw = match self.config().project_root.clone() {
613 Some(r) => r,
614 None => {
615 *self.gitignore.borrow_mut() = None;
616 return;
617 }
618 };
619 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
627 let mut builder = GitignoreBuilder::new(&root);
628 if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
633 if global_ignore.is_file() {
634 if let Some(err) = builder.add(&global_ignore) {
635 crate::slog_warn!(
636 "global gitignore parse error in {}: {}",
637 global_ignore.display(),
638 err
639 );
640 }
641 }
642 }
643 let root_ignore = Path::new(&root).join(".gitignore");
645 if root_ignore.exists() {
646 if let Some(err) = builder.add(&root_ignore) {
647 crate::slog_warn!(
648 "gitignore parse error in {}: {}",
649 root_ignore.display(),
650 err
651 );
652 }
653 }
654 let root_aftignore = Path::new(&root).join(".aftignore");
659 if root_aftignore.exists() {
660 if let Some(err) = builder.add(&root_aftignore) {
661 crate::slog_warn!(
662 "aftignore parse error in {}: {}",
663 root_aftignore.display(),
664 err
665 );
666 }
667 }
668 let info_exclude = self
673 .git_common_dir
674 .borrow()
675 .clone()
676 .unwrap_or_else(|| Path::new(&root).join(".git"))
677 .join("info")
678 .join("exclude");
679 if info_exclude.exists() {
680 if let Some(err) = builder.add(&info_exclude) {
681 crate::slog_warn!(
682 "gitignore parse error in {}: {}",
683 info_exclude.display(),
684 err
685 );
686 }
687 }
688 let walker = ignore::WalkBuilder::new(&root)
694 .standard_filters(true)
695 .hidden(false)
703 .filter_entry(|entry| {
704 let name = entry.file_name().to_string_lossy();
705 !matches!(
706 name.as_ref(),
707 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
708 )
709 })
710 .build();
711 for entry in walker.flatten() {
712 let file_name = entry.file_name();
713 let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
714 let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
715 if is_nested_gitignore || is_nested_aftignore {
716 if let Some(err) = builder.add(entry.path()) {
717 crate::slog_warn!(
718 "nested ignore parse error in {}: {}",
719 entry.path().display(),
720 err
721 );
722 }
723 }
724 }
725 match builder.build() {
726 Ok(gi) => {
727 let count = gi.num_ignores();
728 if count > 0 {
729 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
730 *self.gitignore.borrow_mut() = Some(Arc::new(gi));
731 } else {
732 *self.gitignore.borrow_mut() = None;
733 }
734 }
735 Err(err) => {
736 crate::slog_warn!("gitignore matcher build failed: {}", err);
737 *self.gitignore.borrow_mut() = None;
738 }
739 }
740 }
741
742 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
745 Arc::clone(&self.bash_compress_flag)
746 }
747
748 pub fn sync_bash_compress_flag(&self) {
752 let value = self.config().experimental_bash_compress;
753 self.bash_compress_flag
754 .store(value, std::sync::atomic::Ordering::Relaxed);
755 }
756
757 pub fn set_bash_compress_enabled(&self, enabled: bool) {
758 self.config_mut().experimental_bash_compress = enabled;
759 self.bash_compress_flag
760 .store(enabled, std::sync::atomic::Ordering::Relaxed);
761 }
762
763 pub fn filter_registry(
767 &self,
768 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
769 self.ensure_filter_registry_loaded();
770 match self.filter_registry.read() {
771 Ok(g) => g,
772 Err(poisoned) => poisoned.into_inner(),
773 }
774 }
775
776 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
780 self.ensure_filter_registry_loaded();
781 Arc::clone(&self.filter_registry)
782 }
783
784 pub fn reset_filter_registry(&self) {
788 let new_registry = crate::compress::build_registry_for_context(self);
789 match self.filter_registry.write() {
790 Ok(mut slot) => *slot = new_registry,
791 Err(poisoned) => *poisoned.into_inner() = new_registry,
792 }
793 self.filter_registry_loaded
794 .store(true, std::sync::atomic::Ordering::Release);
795 }
796
797 fn ensure_filter_registry_loaded(&self) {
798 use std::sync::atomic::Ordering;
799 if self.filter_registry_loaded.load(Ordering::Acquire) {
800 return;
801 }
802 let new_registry = crate::compress::build_registry_for_context(self);
805 if let Ok(mut slot) = self.filter_registry.write() {
806 *slot = new_registry;
807 self.filter_registry_loaded.store(true, Ordering::Release);
808 }
809 }
810
811 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
814 self.lsp_child_registry.clone()
815 }
816
817 pub fn stdout_writer(&self) -> SharedStdoutWriter {
818 Arc::clone(&self.stdout_writer)
819 }
820
821 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
822 if let Ok(mut progress_sender) = self.progress_sender.lock() {
823 *progress_sender = sender;
824 }
825 }
826
827 pub fn emit_progress(&self, frame: ProgressFrame) {
828 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
829 return;
830 };
831 if let Some(sender) = progress_sender.as_ref() {
832 sender(PushFrame::Progress(frame));
833 }
834 }
835
836 pub fn status_emitter(&self) -> &StatusEmitter {
837 &self.status_emitter
838 }
839
840 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
848 self.progress_sender
849 .lock()
850 .ok()
851 .and_then(|sender| sender.clone())
852 }
853
854 pub fn advance_configure_generation(&self) -> u64 {
855 self.configure_generation
856 .fetch_add(1, Ordering::SeqCst)
857 .wrapping_add(1)
858 }
859
860 pub fn configure_generation(&self) -> u64 {
861 self.configure_generation.load(Ordering::SeqCst)
862 }
863
864 pub fn configure_warnings_sender(&self) -> mpsc::Sender<(u64, ConfigureWarningsFrame)> {
865 self.configure_warnings_tx.clone()
866 }
867
868 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
869 let mut warnings = Vec::new();
870 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
871 warnings.push(warning);
872 }
873 warnings
874 }
875
876 pub fn bash_background(&self) -> &BgTaskRegistry {
877 &self.bash_background
878 }
879
880 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
881 self.bash_background.drain_completions()
882 }
883
884 pub fn provider(&self) -> &dyn LanguageProvider {
886 self.provider.as_ref()
887 }
888
889 pub fn backup(&self) -> &RefCell<BackupStore> {
891 &self.backup
892 }
893
894 pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
896 &self.checkpoint
897 }
898
899 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
900 *self.db.borrow_mut() = Some(conn);
901 }
902
903 pub fn clear_db(&self) {
904 *self.db.borrow_mut() = None;
905 }
906
907 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
908 self.db.borrow().clone()
909 }
910
911 pub fn config(&self) -> Ref<'_, Config> {
913 self.config.borrow()
914 }
915
916 pub fn config_mut(&self) -> RefMut<'_, Config> {
918 self.config.borrow_mut()
919 }
920
921 pub fn set_harness(&self, harness: Harness) {
922 *self.harness.borrow_mut() = Some(harness);
923 self.bash_background.set_harness(harness);
924 }
925
926 pub fn harness_opt(&self) -> Option<Harness> {
927 *self.harness.borrow()
928 }
929
930 pub fn harness(&self) -> Harness {
931 self.harness_opt()
932 .expect("harness set by configure before any tool call")
933 }
934
935 pub fn storage_dir(&self) -> PathBuf {
936 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
937 }
938
939 pub fn harness_dir(&self) -> PathBuf {
940 self.storage_dir().join(self.harness().as_str())
941 }
942
943 pub fn inspect_dir(&self) -> PathBuf {
944 self.harness_dir().join("inspect")
945 }
946
947 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
948 self.harness_dir()
949 .join("bash-tasks")
950 .join(hash_session(session_id))
951 }
952
953 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
954 self.harness_dir()
955 .join("backups")
956 .join(hash_session(session_id))
957 .join(path_hash)
958 }
959
960 pub fn filters_dir(&self) -> PathBuf {
961 self.harness_dir().join("filters")
962 }
963
964 pub fn trust_file(&self) -> PathBuf {
966 self.storage_dir().join("trusted-filter-projects.json")
967 }
968
969 pub fn set_canonical_cache_root(&self, root: PathBuf) {
970 debug_assert!(root.is_absolute());
971 *self.canonical_cache_root.borrow_mut() = Some(root);
972 }
973
974 pub fn canonical_cache_root(&self) -> PathBuf {
975 self.canonical_cache_root
976 .borrow()
977 .clone()
978 .expect("canonical_cache_root accessed before handle_configure")
979 }
980
981 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
982 self.canonical_cache_root.borrow().clone()
983 }
984
985 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
986 *self.is_worktree_bridge.borrow_mut() = is_worktree_bridge;
987 *self.git_common_dir.borrow_mut() = git_common_dir;
988 }
989
990 pub fn is_worktree_bridge(&self) -> bool {
991 *self.is_worktree_bridge.borrow()
992 }
993
994 pub fn git_common_dir(&self) -> Option<PathBuf> {
995 self.git_common_dir.borrow().clone()
996 }
997
998 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
1002 *self.degraded_reasons.borrow_mut() = reasons;
1003 }
1004
1005 pub fn degraded_reasons(&self) -> Vec<String> {
1009 self.degraded_reasons.borrow().clone()
1010 }
1011
1012 pub fn is_degraded(&self) -> bool {
1014 !self.degraded_reasons.borrow().is_empty()
1015 }
1016
1017 pub fn cache_role(&self) -> &'static str {
1018 if self.canonical_cache_root.borrow().is_none() {
1019 "not_initialized"
1020 } else if self.is_worktree_bridge() {
1021 "worktree"
1022 } else {
1023 "main"
1024 }
1025 }
1026
1027 pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
1029 &self.callgraph
1030 }
1031
1032 pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
1034 &self.search_index
1035 }
1036
1037 pub fn search_index_rx(&self) -> &RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>> {
1039 &self.search_index_rx
1040 }
1041
1042 pub fn add_pending_search_index_paths<I>(&self, paths: I)
1043 where
1044 I: IntoIterator<Item = PathBuf>,
1045 {
1046 self.pending_search_index_paths.borrow_mut().extend(paths);
1047 }
1048
1049 pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
1050 std::mem::take(&mut *self.pending_search_index_paths.borrow_mut())
1051 .into_iter()
1052 .collect()
1053 }
1054
1055 pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
1056 where
1057 I: IntoIterator<Item = PathBuf>,
1058 {
1059 self.pending_semantic_index_paths.borrow_mut().extend(paths);
1060 }
1061
1062 pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
1063 std::mem::take(&mut *self.pending_semantic_index_paths.borrow_mut())
1064 .into_iter()
1065 .collect()
1066 }
1067
1068 pub fn mark_pending_semantic_corpus_refresh(&self) {
1069 *self.pending_semantic_corpus_refresh.borrow_mut() = true;
1070 }
1071
1072 pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
1073 std::mem::take(&mut *self.pending_semantic_corpus_refresh.borrow_mut())
1074 }
1075
1076 pub fn clear_pending_index_updates(&self) {
1077 self.pending_search_index_paths.borrow_mut().clear();
1078 self.pending_semantic_index_paths.borrow_mut().clear();
1079 *self.pending_semantic_corpus_refresh.borrow_mut() = false;
1080 }
1081
1082 pub fn inspect_manager(&self) -> Arc<InspectManager> {
1083 Arc::clone(&self.inspect_manager)
1084 }
1085
1086 pub fn symbol_cache(&self) -> SharedSymbolCache {
1088 Arc::clone(&self.symbol_cache)
1089 }
1090
1091 pub fn reset_symbol_cache(&self) -> u64 {
1093 self.symbol_cache
1094 .write()
1095 .map(|mut cache| cache.reset())
1096 .unwrap_or(0)
1097 }
1098
1099 pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
1101 &self.semantic_index
1102 }
1103
1104 pub fn semantic_index_rx(
1106 &self,
1107 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
1108 &self.semantic_index_rx
1109 }
1110
1111 pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
1112 &self.semantic_index_status
1113 }
1114
1115 pub fn install_semantic_refresh_worker(
1116 &self,
1117 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
1118 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
1119 worker_slot: SemanticRefreshWorkerSlot,
1120 ) {
1121 self.clear_semantic_refresh_worker();
1122 *self.semantic_refresh_tx.borrow_mut() = Some(sender);
1123 *self.semantic_refresh_event_rx.borrow_mut() = Some(event_rx);
1124 *self.semantic_refresh_worker.borrow_mut() = Some(worker_slot);
1125 }
1126
1127 pub fn clear_semantic_refresh_worker(&self) {
1128 *self.semantic_refresh_tx.borrow_mut() = None;
1129 *self.semantic_refresh_event_rx.borrow_mut() = None;
1130 if let Some(worker_slot) = self.semantic_refresh_worker.borrow_mut().take() {
1131 if let Ok(mut handle) = worker_slot.lock() {
1132 drop(handle.take());
1133 }
1134 }
1135 }
1136
1137 pub fn semantic_refresh_sender(
1138 &self,
1139 ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
1140 self.semantic_refresh_tx.borrow().clone()
1141 }
1142
1143 pub fn semantic_refresh_event_rx(
1144 &self,
1145 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
1146 &self.semantic_refresh_event_rx
1147 }
1148
1149 pub fn semantic_embedding_model(
1151 &self,
1152 ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
1153 &self.semantic_embedding_model
1154 }
1155
1156 pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
1158 &self.watcher
1159 }
1160
1161 pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
1163 &self.watcher_rx
1164 }
1165
1166 pub fn lsp(&self) -> RefMut<'_, LspManager> {
1168 self.lsp_manager.borrow_mut()
1169 }
1170
1171 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
1174 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1175 let config = self.config();
1176 if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
1177 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1178 }
1179 }
1180 }
1181
1182 pub fn lsp_notify_and_collect_diagnostics(
1193 &self,
1194 file_path: &Path,
1195 content: &str,
1196 timeout: std::time::Duration,
1197 ) -> crate::lsp::manager::PostEditWaitOutcome {
1198 let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
1199 return crate::lsp::manager::PostEditWaitOutcome::default();
1200 };
1201
1202 lsp.drain_events();
1205
1206 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
1210
1211 let config = self.config();
1213 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
1214 {
1215 Ok(v) => v,
1216 Err(e) => {
1217 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1218 return crate::lsp::manager::PostEditWaitOutcome::default();
1219 }
1220 };
1221
1222 if expected_versions.is_empty() {
1225 return crate::lsp::manager::PostEditWaitOutcome::default();
1226 }
1227
1228 lsp.wait_for_post_edit_diagnostics(
1229 file_path,
1230 &config,
1231 &expected_versions,
1232 &pre_snapshot,
1233 timeout,
1234 )
1235 }
1236
1237 fn custom_lsp_root_markers(&self) -> Vec<String> {
1240 self.config()
1241 .lsp_servers
1242 .iter()
1243 .flat_map(|s| s.root_markers.iter().cloned())
1244 .collect()
1245 }
1246
1247 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
1248 let custom_markers = self.custom_lsp_root_markers();
1249 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
1250 .iter()
1251 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
1252 .cloned()
1253 .map(|path| {
1254 let change_type = if path.exists() {
1255 FileChangeType::CHANGED
1256 } else {
1257 FileChangeType::DELETED
1258 };
1259 (path, change_type)
1260 })
1261 .collect();
1262
1263 self.notify_watched_config_events(&config_paths);
1264 }
1265
1266 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
1267 let paths = params
1268 .get("multi_file_write_paths")
1269 .and_then(|value| value.as_array())?
1270 .iter()
1271 .filter_map(|value| value.as_str())
1272 .map(PathBuf::from)
1273 .collect::<Vec<_>>();
1274
1275 (!paths.is_empty()).then_some(paths)
1276 }
1277
1278 fn watched_file_events_from_params(
1290 params: &serde_json::Value,
1291 extra_markers: &[String],
1292 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
1293 let events = params
1294 .get("multi_file_write_paths")
1295 .and_then(|value| value.as_array())?
1296 .iter()
1297 .filter_map(|entry| {
1298 let path = entry
1300 .get("path")
1301 .and_then(|value| value.as_str())
1302 .map(PathBuf::from)?;
1303
1304 if !is_config_file_path_with_custom(&path, extra_markers) {
1305 return None;
1306 }
1307
1308 let change_type = entry
1309 .get("type")
1310 .and_then(|value| value.as_str())
1311 .and_then(Self::parse_file_change_type)
1312 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
1313
1314 Some((path, change_type))
1315 })
1316 .collect::<Vec<_>>();
1317
1318 (!events.is_empty()).then_some(events)
1319 }
1320
1321 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
1322 match value {
1323 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
1324 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
1325 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
1326 _ => None,
1327 }
1328 }
1329
1330 fn change_type_from_current_state(path: &Path) -> FileChangeType {
1331 if path.exists() {
1332 FileChangeType::CHANGED
1333 } else {
1334 FileChangeType::DELETED
1335 }
1336 }
1337
1338 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
1339 if config_paths.is_empty() {
1340 return;
1341 }
1342
1343 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1344 let config = self.config();
1345 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
1346 crate::slog_warn!("watched-file sync error: {}", e);
1347 }
1348 }
1349 }
1350
1351 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
1352 let custom_markers = self.custom_lsp_root_markers();
1353 if !is_config_file_path_with_custom(file_path, &custom_markers) {
1354 return;
1355 }
1356
1357 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
1358 }
1359
1360 pub fn lsp_post_multi_file_write(
1365 &self,
1366 file_path: &Path,
1367 content: &str,
1368 file_paths: &[PathBuf],
1369 params: &serde_json::Value,
1370 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
1371 self.notify_watched_config_files(file_paths);
1372
1373 let wants_diagnostics = params
1374 .get("diagnostics")
1375 .and_then(|v| v.as_bool())
1376 .unwrap_or(false);
1377
1378 if !wants_diagnostics {
1379 self.lsp_notify_file_changed(file_path, content);
1380 return None;
1381 }
1382
1383 let wait_ms = params
1384 .get("wait_ms")
1385 .and_then(|v| v.as_u64())
1386 .unwrap_or(3000)
1387 .min(10_000);
1388
1389 Some(self.lsp_notify_and_collect_diagnostics(
1390 file_path,
1391 content,
1392 std::time::Duration::from_millis(wait_ms),
1393 ))
1394 }
1395
1396 pub fn lsp_post_write(
1413 &self,
1414 file_path: &Path,
1415 content: &str,
1416 params: &serde_json::Value,
1417 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
1418 let wants_diagnostics = params
1419 .get("diagnostics")
1420 .and_then(|v| v.as_bool())
1421 .unwrap_or(false);
1422
1423 let custom_markers = self.custom_lsp_root_markers();
1424
1425 if !wants_diagnostics {
1426 if let Some(file_paths) = Self::multi_file_write_paths(params) {
1427 self.notify_watched_config_files(&file_paths);
1428 } else if let Some(config_events) =
1429 Self::watched_file_events_from_params(params, &custom_markers)
1430 {
1431 self.notify_watched_config_events(&config_events);
1432 }
1433 self.lsp_notify_file_changed(file_path, content);
1434 return None;
1435 }
1436
1437 let wait_ms = params
1438 .get("wait_ms")
1439 .and_then(|v| v.as_u64())
1440 .unwrap_or(3000)
1441 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
1444 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
1445 }
1446
1447 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
1448 {
1449 self.notify_watched_config_events(&config_events);
1450 }
1451
1452 Some(self.lsp_notify_and_collect_diagnostics(
1453 file_path,
1454 content,
1455 std::time::Duration::from_millis(wait_ms),
1456 ))
1457 }
1458
1459 pub fn validate_path(
1468 &self,
1469 req_id: &str,
1470 path: &Path,
1471 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
1472 let config = self.config();
1473 if !config.restrict_to_project_root {
1475 return Ok(path.to_path_buf());
1476 }
1477 let root = match &config.project_root {
1478 Some(r) => r.clone(),
1479 None => return Ok(path.to_path_buf()), };
1481 drop(config);
1482
1483 let raw_root = root.clone();
1488 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
1489
1490 let path_for_resolution = if path.is_relative() {
1495 raw_root.join(path)
1496 } else {
1497 path.to_path_buf()
1498 };
1499 let resolved = match std::fs::canonicalize(&path_for_resolution) {
1500 Ok(resolved) => resolved,
1501 Err(_) => {
1502 let normalized = normalize_path(&path_for_resolution);
1503 reject_escaping_symlink(
1504 req_id,
1505 &path_for_resolution,
1506 &normalized,
1507 &resolved_root,
1508 &raw_root,
1509 )?;
1510 resolve_with_existing_ancestors(&normalized)
1511 }
1512 };
1513
1514 if !resolved.starts_with(&resolved_root) {
1515 return Err(path_error_response(req_id, path, &resolved_root));
1516 }
1517
1518 Ok(resolved)
1519 }
1520
1521 pub fn lsp_server_count(&self) -> usize {
1523 self.lsp_manager
1524 .try_borrow()
1525 .map(|lsp| lsp.server_count())
1526 .unwrap_or(0)
1527 }
1528
1529 pub fn symbol_cache_stats(&self) -> serde_json::Value {
1531 let entries = self
1532 .symbol_cache
1533 .read()
1534 .map(|cache| cache.len())
1535 .unwrap_or(0);
1536 serde_json::json!({
1537 "local_entries": entries,
1538 "warm_entries": 0,
1539 })
1540 }
1541}
1542
1543#[cfg(test)]
1544mod status_emitter_tests {
1545 use super::*;
1546 use crate::parser::TreeSitterProvider;
1547
1548 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
1549 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
1550 let (tx, rx) = mpsc::channel();
1551 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
1552 let _ = tx.send(frame);
1553 }))));
1554 (ctx, rx)
1555 }
1556
1557 #[test]
1558 fn status_emitter_signal_triggers_push() {
1559 let (ctx, rx) = ctx_with_frame_rx();
1560 ctx.status_emitter().signal(ctx.build_status_snapshot());
1561 let frame = rx
1562 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1563 .expect("status_changed push");
1564 assert!(matches!(frame, PushFrame::StatusChanged(_)));
1565 }
1566
1567 #[test]
1568 fn status_emitter_debounces_burst() {
1569 let (ctx, rx) = ctx_with_frame_rx();
1570 for _ in 0..10 {
1571 ctx.status_emitter().signal(ctx.build_status_snapshot());
1572 }
1573 let frame = rx
1574 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1575 .expect("status_changed push");
1576 assert!(matches!(frame, PushFrame::StatusChanged(_)));
1577 assert!(rx.try_recv().is_err());
1578 }
1579
1580 #[test]
1581 fn status_emitter_separate_windows_separate_pushes() {
1582 let (ctx, rx) = ctx_with_frame_rx();
1583 ctx.status_emitter().signal(ctx.build_status_snapshot());
1584 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1585 .expect("first push");
1586 ctx.status_emitter().signal(ctx.build_status_snapshot());
1587 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1588 .expect("second push");
1589 }
1590
1591 #[test]
1592 fn status_emitter_no_signal_no_push() {
1593 let (_ctx, rx) = ctx_with_frame_rx();
1594 assert!(rx
1595 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
1596 .is_err());
1597 }
1598
1599 #[test]
1600 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
1601 let (ctx, rx) = ctx_with_frame_rx();
1602 drop(ctx);
1603 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
1604 }
1605}
1606
1607#[cfg(test)]
1608mod harness_path_tests {
1609 use super::*;
1610 use crate::harness::Harness;
1611 use crate::parser::TreeSitterProvider;
1612
1613 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
1614 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
1615 ctx.config_mut().storage_dir = Some(storage_dir);
1616 ctx.set_harness(harness);
1617 ctx
1618 }
1619
1620 #[test]
1621 fn harness_dir_resolves_correctly() {
1622 let storage = PathBuf::from("/tmp/cortexkit/aft");
1623 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
1624
1625 assert_eq!(ctx.harness_dir(), storage.join("pi"));
1626 }
1627
1628 #[test]
1629 fn bash_tasks_dir_uses_hash_session() {
1630 let storage = PathBuf::from("/tmp/cortexkit/aft");
1631 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
1632
1633 assert_eq!(
1634 ctx.bash_tasks_dir("ses_abc"),
1635 storage
1636 .join("opencode")
1637 .join("bash-tasks")
1638 .join(hash_session("ses_abc"))
1639 );
1640 }
1641
1642 #[test]
1643 fn backups_dir_includes_path_hash() {
1644 let storage = PathBuf::from("/tmp/cortexkit/aft");
1645 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
1646
1647 assert_eq!(
1648 ctx.backups_dir("ses_abc", "pathhash"),
1649 storage
1650 .join("pi")
1651 .join("backups")
1652 .join(hash_session("ses_abc"))
1653 .join("pathhash")
1654 );
1655 }
1656
1657 #[test]
1658 fn filters_dir_under_harness() {
1659 let storage = PathBuf::from("/tmp/cortexkit/aft");
1660 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
1661
1662 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
1663 }
1664
1665 #[test]
1666 fn trust_file_is_host_global() {
1667 let storage = PathBuf::from("/tmp/cortexkit/aft");
1668 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
1669
1670 assert_eq!(
1671 ctx.trust_file(),
1672 storage.join("trusted-filter-projects.json")
1673 );
1674 }
1675
1676 #[test]
1677 fn same_session_different_harness_resolve_different_paths() {
1678 let storage = PathBuf::from("/tmp/cortexkit/aft");
1679 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
1680 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
1681
1682 assert_ne!(
1683 opencode.bash_tasks_dir("ses_same"),
1684 pi.bash_tasks_dir("ses_same")
1685 );
1686 }
1687}
1688
1689#[cfg(test)]
1690mod gitignore_tests {
1691 use super::*;
1692 use std::fs;
1693 use std::path::Path;
1694 use tempfile::TempDir;
1695
1696 fn make_ctx_with_root(root: &Path) -> AppContext {
1697 let provider = Box::new(crate::parser::TreeSitterProvider::new());
1698 let config = Config {
1699 project_root: Some(root.to_path_buf()),
1700 ..Config::default()
1701 };
1702 AppContext::new(provider, config)
1703 }
1704
1705 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
1712 let Some(matcher) = ctx.gitignore() else {
1713 return false;
1714 };
1715 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1716 if !canonical.starts_with(matcher.path()) {
1717 return false;
1718 }
1719 let is_dir = canonical.is_dir();
1720 matcher
1721 .matched_path_or_any_parents(&canonical, is_dir)
1722 .is_ignore()
1723 }
1724
1725 fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
1736 use std::sync::{Mutex, OnceLock};
1737 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1738 let _guard = LOCK
1739 .get_or_init(|| Mutex::new(()))
1740 .lock()
1741 .unwrap_or_else(|e| e.into_inner());
1742 let tmp = TempDir::new().unwrap();
1743 let prev = std::env::var_os("XDG_CONFIG_HOME");
1744 unsafe {
1746 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
1747 }
1748 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
1749 unsafe {
1750 match prev {
1751 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
1752 None => std::env::remove_var("XDG_CONFIG_HOME"),
1753 }
1754 }
1755 match result {
1756 Ok(r) => r,
1757 Err(p) => std::panic::resume_unwind(p),
1758 }
1759 }
1760
1761 #[test]
1762 fn rebuild_gitignore_returns_none_without_project_root() {
1763 let provider = Box::new(crate::parser::TreeSitterProvider::new());
1764 let ctx = AppContext::new(provider, Config::default());
1765 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
1766 assert!(ctx.gitignore().is_none());
1767 }
1768
1769 #[test]
1770 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
1771 let tmp = TempDir::new().unwrap();
1772 let ctx = make_ctx_with_root(tmp.path());
1773 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
1774 assert!(ctx.gitignore().is_none());
1775 }
1776
1777 #[test]
1778 fn matcher_filters_files_in_ignored_dist_dir() {
1779 let tmp = TempDir::new().unwrap();
1780 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
1781 fs::create_dir_all(tmp.path().join("dist")).unwrap();
1782 fs::create_dir_all(tmp.path().join("src")).unwrap();
1783 let dist_file = tmp.path().join("dist").join("bundle.js");
1784 let src_file = tmp.path().join("src").join("app.ts");
1785 fs::write(&dist_file, "x").unwrap();
1786 fs::write(&src_file, "y").unwrap();
1787
1788 let ctx = make_ctx_with_root(tmp.path());
1789 ctx.rebuild_gitignore();
1790
1791 assert!(ctx.gitignore().is_some());
1792 assert!(
1793 is_ignored(&ctx, &dist_file),
1794 "dist/bundle.js should be ignored"
1795 );
1796 assert!(
1797 !is_ignored(&ctx, &src_file),
1798 "src/app.ts should NOT be ignored"
1799 );
1800 }
1801
1802 #[test]
1803 fn matcher_handles_node_modules_and_target() {
1804 let tmp = TempDir::new().unwrap();
1805 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
1806 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
1807 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
1808 let nm_file = tmp.path().join("node_modules/foo/index.js");
1809 let target_file = tmp.path().join("target/debug/aft");
1810 fs::write(&nm_file, "x").unwrap();
1811 fs::write(&target_file, "x").unwrap();
1812
1813 let ctx = make_ctx_with_root(tmp.path());
1814 ctx.rebuild_gitignore();
1815
1816 assert!(is_ignored(&ctx, &nm_file));
1817 assert!(is_ignored(&ctx, &target_file));
1818 }
1819
1820 #[test]
1821 fn matcher_honors_negation_pattern() {
1822 let tmp = TempDir::new().unwrap();
1824 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
1825 let random_log = tmp.path().join("random.log");
1826 let important_log = tmp.path().join("important.log");
1827 fs::write(&random_log, "x").unwrap();
1828 fs::write(&important_log, "y").unwrap();
1829
1830 let ctx = make_ctx_with_root(tmp.path());
1831 ctx.rebuild_gitignore();
1832
1833 assert!(is_ignored(&ctx, &random_log));
1834 assert!(
1835 !is_ignored(&ctx, &important_log),
1836 "negation pattern should un-ignore important.log"
1837 );
1838 }
1839
1840 #[test]
1841 fn rebuild_picks_up_gitignore_changes() {
1842 let tmp = TempDir::new().unwrap();
1843 let ignore_path = tmp.path().join(".gitignore");
1844 fs::write(&ignore_path, "foo.txt\n").unwrap();
1845 let foo = tmp.path().join("foo.txt");
1846 let bar = tmp.path().join("bar.txt");
1847 fs::write(&foo, "").unwrap();
1848 fs::write(&bar, "").unwrap();
1849
1850 let ctx = make_ctx_with_root(tmp.path());
1851 ctx.rebuild_gitignore();
1852 assert!(is_ignored(&ctx, &foo));
1853 assert!(!is_ignored(&ctx, &bar));
1854
1855 fs::write(&ignore_path, "bar.txt\n").unwrap();
1857 ctx.rebuild_gitignore();
1858 assert!(!is_ignored(&ctx, &foo));
1859 assert!(is_ignored(&ctx, &bar));
1860 }
1861
1862 #[test]
1863 fn gitignore_loads_info_exclude_when_present() {
1864 let tmp = TempDir::new().unwrap();
1865 let info_dir = tmp.path().join(".git/info");
1866 fs::create_dir_all(&info_dir).unwrap();
1867 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
1868 let secrets = tmp.path().join("secrets.txt");
1869 let public = tmp.path().join("public.txt");
1870 fs::write(&secrets, "token").unwrap();
1871 fs::write(&public, "ok").unwrap();
1872
1873 let ctx = make_ctx_with_root(tmp.path());
1874 ctx.rebuild_gitignore();
1875
1876 assert!(is_ignored(&ctx, &secrets));
1877 assert!(!is_ignored(&ctx, &public));
1878 }
1879
1880 #[test]
1881 fn matcher_picks_up_nested_gitignore() {
1882 let tmp = TempDir::new().unwrap();
1883 fs::write(tmp.path().join(".gitignore"), "").unwrap();
1885 let sub = tmp.path().join("packages/foo");
1886 fs::create_dir_all(&sub).unwrap();
1887 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
1888 let generated_file = sub.join("generated").join("out.js");
1889 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
1890 fs::write(&generated_file, "x").unwrap();
1891
1892 let ctx = make_ctx_with_root(tmp.path());
1893 ctx.rebuild_gitignore();
1894
1895 assert!(
1896 is_ignored(&ctx, &generated_file),
1897 "nested gitignore in packages/foo/.gitignore should ignore generated/"
1898 );
1899 }
1900}