1use std::cell::{Ref, RefCell, RefMut};
2use std::collections::BTreeMap;
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 struct SemanticRefreshRequest {
240 pub paths: Vec<PathBuf>,
241}
242
243#[derive(Debug)]
244pub enum SemanticRefreshEvent {
245 Started {
246 paths: Vec<PathBuf>,
247 },
248 Completed {
249 added_entries: Vec<EmbeddingEntry>,
250 updated_metadata: Vec<(PathBuf, FileFreshness)>,
251 completed_paths: Vec<PathBuf>,
252 },
253 Failed {
254 paths: Vec<PathBuf>,
255 error: String,
256 },
257}
258
259pub type SemanticRefreshWorkerSlot = Arc<Mutex<Option<std::thread::JoinHandle<()>>>>;
260
261fn normalize_path(path: &Path) -> PathBuf {
265 let mut result = PathBuf::new();
266 for component in path.components() {
267 match component {
268 Component::ParentDir => {
269 if !result.pop() {
271 result.push(component);
272 }
273 }
274 Component::CurDir => {} _ => result.push(component),
276 }
277 }
278 result
279}
280
281fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
282 let mut existing = path.to_path_buf();
283 let mut tail_segments = Vec::new();
284
285 while !existing.exists() {
286 if let Some(name) = existing.file_name() {
287 tail_segments.push(name.to_owned());
288 } else {
289 break;
290 }
291
292 existing = match existing.parent() {
293 Some(parent) => parent.to_path_buf(),
294 None => break,
295 };
296 }
297
298 let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
299 for segment in tail_segments.into_iter().rev() {
300 resolved.push(segment);
301 }
302
303 resolved
304}
305
306fn path_error_response(
307 req_id: &str,
308 path: &Path,
309 resolved_root: &Path,
310) -> crate::protocol::Response {
311 crate::protocol::Response::error(
312 req_id,
313 "path_outside_root",
314 format!(
315 "path '{}' is outside the project root '{}'",
316 path.display(),
317 resolved_root.display()
318 ),
319 )
320}
321
322fn reject_escaping_symlink(
332 req_id: &str,
333 original_path: &Path,
334 candidate: &Path,
335 resolved_root: &Path,
336 raw_root: &Path,
337) -> Result<(), crate::protocol::Response> {
338 let mut current = PathBuf::new();
339
340 for component in candidate.components() {
341 current.push(component);
342
343 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
344 continue;
345 };
346
347 if !metadata.file_type().is_symlink() {
348 continue;
349 }
350
351 let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
360 if !inside_root {
361 continue;
362 }
363
364 iterative_follow_chain(req_id, original_path, ¤t, resolved_root)?;
365 }
366
367 Ok(())
368}
369
370fn iterative_follow_chain(
373 req_id: &str,
374 original_path: &Path,
375 start: &Path,
376 resolved_root: &Path,
377) -> Result<(), crate::protocol::Response> {
378 let mut link = start.to_path_buf();
379 let mut depth = 0usize;
380
381 loop {
382 if depth > 40 {
383 return Err(path_error_response(req_id, original_path, resolved_root));
384 }
385
386 let target = match std::fs::read_link(&link) {
387 Ok(t) => t,
388 Err(_) => {
389 return Err(path_error_response(req_id, original_path, resolved_root));
391 }
392 };
393
394 let resolved_target = if target.is_absolute() {
395 normalize_path(&target)
396 } else {
397 let parent = link.parent().unwrap_or_else(|| Path::new(""));
398 normalize_path(&parent.join(&target))
399 };
400
401 let canonical_target =
405 std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
406
407 if !canonical_target.starts_with(resolved_root)
408 && !resolved_target.starts_with(resolved_root)
409 {
410 return Err(path_error_response(req_id, original_path, resolved_root));
411 }
412
413 match std::fs::symlink_metadata(&resolved_target) {
415 Ok(meta) if meta.file_type().is_symlink() => {
416 link = resolved_target;
417 depth += 1;
418 }
419 _ => break, }
421 }
422
423 Ok(())
424}
425
426pub struct AppContext {
436 provider: Box<dyn LanguageProvider>,
437 backup: RefCell<BackupStore>,
438 checkpoint: RefCell<CheckpointStore>,
439 db: RefCell<Option<Arc<Mutex<Connection>>>>,
440 config: RefCell<Config>,
441 pub harness: RefCell<Option<Harness>>,
442 canonical_cache_root: RefCell<Option<PathBuf>>,
443 is_worktree_bridge: RefCell<bool>,
444 git_common_dir: RefCell<Option<PathBuf>>,
445 degraded_reasons: RefCell<Vec<String>>,
453 callgraph: RefCell<Option<CallGraph>>,
454 search_index: RefCell<Option<SearchIndex>>,
455 search_index_rx: RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>>,
456 symbol_cache: SharedSymbolCache,
457 inspect_manager: Arc<InspectManager>,
458 semantic_index: RefCell<Option<SemanticIndex>>,
459 semantic_index_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
460 semantic_index_status: RefCell<SemanticIndexStatus>,
461 semantic_refresh_tx: RefCell<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>,
462 semantic_refresh_event_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
463 semantic_refresh_worker: RefCell<Option<SemanticRefreshWorkerSlot>>,
464 semantic_embedding_model: RefCell<Option<crate::semantic_index::EmbeddingModel>>,
465 watcher: RefCell<Option<RecommendedWatcher>>,
466 watcher_rx: RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>>,
467 lsp_manager: RefCell<LspManager>,
468 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
472 stdout_writer: SharedStdoutWriter,
473 progress_sender: SharedProgressSender,
474 configure_generation: AtomicU64,
475 configure_warnings_tx: mpsc::Sender<(u64, ConfigureWarningsFrame)>,
476 configure_warnings_rx: mpsc::Receiver<(u64, ConfigureWarningsFrame)>,
477 status_emitter: StatusEmitter,
478 bash_background: BgTaskRegistry,
479 filter_registry: crate::compress::SharedFilterRegistry,
486 filter_registry_loaded: std::sync::atomic::AtomicBool,
489 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
494 gitignore: RefCell<Option<Arc<ignore::gitignore::Gitignore>>>,
501}
502
503impl AppContext {
504 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
505 let bash_compress_enabled = config.experimental_bash_compress;
506 let progress_sender = Arc::new(Mutex::new(None));
507 let stdout_writer = Arc::new(Mutex::new(BufWriter::new(io::stdout())));
508 let (configure_warnings_tx, configure_warnings_rx) = mpsc::channel();
509 let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
510 let symbol_cache = provider
511 .as_any()
512 .downcast_ref::<crate::parser::TreeSitterProvider>()
513 .map(|provider| provider.symbol_cache())
514 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
515 let lsp_child_registry = crate::lsp::child_registry::LspChildRegistry::new();
516 let mut lsp_manager = LspManager::new();
517 lsp_manager.set_child_registry(lsp_child_registry.clone());
518 AppContext {
519 provider,
520 backup: RefCell::new(BackupStore::new()),
521 checkpoint: RefCell::new(CheckpointStore::new()),
522 db: RefCell::new(None),
523 config: RefCell::new(config),
524 harness: RefCell::new(None),
525 canonical_cache_root: RefCell::new(None),
526 is_worktree_bridge: RefCell::new(false),
527 git_common_dir: RefCell::new(None),
528 degraded_reasons: RefCell::new(Vec::new()),
529 callgraph: RefCell::new(None),
530 search_index: RefCell::new(None),
531 search_index_rx: RefCell::new(None),
532 symbol_cache,
533 inspect_manager: Arc::new(InspectManager::new()),
534 semantic_index: RefCell::new(None),
535 semantic_index_rx: RefCell::new(None),
536 semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
537 semantic_refresh_tx: RefCell::new(None),
538 semantic_refresh_event_rx: RefCell::new(None),
539 semantic_refresh_worker: RefCell::new(None),
540 semantic_embedding_model: RefCell::new(None),
541 watcher: RefCell::new(None),
542 watcher_rx: RefCell::new(None),
543 lsp_manager: RefCell::new(lsp_manager),
544 lsp_child_registry,
545 stdout_writer,
546 progress_sender: Arc::clone(&progress_sender),
547 configure_generation: AtomicU64::new(0),
548 configure_warnings_tx,
549 configure_warnings_rx,
550 status_emitter,
551 bash_background: BgTaskRegistry::new(progress_sender),
552 filter_registry: Arc::new(std::sync::RwLock::new(
553 crate::compress::toml_filter::FilterRegistry::default(),
554 )),
555 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
556 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
557 gitignore: RefCell::new(None),
558 }
559 }
560
561 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
564 self.gitignore.borrow().clone()
565 }
566
567 pub fn clear_gitignore(&self) {
588 *self.gitignore.borrow_mut() = None;
589 }
590
591 pub fn rebuild_gitignore(&self) {
592 use ignore::gitignore::GitignoreBuilder;
593 use std::path::Path;
594 let root_raw = match self.config().project_root.clone() {
595 Some(r) => r,
596 None => {
597 *self.gitignore.borrow_mut() = None;
598 return;
599 }
600 };
601 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
609 let mut builder = GitignoreBuilder::new(&root);
610 let root_ignore = Path::new(&root).join(".gitignore");
612 if root_ignore.exists() {
613 if let Some(err) = builder.add(&root_ignore) {
614 crate::slog_warn!(
615 "gitignore parse error in {}: {}",
616 root_ignore.display(),
617 err
618 );
619 }
620 }
621 let info_exclude = Path::new(&root).join(".git").join("info").join("exclude");
624 if info_exclude.exists() {
625 if let Some(err) = builder.add(&info_exclude) {
626 crate::slog_warn!(
627 "gitignore parse error in {}: {}",
628 info_exclude.display(),
629 err
630 );
631 }
632 }
633 let walker = ignore::WalkBuilder::new(&root)
639 .standard_filters(true)
640 .hidden(false)
643 .max_depth(Some(8))
644 .filter_entry(|entry| {
645 let name = entry.file_name().to_string_lossy();
646 !matches!(
647 name.as_ref(),
648 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
649 )
650 })
651 .build();
652 for entry in walker.flatten() {
653 if entry.file_name() == ".gitignore" && entry.path() != root_ignore {
654 if let Some(err) = builder.add(entry.path()) {
655 crate::slog_warn!(
656 "nested gitignore parse error in {}: {}",
657 entry.path().display(),
658 err
659 );
660 }
661 }
662 }
663 match builder.build() {
664 Ok(gi) => {
665 let count = gi.num_ignores();
666 if count > 0 {
667 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
668 *self.gitignore.borrow_mut() = Some(Arc::new(gi));
669 } else {
670 *self.gitignore.borrow_mut() = None;
671 }
672 }
673 Err(err) => {
674 crate::slog_warn!("gitignore matcher build failed: {}", err);
675 *self.gitignore.borrow_mut() = None;
676 }
677 }
678 }
679
680 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
683 Arc::clone(&self.bash_compress_flag)
684 }
685
686 pub fn sync_bash_compress_flag(&self) {
690 let value = self.config().experimental_bash_compress;
691 self.bash_compress_flag
692 .store(value, std::sync::atomic::Ordering::Relaxed);
693 }
694
695 pub fn set_bash_compress_enabled(&self, enabled: bool) {
696 self.config_mut().experimental_bash_compress = enabled;
697 self.bash_compress_flag
698 .store(enabled, std::sync::atomic::Ordering::Relaxed);
699 }
700
701 pub fn filter_registry(
705 &self,
706 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
707 self.ensure_filter_registry_loaded();
708 match self.filter_registry.read() {
709 Ok(g) => g,
710 Err(poisoned) => poisoned.into_inner(),
711 }
712 }
713
714 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
718 self.ensure_filter_registry_loaded();
719 Arc::clone(&self.filter_registry)
720 }
721
722 pub fn reset_filter_registry(&self) {
726 let new_registry = crate::compress::build_registry_for_context(self);
727 match self.filter_registry.write() {
728 Ok(mut slot) => *slot = new_registry,
729 Err(poisoned) => *poisoned.into_inner() = new_registry,
730 }
731 self.filter_registry_loaded
732 .store(true, std::sync::atomic::Ordering::Release);
733 }
734
735 fn ensure_filter_registry_loaded(&self) {
736 use std::sync::atomic::Ordering;
737 if self.filter_registry_loaded.load(Ordering::Acquire) {
738 return;
739 }
740 let new_registry = crate::compress::build_registry_for_context(self);
743 if let Ok(mut slot) = self.filter_registry.write() {
744 *slot = new_registry;
745 self.filter_registry_loaded.store(true, Ordering::Release);
746 }
747 }
748
749 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
752 self.lsp_child_registry.clone()
753 }
754
755 pub fn stdout_writer(&self) -> SharedStdoutWriter {
756 Arc::clone(&self.stdout_writer)
757 }
758
759 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
760 if let Ok(mut progress_sender) = self.progress_sender.lock() {
761 *progress_sender = sender;
762 }
763 }
764
765 pub fn emit_progress(&self, frame: ProgressFrame) {
766 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
767 return;
768 };
769 if let Some(sender) = progress_sender.as_ref() {
770 sender(PushFrame::Progress(frame));
771 }
772 }
773
774 pub fn status_emitter(&self) -> &StatusEmitter {
775 &self.status_emitter
776 }
777
778 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
786 self.progress_sender
787 .lock()
788 .ok()
789 .and_then(|sender| sender.clone())
790 }
791
792 pub fn advance_configure_generation(&self) -> u64 {
793 self.configure_generation
794 .fetch_add(1, Ordering::SeqCst)
795 .wrapping_add(1)
796 }
797
798 pub fn configure_generation(&self) -> u64 {
799 self.configure_generation.load(Ordering::SeqCst)
800 }
801
802 pub fn configure_warnings_sender(&self) -> mpsc::Sender<(u64, ConfigureWarningsFrame)> {
803 self.configure_warnings_tx.clone()
804 }
805
806 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
807 let mut warnings = Vec::new();
808 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
809 warnings.push(warning);
810 }
811 warnings
812 }
813
814 pub fn bash_background(&self) -> &BgTaskRegistry {
815 &self.bash_background
816 }
817
818 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
819 self.bash_background.drain_completions()
820 }
821
822 pub fn provider(&self) -> &dyn LanguageProvider {
824 self.provider.as_ref()
825 }
826
827 pub fn backup(&self) -> &RefCell<BackupStore> {
829 &self.backup
830 }
831
832 pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
834 &self.checkpoint
835 }
836
837 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
838 *self.db.borrow_mut() = Some(conn);
839 }
840
841 pub fn clear_db(&self) {
842 *self.db.borrow_mut() = None;
843 }
844
845 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
846 self.db.borrow().clone()
847 }
848
849 pub fn config(&self) -> Ref<'_, Config> {
851 self.config.borrow()
852 }
853
854 pub fn config_mut(&self) -> RefMut<'_, Config> {
856 self.config.borrow_mut()
857 }
858
859 pub fn set_harness(&self, harness: Harness) {
860 *self.harness.borrow_mut() = Some(harness);
861 self.bash_background.set_harness(harness);
862 }
863
864 pub fn harness_opt(&self) -> Option<Harness> {
865 *self.harness.borrow()
866 }
867
868 pub fn harness(&self) -> Harness {
869 self.harness_opt()
870 .expect("harness set by configure before any tool call")
871 }
872
873 pub fn storage_dir(&self) -> PathBuf {
874 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
875 }
876
877 pub fn harness_dir(&self) -> PathBuf {
878 self.storage_dir().join(self.harness().as_str())
879 }
880
881 pub fn inspect_dir(&self) -> PathBuf {
882 self.harness_dir().join("inspect")
883 }
884
885 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
886 self.harness_dir()
887 .join("bash-tasks")
888 .join(hash_session(session_id))
889 }
890
891 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
892 self.harness_dir()
893 .join("backups")
894 .join(hash_session(session_id))
895 .join(path_hash)
896 }
897
898 pub fn filters_dir(&self) -> PathBuf {
899 self.harness_dir().join("filters")
900 }
901
902 pub fn trust_file(&self) -> PathBuf {
904 self.storage_dir().join("trusted-filter-projects.json")
905 }
906
907 pub fn set_canonical_cache_root(&self, root: PathBuf) {
908 debug_assert!(root.is_absolute());
909 *self.canonical_cache_root.borrow_mut() = Some(root);
910 }
911
912 pub fn canonical_cache_root(&self) -> PathBuf {
913 self.canonical_cache_root
914 .borrow()
915 .clone()
916 .expect("canonical_cache_root accessed before handle_configure")
917 }
918
919 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
920 self.canonical_cache_root.borrow().clone()
921 }
922
923 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
924 *self.is_worktree_bridge.borrow_mut() = is_worktree_bridge;
925 *self.git_common_dir.borrow_mut() = git_common_dir;
926 }
927
928 pub fn is_worktree_bridge(&self) -> bool {
929 *self.is_worktree_bridge.borrow()
930 }
931
932 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
936 *self.degraded_reasons.borrow_mut() = reasons;
937 }
938
939 pub fn degraded_reasons(&self) -> Vec<String> {
943 self.degraded_reasons.borrow().clone()
944 }
945
946 pub fn is_degraded(&self) -> bool {
948 !self.degraded_reasons.borrow().is_empty()
949 }
950
951 pub fn cache_role(&self) -> &'static str {
952 if self.canonical_cache_root.borrow().is_none() {
953 "not_initialized"
954 } else if self.is_worktree_bridge() {
955 "worktree"
956 } else {
957 "main"
958 }
959 }
960
961 pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
963 &self.callgraph
964 }
965
966 pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
968 &self.search_index
969 }
970
971 pub fn search_index_rx(&self) -> &RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>> {
973 &self.search_index_rx
974 }
975
976 pub fn inspect_manager(&self) -> Arc<InspectManager> {
977 Arc::clone(&self.inspect_manager)
978 }
979
980 pub fn symbol_cache(&self) -> SharedSymbolCache {
982 Arc::clone(&self.symbol_cache)
983 }
984
985 pub fn reset_symbol_cache(&self) -> u64 {
987 self.symbol_cache
988 .write()
989 .map(|mut cache| cache.reset())
990 .unwrap_or(0)
991 }
992
993 pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
995 &self.semantic_index
996 }
997
998 pub fn semantic_index_rx(
1000 &self,
1001 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
1002 &self.semantic_index_rx
1003 }
1004
1005 pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
1006 &self.semantic_index_status
1007 }
1008
1009 pub fn install_semantic_refresh_worker(
1010 &self,
1011 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
1012 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
1013 worker_slot: SemanticRefreshWorkerSlot,
1014 ) {
1015 self.clear_semantic_refresh_worker();
1016 *self.semantic_refresh_tx.borrow_mut() = Some(sender);
1017 *self.semantic_refresh_event_rx.borrow_mut() = Some(event_rx);
1018 *self.semantic_refresh_worker.borrow_mut() = Some(worker_slot);
1019 }
1020
1021 pub fn clear_semantic_refresh_worker(&self) {
1022 *self.semantic_refresh_tx.borrow_mut() = None;
1023 *self.semantic_refresh_event_rx.borrow_mut() = None;
1024 if let Some(worker_slot) = self.semantic_refresh_worker.borrow_mut().take() {
1025 if let Ok(mut handle) = worker_slot.lock() {
1026 drop(handle.take());
1027 }
1028 }
1029 }
1030
1031 pub fn semantic_refresh_sender(
1032 &self,
1033 ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
1034 self.semantic_refresh_tx.borrow().clone()
1035 }
1036
1037 pub fn semantic_refresh_event_rx(
1038 &self,
1039 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
1040 &self.semantic_refresh_event_rx
1041 }
1042
1043 pub fn semantic_embedding_model(
1045 &self,
1046 ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
1047 &self.semantic_embedding_model
1048 }
1049
1050 pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
1052 &self.watcher
1053 }
1054
1055 pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
1057 &self.watcher_rx
1058 }
1059
1060 pub fn lsp(&self) -> RefMut<'_, LspManager> {
1062 self.lsp_manager.borrow_mut()
1063 }
1064
1065 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
1068 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1069 let config = self.config();
1070 if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
1071 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1072 }
1073 }
1074 }
1075
1076 pub fn lsp_notify_and_collect_diagnostics(
1087 &self,
1088 file_path: &Path,
1089 content: &str,
1090 timeout: std::time::Duration,
1091 ) -> crate::lsp::manager::PostEditWaitOutcome {
1092 let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
1093 return crate::lsp::manager::PostEditWaitOutcome::default();
1094 };
1095
1096 lsp.drain_events();
1099
1100 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
1104
1105 let config = self.config();
1107 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
1108 {
1109 Ok(v) => v,
1110 Err(e) => {
1111 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
1112 return crate::lsp::manager::PostEditWaitOutcome::default();
1113 }
1114 };
1115
1116 if expected_versions.is_empty() {
1119 return crate::lsp::manager::PostEditWaitOutcome::default();
1120 }
1121
1122 lsp.wait_for_post_edit_diagnostics(
1123 file_path,
1124 &config,
1125 &expected_versions,
1126 &pre_snapshot,
1127 timeout,
1128 )
1129 }
1130
1131 fn custom_lsp_root_markers(&self) -> Vec<String> {
1134 self.config()
1135 .lsp_servers
1136 .iter()
1137 .flat_map(|s| s.root_markers.iter().cloned())
1138 .collect()
1139 }
1140
1141 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
1142 let custom_markers = self.custom_lsp_root_markers();
1143 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
1144 .iter()
1145 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
1146 .cloned()
1147 .map(|path| {
1148 let change_type = if path.exists() {
1149 FileChangeType::CHANGED
1150 } else {
1151 FileChangeType::DELETED
1152 };
1153 (path, change_type)
1154 })
1155 .collect();
1156
1157 self.notify_watched_config_events(&config_paths);
1158 }
1159
1160 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
1161 let paths = params
1162 .get("multi_file_write_paths")
1163 .and_then(|value| value.as_array())?
1164 .iter()
1165 .filter_map(|value| value.as_str())
1166 .map(PathBuf::from)
1167 .collect::<Vec<_>>();
1168
1169 (!paths.is_empty()).then_some(paths)
1170 }
1171
1172 fn watched_file_events_from_params(
1184 params: &serde_json::Value,
1185 extra_markers: &[String],
1186 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
1187 let events = params
1188 .get("multi_file_write_paths")
1189 .and_then(|value| value.as_array())?
1190 .iter()
1191 .filter_map(|entry| {
1192 let path = entry
1194 .get("path")
1195 .and_then(|value| value.as_str())
1196 .map(PathBuf::from)?;
1197
1198 if !is_config_file_path_with_custom(&path, extra_markers) {
1199 return None;
1200 }
1201
1202 let change_type = entry
1203 .get("type")
1204 .and_then(|value| value.as_str())
1205 .and_then(Self::parse_file_change_type)
1206 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
1207
1208 Some((path, change_type))
1209 })
1210 .collect::<Vec<_>>();
1211
1212 (!events.is_empty()).then_some(events)
1213 }
1214
1215 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
1216 match value {
1217 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
1218 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
1219 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
1220 _ => None,
1221 }
1222 }
1223
1224 fn change_type_from_current_state(path: &Path) -> FileChangeType {
1225 if path.exists() {
1226 FileChangeType::CHANGED
1227 } else {
1228 FileChangeType::DELETED
1229 }
1230 }
1231
1232 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
1233 if config_paths.is_empty() {
1234 return;
1235 }
1236
1237 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
1238 let config = self.config();
1239 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
1240 crate::slog_warn!("watched-file sync error: {}", e);
1241 }
1242 }
1243 }
1244
1245 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
1246 let custom_markers = self.custom_lsp_root_markers();
1247 if !is_config_file_path_with_custom(file_path, &custom_markers) {
1248 return;
1249 }
1250
1251 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
1252 }
1253
1254 pub fn lsp_post_multi_file_write(
1259 &self,
1260 file_path: &Path,
1261 content: &str,
1262 file_paths: &[PathBuf],
1263 params: &serde_json::Value,
1264 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
1265 self.notify_watched_config_files(file_paths);
1266
1267 let wants_diagnostics = params
1268 .get("diagnostics")
1269 .and_then(|v| v.as_bool())
1270 .unwrap_or(false);
1271
1272 if !wants_diagnostics {
1273 self.lsp_notify_file_changed(file_path, content);
1274 return None;
1275 }
1276
1277 let wait_ms = params
1278 .get("wait_ms")
1279 .and_then(|v| v.as_u64())
1280 .unwrap_or(3000)
1281 .min(10_000);
1282
1283 Some(self.lsp_notify_and_collect_diagnostics(
1284 file_path,
1285 content,
1286 std::time::Duration::from_millis(wait_ms),
1287 ))
1288 }
1289
1290 pub fn lsp_post_write(
1307 &self,
1308 file_path: &Path,
1309 content: &str,
1310 params: &serde_json::Value,
1311 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
1312 let wants_diagnostics = params
1313 .get("diagnostics")
1314 .and_then(|v| v.as_bool())
1315 .unwrap_or(false);
1316
1317 let custom_markers = self.custom_lsp_root_markers();
1318
1319 if !wants_diagnostics {
1320 if let Some(file_paths) = Self::multi_file_write_paths(params) {
1321 self.notify_watched_config_files(&file_paths);
1322 } else if let Some(config_events) =
1323 Self::watched_file_events_from_params(params, &custom_markers)
1324 {
1325 self.notify_watched_config_events(&config_events);
1326 }
1327 self.lsp_notify_file_changed(file_path, content);
1328 return None;
1329 }
1330
1331 let wait_ms = params
1332 .get("wait_ms")
1333 .and_then(|v| v.as_u64())
1334 .unwrap_or(3000)
1335 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
1338 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
1339 }
1340
1341 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
1342 {
1343 self.notify_watched_config_events(&config_events);
1344 }
1345
1346 Some(self.lsp_notify_and_collect_diagnostics(
1347 file_path,
1348 content,
1349 std::time::Duration::from_millis(wait_ms),
1350 ))
1351 }
1352
1353 pub fn validate_path(
1362 &self,
1363 req_id: &str,
1364 path: &Path,
1365 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
1366 let config = self.config();
1367 if !config.restrict_to_project_root {
1369 return Ok(path.to_path_buf());
1370 }
1371 let root = match &config.project_root {
1372 Some(r) => r.clone(),
1373 None => return Ok(path.to_path_buf()), };
1375 drop(config);
1376
1377 let raw_root = root.clone();
1382 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
1383
1384 let path_for_resolution = if path.is_relative() {
1389 raw_root.join(path)
1390 } else {
1391 path.to_path_buf()
1392 };
1393 let resolved = match std::fs::canonicalize(&path_for_resolution) {
1394 Ok(resolved) => resolved,
1395 Err(_) => {
1396 let normalized = normalize_path(&path_for_resolution);
1397 reject_escaping_symlink(
1398 req_id,
1399 &path_for_resolution,
1400 &normalized,
1401 &resolved_root,
1402 &raw_root,
1403 )?;
1404 resolve_with_existing_ancestors(&normalized)
1405 }
1406 };
1407
1408 if !resolved.starts_with(&resolved_root) {
1409 return Err(path_error_response(req_id, path, &resolved_root));
1410 }
1411
1412 Ok(resolved)
1413 }
1414
1415 pub fn lsp_server_count(&self) -> usize {
1417 self.lsp_manager
1418 .try_borrow()
1419 .map(|lsp| lsp.server_count())
1420 .unwrap_or(0)
1421 }
1422
1423 pub fn symbol_cache_stats(&self) -> serde_json::Value {
1425 let entries = self
1426 .symbol_cache
1427 .read()
1428 .map(|cache| cache.len())
1429 .unwrap_or(0);
1430 serde_json::json!({
1431 "local_entries": entries,
1432 "warm_entries": 0,
1433 })
1434 }
1435}
1436
1437#[cfg(test)]
1438mod status_emitter_tests {
1439 use super::*;
1440 use crate::parser::TreeSitterProvider;
1441
1442 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
1443 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
1444 let (tx, rx) = mpsc::channel();
1445 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
1446 let _ = tx.send(frame);
1447 }))));
1448 (ctx, rx)
1449 }
1450
1451 #[test]
1452 fn status_emitter_signal_triggers_push() {
1453 let (ctx, rx) = ctx_with_frame_rx();
1454 ctx.status_emitter().signal(ctx.build_status_snapshot());
1455 let frame = rx
1456 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1457 .expect("status_changed push");
1458 assert!(matches!(frame, PushFrame::StatusChanged(_)));
1459 }
1460
1461 #[test]
1462 fn status_emitter_debounces_burst() {
1463 let (ctx, rx) = ctx_with_frame_rx();
1464 for _ in 0..10 {
1465 ctx.status_emitter().signal(ctx.build_status_snapshot());
1466 }
1467 let frame = rx
1468 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1469 .expect("status_changed push");
1470 assert!(matches!(frame, PushFrame::StatusChanged(_)));
1471 assert!(rx.try_recv().is_err());
1472 }
1473
1474 #[test]
1475 fn status_emitter_separate_windows_separate_pushes() {
1476 let (ctx, rx) = ctx_with_frame_rx();
1477 ctx.status_emitter().signal(ctx.build_status_snapshot());
1478 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1479 .expect("first push");
1480 ctx.status_emitter().signal(ctx.build_status_snapshot());
1481 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1482 .expect("second push");
1483 }
1484
1485 #[test]
1486 fn status_emitter_no_signal_no_push() {
1487 let (_ctx, rx) = ctx_with_frame_rx();
1488 assert!(rx
1489 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
1490 .is_err());
1491 }
1492
1493 #[test]
1494 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
1495 let (ctx, rx) = ctx_with_frame_rx();
1496 drop(ctx);
1497 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
1498 }
1499}
1500
1501#[cfg(test)]
1502mod harness_path_tests {
1503 use super::*;
1504 use crate::harness::Harness;
1505 use crate::parser::TreeSitterProvider;
1506
1507 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
1508 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
1509 ctx.config_mut().storage_dir = Some(storage_dir);
1510 ctx.set_harness(harness);
1511 ctx
1512 }
1513
1514 #[test]
1515 fn harness_dir_resolves_correctly() {
1516 let storage = PathBuf::from("/tmp/cortexkit/aft");
1517 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
1518
1519 assert_eq!(ctx.harness_dir(), storage.join("pi"));
1520 }
1521
1522 #[test]
1523 fn bash_tasks_dir_uses_hash_session() {
1524 let storage = PathBuf::from("/tmp/cortexkit/aft");
1525 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
1526
1527 assert_eq!(
1528 ctx.bash_tasks_dir("ses_abc"),
1529 storage
1530 .join("opencode")
1531 .join("bash-tasks")
1532 .join(hash_session("ses_abc"))
1533 );
1534 }
1535
1536 #[test]
1537 fn backups_dir_includes_path_hash() {
1538 let storage = PathBuf::from("/tmp/cortexkit/aft");
1539 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
1540
1541 assert_eq!(
1542 ctx.backups_dir("ses_abc", "pathhash"),
1543 storage
1544 .join("pi")
1545 .join("backups")
1546 .join(hash_session("ses_abc"))
1547 .join("pathhash")
1548 );
1549 }
1550
1551 #[test]
1552 fn filters_dir_under_harness() {
1553 let storage = PathBuf::from("/tmp/cortexkit/aft");
1554 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
1555
1556 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
1557 }
1558
1559 #[test]
1560 fn trust_file_is_host_global() {
1561 let storage = PathBuf::from("/tmp/cortexkit/aft");
1562 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
1563
1564 assert_eq!(
1565 ctx.trust_file(),
1566 storage.join("trusted-filter-projects.json")
1567 );
1568 }
1569
1570 #[test]
1571 fn same_session_different_harness_resolve_different_paths() {
1572 let storage = PathBuf::from("/tmp/cortexkit/aft");
1573 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
1574 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
1575
1576 assert_ne!(
1577 opencode.bash_tasks_dir("ses_same"),
1578 pi.bash_tasks_dir("ses_same")
1579 );
1580 }
1581}
1582
1583#[cfg(test)]
1584mod gitignore_tests {
1585 use super::*;
1586 use std::fs;
1587 use std::path::Path;
1588 use tempfile::TempDir;
1589
1590 fn make_ctx_with_root(root: &Path) -> AppContext {
1591 let provider = Box::new(crate::parser::TreeSitterProvider::new());
1592 let config = Config {
1593 project_root: Some(root.to_path_buf()),
1594 ..Config::default()
1595 };
1596 AppContext::new(provider, config)
1597 }
1598
1599 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
1606 let Some(matcher) = ctx.gitignore() else {
1607 return false;
1608 };
1609 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1610 if !canonical.starts_with(matcher.path()) {
1611 return false;
1612 }
1613 let is_dir = canonical.is_dir();
1614 matcher
1615 .matched_path_or_any_parents(&canonical, is_dir)
1616 .is_ignore()
1617 }
1618
1619 #[test]
1620 fn rebuild_gitignore_returns_none_without_project_root() {
1621 let provider = Box::new(crate::parser::TreeSitterProvider::new());
1622 let ctx = AppContext::new(provider, Config::default());
1623 ctx.rebuild_gitignore();
1624 assert!(ctx.gitignore().is_none());
1625 }
1626
1627 #[test]
1628 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
1629 let tmp = TempDir::new().unwrap();
1630 let ctx = make_ctx_with_root(tmp.path());
1631 ctx.rebuild_gitignore();
1632 assert!(ctx.gitignore().is_none());
1633 }
1634
1635 #[test]
1636 fn matcher_filters_files_in_ignored_dist_dir() {
1637 let tmp = TempDir::new().unwrap();
1638 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
1639 fs::create_dir_all(tmp.path().join("dist")).unwrap();
1640 fs::create_dir_all(tmp.path().join("src")).unwrap();
1641 let dist_file = tmp.path().join("dist").join("bundle.js");
1642 let src_file = tmp.path().join("src").join("app.ts");
1643 fs::write(&dist_file, "x").unwrap();
1644 fs::write(&src_file, "y").unwrap();
1645
1646 let ctx = make_ctx_with_root(tmp.path());
1647 ctx.rebuild_gitignore();
1648
1649 assert!(ctx.gitignore().is_some());
1650 assert!(
1651 is_ignored(&ctx, &dist_file),
1652 "dist/bundle.js should be ignored"
1653 );
1654 assert!(
1655 !is_ignored(&ctx, &src_file),
1656 "src/app.ts should NOT be ignored"
1657 );
1658 }
1659
1660 #[test]
1661 fn matcher_handles_node_modules_and_target() {
1662 let tmp = TempDir::new().unwrap();
1663 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
1664 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
1665 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
1666 let nm_file = tmp.path().join("node_modules/foo/index.js");
1667 let target_file = tmp.path().join("target/debug/aft");
1668 fs::write(&nm_file, "x").unwrap();
1669 fs::write(&target_file, "x").unwrap();
1670
1671 let ctx = make_ctx_with_root(tmp.path());
1672 ctx.rebuild_gitignore();
1673
1674 assert!(is_ignored(&ctx, &nm_file));
1675 assert!(is_ignored(&ctx, &target_file));
1676 }
1677
1678 #[test]
1679 fn matcher_honors_negation_pattern() {
1680 let tmp = TempDir::new().unwrap();
1682 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
1683 let random_log = tmp.path().join("random.log");
1684 let important_log = tmp.path().join("important.log");
1685 fs::write(&random_log, "x").unwrap();
1686 fs::write(&important_log, "y").unwrap();
1687
1688 let ctx = make_ctx_with_root(tmp.path());
1689 ctx.rebuild_gitignore();
1690
1691 assert!(is_ignored(&ctx, &random_log));
1692 assert!(
1693 !is_ignored(&ctx, &important_log),
1694 "negation pattern should un-ignore important.log"
1695 );
1696 }
1697
1698 #[test]
1699 fn rebuild_picks_up_gitignore_changes() {
1700 let tmp = TempDir::new().unwrap();
1701 let ignore_path = tmp.path().join(".gitignore");
1702 fs::write(&ignore_path, "foo.txt\n").unwrap();
1703 let foo = tmp.path().join("foo.txt");
1704 let bar = tmp.path().join("bar.txt");
1705 fs::write(&foo, "").unwrap();
1706 fs::write(&bar, "").unwrap();
1707
1708 let ctx = make_ctx_with_root(tmp.path());
1709 ctx.rebuild_gitignore();
1710 assert!(is_ignored(&ctx, &foo));
1711 assert!(!is_ignored(&ctx, &bar));
1712
1713 fs::write(&ignore_path, "bar.txt\n").unwrap();
1715 ctx.rebuild_gitignore();
1716 assert!(!is_ignored(&ctx, &foo));
1717 assert!(is_ignored(&ctx, &bar));
1718 }
1719
1720 #[test]
1721 fn gitignore_loads_info_exclude_when_present() {
1722 let tmp = TempDir::new().unwrap();
1723 let info_dir = tmp.path().join(".git/info");
1724 fs::create_dir_all(&info_dir).unwrap();
1725 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
1726 let secrets = tmp.path().join("secrets.txt");
1727 let public = tmp.path().join("public.txt");
1728 fs::write(&secrets, "token").unwrap();
1729 fs::write(&public, "ok").unwrap();
1730
1731 let ctx = make_ctx_with_root(tmp.path());
1732 ctx.rebuild_gitignore();
1733
1734 assert!(is_ignored(&ctx, &secrets));
1735 assert!(!is_ignored(&ctx, &public));
1736 }
1737
1738 #[test]
1739 fn matcher_picks_up_nested_gitignore() {
1740 let tmp = TempDir::new().unwrap();
1741 fs::write(tmp.path().join(".gitignore"), "").unwrap();
1743 let sub = tmp.path().join("packages/foo");
1744 fs::create_dir_all(&sub).unwrap();
1745 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
1746 let generated_file = sub.join("generated").join("out.js");
1747 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
1748 fs::write(&generated_file, "x").unwrap();
1749
1750 let ctx = make_ctx_with_root(tmp.path());
1751 ctx.rebuild_gitignore();
1752
1753 assert!(
1754 is_ignored(&ctx, &generated_file),
1755 "nested gitignore in packages/foo/.gitignore should ignore generated/"
1756 );
1757 }
1758}