1use std::collections::{BTreeMap, BTreeSet, VecDeque};
2use std::io::{self, BufWriter};
3use std::path::{Component, Path, PathBuf};
4use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
5use std::sync::{mpsc, Arc, Mutex, RwLock};
6use std::time::{Duration, Instant};
7
8use lsp_types::FileChangeType;
9use notify::RecommendedWatcher;
10use rusqlite::Connection;
11use serde::Serialize;
12
13use crate::artifact_owner::{
14 ArtifactOwnerLease, ArtifactOwnerLeaseRegistration, ArtifactOwnerMode, ArtifactOwnerStatus,
15};
16use crate::backup::hash_session;
17use crate::backup::BackupStore;
18use crate::bash_background::{BgCompletion, BgTaskHealthCounts, BgTaskRegistry};
19use crate::callgraph_store::{CallGraphStore, CallGraphStoreError};
20use crate::checkpoint::CheckpointStore;
21use crate::config::Config;
22use crate::harness::Harness;
23use crate::inspect::{
24 InspectCategory, InspectManager, InspectSnapshot, Tier2RefreshScheduler, Tier2TriggerReason,
25};
26use crate::language::LanguageProvider;
27use crate::lsp::manager::{LspManager, StaleDiagnosticsMark};
28use crate::lsp::registry::is_config_file_path_with_custom;
29use crate::parser::{SharedSymbolCache, SymbolCache, TreeSitterProvider};
30use crate::protocol::{
31 ConfigureWarningsFrame, ProgressFrame, PushFrame, StatusChangedFrame, StatusPayload,
32};
33use crate::watcher_filter::{SharedGitignore, WatcherDispatchEvent, WatcherThreadHandle};
34
35pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
36pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
37pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
38const STATUS_DEBOUNCE_MS: u64 = 1_000;
39
40#[derive(Debug, Clone, Default, PartialEq, Eq)]
48pub struct StatusBarCounts {
49 pub errors: usize,
50 pub warnings: usize,
51 pub dead_code: usize,
52 pub unused_exports: usize,
53 pub duplicates: usize,
54 pub todos: usize,
55 pub tier2_stale: bool,
56}
57
58#[derive(Debug, Clone, Default)]
67struct StatusBarTier2 {
68 dead_code: Option<usize>,
69 unused_exports: Option<usize>,
70 duplicates: Option<usize>,
71 todos: Option<usize>,
72 stale: bool,
73}
74
75#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
76#[serde(rename_all = "snake_case")]
77pub enum RootHealthState {
78 Ready,
79 Busy,
80}
81
82#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
83pub struct HealthComponentSnapshot {
84 pub status: &'static str,
85}
86
87#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
88pub struct Tier2HealthSnapshot {
89 pub status: &'static str,
90}
91
92#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
93pub struct RootHealthSnapshot {
94 pub project_root: String,
95 pub actor_count: usize,
96 pub state: RootHealthState,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub search_index: Option<HealthComponentSnapshot>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub semantic_index: Option<HealthComponentSnapshot>,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub callgraph_store: Option<HealthComponentSnapshot>,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub tier2: Option<Tier2HealthSnapshot>,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub bash: Option<BgTaskHealthCounts>,
107}
108
109impl RootHealthSnapshot {
110 fn busy(project_root: &Path) -> Self {
111 Self {
112 project_root: project_root.display().to_string(),
113 actor_count: 1,
114 state: RootHealthState::Busy,
115 search_index: None,
116 semantic_index: None,
117 callgraph_store: None,
118 tier2: None,
119 bash: None,
120 }
121 }
122
123 pub fn is_fully_ready(&self) -> bool {
124 let component_is_satisfied =
125 |status: &HealthComponentSnapshot| matches!(status.status, "ready" | "disabled");
126 let tier2_is_satisfied =
127 |tier2: &Tier2HealthSnapshot| matches!(tier2.status, "ready" | "disabled");
128
129 matches!(self.state, RootHealthState::Ready)
130 && self
131 .search_index
132 .as_ref()
133 .is_some_and(component_is_satisfied)
134 && self
135 .semantic_index
136 .as_ref()
137 .is_some_and(component_is_satisfied)
138 && self
139 .callgraph_store
140 .as_ref()
141 .is_some_and(component_is_satisfied)
142 && self.tier2.as_ref().is_some_and(tier2_is_satisfied)
143 }
144}
145
146pub struct StatusEmitter {
147 latest: Arc<Mutex<Option<StatusPayload>>>,
148 notify: mpsc::Sender<()>,
149}
150
151#[derive(Clone, Debug, Default)]
152struct ConfigureWarmState {
153 generation: u64,
154 key: Option<String>,
155}
156
157#[derive(Clone, Debug)]
158pub(crate) struct ConfigureMaintenanceJob {
159 pub(crate) generation: u64,
160 pub(crate) root_path: PathBuf,
161 pub(crate) canonical_cache_root: PathBuf,
162 pub(crate) harness: Harness,
163 pub(crate) storage_root: PathBuf,
164 pub(crate) harness_dir: PathBuf,
165 pub(crate) session_id: String,
166 pub(crate) home_match: bool,
167 pub(crate) format_tool_cache_clear_needed: bool,
168 pub(crate) run_bash_replay: bool,
169 pub(crate) refresh_project_runtime: bool,
170 pub(crate) sync_bash_compress_flag: bool,
171 pub(crate) reset_filter_registry: bool,
172 pub(crate) clear_failed_spawns: bool,
173 pub(crate) warm_callgraph_store: bool,
174}
175
176impl StatusEmitter {
177 fn new(progress_sender: SharedProgressSender) -> Self {
178 let (notify, rx) = mpsc::channel();
179 let latest = Arc::new(Mutex::new(None));
180 let latest_for_thread = Arc::clone(&latest);
181 std::thread::spawn(move || {
182 status_debounce_loop(rx, latest_for_thread, progress_sender);
183 });
184 Self { latest, notify }
185 }
186
187 pub fn signal(&self, snapshot: StatusPayload) {
188 if let Ok(mut latest) = self.latest.lock() {
189 *latest = Some(snapshot);
190 }
191 let _ = self.notify.send(());
192 }
193}
194
195fn status_debounce_loop(
196 rx: mpsc::Receiver<()>,
197 latest: Arc<Mutex<Option<StatusPayload>>>,
198 progress_sender: SharedProgressSender,
199) {
200 while rx.recv().is_ok() {
201 let deadline = Instant::now() + Duration::from_millis(STATUS_DEBOUNCE_MS);
202 while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
203 match rx.recv_timeout(remaining) {
204 Ok(()) => continue,
205 Err(mpsc::RecvTimeoutError::Timeout) => break,
206 Err(mpsc::RecvTimeoutError::Disconnected) => return,
207 }
208 }
209
210 let snapshot = latest.lock().ok().and_then(|mut latest| latest.take());
211 let Some(snapshot) = snapshot else { continue };
212 let sender = progress_sender
213 .lock()
214 .ok()
215 .and_then(|sender| sender.clone());
216 if let Some(sender) = sender {
217 sender(PushFrame::StatusChanged(StatusChangedFrame::new(
218 None, snapshot,
219 )));
220 }
221 }
222}
223use crate::cache_freshness::FileFreshness;
224use crate::search_index::SearchIndex;
225use crate::semantic_index::{EmbeddingEntry, SemanticIndex};
226
227#[derive(Debug, Default, Clone)]
231#[doc(hidden)]
232pub struct SemanticRefreshAccounting {
233 #[doc(hidden)]
234 pub pending: usize,
235 #[doc(hidden)]
236 pub in_flight: usize,
237}
238
239#[derive(Debug, Default)]
240struct SemanticRefreshCircuit {
241 consecutive_transient_failures: AtomicUsize,
242 open: AtomicBool,
243 probe_in_flight: AtomicBool,
244 probe_ready: AtomicBool,
245}
246
247fn ensure_refreshing_path(refreshing: &mut Vec<PathBuf>, path: PathBuf) {
248 if !refreshing.iter().any(|existing| existing == &path) {
249 refreshing.push(path);
250 refreshing.sort();
251 }
252}
253
254fn remove_refreshing_path(refreshing: &mut Vec<PathBuf>, path: &Path) {
255 refreshing.retain(|existing| existing != path);
256}
257
258#[derive(Debug, Clone)]
259pub enum SemanticIndexStatus {
260 Disabled,
261 Building {
262 stage: String,
264 files: Option<usize>,
265 entries_done: Option<usize>,
266 entries_total: Option<usize>,
267 },
268 Ready {
269 refreshing: Vec<PathBuf>,
272 #[doc(hidden)]
276 accounting: BTreeMap<PathBuf, SemanticRefreshAccounting>,
277 },
278 Failed(String),
279}
280
281impl SemanticIndexStatus {
282 pub fn ready() -> Self {
283 Self::Ready {
284 refreshing: Vec::new(),
285 accounting: BTreeMap::new(),
286 }
287 }
288
289 pub fn add_refreshing_file(&mut self, path: PathBuf) {
290 if let Self::Ready {
291 refreshing,
292 accounting,
293 } = self
294 {
295 let state = accounting.entry(path.clone()).or_default();
296 state.pending = state.pending.saturating_add(1);
297 ensure_refreshing_path(refreshing, path);
298 }
299 }
300
301 pub fn start_refreshing_file(&mut self, path: PathBuf) {
302 if let Self::Ready {
303 refreshing,
304 accounting,
305 } = self
306 {
307 let state = accounting.entry(path.clone()).or_default();
308 if state.pending == 0 {
309 state.pending = 1;
310 }
311 if state.in_flight == 0 {
312 state.in_flight = state.pending;
313 }
314 ensure_refreshing_path(refreshing, path);
315 }
316 }
317
318 pub fn cancel_refreshing_file(&mut self, path: &Path) {
319 self.finish_refreshing_file(path, false);
320 }
321
322 pub fn complete_refreshing_file(&mut self, path: &Path) {
323 self.finish_refreshing_file(path, true);
324 }
325
326 pub fn remove_refreshing_file(&mut self, path: &Path) {
327 self.complete_refreshing_file(path);
328 }
329
330 fn finish_refreshing_file(&mut self, path: &Path, complete_in_flight: bool) {
331 if let Self::Ready {
332 refreshing,
333 accounting,
334 } = self
335 {
336 let mut keep_refreshing = false;
337 if let Some(state) = accounting.get_mut(path) {
338 let finished = if complete_in_flight {
339 state.in_flight.max(1)
340 } else {
341 1
342 };
343 state.pending = state.pending.saturating_sub(finished);
344 if complete_in_flight {
345 state.in_flight = 0;
346 } else {
347 state.in_flight = state.in_flight.min(state.pending);
348 }
349 keep_refreshing = state.pending > 0;
350 if !keep_refreshing {
351 accounting.remove(path);
352 }
353 }
354
355 if !keep_refreshing {
356 remove_refreshing_path(refreshing, path);
357 }
358 }
359 }
360
361 pub fn refreshing_count(&self) -> usize {
362 match self {
363 Self::Ready { refreshing, .. } => refreshing.len(),
364 _ => 0,
365 }
366 }
367}
368
369pub enum SemanticIndexEvent {
370 Progress {
371 stage: String,
372 files: Option<usize>,
373 entries_done: Option<usize>,
374 entries_total: Option<usize>,
375 },
376 ColdSeedGateCleared,
381 Ready(SemanticIndex),
382 Failed(String),
383}
384
385#[derive(Debug, Clone)]
386pub enum SemanticRefreshRequest {
387 Files {
388 paths: Vec<PathBuf>,
389 },
390 Corpus,
394}
395
396#[derive(Debug)]
397pub enum SemanticRefreshEvent {
398 Started {
399 paths: Vec<PathBuf>,
400 },
401 CorpusStarted {
402 files: usize,
403 },
404 Completed {
405 added_entries: Vec<EmbeddingEntry>,
406 updated_metadata: Vec<(PathBuf, FileFreshness)>,
407 completed_paths: Vec<PathBuf>,
408 },
409 CorpusCompleted {
410 index: SemanticIndex,
411 changed: usize,
412 added: usize,
413 deleted: usize,
414 total_processed: usize,
415 },
416 Failed {
417 paths: Vec<PathBuf>,
418 error: String,
419 },
420 CorpusFailed {
421 error: String,
422 },
423}
424
425pub type SemanticRefreshWorkerSlot = Arc<Mutex<Option<std::thread::JoinHandle<()>>>>;
426
427fn normalize_path(path: &Path) -> PathBuf {
431 let mut result = PathBuf::new();
432 for component in path.components() {
433 match component {
434 Component::ParentDir => {
435 if !result.pop() {
437 result.push(component);
438 }
439 }
440 Component::CurDir => {} _ => result.push(component),
442 }
443 }
444 result
445}
446
447fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
448 let mut existing = path.to_path_buf();
449 let mut tail_segments = Vec::new();
450
451 while !existing.exists() {
452 if let Some(name) = existing.file_name() {
453 tail_segments.push(name.to_owned());
454 } else {
455 break;
456 }
457
458 existing = match existing.parent() {
459 Some(parent) => parent.to_path_buf(),
460 None => break,
461 };
462 }
463
464 let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
465 for segment in tail_segments.into_iter().rev() {
466 resolved.push(segment);
467 }
468
469 resolved
470}
471
472fn path_error_response(
473 req_id: &str,
474 path: &Path,
475 resolved_root: &Path,
476) -> crate::protocol::Response {
477 crate::protocol::Response::error(
478 req_id,
479 "path_outside_root",
480 format!(
481 "path '{}' is outside the project root '{}'",
482 path.display(),
483 resolved_root.display()
484 ),
485 )
486}
487
488fn reject_escaping_symlink(
498 req_id: &str,
499 original_path: &Path,
500 candidate: &Path,
501 resolved_root: &Path,
502 raw_root: &Path,
503) -> Result<(), crate::protocol::Response> {
504 let mut current = PathBuf::new();
505
506 for component in candidate.components() {
507 current.push(component);
508
509 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
510 continue;
511 };
512
513 if !metadata.file_type().is_symlink() {
514 continue;
515 }
516
517 let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
526 if !inside_root {
527 continue;
528 }
529
530 iterative_follow_chain(req_id, original_path, ¤t, resolved_root)?;
531 }
532
533 Ok(())
534}
535
536fn iterative_follow_chain(
539 req_id: &str,
540 original_path: &Path,
541 start: &Path,
542 resolved_root: &Path,
543) -> Result<(), crate::protocol::Response> {
544 let mut link = start.to_path_buf();
545 let mut depth = 0usize;
546
547 loop {
548 if depth > 40 {
549 return Err(path_error_response(req_id, original_path, resolved_root));
550 }
551
552 let target = match std::fs::read_link(&link) {
553 Ok(t) => t,
554 Err(_) => {
555 return Err(path_error_response(req_id, original_path, resolved_root));
557 }
558 };
559
560 let resolved_target = if target.is_absolute() {
561 normalize_path(&target)
562 } else {
563 let parent = link.parent().unwrap_or_else(|| Path::new(""));
564 normalize_path(&parent.join(&target))
565 };
566
567 let canonical_target =
571 std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
572
573 if !canonical_target.starts_with(resolved_root)
574 && !resolved_target.starts_with(resolved_root)
575 {
576 return Err(path_error_response(req_id, original_path, resolved_root));
577 }
578
579 match std::fs::symlink_metadata(&resolved_target) {
581 Ok(meta) if meta.file_type().is_symlink() => {
582 link = resolved_target;
583 depth += 1;
584 }
585 _ => break, }
587 }
588
589 Ok(())
590}
591
592pub type LanguageProviderFactory = fn() -> Box<dyn LanguageProvider>;
593
594pub fn default_language_provider_factory() -> Box<dyn LanguageProvider> {
595 Box::new(TreeSitterProvider::new())
596}
597
598pub struct App {
603 db: parking_lot::Mutex<Option<Arc<Mutex<Connection>>>>,
604 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
605 stdout_writer: SharedStdoutWriter,
606 provider_factory: LanguageProviderFactory,
607}
608
609impl App {
610 pub fn new(provider_factory: LanguageProviderFactory) -> Self {
611 Self {
612 db: parking_lot::Mutex::new(None),
613 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry::new(),
614 stdout_writer: Arc::new(Mutex::new(BufWriter::new(io::stdout()))),
615 provider_factory,
616 }
617 }
618
619 pub fn shared(provider_factory: LanguageProviderFactory) -> Arc<Self> {
621 Arc::new(Self::new(provider_factory))
622 }
623
624 pub fn default_shared() -> Arc<Self> {
625 Self::shared(default_language_provider_factory)
626 }
627
628 pub fn create_provider(&self) -> Box<dyn LanguageProvider> {
629 (self.provider_factory)()
630 }
631
632 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
633 self.lsp_child_registry.clone()
634 }
635
636 pub fn stdout_writer(&self) -> SharedStdoutWriter {
637 Arc::clone(&self.stdout_writer)
638 }
639
640 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
641 *self.db.lock() = Some(conn);
642 }
643
644 pub fn clear_db(&self) {
645 *self.db.lock() = None;
646 }
647
648 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
649 self.db.lock().clone()
650 }
651}
652
653impl Default for App {
654 fn default() -> Self {
655 Self::new(default_language_provider_factory)
656 }
657}
658
659const _: fn() = || {
660 fn assert_send_sync<T: Send + Sync>() {}
661 fn assert_send<T: Send>() {}
662
663 assert_send_sync::<App>();
664 assert_send_sync::<AppContext>();
665 assert_send::<crate::lsp::manager::LspManager>();
666 assert_send::<crate::semantic_index::EmbeddingModel>();
667};
668
669pub struct AppContext {
681 app: Arc<App>,
682 provider: Box<dyn LanguageProvider>,
683 backup: parking_lot::Mutex<BackupStore>,
684 checkpoint: parking_lot::Mutex<CheckpointStore>,
685 config: RwLock<Arc<Config>>,
686 force_restrict_requests: parking_lot::Mutex<BTreeMap<String, usize>>,
687 pub harness: parking_lot::Mutex<Option<Harness>>,
688 canonical_cache_root: parking_lot::Mutex<Option<PathBuf>>,
689 is_worktree_bridge: parking_lot::Mutex<bool>,
690 git_common_dir: parking_lot::Mutex<Option<PathBuf>>,
691 shared_artifacts_read_only: parking_lot::Mutex<bool>,
692 artifact_owner_status: parking_lot::Mutex<Option<ArtifactOwnerStatus>>,
693 artifact_owner_lease: parking_lot::Mutex<Option<ArtifactOwnerLeaseRegistration>>,
694 degraded_reasons: parking_lot::Mutex<Vec<String>>,
701 heavy_root_work_allowed: Arc<AtomicBool>,
706 callgraph_store: RwLock<Option<Arc<CallGraphStore>>>,
707 callgraph_store_force_rebuild: parking_lot::Mutex<bool>,
708 callgraph_store_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStore>>>,
709 pending_callgraph_store_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
710 search_index: RwLock<Option<SearchIndex>>,
711 search_index_rx: RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>>,
712 pending_search_index_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
713 symbol_cache: SharedSymbolCache,
714 inspect_manager: Arc<InspectManager>,
715 tier2_refresh_scheduler: parking_lot::Mutex<Tier2RefreshScheduler>,
716 pending_tier2_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
717 semantic_index: RwLock<Option<SemanticIndex>>,
718 semantic_index_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
719 semantic_index_status: RwLock<SemanticIndexStatus>,
720 semantic_cold_seed_active: Arc<AtomicBool>,
724 semantic_cold_seed_generation: Arc<AtomicU64>,
727 semantic_callgraph_warm_deferred: AtomicBool,
728 pending_semantic_index_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
729 pending_semantic_corpus_refresh: parking_lot::Mutex<bool>,
730 semantic_refresh_tx:
731 parking_lot::Mutex<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>,
732 semantic_refresh_event_rx:
733 parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
734 semantic_refresh_worker: parking_lot::Mutex<Option<SemanticRefreshWorkerSlot>>,
735 semantic_refresh_retry_attempts: parking_lot::Mutex<BTreeMap<PathBuf, usize>>,
736 semantic_refresh_circuit: Arc<SemanticRefreshCircuit>,
737 semantic_embedding_model: parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>>,
738 watcher: parking_lot::Mutex<Option<RecommendedWatcher>>,
739 watcher_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>>,
740 watcher_thread: parking_lot::Mutex<Option<WatcherThreadHandle>>,
741 lsp_manager: parking_lot::Mutex<LspManager>,
742 configure_generation: Arc<AtomicU64>,
743 configure_warm_state: parking_lot::Mutex<ConfigureWarmState>,
744 configured_session_roots: parking_lot::Mutex<BTreeSet<(PathBuf, String)>>,
745 configure_maintenance_jobs: parking_lot::Mutex<VecDeque<ConfigureMaintenanceJob>>,
746 artifact_cache_keys: parking_lot::Mutex<BTreeMap<PathBuf, String>>,
747 artifact_cache_key_derivations: AtomicU64,
748 last_seen_reuse_completions: AtomicU64,
752 configure_warnings_tx: crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)>,
753 configure_warnings_rx: crossbeam_channel::Receiver<(u64, ConfigureWarningsFrame)>,
754 progress_sender: SharedProgressSender,
757 status_emitter: StatusEmitter,
758 status_bar_last_emitted: RwLock<Option<StatusBarCounts>>,
762 bash_background: BgTaskRegistry,
763 filter_registry: crate::compress::SharedFilterRegistry,
770 filter_registry_rebuild_count: AtomicU64,
771 filter_registry_loaded: std::sync::atomic::AtomicBool,
774 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
779 gitignore: SharedGitignore,
786 gitignore_generation: Arc<AtomicU64>,
787 status_bar_tier2: RwLock<StatusBarTier2>,
791 tsconfig_membership:
798 parking_lot::Mutex<crate::lsp::tsconfig_membership::TsconfigMembershipCache>,
799}
800
801pub struct ForceRestrictGuard<'a> {
807 ctx: &'a AppContext,
808 req_id: String,
809}
810
811impl Drop for ForceRestrictGuard<'_> {
812 fn drop(&mut self) {
813 self.ctx.release_force_restrict(&self.req_id);
814 }
815}
816
817impl Drop for AppContext {
818 fn drop(&mut self) {
819 self.artifact_owner_lease.get_mut().take();
820 if let Some(runtime) = self.watcher_thread.get_mut().take() {
821 runtime.shutdown_and_join();
822 }
823 }
824}
825
826pub enum CallgraphStoreAccess {
834 Ready(Arc<CallGraphStore>),
836 Building,
838 Unavailable,
840 Error(CallGraphStoreError),
842}
843
844fn callgraph_build_wait_window() -> Duration {
849 std::env::var("AFT_CALLGRAPH_BUILD_WAIT_MS")
850 .ok()
851 .and_then(|raw| raw.parse::<u64>().ok())
852 .map(Duration::from_millis)
853 .unwrap_or(Duration::ZERO)
854}
855
856static CALLGRAPH_COLD_BUILD_SPAWN_COUNT: AtomicUsize = AtomicUsize::new(0);
857
858#[doc(hidden)]
859pub fn reset_callgraph_cold_build_spawn_count_for_test() {
860 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
861}
862
863#[doc(hidden)]
864pub fn callgraph_cold_build_spawn_count_for_test() -> usize {
865 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst)
866}
867
868impl AppContext {
869 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
870 Self::with_app_and_provider(App::default_shared(), provider, config)
871 }
872
873 pub fn from_app(app: Arc<App>, config: Config) -> Self {
874 let provider = app.create_provider();
875 Self::with_app_and_provider(app, provider, config)
876 }
877
878 pub fn with_app_and_provider(
879 app: Arc<App>,
880 provider: Box<dyn LanguageProvider>,
881 config: Config,
882 ) -> Self {
883 let bash_compress_enabled = config.experimental_bash_compress;
884 let (configure_warnings_tx, configure_warnings_rx) = crossbeam_channel::unbounded();
885 let progress_sender: SharedProgressSender = Arc::new(Mutex::new(None));
886 let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
887 let heavy_root_work_allowed = Arc::new(AtomicBool::new(true));
888 let symbol_cache = provider
889 .as_any()
890 .downcast_ref::<TreeSitterProvider>()
891 .map(|provider| provider.symbol_cache())
892 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
893 let mut lsp_manager = LspManager::new();
894 lsp_manager.set_child_registry(app.lsp_child_registry());
895 lsp_manager.set_diagnostic_capacity(config.diagnostic_cache_size);
898 AppContext {
899 app: Arc::clone(&app),
900 provider,
901 backup: parking_lot::Mutex::new(BackupStore::new()),
902 checkpoint: parking_lot::Mutex::new(CheckpointStore::new()),
903 config: RwLock::new(Arc::new(config)),
904 force_restrict_requests: parking_lot::Mutex::new(BTreeMap::new()),
905 harness: parking_lot::Mutex::new(None),
906 canonical_cache_root: parking_lot::Mutex::new(None),
907 is_worktree_bridge: parking_lot::Mutex::new(false),
908 git_common_dir: parking_lot::Mutex::new(None),
909 shared_artifacts_read_only: parking_lot::Mutex::new(false),
910 artifact_owner_status: parking_lot::Mutex::new(None),
911 artifact_owner_lease: parking_lot::Mutex::new(None),
912 degraded_reasons: parking_lot::Mutex::new(Vec::new()),
913 heavy_root_work_allowed: Arc::clone(&heavy_root_work_allowed),
914 callgraph_store: RwLock::new(None),
915 callgraph_store_force_rebuild: parking_lot::Mutex::new(false),
916 callgraph_store_rx: parking_lot::Mutex::new(None),
917 pending_callgraph_store_paths: parking_lot::Mutex::new(BTreeSet::new()),
918 search_index: RwLock::new(None),
919 search_index_rx: RwLock::new(None),
920 pending_search_index_paths: parking_lot::Mutex::new(BTreeSet::new()),
921 symbol_cache,
922 inspect_manager: Arc::new(InspectManager::with_heavy_root_work_gate(Arc::clone(
923 &heavy_root_work_allowed,
924 ))),
925 tier2_refresh_scheduler: parking_lot::Mutex::new(Tier2RefreshScheduler::new()),
926 pending_tier2_paths: parking_lot::Mutex::new(BTreeSet::new()),
927 semantic_index: RwLock::new(None),
928 semantic_index_rx: parking_lot::Mutex::new(None),
929 semantic_index_status: RwLock::new(SemanticIndexStatus::Disabled),
930 semantic_cold_seed_active: Arc::new(AtomicBool::new(false)),
931 semantic_cold_seed_generation: Arc::new(AtomicU64::new(0)),
932 semantic_callgraph_warm_deferred: AtomicBool::new(false),
933 pending_semantic_index_paths: parking_lot::Mutex::new(BTreeSet::new()),
934 pending_semantic_corpus_refresh: parking_lot::Mutex::new(false),
935 semantic_refresh_tx: parking_lot::Mutex::new(None),
936 semantic_refresh_event_rx: parking_lot::Mutex::new(None),
937 semantic_refresh_worker: parking_lot::Mutex::new(None),
938 semantic_refresh_retry_attempts: parking_lot::Mutex::new(BTreeMap::new()),
939 semantic_refresh_circuit: Arc::new(SemanticRefreshCircuit::default()),
940 semantic_embedding_model: parking_lot::Mutex::new(None),
941 watcher: parking_lot::Mutex::new(None),
942 watcher_rx: parking_lot::Mutex::new(None),
943 watcher_thread: parking_lot::Mutex::new(None),
944 lsp_manager: parking_lot::Mutex::new(lsp_manager),
945 configure_generation: Arc::new(AtomicU64::new(0)),
946 configure_warm_state: parking_lot::Mutex::new(ConfigureWarmState::default()),
947 configured_session_roots: parking_lot::Mutex::new(BTreeSet::new()),
948 configure_maintenance_jobs: parking_lot::Mutex::new(VecDeque::new()),
949 artifact_cache_keys: parking_lot::Mutex::new(BTreeMap::new()),
950 artifact_cache_key_derivations: AtomicU64::new(0),
951 last_seen_reuse_completions: AtomicU64::new(0),
952 configure_warnings_tx,
953 configure_warnings_rx,
954 progress_sender: Arc::clone(&progress_sender),
955 status_emitter,
956 status_bar_last_emitted: RwLock::new(None),
957 bash_background: BgTaskRegistry::new(Arc::clone(&progress_sender)),
958 filter_registry: Arc::new(std::sync::RwLock::new(
959 crate::compress::toml_filter::FilterRegistry::default(),
960 )),
961 filter_registry_rebuild_count: AtomicU64::new(0),
962 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
963 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
964 gitignore: Arc::new(std::sync::RwLock::new(None)),
965 gitignore_generation: Arc::new(AtomicU64::new(0)),
966 status_bar_tier2: RwLock::new(StatusBarTier2::default()),
967 tsconfig_membership: parking_lot::Mutex::new(
968 crate::lsp::tsconfig_membership::TsconfigMembershipCache::new(),
969 ),
970 }
971 }
972
973 pub fn status_bar_counts(&self) -> Option<StatusBarCounts> {
979 let (dead_code, unused_exports, duplicates, todos, tier2_stale) = {
985 let tier2 = self
986 .status_bar_tier2
987 .read()
988 .unwrap_or_else(std::sync::PoisonError::into_inner);
989 let (Some(dead_code), Some(unused_exports), Some(duplicates)) =
990 (tier2.dead_code, tier2.unused_exports, tier2.duplicates)
991 else {
992 return None;
993 };
994 (
995 dead_code,
996 unused_exports,
997 duplicates,
998 tier2.todos.unwrap_or(0),
999 tier2.stale,
1000 )
1001 };
1002 let (errors, warnings) = self.status_bar_error_warning_counts();
1003 Some(StatusBarCounts {
1004 errors,
1005 warnings,
1006 dead_code,
1007 unused_exports,
1008 duplicates,
1009 todos,
1010 tier2_stale,
1011 })
1012 }
1013
1014 pub fn try_health_snapshot(&self, project_root: &Path) -> RootHealthSnapshot {
1015 let config = match self.config.try_read() {
1016 Ok(guard) => Arc::clone(&*guard),
1017 Err(_) => return RootHealthSnapshot::busy(project_root),
1018 };
1019 let search_index = match self.search_index.try_read() {
1020 Ok(guard) => guard,
1021 Err(_) => return RootHealthSnapshot::busy(project_root),
1022 };
1023 let search_index_rx = match self.search_index_rx.try_read() {
1024 Ok(guard) => guard,
1025 Err(_) => return RootHealthSnapshot::busy(project_root),
1026 };
1027 let semantic_status = match self.semantic_index_status.try_read() {
1028 Ok(guard) => guard,
1029 Err(_) => return RootHealthSnapshot::busy(project_root),
1030 };
1031 let callgraph_store = match self.callgraph_store.try_read() {
1032 Ok(guard) => guard,
1033 Err(_) => return RootHealthSnapshot::busy(project_root),
1034 };
1035 let callgraph_store_rx = match self.callgraph_store_rx.try_lock() {
1036 Some(guard) => guard,
1037 None => return RootHealthSnapshot::busy(project_root),
1038 };
1039 let tier2 = match self.status_bar_tier2.try_read() {
1040 Ok(guard) => guard,
1041 Err(_) => return RootHealthSnapshot::busy(project_root),
1042 };
1043 let bash = match self.bash_background.try_health_counts() {
1044 Some(counts) => counts,
1045 None => return RootHealthSnapshot::busy(project_root),
1046 };
1047
1048 let search_index_status = if search_index.as_ref().is_some_and(|index| index.ready) {
1049 "ready"
1050 } else if config.search_index
1051 || search_index.as_ref().is_some()
1052 || search_index_rx.as_ref().is_some()
1053 {
1054 "building"
1055 } else {
1056 "disabled"
1057 };
1058 let semantic_index_status = match &*semantic_status {
1059 SemanticIndexStatus::Ready { .. } => "ready",
1060 SemanticIndexStatus::Building { .. } => "building",
1061 SemanticIndexStatus::Disabled => "disabled",
1062 SemanticIndexStatus::Failed(_) => "degraded",
1063 };
1064 let callgraph_store_status = if !self.heavy_root_work_allowed() {
1065 "disabled"
1066 } else if callgraph_store.as_ref().is_some() {
1067 "ready"
1068 } else if callgraph_store_rx.is_some() || config.callgraph_store {
1069 "building"
1070 } else {
1071 "disabled"
1072 };
1073 let tier2_status = if !config.inspect.enabled
1074 || (tier2.dead_code.is_none()
1075 && tier2.unused_exports.is_none()
1076 && tier2.duplicates.is_none())
1077 {
1078 "disabled"
1079 } else if tier2.dead_code.is_some()
1080 && tier2.unused_exports.is_some()
1081 && tier2.duplicates.is_some()
1082 && !tier2.stale
1083 {
1084 "ready"
1085 } else {
1086 "building"
1087 };
1088
1089 RootHealthSnapshot {
1090 project_root: project_root.display().to_string(),
1091 actor_count: 1,
1092 state: RootHealthState::Ready,
1093 search_index: Some(HealthComponentSnapshot {
1094 status: search_index_status,
1095 }),
1096 semantic_index: Some(HealthComponentSnapshot {
1097 status: semantic_index_status,
1098 }),
1099 callgraph_store: Some(HealthComponentSnapshot {
1100 status: callgraph_store_status,
1101 }),
1102 tier2: Some(Tier2HealthSnapshot {
1103 status: tier2_status,
1104 }),
1105 bash: Some(bash),
1106 }
1107 }
1108
1109 pub fn should_emit_status_bar(&self, counts: &StatusBarCounts) -> bool {
1110 let mut last = self
1111 .status_bar_last_emitted
1112 .write()
1113 .unwrap_or_else(std::sync::PoisonError::into_inner);
1114 if last.as_ref() == Some(counts) {
1115 return false;
1116 }
1117 *last = Some(counts.clone());
1118 true
1119 }
1120
1121 fn status_bar_error_warning_counts(&self) -> (usize, usize) {
1127 let Some(root) = self.canonical_cache_root_opt() else {
1128 return self.lsp_manager.lock().warm_error_warning_counts();
1131 };
1132 let lsp = self.lsp_manager.lock();
1133 let mut membership = self.tsconfig_membership.lock();
1134 lsp.filtered_error_warning_counts(|file| {
1135 file.starts_with(&root) && !membership.should_skip_diagnostics(file)
1136 })
1137 }
1138
1139 pub fn clear_tsconfig_membership_cache(&self) {
1143 self.tsconfig_membership.lock().clear();
1144 }
1145
1146 #[cfg(test)]
1147 pub fn tsconfig_membership_clear_generation_for_test(&self) -> u64 {
1148 self.tsconfig_membership.lock().clear_generation()
1149 }
1150
1151 pub fn mark_status_bar_tier2_stale(&self) -> bool {
1157 let mut tier2 = self
1158 .status_bar_tier2
1159 .write()
1160 .unwrap_or_else(std::sync::PoisonError::into_inner);
1161 if tier2.dead_code.is_some() && tier2.unused_exports.is_some() && tier2.duplicates.is_some()
1163 {
1164 let changed = !tier2.stale;
1165 tier2.stale = true;
1166 return changed;
1167 }
1168 false
1169 }
1170
1171 pub fn update_status_bar_tier2(
1177 &self,
1178 dead_code: Option<usize>,
1179 unused_exports: Option<usize>,
1180 duplicates: Option<usize>,
1181 todos: Option<usize>,
1182 stale: bool,
1183 ) {
1184 let mut tier2 = self
1185 .status_bar_tier2
1186 .write()
1187 .unwrap_or_else(std::sync::PoisonError::into_inner);
1188 if let Some(dead_code) = dead_code {
1189 tier2.dead_code = Some(dead_code);
1190 }
1191 if let Some(unused_exports) = unused_exports {
1192 tier2.unused_exports = Some(unused_exports);
1193 }
1194 if let Some(duplicates) = duplicates {
1195 tier2.duplicates = Some(duplicates);
1196 }
1197 if let Some(todos) = todos {
1198 tier2.todos = Some(todos);
1199 }
1200 tier2.stale = stale;
1201 }
1202
1203 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
1206 self.gitignore
1207 .read()
1208 .unwrap_or_else(|poisoned| poisoned.into_inner())
1209 .clone()
1210 }
1211
1212 pub fn shared_gitignore(&self) -> SharedGitignore {
1214 Arc::clone(&self.gitignore)
1215 }
1216
1217 pub fn gitignore_generation(&self) -> Arc<AtomicU64> {
1221 Arc::clone(&self.gitignore_generation)
1222 }
1223
1224 fn set_gitignore(&self, matcher: Option<Arc<ignore::gitignore::Gitignore>>) {
1225 *self
1226 .gitignore
1227 .write()
1228 .unwrap_or_else(|poisoned| poisoned.into_inner()) = matcher;
1229 self.gitignore_generation.fetch_add(1, Ordering::SeqCst);
1230 }
1231
1232 pub fn clear_gitignore(&self) {
1254 self.set_gitignore(None);
1255 }
1256
1257 pub fn rebuild_gitignore(&self) {
1258 use ignore::gitignore::GitignoreBuilder;
1259 use std::path::Path;
1260 let root_raw = match self.config().project_root.clone() {
1261 Some(r) => r,
1262 None => {
1263 self.set_gitignore(None);
1264 return;
1265 }
1266 };
1267 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
1275 let mut builder = GitignoreBuilder::new(&root);
1276 if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
1281 if global_ignore.is_file() {
1282 if let Some(err) = builder.add(&global_ignore) {
1283 crate::slog_warn!(
1284 "global gitignore parse error in {}: {}",
1285 global_ignore.display(),
1286 err
1287 );
1288 }
1289 }
1290 }
1291 let root_ignore = Path::new(&root).join(".gitignore");
1293 if root_ignore.exists() {
1294 if let Some(err) = builder.add(&root_ignore) {
1295 crate::slog_warn!(
1296 "gitignore parse error in {}: {}",
1297 root_ignore.display(),
1298 err
1299 );
1300 }
1301 }
1302 let root_aftignore = Path::new(&root).join(".aftignore");
1307 if root_aftignore.exists() {
1308 if let Some(err) = builder.add(&root_aftignore) {
1309 crate::slog_warn!(
1310 "aftignore parse error in {}: {}",
1311 root_aftignore.display(),
1312 err
1313 );
1314 }
1315 }
1316 let info_exclude = self
1321 .git_common_dir
1322 .lock()
1323 .clone()
1324 .unwrap_or_else(|| Path::new(&root).join(".git"))
1325 .join("info")
1326 .join("exclude");
1327 if info_exclude.exists() {
1328 if let Some(err) = builder.add(&info_exclude) {
1329 crate::slog_warn!(
1330 "gitignore parse error in {}: {}",
1331 info_exclude.display(),
1332 err
1333 );
1334 }
1335 }
1336 let walker = ignore::WalkBuilder::new(&root)
1342 .standard_filters(true)
1343 .hidden(false)
1351 .filter_entry(|entry| {
1352 let name = entry.file_name().to_string_lossy();
1353 !matches!(
1354 name.as_ref(),
1355 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
1356 )
1357 })
1358 .build();
1359 for entry in walker.flatten() {
1360 let file_name = entry.file_name();
1361 let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
1362 let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
1363 if is_nested_gitignore || is_nested_aftignore {
1364 if let Some(err) = builder.add(entry.path()) {
1365 crate::slog_warn!(
1366 "nested ignore parse error in {}: {}",
1367 entry.path().display(),
1368 err
1369 );
1370 }
1371 }
1372 }
1373 match builder.build() {
1374 Ok(gi) => {
1375 let count = gi.num_ignores();
1376 if count > 0 {
1377 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
1378 self.set_gitignore(Some(Arc::new(gi)));
1379 } else {
1380 self.set_gitignore(None);
1381 }
1382 }
1383 Err(err) => {
1384 crate::slog_warn!("gitignore matcher build failed: {}", err);
1385 self.set_gitignore(None);
1386 }
1387 }
1388 }
1389
1390 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
1393 Arc::clone(&self.bash_compress_flag)
1394 }
1395
1396 pub fn sync_bash_compress_flag(&self) {
1400 let value = self.config().experimental_bash_compress;
1401 self.bash_compress_flag
1402 .store(value, std::sync::atomic::Ordering::Relaxed);
1403 }
1404
1405 pub fn set_bash_compress_enabled(&self, enabled: bool) {
1406 self.update_config(|config| {
1407 config.experimental_bash_compress = enabled;
1408 });
1409 self.bash_compress_flag
1410 .store(enabled, std::sync::atomic::Ordering::Relaxed);
1411 }
1412
1413 pub fn filter_registry(
1417 &self,
1418 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
1419 self.ensure_filter_registry_loaded();
1420 match self.filter_registry.read() {
1421 Ok(g) => g,
1422 Err(poisoned) => poisoned.into_inner(),
1423 }
1424 }
1425
1426 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
1430 self.ensure_filter_registry_loaded();
1431 Arc::clone(&self.filter_registry)
1432 }
1433
1434 pub fn reset_filter_registry(&self) {
1438 let new_registry = crate::compress::build_registry_for_context(self);
1439 self.filter_registry_rebuild_count
1440 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1441 match self.filter_registry.write() {
1442 Ok(mut slot) => *slot = new_registry,
1443 Err(poisoned) => *poisoned.into_inner() = new_registry,
1444 }
1445 self.filter_registry_loaded
1446 .store(true, std::sync::atomic::Ordering::Release);
1447 }
1448
1449 fn ensure_filter_registry_loaded(&self) {
1450 use std::sync::atomic::Ordering;
1451 if self.filter_registry_loaded.load(Ordering::Acquire) {
1452 return;
1453 }
1454 let new_registry = crate::compress::build_registry_for_context(self);
1457 self.filter_registry_rebuild_count
1458 .fetch_add(1, Ordering::SeqCst);
1459 if let Ok(mut slot) = self.filter_registry.write() {
1460 *slot = new_registry;
1461 self.filter_registry_loaded.store(true, Ordering::Release);
1462 }
1463 }
1464
1465 #[cfg(test)]
1466 pub fn filter_registry_rebuild_count_for_test(&self) -> u64 {
1467 self.filter_registry_rebuild_count.load(Ordering::SeqCst)
1468 }
1469
1470 pub fn app(&self) -> Arc<App> {
1471 Arc::clone(&self.app)
1472 }
1473
1474 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
1477 self.app.lsp_child_registry()
1478 }
1479
1480 pub fn stdout_writer(&self) -> SharedStdoutWriter {
1481 self.app.stdout_writer()
1482 }
1483
1484 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
1485 if let Ok(mut progress_sender) = self.progress_sender.lock() {
1486 *progress_sender = sender;
1487 }
1488 }
1489
1490 pub fn emit_progress(&self, frame: ProgressFrame) {
1491 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
1492 return;
1493 };
1494 if let Some(sender) = progress_sender.as_ref() {
1495 sender(PushFrame::Progress(frame));
1496 }
1497 }
1498
1499 pub fn status_emitter(&self) -> &StatusEmitter {
1500 &self.status_emitter
1501 }
1502
1503 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
1511 self.progress_sender
1512 .lock()
1513 .ok()
1514 .and_then(|sender| sender.clone())
1515 }
1516
1517 pub fn advance_configure_generation(&self) -> u64 {
1518 self.configure_generation
1519 .fetch_add(1, Ordering::SeqCst)
1520 .wrapping_add(1)
1521 }
1522
1523 pub fn note_configure_warm_key(&self, key: String) -> (u64, bool) {
1534 let mut state = self.configure_warm_state.lock();
1535 let equivalent = state.key.as_ref().is_some_and(|previous| *previous == key);
1536 let generation = if equivalent {
1537 self.configure_generation()
1538 } else {
1539 self.advance_configure_generation()
1540 };
1541 state.generation = generation;
1542 state.key = Some(key);
1543 (generation, equivalent)
1544 }
1545
1546 pub fn note_configure_session_binding(&self, root: PathBuf, session_id: String) -> bool {
1547 self.configured_session_roots
1548 .lock()
1549 .insert((root, session_id))
1550 }
1551
1552 pub fn forget_configure_session_binding(&self, root: &Path, session_id: &str) {
1556 self.configured_session_roots
1557 .lock()
1558 .remove(&(root.to_path_buf(), session_id.to_string()));
1559 }
1560
1561 pub(crate) fn enqueue_configure_maintenance(&self, job: ConfigureMaintenanceJob) {
1562 self.configure_maintenance_jobs.lock().push_back(job);
1563 }
1564
1565 pub(crate) fn drain_configure_maintenance(&self) -> Vec<ConfigureMaintenanceJob> {
1566 self.configure_maintenance_jobs.lock().drain(..).collect()
1567 }
1568
1569 pub fn memoized_artifact_cache_key(&self, canonical_root: &Path) -> String {
1570 let mut keys = self.artifact_cache_keys.lock();
1571 if let Some(key) = keys.get(canonical_root).cloned() {
1572 return key;
1573 }
1574 let key = crate::search_index::artifact_cache_key(canonical_root);
1575 self.artifact_cache_key_derivations
1576 .fetch_add(1, Ordering::SeqCst);
1577 keys.insert(canonical_root.to_path_buf(), key.clone());
1578 key
1579 }
1580
1581 #[cfg(test)]
1582 pub fn artifact_cache_key_derivation_count_for_test(&self) -> u64 {
1583 self.artifact_cache_key_derivations.load(Ordering::SeqCst)
1584 }
1585
1586 pub fn configure_generation(&self) -> u64 {
1587 self.configure_generation.load(Ordering::SeqCst)
1588 }
1589
1590 pub fn configure_generation_flag(&self) -> Arc<AtomicU64> {
1591 Arc::clone(&self.configure_generation)
1592 }
1593
1594 pub fn configure_warnings_sender(
1595 &self,
1596 ) -> crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)> {
1597 self.configure_warnings_tx.clone()
1598 }
1599
1600 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
1601 let mut warnings = Vec::new();
1602 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
1603 warnings.push(warning);
1604 }
1605 warnings
1606 }
1607
1608 pub fn bash_background(&self) -> &BgTaskRegistry {
1609 &self.bash_background
1610 }
1611
1612 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
1613 self.bash_background.drain_completions()
1614 }
1615
1616 pub fn provider(&self) -> &dyn LanguageProvider {
1618 self.provider.as_ref()
1619 }
1620
1621 pub fn backup(&self) -> &parking_lot::Mutex<BackupStore> {
1623 &self.backup
1624 }
1625
1626 pub fn checkpoint(&self) -> &parking_lot::Mutex<CheckpointStore> {
1628 &self.checkpoint
1629 }
1630
1631 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
1632 self.app.set_db(conn);
1633 }
1634
1635 pub fn clear_db(&self) {
1636 self.app.clear_db();
1637 }
1638
1639 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
1640 self.app.db()
1641 }
1642
1643 pub fn config(&self) -> Arc<Config> {
1645 let guard = match self.config.read() {
1646 Ok(guard) => guard,
1647 Err(poisoned) => poisoned.into_inner(),
1648 };
1649 Arc::clone(&*guard)
1650 }
1651
1652 pub fn set_config(&self, config: Config) {
1654 let next = Arc::new(config);
1655 match self.config.write() {
1656 Ok(mut guard) => *guard = next,
1657 Err(poisoned) => *poisoned.into_inner() = next,
1658 }
1659 }
1660
1661 pub fn update_config(&self, update: impl FnOnce(&mut Config)) {
1663 let mut next = self.config().as_ref().clone();
1664 update(&mut next);
1665 self.set_config(next);
1666 }
1667
1668 pub fn force_restrict_guard(&self, req_id: &str) -> ForceRestrictGuard<'_> {
1669 let mut requests = self.force_restrict_requests.lock();
1670 *requests.entry(req_id.to_string()).or_insert(0) += 1;
1671 ForceRestrictGuard {
1672 ctx: self,
1673 req_id: req_id.to_string(),
1674 }
1675 }
1676
1677 pub fn with_force_restrict<R>(&self, req_id: &str, f: impl FnOnce() -> R) -> R {
1678 let _guard = self.force_restrict_guard(req_id);
1679 f()
1680 }
1681
1682 pub fn request_force_restrict(&self, req_id: &str) -> bool {
1683 self.force_restrict_requests.lock().contains_key(req_id)
1684 }
1685
1686 fn release_force_restrict(&self, req_id: &str) {
1687 let mut requests = self.force_restrict_requests.lock();
1688 match requests.get_mut(req_id) {
1689 Some(count) if *count > 1 => *count -= 1,
1690 Some(_) => {
1691 requests.remove(req_id);
1692 }
1693 None => {}
1694 }
1695 }
1696
1697 pub fn set_harness(&self, harness: Harness) {
1698 self.bash_background.set_harness(harness.clone());
1699 *self.harness.lock() = Some(harness);
1700 }
1701
1702 pub fn harness_opt(&self) -> Option<Harness> {
1703 self.harness.lock().clone()
1704 }
1705
1706 pub fn harness(&self) -> Harness {
1707 self.harness_opt()
1708 .expect("harness set by configure before any tool call")
1709 }
1710
1711 pub fn storage_dir(&self) -> PathBuf {
1712 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
1713 }
1714
1715 pub fn harness_dir(&self) -> PathBuf {
1716 self.storage_dir().join(self.harness().storage_segment())
1717 }
1718
1719 pub fn inspect_dir(&self) -> PathBuf {
1720 self.harness_dir().join("inspect")
1721 }
1722
1723 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
1724 self.harness_dir()
1725 .join("bash-tasks")
1726 .join(hash_session(session_id))
1727 }
1728
1729 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
1730 self.harness_dir()
1731 .join("backups")
1732 .join(hash_session(session_id))
1733 .join(path_hash)
1734 }
1735
1736 pub fn filters_dir(&self) -> PathBuf {
1737 self.harness_dir().join("filters")
1738 }
1739
1740 pub fn trust_file(&self) -> PathBuf {
1742 self.storage_dir().join("trusted-filter-projects.json")
1743 }
1744
1745 pub fn set_canonical_cache_root(&self, root: PathBuf) {
1746 debug_assert!(root.is_absolute());
1747 *self.canonical_cache_root.lock() = Some(root);
1748 }
1749
1750 pub fn canonical_cache_root(&self) -> PathBuf {
1751 self.canonical_cache_root
1752 .lock()
1753 .clone()
1754 .expect("canonical_cache_root accessed before handle_configure")
1755 }
1756
1757 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
1758 self.canonical_cache_root.lock().clone()
1759 }
1760
1761 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
1762 *self.is_worktree_bridge.lock() = is_worktree_bridge;
1763 *self.git_common_dir.lock() = git_common_dir;
1764 }
1765
1766 pub fn set_artifact_owner(
1767 &self,
1768 status: Option<ArtifactOwnerStatus>,
1769 lease: Option<ArtifactOwnerLease>,
1770 ) {
1771 let read_only = status
1772 .as_ref()
1773 .is_some_and(|status| status.mode == ArtifactOwnerMode::ReadOnly);
1774 *self.shared_artifacts_read_only.lock() = read_only;
1775 *self.artifact_owner_status.lock() = status;
1776 *self.artifact_owner_lease.lock() = lease.map(crate::artifact_owner::register_heartbeat);
1777 }
1778
1779 pub fn shared_artifacts_read_only(&self) -> bool {
1780 self.is_worktree_bridge() || *self.shared_artifacts_read_only.lock()
1781 }
1782
1783 pub fn artifact_owner_status(&self) -> Option<ArtifactOwnerStatus> {
1784 self.artifact_owner_status.lock().clone()
1785 }
1786
1787 pub fn is_worktree_bridge(&self) -> bool {
1788 *self.is_worktree_bridge.lock()
1789 }
1790
1791 pub fn git_common_dir(&self) -> Option<PathBuf> {
1792 self.git_common_dir.lock().clone()
1793 }
1794
1795 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
1799 *self.degraded_reasons.lock() = reasons;
1800 }
1801
1802 pub fn set_heavy_root_work_allowed(&self, allowed: bool) {
1803 self.heavy_root_work_allowed
1804 .store(allowed, Ordering::SeqCst);
1805 }
1806
1807 pub fn heavy_root_work_allowed(&self) -> bool {
1808 self.heavy_root_work_allowed.load(Ordering::SeqCst)
1809 }
1810
1811 pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
1812 let reason = reason.into();
1813 let mut reasons = self.degraded_reasons.lock();
1814 if reasons.iter().any(|existing| existing == &reason) {
1815 return false;
1816 }
1817 reasons.push(reason);
1818 true
1819 }
1820
1821 pub fn degraded_reasons(&self) -> Vec<String> {
1825 self.degraded_reasons.lock().clone()
1826 }
1827
1828 pub fn is_degraded(&self) -> bool {
1830 !self.degraded_reasons.lock().is_empty()
1831 }
1832
1833 pub fn cache_role(&self) -> &'static str {
1834 if self.canonical_cache_root.lock().is_none() {
1835 "not_initialized"
1836 } else if self.is_worktree_bridge() {
1837 "worktree"
1838 } else if *self.shared_artifacts_read_only.lock() {
1839 "read_only"
1840 } else {
1841 "main"
1842 }
1843 }
1844
1845 pub fn callgraph_store(&self) -> &RwLock<Option<Arc<CallGraphStore>>> {
1847 &self.callgraph_store
1848 }
1849
1850 pub fn mark_callgraph_store_force_rebuild(&self) {
1851 *self.callgraph_store_force_rebuild.lock() = true;
1852 }
1853
1854 fn take_callgraph_store_force_rebuild(&self) -> bool {
1855 let mut force = self.callgraph_store_force_rebuild.lock();
1856 let was_forced = *force;
1857 *force = false;
1858 was_forced
1859 }
1860
1861 pub fn callgraph_store_dir(&self) -> PathBuf {
1862 match self.harness_opt() {
1863 Some(harness) => self
1864 .storage_dir()
1865 .join(harness.storage_segment())
1866 .join("callgraph"),
1867 None => self.storage_dir().join("callgraph"),
1868 }
1869 }
1870
1871 pub fn ensure_callgraph_store(
1872 &self,
1873 ) -> Result<Option<Arc<CallGraphStore>>, CallGraphStoreError> {
1874 self.ensure_callgraph_store_with_flag(true)
1875 }
1876
1877 fn ensure_callgraph_store_with_flag(
1878 &self,
1879 respect_config_flag: bool,
1880 ) -> Result<Option<Arc<CallGraphStore>>, CallGraphStoreError> {
1881 if respect_config_flag && !self.config().callgraph_store {
1882 return Ok(None);
1883 }
1884 if !self.heavy_root_work_allowed() {
1885 return Ok(None);
1886 }
1887 if let Some(store) = {
1888 let guard = self
1889 .callgraph_store
1890 .read()
1891 .unwrap_or_else(std::sync::PoisonError::into_inner);
1892 guard.as_ref().map(Arc::clone)
1893 } {
1894 return Ok(Some(store));
1895 }
1896
1897 let Some(project_root) = self.callgraph_project_root() else {
1898 return Ok(None);
1899 };
1900 let callgraph_dir = self.callgraph_store_dir();
1901 let force_rebuild = self.take_callgraph_store_force_rebuild();
1902 let store = if self.is_worktree_bridge() {
1903 CallGraphStore::open_readonly(callgraph_dir, project_root)?
1904 } else if force_rebuild {
1905 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1906 let (store, _stats) = CallGraphStore::cold_build_with_lease_chunked(
1907 callgraph_dir,
1908 project_root,
1909 &files,
1910 self.config().callgraph_chunk_size,
1911 )?;
1912 Some(store)
1913 } else if CallGraphStore::needs_cold_build(&callgraph_dir, &project_root)? {
1914 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1915 let (store, _stats) = CallGraphStore::ensure_built_with_lease_chunked(
1916 callgraph_dir,
1917 project_root,
1918 &files,
1919 self.config().callgraph_chunk_size,
1920 )?;
1921 Some(store)
1922 } else {
1923 Some(CallGraphStore::open(callgraph_dir, project_root)?)
1924 };
1925
1926 let Some(store) = store else {
1927 return Ok(None);
1928 };
1929 let store = Arc::new(store);
1930 {
1931 let mut guard = self
1932 .callgraph_store
1933 .write()
1934 .unwrap_or_else(std::sync::PoisonError::into_inner);
1935 *guard = Some(Arc::clone(&store));
1936 }
1937 Ok(Some(store))
1938 }
1939
1940 fn callgraph_project_root(&self) -> Option<PathBuf> {
1943 self.canonical_cache_root_opt().or_else(|| {
1944 self.config()
1945 .project_root
1946 .clone()
1947 .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
1948 })
1949 }
1950
1951 pub fn revalidate_callgraph_store_generation(&self) {
1969 if self.callgraph_store_rx.lock().is_some() {
1972 return;
1973 }
1974 let superseded = {
1975 let guard = self
1976 .callgraph_store
1977 .read()
1978 .unwrap_or_else(std::sync::PoisonError::into_inner);
1979 guard.as_ref().is_some_and(|store| !store.is_current())
1980 };
1981 if superseded {
1982 let mut guard = self
1983 .callgraph_store
1984 .write()
1985 .unwrap_or_else(std::sync::PoisonError::into_inner);
1986 *guard = None;
1987 }
1988 }
1989
1990 pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess {
1991 if !self.heavy_root_work_allowed() {
1992 return CallgraphStoreAccess::Unavailable;
1993 }
1994
1995 self.revalidate_callgraph_store_generation();
1999 if let Some(store) = {
2000 let guard = self
2001 .callgraph_store
2002 .read()
2003 .unwrap_or_else(std::sync::PoisonError::into_inner);
2004 guard.as_ref().map(Arc::clone)
2005 } {
2006 return CallgraphStoreAccess::Ready(store);
2007 }
2008
2009 if self.callgraph_store_rx.lock().is_some() {
2011 return CallgraphStoreAccess::Building;
2012 }
2013
2014 let Some(project_root) = self.callgraph_project_root() else {
2015 return CallgraphStoreAccess::Unavailable;
2016 };
2017 let callgraph_dir = self.callgraph_store_dir();
2018
2019 if self.is_worktree_bridge() {
2022 match CallGraphStore::open_readonly(callgraph_dir, project_root) {
2023 Ok(Some(store)) => {
2024 let store = Arc::new(store);
2025 {
2026 let mut guard = self
2027 .callgraph_store
2028 .write()
2029 .unwrap_or_else(std::sync::PoisonError::into_inner);
2030 *guard = Some(Arc::clone(&store));
2031 }
2032 return CallgraphStoreAccess::Ready(store);
2033 }
2034 Ok(None) | Err(_) => return CallgraphStoreAccess::Unavailable,
2035 }
2036 }
2037
2038 let force_rebuild = *self.callgraph_store_force_rebuild.lock();
2039 if !force_rebuild {
2042 match CallGraphStore::needs_cold_build(&callgraph_dir, &project_root) {
2043 Ok(false) => match CallGraphStore::open(callgraph_dir, project_root) {
2044 Ok(store) => {
2045 let store = Arc::new(store);
2046 {
2047 let mut guard = self
2048 .callgraph_store
2049 .write()
2050 .unwrap_or_else(std::sync::PoisonError::into_inner);
2051 *guard = Some(Arc::clone(&store));
2052 }
2053 return CallgraphStoreAccess::Ready(store);
2054 }
2055 Err(error) => return CallgraphStoreAccess::Error(error),
2056 },
2057 Ok(true) => {}
2058 Err(error) => return CallgraphStoreAccess::Error(error),
2059 }
2060 }
2061
2062 if self.semantic_cold_seed_active() {
2063 self.defer_callgraph_store_warm_for_semantic_cold_seed();
2064 return CallgraphStoreAccess::Building;
2065 }
2066
2067 if !self.spawn_callgraph_store_cold_build(project_root, callgraph_dir, force_rebuild) {
2075 return CallgraphStoreAccess::Building;
2076 }
2077
2078 let wait = callgraph_build_wait_window();
2079 if !wait.is_zero() {
2080 let received = {
2081 let rx_ref = self.callgraph_store_rx.lock();
2082 let Some(rx) = rx_ref.as_ref() else {
2083 return CallgraphStoreAccess::Building;
2084 };
2085 rx.recv_timeout(wait)
2086 };
2087 match received {
2088 Ok(store) => {
2089 let pending = self.take_pending_callgraph_store_paths();
2093 if !pending.is_empty() {
2094 if let Err(error) = store.refresh_files(&pending) {
2095 crate::slog_warn!(
2096 "callgraph store inline post-build refresh failed: {}",
2097 error
2098 );
2099 let _ = store.mark_files_stale(&pending);
2100 }
2101 }
2102 let store = Arc::new(store);
2103 {
2104 let mut guard = self
2105 .callgraph_store
2106 .write()
2107 .unwrap_or_else(std::sync::PoisonError::into_inner);
2108 *guard = Some(Arc::clone(&store));
2109 }
2110 *self.callgraph_store_rx.lock() = None;
2111 return CallgraphStoreAccess::Ready(store);
2112 }
2113 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
2114 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
2115 *self.callgraph_store_rx.lock() = None;
2118 }
2119 }
2120 }
2121 CallgraphStoreAccess::Building
2122 }
2123
2124 fn spawn_callgraph_store_cold_build(
2131 &self,
2132 project_root: PathBuf,
2133 callgraph_dir: PathBuf,
2134 force_rebuild: bool,
2135 ) -> bool {
2136 if !self.heavy_root_work_allowed() {
2137 return false;
2138 }
2139
2140 let session_id = crate::log_ctx::current_session();
2141 let chunk_size = self.config().callgraph_chunk_size;
2142 let build_generation = self.configure_generation();
2143 let generation_flag = self.configure_generation_flag();
2144
2145 let mut rx_guard = self.callgraph_store_rx.lock();
2146 if rx_guard.is_some() {
2147 return false;
2148 }
2149
2150 let Some(permit) = crate::cold_build_limiter::try_acquire() else {
2151 crate::slog_info!(
2152 "callgraph store cold build deferred by cold build limit ({})",
2153 crate::cold_build_limiter::limit()
2154 );
2155 return false;
2156 };
2157
2158 if force_rebuild {
2159 self.take_callgraph_store_force_rebuild();
2162 }
2163 let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStore>();
2164 *rx_guard = Some(rx);
2165
2166 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.fetch_add(1, Ordering::SeqCst);
2167
2168 std::thread::spawn(move || {
2169 let _permit = permit;
2170 crate::log_ctx::with_session(session_id, || {
2171 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2172 let built = if force_rebuild {
2173 CallGraphStore::cold_build_with_lease_chunked(
2174 callgraph_dir,
2175 project_root,
2176 &files,
2177 chunk_size,
2178 )
2179 .map(|(store, _)| store)
2180 } else {
2181 CallGraphStore::ensure_built_with_lease_chunked(
2182 callgraph_dir,
2183 project_root,
2184 &files,
2185 chunk_size,
2186 )
2187 .map(|(store, _)| store)
2188 };
2189 match built {
2190 Ok(store) => {
2191 if generation_flag.load(Ordering::SeqCst) == build_generation {
2192 let _ = tx.send(store);
2193 } else {
2194 crate::slog_info!(
2195 "callgraph store warm build result discarded for stale generation {}",
2196 build_generation
2197 );
2198 }
2199 }
2200 Err(error) => {
2201 crate::slog_warn!("callgraph store cold build failed: {}", error);
2202 }
2205 }
2206 });
2207 });
2208 true
2209 }
2210
2211 pub fn callgraph_store_rx(
2214 &self,
2215 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStore>>> {
2216 &self.callgraph_store_rx
2217 }
2218
2219 pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
2222 where
2223 I: IntoIterator<Item = PathBuf>,
2224 {
2225 self.pending_callgraph_store_paths.lock().extend(paths);
2226 }
2227
2228 pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
2230 std::mem::take(&mut *self.pending_callgraph_store_paths.lock())
2231 .into_iter()
2232 .collect()
2233 }
2234
2235 pub fn search_index(&self) -> &RwLock<Option<SearchIndex>> {
2237 &self.search_index
2238 }
2239
2240 pub fn search_index_rx(&self) -> &RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>> {
2242 &self.search_index_rx
2243 }
2244
2245 pub fn add_pending_search_index_paths<I>(&self, paths: I)
2246 where
2247 I: IntoIterator<Item = PathBuf>,
2248 {
2249 self.pending_search_index_paths.lock().extend(paths);
2250 }
2251
2252 pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
2253 std::mem::take(&mut *self.pending_search_index_paths.lock())
2254 .into_iter()
2255 .collect()
2256 }
2257
2258 pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
2259 where
2260 I: IntoIterator<Item = PathBuf>,
2261 {
2262 self.pending_semantic_index_paths.lock().extend(paths);
2263 }
2264
2265 pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
2266 std::mem::take(&mut *self.pending_semantic_index_paths.lock())
2267 .into_iter()
2268 .collect()
2269 }
2270
2271 pub fn mark_pending_semantic_corpus_refresh(&self) {
2272 *self.pending_semantic_corpus_refresh.lock() = true;
2273 }
2274
2275 pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
2276 std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock())
2277 }
2278
2279 pub fn clear_pending_index_updates(&self) {
2280 self.pending_search_index_paths.lock().clear();
2281 self.pending_callgraph_store_paths.lock().clear();
2282 self.pending_tier2_paths.lock().clear();
2283 self.pending_semantic_index_paths.lock().clear();
2284 *self.pending_semantic_corpus_refresh.lock() = false;
2285 }
2286
2287 #[doc(hidden)]
2291 pub fn flush_search_index_on_graceful_shutdown(&self) -> bool {
2292 if self.shared_artifacts_read_only() {
2293 return false;
2294 }
2295
2296 crate::runtime_drain::drain_watcher_events(self);
2297 crate::runtime_drain::drain_search_index_events(self);
2298
2299 if self
2300 .search_index_rx()
2301 .read()
2302 .unwrap_or_else(std::sync::PoisonError::into_inner)
2303 .is_some()
2304 {
2305 return false;
2306 }
2307
2308 let Some(canonical_root) = self.canonical_cache_root_opt() else {
2309 return false;
2310 };
2311 let config = self.config();
2312 let cache_dir =
2313 crate::search_index::resolve_cache_dir(&canonical_root, config.storage_dir.as_deref());
2314
2315 {
2316 let search_index = self
2317 .search_index()
2318 .read()
2319 .unwrap_or_else(std::sync::PoisonError::into_inner);
2320 let Some(index) = search_index.as_ref() else {
2321 return false;
2322 };
2323 if !index.ready || !index.has_pending_disk_changes() {
2324 return false;
2325 }
2326 }
2327
2328 let _cache_lock = match crate::search_index::CacheLock::try_acquire_for_shutdown(&cache_dir)
2329 {
2330 Ok(lock) => lock,
2331 Err(error) => {
2332 crate::slog_warn!(
2333 "search index: skipped shutdown flush because cache lock was unavailable: {}",
2334 error
2335 );
2336 return false;
2337 }
2338 };
2339
2340 let mut search_index = self
2341 .search_index()
2342 .write()
2343 .unwrap_or_else(std::sync::PoisonError::into_inner);
2344 let Some(index) = search_index.as_mut() else {
2345 return false;
2346 };
2347 if !index.ready || !index.has_pending_disk_changes() {
2348 return false;
2349 }
2350
2351 let git_head = index.stored_git_head().map(str::to_owned);
2352 index.write_to_disk(&cache_dir, git_head.as_deref());
2353 true
2354 }
2355
2356 pub fn inspect_manager(&self) -> Arc<InspectManager> {
2357 Arc::clone(&self.inspect_manager)
2358 }
2359
2360 pub fn add_pending_tier2_paths<I>(&self, paths: I)
2361 where
2362 I: IntoIterator<Item = PathBuf>,
2363 {
2364 self.pending_tier2_paths.lock().extend(paths);
2365 }
2366
2367 pub fn pending_tier2_paths(&self) -> Vec<PathBuf> {
2368 self.pending_tier2_paths.lock().iter().cloned().collect()
2369 }
2370
2371 pub fn remove_pending_tier2_paths<I>(&self, paths: I)
2372 where
2373 I: IntoIterator<Item = PathBuf>,
2374 {
2375 let mut pending = self.pending_tier2_paths.lock();
2376 for path in paths {
2377 pending.remove(&path);
2378 }
2379 }
2380
2381 pub fn take_new_reuse_completions(&self) -> bool {
2386 let current = self.inspect_manager.reuse_completion_count();
2387 let previous = self
2388 .last_seen_reuse_completions
2389 .swap(current, Ordering::SeqCst);
2390 current != previous
2391 }
2392
2393 pub fn reset_tier2_refresh_scheduler(&self) {
2394 self.reset_tier2_refresh_scheduler_at(Instant::now());
2395 }
2396
2397 #[doc(hidden)]
2398 pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
2399 self.tier2_refresh_scheduler
2400 .lock()
2401 .reset_after_configure(now);
2402 }
2403
2404 pub fn request_tier2_refresh_pull(&self) -> bool {
2405 let can_schedule = !self.is_worktree_bridge() && self.heavy_root_work_allowed();
2406 self.tier2_refresh_scheduler
2407 .lock()
2408 .request_pull(can_schedule)
2409 }
2410
2411 pub fn tick_tier2_refresh_scheduler(
2412 &self,
2413 changed_path_count: usize,
2414 ) -> Option<Tier2TriggerReason> {
2415 self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
2416 }
2417
2418 #[doc(hidden)]
2419 pub fn tick_tier2_refresh_scheduler_at(
2420 &self,
2421 now: Instant,
2422 changed_path_count: usize,
2423 ) -> Option<Tier2TriggerReason> {
2424 let manager = self.inspect_manager();
2425 let can_write = !self.is_worktree_bridge() && self.heavy_root_work_allowed();
2426 let in_flight = manager.tier2_any_in_flight();
2427 let semantic_cold_seed_active = self.semantic_cold_seed_active();
2428 let decision = self.tier2_refresh_scheduler.lock().tick_with_semantic_gate(
2429 now,
2430 changed_path_count,
2431 can_write,
2432 in_flight,
2433 semantic_cold_seed_active,
2434 );
2435
2436 if let Some(reason) = decision {
2437 self.start_tier2_refresh(reason, manager);
2438 }
2439
2440 decision
2441 }
2442
2443 pub fn note_tier2_refresh_started(&self) {
2444 self.note_tier2_refresh_started_at(Instant::now());
2445 }
2446
2447 #[doc(hidden)]
2448 pub fn note_tier2_refresh_started_at(&self, now: Instant) {
2449 self.tier2_refresh_scheduler
2450 .lock()
2451 .note_external_scan_started(now);
2452 }
2453
2454 pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
2455 self.tier2_refresh_scheduler
2456 .lock()
2457 .last_trigger_reason()
2458 .map(Tier2TriggerReason::as_str)
2459 }
2460
2461 #[doc(hidden)]
2462 pub fn tier2_pull_demand_pending(&self) -> bool {
2463 self.tier2_refresh_scheduler.lock().pull_demand_pending()
2464 }
2465
2466 fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
2467 if self.is_worktree_bridge()
2468 || !self.heavy_root_work_allowed()
2469 || !self.config().inspect.enabled
2470 {
2471 return;
2472 }
2473 let Some(snapshot) = self.tier2_refresh_snapshot() else {
2474 return;
2475 };
2476 let categories = InspectCategory::active()
2477 .iter()
2478 .copied()
2479 .filter(|category| category.is_tier2())
2480 .collect::<Vec<_>>();
2481 let submission =
2482 manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
2483 if !submission.deferred_categories.is_empty() {
2484 self.tier2_refresh_scheduler.lock().note_dispatch_deferred();
2485 crate::slog_info!(
2486 "tier2 refresh deferred by cold build limit: categories={:?}",
2487 submission
2488 .deferred_categories
2489 .iter()
2490 .map(|category| category.as_str())
2491 .collect::<Vec<_>>()
2492 );
2493 }
2494 if submission.has_new_work() {
2495 crate::slog_info!(
2496 "tier2 refresh scheduled: reason={}, categories={:?}",
2497 reason.as_str(),
2498 submission
2499 .newly_queued_categories
2500 .iter()
2501 .map(|category| category.as_str())
2502 .collect::<Vec<_>>()
2503 );
2504 }
2505 for error in submission.errors {
2506 crate::slog_warn!(
2507 "tier2 refresh schedule failed for {}: {}",
2508 error.category,
2509 error.message
2510 );
2511 }
2512 }
2513
2514 fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
2515 self.harness_opt()?;
2516 let config = self.config();
2517 let project_root = config
2518 .project_root
2519 .clone()
2520 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
2521 let project_root = std::fs::canonicalize(&project_root).unwrap_or(project_root);
2522 Some(InspectSnapshot::new(
2523 project_root,
2524 self.inspect_dir(),
2525 config,
2526 self.symbol_cache(),
2527 ))
2528 }
2529
2530 pub fn symbol_cache(&self) -> SharedSymbolCache {
2532 Arc::clone(&self.symbol_cache)
2533 }
2534
2535 pub fn reset_symbol_cache(&self) -> u64 {
2537 self.symbol_cache
2538 .write()
2539 .map(|mut cache| cache.reset())
2540 .unwrap_or(0)
2541 }
2542
2543 pub fn semantic_index(&self) -> &RwLock<Option<SemanticIndex>> {
2545 &self.semantic_index
2546 }
2547
2548 pub fn semantic_index_rx(
2550 &self,
2551 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
2552 &self.semantic_index_rx
2553 }
2554
2555 pub fn semantic_index_status(&self) -> &RwLock<SemanticIndexStatus> {
2556 &self.semantic_index_status
2557 }
2558
2559 pub fn reset_semantic_cold_seed_gate_for_configure(&self) -> u64 {
2562 self.semantic_cold_seed_active
2563 .store(false, Ordering::SeqCst);
2564 self.semantic_callgraph_warm_deferred
2565 .store(false, Ordering::SeqCst);
2566 self.semantic_cold_seed_generation
2567 .fetch_add(1, Ordering::SeqCst)
2568 .wrapping_add(1)
2569 }
2570
2571 pub fn semantic_cold_seed_active_flag(&self) -> Arc<AtomicBool> {
2572 Arc::clone(&self.semantic_cold_seed_active)
2573 }
2574
2575 pub fn semantic_cold_seed_generation_flag(&self) -> Arc<AtomicU64> {
2576 Arc::clone(&self.semantic_cold_seed_generation)
2577 }
2578
2579 pub fn semantic_cold_seed_active(&self) -> bool {
2580 self.semantic_cold_seed_active.load(Ordering::SeqCst)
2581 }
2582
2583 pub fn schedule_semantic_cold_seed_gate_for_configure(&self) {
2584 self.semantic_cold_seed_active.store(true, Ordering::SeqCst);
2585 }
2586
2587 pub fn defer_callgraph_store_warm_for_semantic_cold_seed(&self) {
2588 self.semantic_callgraph_warm_deferred
2589 .store(true, Ordering::SeqCst);
2590 }
2591
2592 fn semantic_callgraph_warm_deferred(&self) -> bool {
2593 self.semantic_callgraph_warm_deferred.load(Ordering::SeqCst)
2594 }
2595
2596 pub fn clear_semantic_cold_seed_gate_and_resume_deferred_work(&self) {
2600 self.resume_semantic_cold_seed_deferred_work(false);
2601 }
2602
2603 pub fn resume_deferred_work_after_semantic_cold_seed_gate_cleared(&self) {
2606 self.resume_semantic_cold_seed_deferred_work(true);
2607 }
2608
2609 fn resume_semantic_cold_seed_deferred_work(&self, force: bool) {
2610 let was_active = self.semantic_cold_seed_active.swap(false, Ordering::SeqCst);
2611 let had_deferred_callgraph = self.semantic_callgraph_warm_deferred();
2612
2613 if force || was_active || had_deferred_callgraph {
2614 let _ = self.request_tier2_refresh_pull();
2615 }
2616
2617 if self
2618 .semantic_callgraph_warm_deferred
2619 .swap(false, Ordering::SeqCst)
2620 {
2621 if !self.config().callgraph_store || !self.heavy_root_work_allowed() {
2622 return;
2623 }
2624
2625 match self.callgraph_store_for_ops() {
2626 CallgraphStoreAccess::Ready(_) => {
2627 crate::slog_debug!(
2628 "deferred callgraph store warm completed after semantic cold seed gate cleared"
2629 );
2630 }
2631 CallgraphStoreAccess::Building => {
2632 crate::slog_info!(
2633 "deferred callgraph store warm scheduled after semantic cold seed gate cleared"
2634 );
2635 }
2636 CallgraphStoreAccess::Unavailable => {
2637 crate::slog_info!(
2638 "deferred callgraph store warm unavailable after semantic cold seed gate cleared"
2639 );
2640 }
2641 CallgraphStoreAccess::Error(error) => {
2642 crate::slog_warn!(
2643 "deferred callgraph store warm failed after semantic cold seed gate cleared: {}",
2644 error
2645 );
2646 }
2647 }
2648 }
2649 }
2650
2651 #[doc(hidden)]
2652 pub fn set_semantic_cold_seed_active_for_test(&self, active: bool) {
2653 self.semantic_cold_seed_active
2654 .store(active, Ordering::SeqCst);
2655 }
2656
2657 #[doc(hidden)]
2658 pub fn semantic_callgraph_warm_deferred_for_test(&self) -> bool {
2659 self.semantic_callgraph_warm_deferred()
2660 }
2661
2662 pub fn install_semantic_refresh_worker(
2663 &self,
2664 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
2665 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
2666 worker_slot: SemanticRefreshWorkerSlot,
2667 ) {
2668 self.clear_semantic_refresh_worker();
2669 *self.semantic_refresh_tx.lock() = Some(sender);
2670 *self.semantic_refresh_event_rx.lock() = Some(event_rx);
2671 *self.semantic_refresh_worker.lock() = Some(worker_slot);
2672 }
2673
2674 pub fn clear_semantic_refresh_worker(&self) {
2675 *self.semantic_refresh_tx.lock() = None;
2676 *self.semantic_refresh_event_rx.lock() = None;
2677 if let Some(worker_slot) = self.semantic_refresh_worker.lock().take() {
2678 if let Ok(mut handle) = worker_slot.lock() {
2679 drop(handle.take());
2680 }
2681 }
2682 }
2683
2684 pub fn semantic_refresh_sender(
2685 &self,
2686 ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
2687 self.semantic_refresh_tx.lock().clone()
2688 }
2689
2690 pub fn semantic_refresh_event_rx(
2691 &self,
2692 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
2693 &self.semantic_refresh_event_rx
2694 }
2695
2696 pub fn with_semantic_refresh_retry_attempts_mut<R>(
2697 &self,
2698 f: impl FnOnce(&mut BTreeMap<PathBuf, usize>) -> R,
2699 ) -> R {
2700 let mut attempts = self.semantic_refresh_retry_attempts.lock();
2701 f(&mut attempts)
2702 }
2703
2704 pub fn clear_semantic_refresh_retry_attempts(&self, paths: &[PathBuf]) {
2705 let mut attempts = self.semantic_refresh_retry_attempts.lock();
2706 for path in paths {
2707 attempts.remove(path);
2708 }
2709 }
2710
2711 pub fn clear_all_semantic_refresh_retry_attempts(&self) {
2712 self.semantic_refresh_retry_attempts.lock().clear();
2713 }
2714
2715 pub fn semantic_refresh_circuit_is_open(&self) -> bool {
2716 self.semantic_refresh_circuit.open.load(Ordering::SeqCst)
2717 }
2718
2719 pub fn record_semantic_refresh_transient_failure(&self, trip_threshold: usize) -> bool {
2720 let failures = self
2721 .semantic_refresh_circuit
2722 .consecutive_transient_failures
2723 .fetch_add(1, Ordering::SeqCst)
2724 .saturating_add(1);
2725 if failures >= trip_threshold
2726 && !self
2727 .semantic_refresh_circuit
2728 .open
2729 .swap(true, Ordering::SeqCst)
2730 {
2731 crate::slog_warn!(
2732 "embedding backend appears down; suspending active retries, will resume on next change or successful probe"
2733 );
2734 }
2735 self.semantic_refresh_circuit_is_open()
2736 }
2737
2738 pub fn reset_semantic_refresh_transient_failure_count(&self) {
2739 self.semantic_refresh_circuit
2740 .consecutive_transient_failures
2741 .store(0, Ordering::SeqCst);
2742 }
2743
2744 pub fn reset_semantic_refresh_circuit_after_success(&self) {
2745 self.reset_semantic_refresh_transient_failure_count();
2746 self.semantic_refresh_circuit
2747 .probe_ready
2748 .store(false, Ordering::SeqCst);
2749 if self
2750 .semantic_refresh_circuit
2751 .open
2752 .swap(false, Ordering::SeqCst)
2753 {
2754 crate::slog_info!("embedding backend recovered; resuming normal refresh retries");
2755 }
2756 }
2757
2758 pub fn semantic_refresh_transient_failure_count(&self) -> usize {
2759 self.semantic_refresh_circuit
2760 .consecutive_transient_failures
2761 .load(Ordering::SeqCst)
2762 }
2763
2764 pub fn semantic_refresh_probe_is_scheduled(&self) -> bool {
2765 self.semantic_refresh_circuit
2766 .probe_in_flight
2767 .load(Ordering::SeqCst)
2768 || self
2769 .semantic_refresh_circuit
2770 .probe_ready
2771 .load(Ordering::SeqCst)
2772 }
2773
2774 pub fn take_semantic_refresh_probe_ready(&self) -> bool {
2775 self.semantic_refresh_circuit
2776 .probe_ready
2777 .swap(false, Ordering::SeqCst)
2778 }
2779
2780 pub fn ensure_semantic_refresh_probe_scheduled(&self, delay: Duration) {
2781 if self
2782 .semantic_refresh_circuit
2783 .probe_ready
2784 .load(Ordering::SeqCst)
2785 {
2786 return;
2787 }
2788 if self
2789 .semantic_refresh_circuit
2790 .probe_in_flight
2791 .swap(true, Ordering::SeqCst)
2792 {
2793 return;
2794 }
2795 if self
2796 .semantic_refresh_circuit
2797 .probe_ready
2798 .load(Ordering::SeqCst)
2799 {
2800 self.semantic_refresh_circuit
2801 .probe_in_flight
2802 .store(false, Ordering::SeqCst);
2803 return;
2804 }
2805
2806 let circuit = Arc::clone(&self.semantic_refresh_circuit);
2807 let session_id = crate::log_ctx::current_session();
2808 std::thread::spawn(move || {
2809 crate::log_ctx::with_session(session_id, || {
2810 std::thread::sleep(delay);
2811 circuit.probe_ready.store(true, Ordering::SeqCst);
2812 circuit.probe_in_flight.store(false, Ordering::SeqCst);
2813 });
2814 });
2815 }
2816
2817 pub fn semantic_embedding_model(
2819 &self,
2820 ) -> &parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>> {
2821 &self.semantic_embedding_model
2822 }
2823
2824 pub fn watcher(&self) -> &parking_lot::Mutex<Option<RecommendedWatcher>> {
2826 &self.watcher
2827 }
2828
2829 pub fn watcher_rx(
2831 &self,
2832 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>> {
2833 &self.watcher_rx
2834 }
2835
2836 pub fn install_watcher_runtime(
2839 &self,
2840 rx: crossbeam_channel::Receiver<WatcherDispatchEvent>,
2841 runtime: WatcherThreadHandle,
2842 ) {
2843 *self.watcher_rx.lock() = Some(rx);
2844 *self.watcher_thread.lock() = Some(runtime);
2845 }
2846
2847 pub fn stop_watcher_runtime(&self) {
2850 if let Some(runtime) = self.watcher_thread.lock().take() {
2851 runtime.shutdown_and_join();
2852 }
2853 *self.watcher_rx.lock() = None;
2854 *self.watcher.lock() = None;
2855 }
2856
2857 pub fn lsp(&self) -> parking_lot::MutexGuard<'_, LspManager> {
2859 self.lsp_manager.lock()
2860 }
2861
2862 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
2865 let config = self.config();
2866 if let Some(mut lsp) = self.lsp_manager.try_lock() {
2867 if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
2868 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
2869 }
2870 }
2871 }
2872
2873 pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
2879 if let Some(mut lsp) = self.lsp_manager.try_lock() {
2880 lsp.clear_diagnostics_for_file(file_path)
2881 } else {
2882 false
2883 }
2884 }
2885
2886 pub fn lsp_mark_diagnostics_stale_for_file(&self, file_path: &Path) -> StaleDiagnosticsMark {
2890 if let Some(mut lsp) = self.lsp_manager.try_lock() {
2891 lsp.mark_diagnostics_stale_for_file(file_path)
2892 } else {
2893 StaleDiagnosticsMark::default()
2894 }
2895 }
2896
2897 pub fn lsp_resync_changed_file_for_diagnostics(&self, file_path: &Path) -> bool {
2905 if !file_path.is_file() {
2906 return false;
2907 }
2908
2909 let content = match std::fs::read_to_string(file_path) {
2910 Ok(content) => content,
2911 Err(err) => {
2912 crate::slog_warn!(
2913 "skipping LSP resync for {} after external edit: {}",
2914 file_path.display(),
2915 err
2916 );
2917 return false;
2918 }
2919 };
2920
2921 let config = self.config();
2922 if let Some(mut lsp) = self.lsp_manager.try_lock() {
2923 if let Err(err) = lsp.notify_file_changed(file_path, &content, &config) {
2924 crate::slog_warn!(
2925 "LSP resync failed for {} after external edit: {}",
2926 file_path.display(),
2927 err
2928 );
2929 return false;
2930 }
2931 true
2932 } else {
2933 false
2934 }
2935 }
2936
2937 pub fn lsp_notify_and_collect_diagnostics(
2948 &self,
2949 file_path: &Path,
2950 content: &str,
2951 timeout: std::time::Duration,
2952 ) -> crate::lsp::manager::PostEditWaitOutcome {
2953 let config = self.config();
2954 let Some(mut lsp) = self.lsp_manager.try_lock() else {
2955 return crate::lsp::manager::PostEditWaitOutcome::default();
2956 };
2957
2958 lsp.drain_events();
2961
2962 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
2966
2967 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
2969 {
2970 Ok(v) => v,
2971 Err(e) => {
2972 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
2973 return crate::lsp::manager::PostEditWaitOutcome::default();
2974 }
2975 };
2976
2977 if expected_versions.is_empty() {
2980 return crate::lsp::manager::PostEditWaitOutcome::default();
2981 }
2982
2983 lsp.wait_for_post_edit_diagnostics(
2984 file_path,
2985 &config,
2986 &expected_versions,
2987 &pre_snapshot,
2988 timeout,
2989 )
2990 }
2991
2992 fn custom_lsp_root_markers(&self) -> Vec<String> {
2995 self.config()
2996 .lsp_servers
2997 .iter()
2998 .flat_map(|s| s.root_markers.iter().cloned())
2999 .collect()
3000 }
3001
3002 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
3003 let custom_markers = self.custom_lsp_root_markers();
3004 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
3005 .iter()
3006 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
3007 .cloned()
3008 .map(|path| {
3009 let change_type = if path.exists() {
3010 FileChangeType::CHANGED
3011 } else {
3012 FileChangeType::DELETED
3013 };
3014 (path, change_type)
3015 })
3016 .collect();
3017
3018 self.notify_watched_config_events(&config_paths);
3019 }
3020
3021 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
3022 let paths = params
3023 .get("multi_file_write_paths")
3024 .and_then(|value| value.as_array())?
3025 .iter()
3026 .filter_map(|value| value.as_str())
3027 .map(PathBuf::from)
3028 .collect::<Vec<_>>();
3029
3030 (!paths.is_empty()).then_some(paths)
3031 }
3032
3033 fn watched_file_events_from_params(
3045 params: &serde_json::Value,
3046 extra_markers: &[String],
3047 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
3048 let events = params
3049 .get("multi_file_write_paths")
3050 .and_then(|value| value.as_array())?
3051 .iter()
3052 .filter_map(|entry| {
3053 let path = entry
3055 .get("path")
3056 .and_then(|value| value.as_str())
3057 .map(PathBuf::from)?;
3058
3059 if !is_config_file_path_with_custom(&path, extra_markers) {
3060 return None;
3061 }
3062
3063 let change_type = entry
3064 .get("type")
3065 .and_then(|value| value.as_str())
3066 .and_then(Self::parse_file_change_type)
3067 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
3068
3069 Some((path, change_type))
3070 })
3071 .collect::<Vec<_>>();
3072
3073 (!events.is_empty()).then_some(events)
3074 }
3075
3076 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
3077 match value {
3078 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
3079 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
3080 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
3081 _ => None,
3082 }
3083 }
3084
3085 fn change_type_from_current_state(path: &Path) -> FileChangeType {
3086 if path.exists() {
3087 FileChangeType::CHANGED
3088 } else {
3089 FileChangeType::DELETED
3090 }
3091 }
3092
3093 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
3094 if config_paths.is_empty() {
3095 return;
3096 }
3097
3098 let config = self.config();
3099 if let Some(mut lsp) = self.lsp_manager.try_lock() {
3100 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
3101 crate::slog_warn!("watched-file sync error: {}", e);
3102 }
3103 }
3104 }
3105
3106 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
3107 let custom_markers = self.custom_lsp_root_markers();
3108 if !is_config_file_path_with_custom(file_path, &custom_markers) {
3109 return;
3110 }
3111
3112 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
3113 }
3114
3115 pub fn lsp_post_multi_file_write(
3120 &self,
3121 file_path: &Path,
3122 content: &str,
3123 file_paths: &[PathBuf],
3124 params: &serde_json::Value,
3125 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
3126 self.notify_watched_config_files(file_paths);
3127 self.add_pending_tier2_paths(file_paths.iter().cloned());
3128 let _ = self.mark_status_bar_tier2_stale();
3129
3130 let wants_diagnostics = params
3131 .get("diagnostics")
3132 .and_then(|v| v.as_bool())
3133 .unwrap_or(false);
3134
3135 if !wants_diagnostics {
3136 self.lsp_notify_file_changed(file_path, content);
3137 return None;
3138 }
3139
3140 let wait_ms = params
3141 .get("wait_ms")
3142 .and_then(|v| v.as_u64())
3143 .unwrap_or(3000)
3144 .min(10_000);
3145
3146 Some(self.lsp_notify_and_collect_diagnostics(
3147 file_path,
3148 content,
3149 std::time::Duration::from_millis(wait_ms),
3150 ))
3151 }
3152
3153 pub fn lsp_post_write(
3170 &self,
3171 file_path: &Path,
3172 content: &str,
3173 params: &serde_json::Value,
3174 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
3175 let wants_diagnostics = params
3176 .get("diagnostics")
3177 .and_then(|v| v.as_bool())
3178 .unwrap_or(false);
3179
3180 let custom_markers = self.custom_lsp_root_markers();
3181 if let Some(file_paths) = Self::multi_file_write_paths(params) {
3182 self.add_pending_tier2_paths(file_paths);
3183 } else {
3184 self.add_pending_tier2_paths([file_path.to_path_buf()]);
3185 }
3186 let _ = self.mark_status_bar_tier2_stale();
3187
3188 if !wants_diagnostics {
3189 if let Some(file_paths) = Self::multi_file_write_paths(params) {
3190 self.notify_watched_config_files(&file_paths);
3191 } else if let Some(config_events) =
3192 Self::watched_file_events_from_params(params, &custom_markers)
3193 {
3194 self.notify_watched_config_events(&config_events);
3195 }
3196 self.lsp_notify_file_changed(file_path, content);
3197 return None;
3198 }
3199
3200 let wait_ms = params
3201 .get("wait_ms")
3202 .and_then(|v| v.as_u64())
3203 .unwrap_or(3000)
3204 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
3207 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
3208 }
3209
3210 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
3211 {
3212 self.notify_watched_config_events(&config_events);
3213 }
3214
3215 Some(self.lsp_notify_and_collect_diagnostics(
3216 file_path,
3217 content,
3218 std::time::Duration::from_millis(wait_ms),
3219 ))
3220 }
3221
3222 pub fn validate_path(
3231 &self,
3232 req_id: &str,
3233 path: &Path,
3234 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
3235 let config = self.config();
3236 let force_restrict = self.request_force_restrict(req_id);
3237 let enforce = config.restrict_to_project_root || force_restrict;
3238 if !enforce {
3241 return Ok(path.to_path_buf());
3242 }
3243 let root = match &config.project_root {
3244 Some(r) => r.clone(),
3245 None if force_restrict => {
3246 return Err(crate::protocol::Response::error(
3247 req_id,
3248 "path_outside_root",
3249 "project root is required when path restriction is forced",
3250 ));
3251 }
3252 None => return Ok(path.to_path_buf()), };
3254 drop(config);
3255
3256 let raw_root = root.clone();
3261 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
3262
3263 let path_for_resolution = if path.is_relative() {
3268 raw_root.join(path)
3269 } else {
3270 path.to_path_buf()
3271 };
3272 let resolved = match std::fs::canonicalize(&path_for_resolution) {
3273 Ok(resolved) => resolved,
3274 Err(_) => {
3275 let normalized = normalize_path(&path_for_resolution);
3276 reject_escaping_symlink(
3277 req_id,
3278 &path_for_resolution,
3279 &normalized,
3280 &resolved_root,
3281 &raw_root,
3282 )?;
3283 resolve_with_existing_ancestors(&normalized)
3284 }
3285 };
3286
3287 if !resolved.starts_with(&resolved_root) {
3288 return Err(path_error_response(req_id, path, &resolved_root));
3289 }
3290
3291 Ok(resolved)
3292 }
3293
3294 pub fn lsp_server_count(&self) -> usize {
3296 self.lsp_manager
3297 .try_lock()
3298 .map(|lsp| lsp.server_count())
3299 .unwrap_or(0)
3300 }
3301
3302 pub fn symbol_cache_stats(&self) -> serde_json::Value {
3304 let entries = self
3305 .symbol_cache
3306 .read()
3307 .map(|cache| cache.len())
3308 .unwrap_or(0);
3309 serde_json::json!({
3310 "local_entries": entries,
3311 "warm_entries": 0,
3312 })
3313 }
3314}
3315
3316#[cfg(test)]
3317mod force_restrict_tests {
3318 use super::*;
3319 use crate::language::StubProvider;
3320 use tempfile::TempDir;
3321
3322 fn test_context(project_root: Option<PathBuf>, restrict_to_project_root: bool) -> AppContext {
3323 AppContext::new(
3324 Box::new(StubProvider),
3325 Config {
3326 project_root,
3327 restrict_to_project_root,
3328 ..Config::default()
3329 },
3330 )
3331 }
3332
3333 #[test]
3334 fn standalone_validate_path_parity_without_force_restrict() {
3335 let root = TempDir::new().expect("root tempdir");
3336 let outside = TempDir::new().expect("outside tempdir");
3337 let outside_path = outside.path().join("outside.txt");
3338
3339 let unrestricted = test_context(Some(root.path().to_path_buf()), false);
3340 assert_eq!(
3341 unrestricted
3342 .validate_path("standalone-unrestricted", &outside_path)
3343 .expect("unrestricted standalone validates"),
3344 outside_path
3345 );
3346
3347 let restricted = test_context(Some(root.path().to_path_buf()), true);
3348 let err = restricted
3349 .validate_path("standalone-restricted", &outside_path)
3350 .expect_err("restricted standalone rejects outside root");
3351 assert_eq!(
3352 serde_json::to_value(err).unwrap()["code"],
3353 "path_outside_root"
3354 );
3355 }
3356
3357 #[test]
3358 fn force_restrict_guard_refcounts_duplicate_request_ids() {
3359 let root = TempDir::new().expect("root tempdir");
3360 let outside = TempDir::new().expect("outside tempdir");
3361 let outside_path = outside.path().join("outside.txt");
3362 let ctx = test_context(Some(root.path().to_path_buf()), false);
3363
3364 assert!(ctx.validate_path("dup", &outside_path).is_ok());
3365 let guard1 = ctx.force_restrict_guard("dup");
3366 let guard2 = ctx.force_restrict_guard("dup");
3367 assert!(ctx.validate_path("dup", &outside_path).is_err());
3368 drop(guard1);
3369 assert!(
3370 ctx.validate_path("dup", &outside_path).is_err(),
3371 "duplicate guard must keep the request over-restricted"
3372 );
3373 drop(guard2);
3374 assert!(ctx.validate_path("dup", &outside_path).is_ok());
3375 }
3376
3377 #[test]
3378 fn with_force_restrict_cleans_up_after_normal_completion_and_panic() {
3379 let root = TempDir::new().expect("root tempdir");
3380 let outside = TempDir::new().expect("outside tempdir");
3381 let outside_path = outside.path().join("outside.txt");
3382 let ctx = test_context(Some(root.path().to_path_buf()), false);
3383
3384 ctx.with_force_restrict("normal", || {
3385 assert!(ctx.validate_path("normal", &outside_path).is_err());
3386 });
3387 assert!(!ctx.request_force_restrict("normal"));
3388 assert!(ctx.validate_path("normal", &outside_path).is_ok());
3389
3390 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3391 ctx.with_force_restrict("panic", || {
3392 assert!(ctx.validate_path("panic", &outside_path).is_err());
3393 panic!("intentional force-restrict cleanup panic");
3394 });
3395 }));
3396 assert!(panicked.is_err());
3397 assert!(!ctx.request_force_restrict("panic"));
3398 assert!(ctx.validate_path("panic", &outside_path).is_ok());
3399 }
3400
3401 #[test]
3402 fn forced_restrict_without_project_root_fails_closed() {
3403 let ctx = test_context(None, false);
3404 let _guard = ctx.force_restrict_guard("missing-root");
3405 let err = ctx
3406 .validate_path("missing-root", Path::new("relative.txt"))
3407 .expect_err("forced restriction without a root must fail closed");
3408 assert_eq!(
3409 serde_json::to_value(err).unwrap()["code"],
3410 "path_outside_root"
3411 );
3412 }
3413}
3414
3415#[cfg(test)]
3416mod callgraph_store_for_ops_tests {
3417 use super::*;
3418 use crate::inspect::{InspectCategory, InspectSnapshot, JobOutcome, JobScope};
3419 use crate::parser::TreeSitterProvider;
3420 use crate::protocol::RawRequest;
3421 use serde_json::json;
3422 use std::ffi::OsString;
3423 use std::path::Path;
3424 use std::sync::{Barrier, Mutex as StdMutex, MutexGuard, OnceLock};
3425 use tempfile::TempDir;
3426
3427 struct CallgraphWaitWindowEnvGuard {
3428 _guard: MutexGuard<'static, ()>,
3429 previous: Option<OsString>,
3430 }
3431
3432 impl Drop for CallgraphWaitWindowEnvGuard {
3433 fn drop(&mut self) {
3434 unsafe {
3437 match &self.previous {
3438 Some(value) => std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", value),
3439 None => std::env::remove_var("AFT_CALLGRAPH_BUILD_WAIT_MS"),
3440 }
3441 }
3442 }
3443 }
3444
3445 fn force_async_callgraph_builds() -> CallgraphWaitWindowEnvGuard {
3446 static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
3447 let guard = LOCK
3448 .get_or_init(|| StdMutex::new(()))
3449 .lock()
3450 .unwrap_or_else(|error| error.into_inner());
3451 let previous = std::env::var_os("AFT_CALLGRAPH_BUILD_WAIT_MS");
3452 unsafe {
3454 std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", "0");
3455 }
3456 CallgraphWaitWindowEnvGuard {
3457 _guard: guard,
3458 previous,
3459 }
3460 }
3461
3462 fn cold_build_context() -> Arc<AppContext> {
3463 let project = TempDir::new().expect("project tempdir");
3464 let storage = TempDir::new().expect("storage tempdir");
3465 let source_dir = project.path().join("src");
3466 std::fs::create_dir_all(&source_dir).expect("source dir");
3467 std::fs::write(
3468 source_dir.join("lib.rs"),
3469 "pub fn caller() { callee(); }\npub fn callee() {}\n",
3470 )
3471 .expect("source file");
3472
3473 Arc::new(AppContext::new(
3474 Box::new(TreeSitterProvider::new()),
3475 Config {
3476 project_root: Some(project.keep()),
3477 storage_dir: Some(storage.keep()),
3478 callgraph_chunk_size: 1,
3479 ..Config::default()
3480 },
3481 ))
3482 }
3483
3484 fn with_fake_home_env<R>(home: &Path, f: impl FnOnce() -> R) -> R {
3485 let _guard = crate::test_env::process_env_lock();
3486 let prev_home = std::env::var_os("HOME");
3487 let prev_userprofile = std::env::var_os("USERPROFILE");
3488 unsafe {
3489 std::env::set_var("HOME", home);
3490 std::env::set_var("USERPROFILE", home);
3491 }
3492 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
3493 unsafe {
3494 match prev_home {
3495 Some(value) => std::env::set_var("HOME", value),
3496 None => std::env::remove_var("HOME"),
3497 }
3498 match prev_userprofile {
3499 Some(value) => std::env::set_var("USERPROFILE", value),
3500 None => std::env::remove_var("USERPROFILE"),
3501 }
3502 }
3503 match result {
3504 Ok(value) => value,
3505 Err(payload) => std::panic::resume_unwind(payload),
3506 }
3507 }
3508
3509 fn configure_request_with_params(params: serde_json::Value) -> RawRequest {
3510 RawRequest {
3511 id: "cfg".to_string(),
3512 command: "configure".to_string(),
3513 lsp_hints: None,
3514 session_id: None,
3515 params,
3516 }
3517 }
3518
3519 fn user_tier(doc: serde_json::Value) -> serde_json::Value {
3520 json!({
3521 "tier": "user",
3522 "source": "/u/aft.jsonc",
3523 "doc": doc.to_string(),
3524 })
3525 }
3526
3527 fn configure_context(project_root: &Path, storage_dir: &Path) -> AppContext {
3528 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3529 let response = crate::commands::configure::handle_configure(
3530 &configure_request_with_params(json!({
3531 "project_root": project_root,
3532 "harness": "opencode",
3533 "storage_dir": storage_dir,
3534 "config": [user_tier(json!({
3535 "callgraph_store": true,
3536 "search_index": true,
3537 "semantic_search": true,
3538 }))],
3539 })),
3540 &ctx,
3541 );
3542 assert!(response.success, "configure should succeed: {response:?}");
3543 ctx
3544 }
3545
3546 fn inspect_snapshot(ctx: &AppContext) -> InspectSnapshot {
3547 InspectSnapshot::new(
3548 ctx.canonical_cache_root(),
3549 ctx.inspect_dir(),
3550 ctx.config(),
3551 ctx.symbol_cache(),
3552 )
3553 }
3554
3555 fn empty_semantic_index_for_ctx(ctx: &AppContext) -> SemanticIndex {
3556 let project_root = ctx
3557 .config()
3558 .project_root
3559 .clone()
3560 .expect("test context has a project root");
3561 let files: Vec<PathBuf> = Vec::new();
3562 let mut embed = |_texts: Vec<String>| -> Result<Vec<Vec<f32>>, String> { Ok(Vec::new()) };
3563 SemanticIndex::build(&project_root, &files, &mut embed, 1)
3564 .expect("empty semantic index should build")
3565 }
3566
3567 #[test]
3568 fn home_root_gate_blocks_callgraph_store_entry_points() {
3569 let _wait_guard = force_async_callgraph_builds();
3570 let home = TempDir::new().expect("home tempdir");
3571 let storage = TempDir::new().expect("storage tempdir");
3572 let source_dir = home.path().join("src");
3573 std::fs::create_dir_all(&source_dir).expect("source dir");
3574 std::fs::write(
3575 source_dir.join("lib.rs"),
3576 "pub fn caller() { callee(); }\npub fn callee() {}\n",
3577 )
3578 .expect("source file");
3579
3580 with_fake_home_env(home.path(), || {
3581 let ctx = configure_context(home.path(), storage.path());
3582 assert!(
3583 !ctx.heavy_root_work_allowed(),
3584 "HOME root configure must close the heavy-root-work gate"
3585 );
3586 assert_eq!(
3587 ctx.try_health_snapshot(home.path())
3588 .callgraph_store
3589 .as_ref()
3590 .map(|component| component.status),
3591 Some("disabled"),
3592 "HOME root health must not advertise callgraph building"
3593 );
3594
3595 reset_callgraph_cold_build_spawn_count_for_test();
3596 assert!(matches!(
3597 ctx.callgraph_store_for_ops(),
3598 CallgraphStoreAccess::Unavailable
3599 ));
3600 assert!(
3601 ctx.ensure_callgraph_store()
3602 .expect("ensure_callgraph_store should not error")
3603 .is_none(),
3604 "shared gate must also block synchronous standalone callgraph builds"
3605 );
3606 assert_eq!(
3607 callgraph_cold_build_spawn_count_for_test(),
3608 0,
3609 "HOME root gate must not spawn a cold callgraph build"
3610 );
3611 });
3612 }
3613
3614 #[test]
3615 fn home_root_gate_blocks_inspect_manager_submit_paths() {
3616 let home = TempDir::new().expect("home tempdir");
3617 let storage = TempDir::new().expect("storage tempdir");
3618 let source_dir = home.path().join("src");
3619 std::fs::create_dir_all(&source_dir).expect("source dir");
3620 std::fs::write(source_dir.join("lib.rs"), "pub fn one() {}\n").expect("source file");
3621
3622 with_fake_home_env(home.path(), || {
3623 let ctx = configure_context(home.path(), storage.path());
3624 let snapshot = inspect_snapshot(&ctx);
3625 let scope = JobScope::for_project(snapshot.project_root.clone());
3626 let manager = ctx.inspect_manager();
3627
3628 assert!(matches!(
3629 manager.submit_category(snapshot.clone(), InspectCategory::Metrics, scope.clone()),
3630 JobOutcome::Failed { .. }
3631 ));
3632
3633 let submission = manager.submit_tier2_run_with_reuse_serial_background(
3634 snapshot,
3635 vec![InspectCategory::DeadCode],
3636 );
3637 assert!(submission.queued_categories.is_empty());
3638 assert!(submission.newly_queued_categories.is_empty());
3639 assert!(submission.deferred_categories.is_empty());
3640 assert_eq!(submission.errors.len(), 1);
3641 assert!(
3642 !manager.tier2_any_in_flight(),
3643 "HOME root gate must reject Tier-2 submission before any job is queued"
3644 );
3645 });
3646 }
3647
3648 #[test]
3649 fn non_home_root_still_allows_callgraph_cold_builds() {
3650 let _env_guard = force_async_callgraph_builds();
3651 reset_callgraph_cold_build_spawn_count_for_test();
3652 let ctx = cold_build_context();
3653
3654 assert!(ctx.heavy_root_work_allowed());
3655 assert!(matches!(
3656 ctx.callgraph_store_for_ops(),
3657 CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
3658 ));
3659 assert_eq!(
3660 callgraph_cold_build_spawn_count_for_test(),
3661 1,
3662 "non-home roots must still be able to cold-build the callgraph store"
3663 );
3664
3665 let rx = ctx
3666 .callgraph_store_rx
3667 .lock()
3668 .as_ref()
3669 .cloned()
3670 .expect("non-home cold build should install an in-flight receiver");
3671 rx.recv_timeout(Duration::from_secs(30))
3672 .expect("background cold build should complete");
3673 *ctx.callgraph_store_rx.lock() = None;
3674 }
3675
3676 #[test]
3677 fn semantic_ready_event_resumes_deferred_callgraph_and_tier2() {
3678 let _env_guard = force_async_callgraph_builds();
3679 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
3680 let ctx = cold_build_context();
3681 let (tx, rx) = crossbeam_channel::unbounded();
3682 *ctx.semantic_index_rx().lock() = Some(rx);
3683 ctx.schedule_semantic_cold_seed_gate_for_configure();
3684
3685 assert!(matches!(
3686 ctx.callgraph_store_for_ops(),
3687 CallgraphStoreAccess::Building
3688 ));
3689 assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
3690 tx.send(SemanticIndexEvent::Ready(empty_semantic_index_for_ctx(
3691 &ctx,
3692 )))
3693 .expect("send ready event");
3694
3695 crate::runtime_drain::drain_semantic_index_events(&ctx);
3696
3697 assert!(
3698 !ctx.semantic_cold_seed_active(),
3699 "semantic Ready must clear the scheduled cold gate"
3700 );
3701 assert!(
3702 ctx.tier2_pull_demand_pending(),
3703 "semantic Ready must resume deferred Tier-2 work"
3704 );
3705 assert_eq!(
3706 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3707 1,
3708 "semantic Ready must resume the deferred callgraph warm"
3709 );
3710 let rx = ctx
3711 .callgraph_store_rx
3712 .lock()
3713 .as_ref()
3714 .cloned()
3715 .expect("ready resume should install an in-flight callgraph receiver");
3716 rx.recv_timeout(Duration::from_secs(30))
3717 .expect("background cold build should complete");
3718 *ctx.callgraph_store_rx.lock() = None;
3719 }
3720
3721 #[test]
3722 fn semantic_gate_cleared_event_resumes_deferred_callgraph_and_tier2() {
3723 let _env_guard = force_async_callgraph_builds();
3724 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
3725 let ctx = cold_build_context();
3726 ctx.schedule_semantic_cold_seed_gate_for_configure();
3727
3728 assert!(matches!(
3729 ctx.callgraph_store_for_ops(),
3730 CallgraphStoreAccess::Building
3731 ));
3732 assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
3733 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
3734
3735 assert!(
3736 !ctx.semantic_cold_seed_active(),
3737 "cached-load or retry-wait clear must reopen the semantic cold gate"
3738 );
3739 assert!(
3740 ctx.tier2_pull_demand_pending(),
3741 "cached-load or retry-wait clear must resume deferred Tier-2 work"
3742 );
3743 assert_eq!(
3744 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3745 1,
3746 "cached-load or retry-wait clear must resume deferred callgraph warm"
3747 );
3748 let rx = ctx
3749 .callgraph_store_rx
3750 .lock()
3751 .as_ref()
3752 .cloned()
3753 .expect("gate-clear resume should install an in-flight callgraph receiver");
3754 rx.recv_timeout(Duration::from_secs(30))
3755 .expect("background cold build should complete");
3756 *ctx.callgraph_store_rx.lock() = None;
3757 }
3758
3759 #[test]
3760 fn semantic_cold_seed_gate_defers_callgraph_cold_spawn_until_resume() {
3761 let _env_guard = force_async_callgraph_builds();
3762 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
3763 let ctx = cold_build_context();
3764
3765 ctx.set_semantic_cold_seed_active_for_test(true);
3766 assert!(
3767 matches!(
3768 ctx.callgraph_store_for_ops(),
3769 CallgraphStoreAccess::Building
3770 ),
3771 "callgraph ops should degrade as building while the semantic cold gate is active"
3772 );
3773 assert_eq!(
3774 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3775 0,
3776 "semantic cold gate must not spawn a competing callgraph cold build"
3777 );
3778 assert!(ctx.semantic_callgraph_warm_deferred_for_test());
3779
3780 ctx.clear_semantic_cold_seed_gate_and_resume_deferred_work();
3781 assert_eq!(
3782 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3783 1,
3784 "clearing the semantic cold gate should resume the deferred callgraph warm"
3785 );
3786
3787 let rx = ctx
3788 .callgraph_store_rx
3789 .lock()
3790 .as_ref()
3791 .cloned()
3792 .expect("deferred warm should install an in-flight receiver");
3793 rx.recv_timeout(Duration::from_secs(30))
3794 .expect("background cold build should complete");
3795 *ctx.callgraph_store_rx.lock() = None;
3796 }
3797
3798 #[test]
3799 fn semantic_cold_seed_gate_clear_requests_tier2_pull() {
3800 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3801 ctx.schedule_semantic_cold_seed_gate_for_configure();
3802
3803 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
3804
3805 assert!(
3806 !ctx.semantic_cold_seed_active(),
3807 "retry-wait or cached-load events must reopen the semantic cold gate"
3808 );
3809 assert!(
3810 ctx.tier2_pull_demand_pending(),
3811 "clearing the semantic cold gate should kick a Tier-2 pull refresh"
3812 );
3813 }
3814
3815 #[test]
3816 fn semantic_failed_event_clears_scheduled_gate_and_requests_tier2_pull() {
3817 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3818 let (tx, rx) = crossbeam_channel::unbounded();
3819 *ctx.semantic_index_rx().lock() = Some(rx);
3820 ctx.schedule_semantic_cold_seed_gate_for_configure();
3821 tx.send(SemanticIndexEvent::Failed(
3822 "embedding backend failed".to_string(),
3823 ))
3824 .expect("send failed event");
3825
3826 crate::runtime_drain::drain_semantic_index_events(&ctx);
3827
3828 assert!(
3829 !ctx.semantic_cold_seed_active(),
3830 "semantic Failed must clear the scheduled cold gate"
3831 );
3832 assert!(
3833 ctx.tier2_pull_demand_pending(),
3834 "semantic Failed must resume deferred Tier-2 work"
3835 );
3836 }
3837
3838 #[test]
3839 fn semantic_disconnect_clears_scheduled_gate_and_requests_tier2_pull() {
3840 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3841 let (tx, rx) = crossbeam_channel::unbounded::<SemanticIndexEvent>();
3842 *ctx.semantic_index_rx().lock() = Some(rx);
3843 ctx.schedule_semantic_cold_seed_gate_for_configure();
3844 drop(tx);
3845
3846 crate::runtime_drain::drain_semantic_index_events(&ctx);
3847
3848 assert!(
3849 !ctx.semantic_cold_seed_active(),
3850 "semantic worker disconnect must clear the scheduled cold gate"
3851 );
3852 assert!(
3853 ctx.tier2_pull_demand_pending(),
3854 "semantic worker disconnect must resume deferred Tier-2 work"
3855 );
3856 }
3857
3858 #[test]
3859 fn semantic_cold_seed_gate_is_per_context_for_tier2_scheduler() {
3860 let ctx_a = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3861 let ctx_b = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3862 let base = Instant::now();
3863 ctx_a.reset_tier2_refresh_scheduler_at(base);
3864 ctx_b.reset_tier2_refresh_scheduler_at(base);
3865 ctx_a.set_semantic_cold_seed_active_for_test(true);
3866
3867 assert_eq!(
3868 ctx_a.tick_tier2_refresh_scheduler_at(
3869 base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
3870 0,
3871 ),
3872 None,
3873 "root A should defer Tier-2 while its semantic cold seed is active"
3874 );
3875 assert_eq!(
3876 ctx_b.tick_tier2_refresh_scheduler_at(
3877 base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
3878 0,
3879 ),
3880 Some(Tier2TriggerReason::ConfigureWarm),
3881 "root B must not inherit root A's semantic cold gate"
3882 );
3883 }
3884
3885 #[test]
3886 fn concurrent_cold_callgraph_store_for_ops_spawns_one_build() {
3887 let _env_guard = force_async_callgraph_builds();
3888 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
3889
3890 let project = TempDir::new().expect("project tempdir");
3891 let storage = TempDir::new().expect("storage tempdir");
3892 let source_dir = project.path().join("src");
3893 std::fs::create_dir_all(&source_dir).expect("source dir");
3894 std::fs::write(
3895 source_dir.join("lib.rs"),
3896 "pub fn caller() { callee(); }\npub fn callee() {}\n",
3897 )
3898 .expect("source file");
3899
3900 let ctx = Arc::new(AppContext::new(
3901 Box::new(TreeSitterProvider::new()),
3902 Config {
3903 project_root: Some(project.path().to_path_buf()),
3904 storage_dir: Some(storage.path().to_path_buf()),
3905 callgraph_chunk_size: 1,
3906 ..Config::default()
3907 },
3908 ));
3909
3910 let barrier = Arc::new(Barrier::new(3));
3911 let handles = (0..2)
3912 .map(|_| {
3913 let ctx = Arc::clone(&ctx);
3914 let barrier = Arc::clone(&barrier);
3915 std::thread::spawn(move || {
3916 barrier.wait();
3917 matches!(
3918 ctx.callgraph_store_for_ops(),
3919 CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
3920 )
3921 })
3922 })
3923 .collect::<Vec<_>>();
3924
3925 barrier.wait();
3926 for handle in handles {
3927 assert!(
3928 handle.join().expect("callgraph caller thread"),
3929 "cold callgraph ops should report Building or observe the installed store"
3930 );
3931 }
3932
3933 assert_eq!(
3934 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3935 1,
3936 "concurrent cold callers must share one background build"
3937 );
3938
3939 let rx = ctx
3940 .callgraph_store_rx
3941 .lock()
3942 .as_ref()
3943 .cloned()
3944 .expect("in-flight receiver installed before spawn");
3945 rx.recv_timeout(Duration::from_secs(30))
3946 .expect("background cold build should complete");
3947 *ctx.callgraph_store_rx.lock() = None;
3948 }
3949}
3950
3951#[cfg(test)]
3952mod status_emitter_tests {
3953 use super::*;
3954 use crate::parser::TreeSitterProvider;
3955
3956 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
3957 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3958 let (tx, rx) = mpsc::channel();
3959 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
3960 let _ = tx.send(frame);
3961 }))));
3962 (ctx, rx)
3963 }
3964
3965 #[test]
3966 fn status_emitter_signal_triggers_push() {
3967 let (ctx, rx) = ctx_with_frame_rx();
3968 ctx.status_emitter().signal(ctx.build_status_snapshot());
3969 let frame = rx
3970 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3971 .expect("status_changed push");
3972 assert!(matches!(frame, PushFrame::StatusChanged(_)));
3973 }
3974
3975 #[test]
3976 fn status_emitter_debounces_burst() {
3977 let (ctx, rx) = ctx_with_frame_rx();
3978 for _ in 0..10 {
3979 ctx.status_emitter().signal(ctx.build_status_snapshot());
3980 }
3981 let frame = rx
3982 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3983 .expect("status_changed push");
3984 assert!(matches!(frame, PushFrame::StatusChanged(_)));
3985 assert!(rx.try_recv().is_err());
3986 }
3987
3988 #[test]
3989 fn status_emitter_separate_windows_separate_pushes() {
3990 let (ctx, rx) = ctx_with_frame_rx();
3991 ctx.status_emitter().signal(ctx.build_status_snapshot());
3992 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3993 .expect("first push");
3994 ctx.status_emitter().signal(ctx.build_status_snapshot());
3995 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3996 .expect("second push");
3997 }
3998
3999 #[test]
4000 fn status_emitter_no_signal_no_push() {
4001 let (_ctx, rx) = ctx_with_frame_rx();
4002 assert!(rx
4003 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
4004 .is_err());
4005 }
4006
4007 #[test]
4008 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
4009 let (ctx, rx) = ctx_with_frame_rx();
4010 drop(ctx);
4011 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
4012 }
4013
4014 #[test]
4015 fn progress_sender_slot_is_per_context_for_shared_app() {
4016 let app = App::default_shared();
4017 let ctx_a = AppContext::from_app(Arc::clone(&app), Config::default());
4018 let ctx_b = AppContext::from_app(app, Config::default());
4019 let (tx_a, rx_a) = mpsc::channel();
4020 let (tx_b, rx_b) = mpsc::channel();
4021
4022 ctx_a.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
4023 let _ = tx_a.send(frame);
4024 }))));
4025 ctx_b.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
4026 let _ = tx_b.send(frame);
4027 }))));
4028
4029 ctx_a.emit_progress(ProgressFrame {
4030 frame_type: "progress",
4031 request_id: "ctx-a".to_string(),
4032 kind: crate::protocol::ProgressKind::Stdout,
4033 chunk: "a".to_string(),
4034 });
4035 ctx_b.emit_progress(ProgressFrame {
4036 frame_type: "progress",
4037 request_id: "ctx-b".to_string(),
4038 kind: crate::protocol::ProgressKind::Stdout,
4039 chunk: "b".to_string(),
4040 });
4041
4042 match rx_a
4043 .recv_timeout(Duration::from_millis(50))
4044 .expect("ctx A progress frame")
4045 {
4046 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-a"),
4047 other => panic!("unexpected frame for ctx A: {other:?}"),
4048 }
4049 assert!(rx_a.try_recv().is_err());
4050
4051 match rx_b
4052 .recv_timeout(Duration::from_millis(50))
4053 .expect("ctx B progress frame")
4054 {
4055 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-b"),
4056 other => panic!("unexpected frame for ctx B: {other:?}"),
4057 }
4058 assert!(rx_b.try_recv().is_err());
4059 }
4060}
4061
4062#[cfg(test)]
4063mod status_bar_tests {
4064 use super::*;
4065 use crate::parser::TreeSitterProvider;
4066
4067 fn ctx() -> AppContext {
4068 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
4069 }
4070
4071 #[test]
4072 fn status_bar_counts_none_until_tier2_populated() {
4073 let ctx = ctx();
4074 assert!(ctx.status_bar_counts().is_none());
4076
4077 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
4078 let counts = ctx.status_bar_counts().expect("populated");
4079 assert_eq!(counts.dead_code, 5);
4080 assert_eq!(counts.unused_exports, 3);
4081 assert_eq!(counts.duplicates, 7);
4082 assert_eq!(counts.todos, 2);
4083 assert!(!counts.tier2_stale);
4084 assert_eq!(counts.errors, 0);
4086 assert_eq!(counts.warnings, 0);
4087 }
4088
4089 #[test]
4090 fn partial_tier2_does_not_fabricate_zeros() {
4091 let ctx = ctx();
4092 ctx.update_status_bar_tier2(Some(5), None, None, None, true);
4096 assert!(
4097 ctx.status_bar_counts().is_none(),
4098 "bar must not surface until all three Tier-2 categories are real"
4099 );
4100
4101 ctx.update_status_bar_tier2(None, Some(3), None, None, true);
4103 assert!(ctx.status_bar_counts().is_none());
4104
4105 ctx.update_status_bar_tier2(None, None, Some(7), None, false);
4108 let counts = ctx.status_bar_counts().expect("all three real now");
4109 assert_eq!(counts.dead_code, 5);
4110 assert_eq!(counts.unused_exports, 3);
4111 assert_eq!(counts.duplicates, 7);
4112 }
4113
4114 #[test]
4115 fn update_with_none_todos_preserves_last_known_todos() {
4116 let ctx = ctx();
4117 ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
4118 ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
4120 let counts = ctx.status_bar_counts().expect("populated");
4121 assert_eq!(counts.todos, 9);
4122 assert_eq!(counts.dead_code, 2);
4123 }
4124
4125 #[test]
4126 fn update_with_none_count_preserves_last_known_count() {
4127 let ctx = ctx();
4128 ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
4129 ctx.update_status_bar_tier2(Some(11), None, None, None, false);
4132 let counts = ctx.status_bar_counts().expect("populated");
4133 assert_eq!(counts.dead_code, 11);
4134 assert_eq!(counts.unused_exports, 20);
4135 assert_eq!(counts.duplicates, 30);
4136 }
4137
4138 #[test]
4139 fn mark_stale_sets_flag_only_after_populate() {
4140 let ctx = ctx();
4141 ctx.mark_status_bar_tier2_stale();
4143 assert!(ctx.status_bar_counts().is_none());
4144
4145 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), Some(0), false);
4146 ctx.mark_status_bar_tier2_stale();
4147 assert!(ctx.status_bar_counts().expect("populated").tier2_stale);
4148
4149 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), None, false);
4151 assert!(!ctx.status_bar_counts().expect("populated").tier2_stale);
4152 }
4153
4154 #[test]
4159 fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
4160 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
4161 use crate::lsp::registry::ServerKind;
4162 use crate::lsp::roots::ServerKey;
4163
4164 let ctx = ctx();
4165 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); let file = std::path::PathBuf::from("/proj/gone.ts");
4168 {
4169 let mut lsp = ctx.lsp();
4170 lsp.diagnostics_store_mut_for_test().publish(
4171 ServerKey {
4172 kind: ServerKind::TypeScript,
4173 root: std::path::PathBuf::from("/proj"),
4174 },
4175 file.clone(),
4176 vec![StoredDiagnostic {
4177 file: file.clone(),
4178 line: 1,
4179 column: 1,
4180 end_line: 1,
4181 end_column: 2,
4182 severity: DiagnosticSeverity::Error,
4183 message: "boom".into(),
4184 code: None,
4185 source: None,
4186 }],
4187 );
4188 }
4189
4190 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
4192
4193 let removed = ctx.lsp_clear_diagnostics_for_file(&file);
4195 assert!(removed);
4196 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
4197 }
4198
4199 #[test]
4200 fn status_bar_filtered_counts_ignore_environmental_flap() {
4201 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
4202 use crate::lsp::registry::ServerKind;
4203 use crate::lsp::roots::ServerKey;
4204
4205 let ctx = ctx();
4206 let root = if cfg!(windows) {
4207 std::path::PathBuf::from(r"C:\proj")
4208 } else {
4209 std::path::PathBuf::from("/proj")
4210 };
4211 ctx.set_canonical_cache_root(root.clone());
4212 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
4213
4214 let file = root.join("aft.jsonc");
4215 let key = ServerKey {
4216 kind: ServerKind::TypeScript,
4217 root: root.clone(),
4218 };
4219 let env = StoredDiagnostic {
4220 file: file.clone(),
4221 line: 1,
4222 column: 1,
4223 end_line: 1,
4224 end_column: 2,
4225 severity: DiagnosticSeverity::Error,
4226 message: "Failed to load schema from https://example.com/schema.json".into(),
4227 code: None,
4228 source: Some("json".into()),
4229 };
4230
4231 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
4232
4233 {
4234 let mut lsp = ctx.lsp();
4235 lsp.diagnostics_store_mut_for_test()
4236 .publish(key.clone(), file.clone(), vec![env]);
4237 }
4238 assert_eq!(
4239 ctx.status_bar_counts().expect("populated").errors,
4240 0,
4241 "environmental publish must not change status-bar E"
4242 );
4243
4244 {
4245 let mut lsp = ctx.lsp();
4246 lsp.diagnostics_store_mut_for_test()
4247 .publish(key, file, vec![]);
4248 }
4249 assert_eq!(
4250 ctx.status_bar_counts().expect("populated").errors,
4251 0,
4252 "environmental clear must not change status-bar E"
4253 );
4254 }
4255}
4256
4257#[cfg(test)]
4258mod harness_path_tests {
4259 use super::*;
4260 use crate::harness::Harness;
4261 use crate::parser::TreeSitterProvider;
4262
4263 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
4264 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
4265 ctx.update_config(|config| {
4266 config.storage_dir = Some(storage_dir);
4267 });
4268 ctx.set_harness(harness);
4269 ctx
4270 }
4271
4272 #[test]
4273 fn harness_dir_resolves_correctly() {
4274 let storage = PathBuf::from("/tmp/cortexkit/aft");
4275 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
4276
4277 assert_eq!(ctx.harness_dir(), storage.join("pi"));
4278 }
4279
4280 #[test]
4281 fn bash_tasks_dir_uses_hash_session() {
4282 let storage = PathBuf::from("/tmp/cortexkit/aft");
4283 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
4284
4285 assert_eq!(
4286 ctx.bash_tasks_dir("ses_abc"),
4287 storage
4288 .join("opencode")
4289 .join("bash-tasks")
4290 .join(hash_session("ses_abc"))
4291 );
4292 }
4293
4294 #[test]
4295 fn backups_dir_includes_path_hash() {
4296 let storage = PathBuf::from("/tmp/cortexkit/aft");
4297 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
4298
4299 assert_eq!(
4300 ctx.backups_dir("ses_abc", "pathhash"),
4301 storage
4302 .join("pi")
4303 .join("backups")
4304 .join(hash_session("ses_abc"))
4305 .join("pathhash")
4306 );
4307 }
4308
4309 #[test]
4310 fn filters_dir_under_harness() {
4311 let storage = PathBuf::from("/tmp/cortexkit/aft");
4312 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
4313
4314 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
4315 }
4316
4317 #[test]
4318 fn trust_file_is_host_global() {
4319 let storage = PathBuf::from("/tmp/cortexkit/aft");
4320 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
4321
4322 assert_eq!(
4323 ctx.trust_file(),
4324 storage.join("trusted-filter-projects.json")
4325 );
4326 }
4327
4328 #[test]
4329 fn same_session_different_harness_resolve_different_paths() {
4330 let storage = PathBuf::from("/tmp/cortexkit/aft");
4331 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
4332 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
4333
4334 assert_ne!(
4335 opencode.bash_tasks_dir("ses_same"),
4336 pi.bash_tasks_dir("ses_same")
4337 );
4338 }
4339}
4340
4341#[cfg(test)]
4342mod gitignore_tests {
4343 use super::*;
4344 use std::fs;
4345 use std::path::Path;
4346 use tempfile::TempDir;
4347
4348 fn make_ctx_with_root(root: &Path) -> AppContext {
4349 let provider = Box::new(crate::parser::TreeSitterProvider::new());
4350 let config = Config {
4351 project_root: Some(root.to_path_buf()),
4352 ..Config::default()
4353 };
4354 AppContext::new(provider, config)
4355 }
4356
4357 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
4364 let Some(matcher) = ctx.gitignore() else {
4365 return false;
4366 };
4367 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
4368 if !canonical.starts_with(matcher.path()) {
4369 return false;
4370 }
4371 let is_dir = canonical.is_dir();
4372 matcher
4373 .matched_path_or_any_parents(&canonical, is_dir)
4374 .is_ignore()
4375 }
4376
4377 fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
4390 let _guard = crate::test_env::process_env_lock();
4391 let tmp = TempDir::new().unwrap();
4392 let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");
4393 let prev_home = std::env::var_os("HOME");
4394 let prev_userprofile = std::env::var_os("USERPROFILE");
4395 unsafe {
4398 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
4399 std::env::set_var("HOME", tmp.path());
4400 std::env::set_var("USERPROFILE", tmp.path());
4401 }
4402 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
4403 unsafe {
4404 match prev_xdg {
4405 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
4406 None => std::env::remove_var("XDG_CONFIG_HOME"),
4407 }
4408 match prev_home {
4409 Some(v) => std::env::set_var("HOME", v),
4410 None => std::env::remove_var("HOME"),
4411 }
4412 match prev_userprofile {
4413 Some(v) => std::env::set_var("USERPROFILE", v),
4414 None => std::env::remove_var("USERPROFILE"),
4415 }
4416 }
4417 match result {
4418 Ok(r) => r,
4419 Err(p) => std::panic::resume_unwind(p),
4420 }
4421 }
4422
4423 #[test]
4424 fn rebuild_gitignore_returns_none_without_project_root() {
4425 let provider = Box::new(crate::parser::TreeSitterProvider::new());
4426 let ctx = AppContext::new(provider, Config::default());
4427 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
4428 assert!(ctx.gitignore().is_none());
4429 }
4430
4431 #[test]
4432 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
4433 let tmp = TempDir::new().unwrap();
4434 let ctx = make_ctx_with_root(tmp.path());
4435 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
4436 assert!(ctx.gitignore().is_none());
4437 }
4438
4439 #[test]
4440 fn matcher_filters_files_in_ignored_dist_dir() {
4441 let tmp = TempDir::new().unwrap();
4442 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
4443 fs::create_dir_all(tmp.path().join("dist")).unwrap();
4444 fs::create_dir_all(tmp.path().join("src")).unwrap();
4445 let dist_file = tmp.path().join("dist").join("bundle.js");
4446 let src_file = tmp.path().join("src").join("app.ts");
4447 fs::write(&dist_file, "x").unwrap();
4448 fs::write(&src_file, "y").unwrap();
4449
4450 let ctx = make_ctx_with_root(tmp.path());
4451 ctx.rebuild_gitignore();
4452
4453 assert!(ctx.gitignore().is_some());
4454 assert!(
4455 is_ignored(&ctx, &dist_file),
4456 "dist/bundle.js should be ignored"
4457 );
4458 assert!(
4459 !is_ignored(&ctx, &src_file),
4460 "src/app.ts should NOT be ignored"
4461 );
4462 }
4463
4464 #[test]
4465 fn matcher_handles_node_modules_and_target() {
4466 let tmp = TempDir::new().unwrap();
4467 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
4468 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
4469 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
4470 let nm_file = tmp.path().join("node_modules/foo/index.js");
4471 let target_file = tmp.path().join("target/debug/aft");
4472 fs::write(&nm_file, "x").unwrap();
4473 fs::write(&target_file, "x").unwrap();
4474
4475 let ctx = make_ctx_with_root(tmp.path());
4476 ctx.rebuild_gitignore();
4477
4478 assert!(is_ignored(&ctx, &nm_file));
4479 assert!(is_ignored(&ctx, &target_file));
4480 }
4481
4482 #[test]
4483 fn matcher_honors_negation_pattern() {
4484 let tmp = TempDir::new().unwrap();
4486 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
4487 let random_log = tmp.path().join("random.log");
4488 let important_log = tmp.path().join("important.log");
4489 fs::write(&random_log, "x").unwrap();
4490 fs::write(&important_log, "y").unwrap();
4491
4492 let ctx = make_ctx_with_root(tmp.path());
4493 ctx.rebuild_gitignore();
4494
4495 assert!(is_ignored(&ctx, &random_log));
4496 assert!(
4497 !is_ignored(&ctx, &important_log),
4498 "negation pattern should un-ignore important.log"
4499 );
4500 }
4501
4502 #[test]
4503 fn rebuild_picks_up_gitignore_changes() {
4504 let tmp = TempDir::new().unwrap();
4505 let ignore_path = tmp.path().join(".gitignore");
4506 fs::write(&ignore_path, "foo.txt\n").unwrap();
4507 let foo = tmp.path().join("foo.txt");
4508 let bar = tmp.path().join("bar.txt");
4509 fs::write(&foo, "").unwrap();
4510 fs::write(&bar, "").unwrap();
4511
4512 let ctx = make_ctx_with_root(tmp.path());
4513 ctx.rebuild_gitignore();
4514 assert!(is_ignored(&ctx, &foo));
4515 assert!(!is_ignored(&ctx, &bar));
4516
4517 fs::write(&ignore_path, "bar.txt\n").unwrap();
4519 ctx.rebuild_gitignore();
4520 assert!(!is_ignored(&ctx, &foo));
4521 assert!(is_ignored(&ctx, &bar));
4522 }
4523
4524 #[test]
4525 fn gitignore_loads_info_exclude_when_present() {
4526 let tmp = TempDir::new().unwrap();
4527 let info_dir = tmp.path().join(".git/info");
4528 fs::create_dir_all(&info_dir).unwrap();
4529 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
4530 let secrets = tmp.path().join("secrets.txt");
4531 let public = tmp.path().join("public.txt");
4532 fs::write(&secrets, "token").unwrap();
4533 fs::write(&public, "ok").unwrap();
4534
4535 let ctx = make_ctx_with_root(tmp.path());
4536 ctx.rebuild_gitignore();
4537
4538 assert!(is_ignored(&ctx, &secrets));
4539 assert!(!is_ignored(&ctx, &public));
4540 }
4541
4542 #[test]
4543 fn matcher_picks_up_nested_gitignore() {
4544 let tmp = TempDir::new().unwrap();
4545 fs::write(tmp.path().join(".gitignore"), "").unwrap();
4547 let sub = tmp.path().join("packages/foo");
4548 fs::create_dir_all(&sub).unwrap();
4549 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
4550 let generated_file = sub.join("generated").join("out.js");
4551 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
4552 fs::write(&generated_file, "x").unwrap();
4553
4554 let ctx = make_ctx_with_root(tmp.path());
4555 ctx.rebuild_gitignore();
4556
4557 assert!(
4558 is_ignored(&ctx, &generated_file),
4559 "nested gitignore in packages/foo/.gitignore should ignore generated/"
4560 );
4561 }
4562}