1use super::catalog::ChunkCatalogSnapshot;
9#[cfg(feature = "a3s-vec-fts")]
10use super::chunk::{chunk_id, digest_content};
11use super::lexical::LexicalSearchRequest;
12#[cfg(feature = "a3s-vec-fts")]
13use super::lexical::{path_matches, query_terms};
14#[cfg(feature = "a3s-vec-fts")]
15use super::types::WorkspaceChunk;
16use super::types::{WorkspaceIndexError, WorkspaceIndexResult, WorkspaceLexicalEngine};
17use serde::{Deserialize, Serialize};
18#[cfg(feature = "a3s-vec-fts")]
19use std::collections::HashSet;
20#[cfg(feature = "a3s-vec-fts")]
21use std::fs;
22#[cfg(feature = "a3s-vec-fts")]
23use std::io::Read;
24use std::path::{Path, PathBuf};
25#[cfg(feature = "a3s-vec-fts")]
26use std::sync::atomic::{AtomicBool, Ordering};
27use std::sync::Arc;
28#[cfg(feature = "a3s-vec-fts")]
29use std::sync::{Condvar, Mutex, RwLock};
30
31#[cfg(feature = "a3s-vec-fts")]
32const MANIFEST_SCHEMA_VERSION: u32 = 2;
33#[cfg(feature = "a3s-vec-fts")]
34const CURRENT_FILE: &str = "CURRENT";
35#[cfg(feature = "a3s-vec-fts")]
36const MANIFEST_FILE: &str = "manifest.json";
37#[cfg(feature = "a3s-vec-fts")]
38const STORAGE_LOCK_FILE: &str = ".index.lock";
39
40#[cfg(feature = "a3s-vec-fts")]
41#[derive(Clone, Debug, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase", deny_unknown_fields)]
43struct PersistedChunk {
44 id: String,
45 path: String,
46 language: Option<String>,
47 start_line: usize,
48 end_line: usize,
49 start_byte: usize,
50 end_byte: usize,
51 content_digest: String,
52 text_digest: String,
56 source_revision: u64,
57 text: String,
58}
59
60#[cfg(feature = "a3s-vec-fts")]
61impl PersistedChunk {
62 fn from_chunk(chunk: &WorkspaceChunk) -> Self {
63 Self {
64 id: chunk.id.as_str().to_owned(),
65 path: chunk.path.to_string(),
66 language: chunk.language.as_deref().map(str::to_owned),
67 start_line: chunk.start_line,
68 end_line: chunk.end_line,
69 start_byte: chunk.start_byte,
70 end_byte: chunk.end_byte,
71 content_digest: chunk.content_digest.to_string(),
72 text_digest: digest_content(chunk.text.as_ref()).to_string(),
73 source_revision: chunk.source_revision,
74 text: chunk.text.to_string(),
75 }
76 }
77
78 fn into_chunk(self) -> WorkspaceChunk {
79 WorkspaceChunk {
80 id: super::types::WorkspaceChunkId::new(self.id),
81 path: Arc::from(self.path),
82 language: self.language.map(Arc::from),
83 start_line: self.start_line,
84 end_line: self.end_line,
85 start_byte: self.start_byte,
86 end_byte: self.end_byte,
87 content_digest: Arc::from(self.content_digest),
88 source_revision: self.source_revision,
89 text: Arc::from(self.text),
90 }
91 }
92}
93
94#[cfg(feature = "a3s-vec-fts")]
95#[derive(Clone, Debug, Serialize, Deserialize)]
96#[serde(rename_all = "camelCase", deny_unknown_fields)]
97struct GenerationManifest {
98 schema_version: u32,
99 lexical_engine: String,
100 catalog_revision: u64,
101 source_revision: u64,
102 chunks: Vec<PersistedChunk>,
103}
104
105#[cfg(feature = "a3s-vec-fts")]
106struct PersistentState {
107 generation: String,
108 catalog_revision: u64,
109 source_revision: u64,
110 indexed_chunks: Arc<[Arc<WorkspaceChunk>]>,
111 indexed_files: usize,
112 index: super::a3s_vec::A3sVecLexicalIndex,
113}
114
115#[cfg(feature = "a3s-vec-fts")]
116struct PersistentOperationGuard {
117 active: Arc<(Mutex<usize>, Condvar)>,
118}
119
120#[cfg(feature = "a3s-vec-fts")]
121impl Drop for PersistentOperationGuard {
122 fn drop(&mut self) {
123 let (lock, wake) = &*self.active;
124 if let Ok(mut count) = lock.lock() {
125 *count = count.saturating_sub(1);
126 wake.notify_all();
127 }
128 }
129}
130
131#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
132#[serde(rename_all = "snake_case")]
133pub enum WorkspacePersistentIndexPhase {
134 Absent,
135 Building,
136 Ready,
137}
138
139#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
140#[serde(rename_all = "camelCase")]
141pub struct WorkspacePersistentIndexStatus {
142 pub phase: WorkspacePersistentIndexPhase,
143 pub generation: Option<String>,
144 pub catalog_revision: u64,
145 pub source_revision: u64,
146 pub indexed_chunks: usize,
147}
148
149pub struct WorkspacePersistentIndex {
152 root: PathBuf,
153 engine: WorkspaceLexicalEngine,
154 #[cfg(feature = "a3s-vec-fts")]
155 writer: Mutex<()>,
156 #[cfg(feature = "a3s-vec-fts")]
157 active_operations: Arc<(Mutex<usize>, Condvar)>,
158 #[cfg(feature = "a3s-vec-fts")]
159 building: AtomicBool,
160 #[cfg(feature = "a3s-vec-fts")]
161 state: RwLock<Option<PersistentState>>,
162 #[cfg(all(test, feature = "a3s-vec-fts"))]
165 fail_next_sync: AtomicBool,
166}
167
168#[cfg(feature = "a3s-vec-fts")]
169struct BuildActivityGuard<'a>(&'a AtomicBool);
170
171#[cfg(feature = "a3s-vec-fts")]
172impl Drop for BuildActivityGuard<'_> {
173 fn drop(&mut self) {
174 self.0.store(false, Ordering::Release);
175 }
176}
177
178impl std::fmt::Debug for WorkspacePersistentIndex {
179 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 formatter
181 .debug_struct("WorkspacePersistentIndex")
182 .field("root", &self.root)
183 .field("engine", &self.engine)
184 .finish_non_exhaustive()
185 }
186}
187
188impl WorkspacePersistentIndex {
189 pub fn open(
190 root: impl Into<PathBuf>,
191 engine: WorkspaceLexicalEngine,
192 ) -> WorkspaceIndexResult<Arc<Self>> {
193 let root = strip_windows_verbatim_prefix(root.into());
197 #[cfg(not(feature = "a3s-vec-fts"))]
198 {
199 let _ = (&root, engine);
200 Err(WorkspaceIndexError::InvalidConfig(
201 "persistent workspace indexing requires the a3s-vec-fts feature".to_owned(),
202 ))
203 }
204
205 #[cfg(feature = "a3s-vec-fts")]
206 {
207 if engine != WorkspaceLexicalEngine::A3sVec {
208 return Err(WorkspaceIndexError::InvalidConfig(
209 "persistent workspace indexing currently requires the a3s-vec lexical engine"
210 .to_owned(),
211 ));
212 }
213 fs::create_dir_all(&root).map_err(|error| WorkspaceIndexError::ReadFailed {
214 path: root.display().to_string(),
215 message: error.to_string(),
216 })?;
217 let index = Arc::new(Self {
218 root,
219 engine,
220 writer: Mutex::new(()),
221 active_operations: Arc::new((Mutex::new(0), Condvar::new())),
222 building: AtomicBool::new(false),
223 state: RwLock::new(None),
224 #[cfg(test)]
225 fail_next_sync: AtomicBool::new(false),
226 });
227 let storage_guard = index.acquire_storage_lock()?;
231 if let Err(error) = index.load_current() {
232 tracing::warn!(%error, path = %index.root.display(), "persistent workspace index will be rebuilt");
233 } else if let Some(generation) = index.status().generation {
234 if let Err(error) = gc_generations(&index.root, &generation) {
235 tracing::warn!(%error, path = %index.root.display(), "persistent workspace generation cleanup failed");
236 }
237 }
238 drop(storage_guard);
239 Ok(index)
240 }
241 }
242
243 pub fn root(&self) -> &Path {
244 &self.root
245 }
246
247 pub fn engine(&self) -> WorkspaceLexicalEngine {
248 self.engine
249 }
250
251 #[cfg(all(test, feature = "a3s-vec-fts"))]
253 pub(crate) fn fail_next_sync_for_test(&self) {
254 self.fail_next_sync.store(true, Ordering::SeqCst);
255 }
256
257 #[cfg(all(test, feature = "a3s-vec-fts"))]
259 pub(crate) fn non_retryable_sync_failure_is_armed(&self) -> bool {
260 self.fail_next_sync.load(Ordering::SeqCst)
261 }
262
263 pub fn is_ready(&self) -> bool {
264 #[cfg(feature = "a3s-vec-fts")]
265 {
266 self.state
267 .read()
268 .map(|state| state.is_some())
269 .unwrap_or(false)
270 }
271 #[cfg(not(feature = "a3s-vec-fts"))]
272 {
273 false
274 }
275 }
276
277 pub fn status(&self) -> WorkspacePersistentIndexStatus {
278 #[cfg(feature = "a3s-vec-fts")]
279 {
280 let building = self.building.load(Ordering::Acquire);
281 self.state
282 .read()
283 .ok()
284 .and_then(|state| {
285 state.as_ref().map(|state| WorkspacePersistentIndexStatus {
286 phase: if building {
287 WorkspacePersistentIndexPhase::Building
288 } else {
289 WorkspacePersistentIndexPhase::Ready
290 },
291 generation: Some(state.generation.clone()),
292 catalog_revision: state.catalog_revision,
293 source_revision: state.source_revision,
294 indexed_chunks: state.indexed_chunks.len(),
295 })
296 })
297 .unwrap_or(WorkspacePersistentIndexStatus {
298 phase: if building {
299 WorkspacePersistentIndexPhase::Building
300 } else {
301 WorkspacePersistentIndexPhase::Absent
302 },
303 generation: None,
304 catalog_revision: 0,
305 source_revision: 0,
306 indexed_chunks: 0,
307 })
308 }
309 #[cfg(not(feature = "a3s-vec-fts"))]
310 {
311 WorkspacePersistentIndexStatus {
312 phase: WorkspacePersistentIndexPhase::Absent,
313 generation: None,
314 catalog_revision: 0,
315 source_revision: 0,
316 indexed_chunks: 0,
317 }
318 }
319 }
320
321 pub fn rebuild(&self, snapshot: &ChunkCatalogSnapshot) -> WorkspaceIndexResult<()> {
322 self.sync_snapshot(snapshot)
323 }
324
325 pub fn drop_index(&self) -> WorkspaceIndexResult<()> {
326 #[cfg(not(feature = "a3s-vec-fts"))]
327 {
328 Err(WorkspaceIndexError::InvalidConfig(
329 "persistent workspace indexing requires the a3s-vec-fts feature".to_owned(),
330 ))
331 }
332
333 #[cfg(feature = "a3s-vec-fts")]
334 {
335 let _storage_guard = self.acquire_storage_lock()?;
336 let _write_guard = self
337 .writer
338 .lock()
339 .map_err(|_| WorkspaceIndexError::LockPoisoned)?;
340 self.wait_for_idle();
341 let previous = self
342 .state
343 .write()
344 .map_err(|_| WorkspaceIndexError::LockPoisoned)?
345 .take();
346 drop(previous);
347 let current = self.root.join(CURRENT_FILE);
348 if let Err(error) = fs::remove_file(¤t) {
349 if error.kind() != std::io::ErrorKind::NotFound {
350 return Err(WorkspaceIndexError::ReadFailed {
351 path: current.display().to_string(),
352 message: error.to_string(),
353 });
354 }
355 }
356 for entry in
357 fs::read_dir(&self.root).map_err(|error| WorkspaceIndexError::ReadFailed {
358 path: self.root.display().to_string(),
359 message: error.to_string(),
360 })?
361 {
362 let path = entry
363 .map_err(|error| WorkspaceIndexError::ReadFailed {
364 path: self.root.display().to_string(),
365 message: error.to_string(),
366 })?
367 .path();
368 let name = path
369 .file_name()
370 .and_then(|name| name.to_str())
371 .unwrap_or_default();
372 if name.starts_with("generation-") || name.starts_with(".generation-") {
373 remove_path_if_exists(&path)?;
374 }
375 }
376 Ok(())
377 }
378 }
379
380 pub fn sync_snapshot(&self, snapshot: &ChunkCatalogSnapshot) -> WorkspaceIndexResult<()> {
381 #[cfg(not(feature = "a3s-vec-fts"))]
382 {
383 let _ = snapshot;
384 Err(WorkspaceIndexError::InvalidConfig(
385 "persistent workspace indexing requires the a3s-vec-fts feature".to_owned(),
386 ))
387 }
388
389 #[cfg(feature = "a3s-vec-fts")]
390 {
391 #[cfg(test)]
392 if self.fail_next_sync.swap(false, Ordering::SeqCst) {
393 return Err(WorkspaceIndexError::InvalidQuery(
394 "forced non-retryable sync failure".into(),
395 ));
396 }
397 let _operation = self.begin_operation()?;
398 self.building.store(true, Ordering::Release);
399 let _building = BuildActivityGuard(&self.building);
400 let _storage_guard = self.acquire_storage_lock()?;
404 let _write_guard = self
405 .writer
406 .lock()
407 .map_err(|_| WorkspaceIndexError::LockPoisoned)?;
408 let persisted = match self.read_current_manifest() {
409 Ok(manifest) => manifest,
410 Err(error) => {
411 tracing::warn!(%error, path = %self.root.display(), "ignoring unreadable persistent CURRENT while rebuilding");
412 None
413 }
414 };
415 if let Some((_, manifest)) = persisted {
416 let requested = (snapshot.source_revision(), snapshot.revision());
417 let on_disk = (manifest.source_revision, manifest.catalog_revision);
418 if on_disk > requested {
419 return Err(WorkspaceIndexError::StaleRevision {
420 requested: snapshot.source_revision(),
421 current: manifest.source_revision,
422 });
423 }
424 }
425 let current_state = self
426 .state
427 .read()
428 .map_err(|_| WorkspaceIndexError::LockPoisoned)?;
429 if current_state
430 .as_ref()
431 .is_some_and(|state| state.source_revision > snapshot.source_revision())
432 {
433 let current = current_state
434 .as_ref()
435 .map(|state| state.source_revision)
436 .unwrap_or_default();
437 return Err(WorkspaceIndexError::StaleRevision {
438 requested: snapshot.source_revision(),
439 current,
440 });
441 }
442 if current_state.as_ref().is_some_and(|state| {
443 state.catalog_revision == snapshot.revision()
444 && state.source_revision == snapshot.source_revision()
445 }) {
446 return Ok(());
447 }
448 let chunks = snapshot.chunks();
449 let indexed_chunks: Arc<[Arc<WorkspaceChunk>]> = Arc::from(
450 chunks
451 .iter()
452 .filter(|chunk| !super::lexical::tokenize(chunk.text.as_ref()).is_empty())
453 .cloned()
454 .collect::<Vec<_>>(),
455 );
456
457 if let Some(state) = current_state.as_ref() {
467 if indexed_chunks_match(&state.indexed_chunks, &indexed_chunks) {
468 let generation = state.generation.clone();
469 drop(current_state);
470 let mut current = self
471 .state
472 .write()
473 .map_err(|_| WorkspaceIndexError::LockPoisoned)?;
474 let Some(current) = current.as_mut() else {
475 return Err(WorkspaceIndexError::InvalidConfig(
476 "persistent workspace index disappeared during reuse".to_owned(),
477 ));
478 };
479 if current.generation != generation {
480 return Err(WorkspaceIndexError::StaleRevision {
481 requested: snapshot.source_revision(),
482 current: current.source_revision,
483 });
484 }
485 current.catalog_revision = snapshot.revision();
486 current.source_revision = snapshot.source_revision();
487 current.indexed_files = distinct_path_count(&indexed_chunks);
488 current.indexed_chunks = indexed_chunks;
489 drop(_operation);
490 if let Err(error) = gc_generations(&self.root, &generation) {
491 tracing::warn!(%error, path = %self.root.display(), "persistent workspace generation cleanup failed");
492 }
493 return Ok(());
494 }
495 }
496 drop(current_state);
497
498 let generation = format!("generation-{}", snapshot.revision());
499 let staging = self.root.join(format!(".{generation}.staging"));
500 let destination = self.root.join(&generation);
501 remove_path_if_exists(&staging)?;
502 remove_path_if_exists(&destination)?;
503 fs::create_dir_all(&staging).map_err(|error| WorkspaceIndexError::ReadFailed {
504 path: staging.display().to_string(),
505 message: error.to_string(),
506 })?;
507
508 let collection_path = staging.join("collection");
509 let mut index = super::a3s_vec::A3sVecLexicalIndex::build_at_path(
510 &collection_path,
511 indexed_chunks
512 .iter()
513 .map(|chunk| (chunk.id.as_str().to_owned(), chunk.text.as_ref().to_owned()))
514 .collect(),
515 )
516 .map_err(|error| {
517 WorkspaceIndexError::InvalidConfig(format!(
518 "persistent a3s-vec index failed: {error}"
519 ))
520 })?;
521 let manifest = GenerationManifest {
522 schema_version: MANIFEST_SCHEMA_VERSION,
523 lexical_engine: self.engine.stable_id().to_owned(),
524 catalog_revision: snapshot.revision(),
525 source_revision: snapshot.source_revision(),
526 chunks: chunks
527 .iter()
528 .map(|chunk| PersistedChunk::from_chunk(chunk))
529 .collect(),
530 };
531 write_json_atomic(&staging.join(MANIFEST_FILE), &manifest)?;
532 fs::rename(&staging, &destination).map_err(|error| {
533 WorkspaceIndexError::ReadFailed {
534 path: destination.display().to_string(),
535 message: error.to_string(),
536 }
537 })?;
538 index.relocate_collection_path(destination.join("collection"));
539 write_current(&self.root, &generation)?;
540 let indexed_files = distinct_path_count(&indexed_chunks);
541
542 let next = PersistentState {
543 generation: generation.clone(),
544 catalog_revision: manifest.catalog_revision,
545 source_revision: manifest.source_revision,
546 indexed_chunks,
547 indexed_files,
548 index,
549 };
550 *self
551 .state
552 .write()
553 .map_err(|_| WorkspaceIndexError::LockPoisoned)? = Some(next);
554 drop(_operation);
555 if let Err(error) = gc_generations(&self.root, &generation) {
556 tracing::warn!(%error, path = %self.root.display(), "persistent workspace generation cleanup failed");
557 }
558 Ok(())
559 }
560 }
561
562 pub fn search(
563 &self,
564 request: &LexicalSearchRequest,
565 ) -> WorkspaceIndexResult<super::lexical::LexicalSearchResult> {
566 #[cfg(not(feature = "a3s-vec-fts"))]
567 {
568 let _ = request;
569 Err(WorkspaceIndexError::InvalidConfig(
570 "persistent workspace indexing requires the a3s-vec-fts feature".to_owned(),
571 ))
572 }
573
574 #[cfg(feature = "a3s-vec-fts")]
575 {
576 let _operation = self.begin_operation()?;
577 super::lexical::validate_request(request)?;
578 let _storage_guard = self.acquire_storage_read_lock()?;
583 let terms = query_terms(request.query.trim(), 32);
584 if terms.is_empty() {
585 return Err(WorkspaceIndexError::InvalidQuery(
586 "query must contain a letter, number, underscore, or CJK character".to_owned(),
587 ));
588 }
589 let glob = request
590 .glob
591 .as_deref()
592 .map(glob::Pattern::new)
593 .transpose()
594 .map_err(|error| WorkspaceIndexError::InvalidQuery(error.to_string()))?;
595 let state_guard = self
596 .state
597 .read()
598 .map_err(|_| WorkspaceIndexError::LockPoisoned)?;
599 let Some(state) = state_guard.as_ref() else {
600 return Ok(super::lexical::LexicalSearchResult {
601 catalog_revision: 0,
602 source_revision: 0,
603 lexical_engine: self.engine,
604 query_terms: terms,
605 matching_files: 0,
606 selected_files: 0,
607 scored_chunks: 0,
608 candidate_truncated: false,
609 hits: Vec::new(),
610 });
611 };
612
613 let has_path_filter = !request.path.is_root() || glob.is_some();
614 let matching_files = if has_path_filter {
615 state
616 .indexed_chunks
617 .iter()
618 .filter(|chunk| path_matches(chunk.path.as_ref(), &request.path, glob.as_ref()))
619 .map(|chunk| chunk.path.clone())
620 .collect::<HashSet<_>>()
621 .len()
622 } else {
623 state.indexed_files
624 };
625 let query_limit = request
626 .limit
627 .saturating_mul(request.max_candidate_files.max(1))
628 .min(state.index.document_count().max(request.limit));
629 let ranked = state.index.search(&terms, query_limit).map_err(|error| {
630 WorkspaceIndexError::InvalidQuery(format!("a3s-vec FTS search failed: {error}"))
631 })?;
632 let mut selected_files = HashSet::new();
633 let mut hits = Vec::new();
634 let mut per_file = std::collections::HashMap::<Arc<str>, usize>::new();
635 for (ordinal, score) in ranked {
636 let Some(chunk) = state.indexed_chunks.get(ordinal).cloned() else {
637 continue;
638 };
639 if !path_matches(chunk.path.as_ref(), &request.path, glob.as_ref()) {
640 continue;
641 }
642 if !selected_files.contains(&chunk.path)
643 && selected_files.len() >= request.max_candidate_files
644 {
645 continue;
646 }
647 selected_files.insert(chunk.path.clone());
648 let count = per_file.entry(Arc::clone(&chunk.path)).or_default();
649 if *count >= request.max_results_per_file {
650 continue;
651 }
652 *count += 1;
653 hits.push(super::lexical::LexicalSearchHit { chunk, score });
654 if hits.len() >= request.limit {
655 break;
656 }
657 }
658 let candidate_truncated = matching_files > request.max_candidate_files;
659 Ok(super::lexical::LexicalSearchResult {
660 catalog_revision: state.catalog_revision,
661 source_revision: state.source_revision,
662 lexical_engine: self.engine,
663 query_terms: terms,
664 matching_files,
665 selected_files: selected_files.len(),
666 scored_chunks: state.indexed_chunks.len(),
667 candidate_truncated,
668 hits,
669 })
670 }
671 }
672
673 pub(crate) fn wait_for_idle(&self) {
679 #[cfg(feature = "a3s-vec-fts")]
680 {
681 let (lock, wake) = &*self.active_operations;
682 let Ok(mut count) = lock.lock() else {
683 return;
684 };
685 while *count != 0 {
686 match wake.wait(count) {
687 Ok(next) => count = next,
688 Err(_) => return,
689 }
690 }
691 }
692 }
693
694 #[cfg(feature = "a3s-vec-fts")]
695 fn begin_operation(&self) -> WorkspaceIndexResult<PersistentOperationGuard> {
696 let (lock, _) = &*self.active_operations;
697 let mut count = lock.lock().map_err(|_| WorkspaceIndexError::LockPoisoned)?;
698 *count = count.checked_add(1).ok_or_else(|| {
699 WorkspaceIndexError::InvalidConfig("persistent operation count overflow".to_owned())
700 })?;
701 Ok(PersistentOperationGuard {
702 active: Arc::clone(&self.active_operations),
703 })
704 }
705
706 #[cfg(feature = "a3s-vec-fts")]
707 fn acquire_storage_lock(&self) -> WorkspaceIndexResult<std::fs::File> {
708 use fs2::FileExt;
709
710 let file = self.open_storage_lock_file()?;
711 let path = self.root.join(STORAGE_LOCK_FILE);
712 file.lock_exclusive()
713 .map_err(|error| WorkspaceIndexError::ReadFailed {
714 path: path.display().to_string(),
715 message: format!("failed to lock persistent index: {error}"),
716 })?;
717 Ok(file)
718 }
719
720 #[cfg(feature = "a3s-vec-fts")]
721 fn acquire_storage_read_lock(&self) -> WorkspaceIndexResult<std::fs::File> {
722 let file = self.open_storage_lock_file()?;
723 let path = self.root.join(STORAGE_LOCK_FILE);
724 fs2::FileExt::lock_shared(&file).map_err(|error| WorkspaceIndexError::ReadFailed {
725 path: path.display().to_string(),
726 message: format!("failed to share-lock persistent index: {error}"),
727 })?;
728 Ok(file)
729 }
730
731 #[cfg(feature = "a3s-vec-fts")]
732 fn open_storage_lock_file(&self) -> WorkspaceIndexResult<std::fs::File> {
733 let path = self.root.join(STORAGE_LOCK_FILE);
734 let file = fs::OpenOptions::new()
735 .create(true)
736 .truncate(false)
737 .read(true)
738 .write(true)
739 .open(&path)
740 .map_err(|error| WorkspaceIndexError::ReadFailed {
741 path: path.display().to_string(),
742 message: format!("failed to open persistent index lock: {error}"),
743 })?;
744 Ok(file)
745 }
746
747 #[cfg(feature = "a3s-vec-fts")]
748 fn load_current(&self) -> WorkspaceIndexResult<()> {
749 let Some((generation, manifest)) = self.read_current_manifest()? else {
750 return Ok(());
751 };
752 let generation_root = self.root.join(&generation);
753 let chunks: Arc<[Arc<WorkspaceChunk>]> = Arc::from(
754 manifest
755 .chunks
756 .into_iter()
757 .map(PersistedChunk::into_chunk)
758 .map(Arc::new)
759 .collect::<Vec<_>>(),
760 );
761 let indexed_chunks: Arc<[Arc<WorkspaceChunk>]> = Arc::from(
762 chunks
763 .iter()
764 .filter(|chunk| !super::lexical::tokenize(chunk.text.as_ref()).is_empty())
765 .cloned()
766 .collect::<Vec<_>>(),
767 );
768 let index = super::a3s_vec::A3sVecLexicalIndex::open_persistent(
769 generation_root.join("collection"),
770 indexed_chunks
771 .iter()
772 .map(|chunk| (chunk.id.as_str().to_owned(), chunk.text.as_ref().to_owned()))
773 .collect(),
774 )
775 .map_err(|error| {
776 WorkspaceIndexError::InvalidConfig(format!(
777 "persistent a3s-vec index failed to open: {error}"
778 ))
779 })?;
780 let indexed_files = distinct_path_count(&indexed_chunks);
781 *self
782 .state
783 .write()
784 .map_err(|_| WorkspaceIndexError::LockPoisoned)? = Some(PersistentState {
785 generation,
786 catalog_revision: manifest.catalog_revision,
787 source_revision: manifest.source_revision,
788 indexed_chunks,
789 indexed_files,
790 index,
791 });
792 Ok(())
793 }
794
795 #[cfg(feature = "a3s-vec-fts")]
796 fn read_current_manifest(&self) -> WorkspaceIndexResult<Option<(String, GenerationManifest)>> {
797 let current = self.root.join(CURRENT_FILE);
798 let generation = match fs::read_to_string(¤t) {
799 Ok(value) => value.trim().to_owned(),
800 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
801 Err(error) => {
802 return Err(WorkspaceIndexError::ReadFailed {
803 path: current.display().to_string(),
804 message: error.to_string(),
805 })
806 }
807 };
808 if generation.is_empty() || !safe_generation_name(&generation) {
809 return Err(WorkspaceIndexError::InvalidConfig(
810 "persistent a3s-vec CURRENT contains an invalid generation name".to_owned(),
811 ));
812 }
813 let generation_root = self.root.join(&generation);
814 let manifest: GenerationManifest = read_json(&generation_root.join(MANIFEST_FILE))?;
815 if manifest.schema_version != MANIFEST_SCHEMA_VERSION
816 || manifest.lexical_engine != self.engine.stable_id()
817 {
818 return Err(WorkspaceIndexError::InvalidConfig(
819 "persistent a3s-vec index schema or lexical engine is incompatible".to_owned(),
820 ));
821 }
822 validate_persisted_chunks(&manifest.chunks)?;
823 Ok(Some((generation, manifest)))
824 }
825}
826
827#[cfg(feature = "a3s-vec-fts")]
828fn safe_generation_name(value: &str) -> bool {
829 !value.is_empty()
830 && value.chars().all(|character| {
831 character.is_ascii_alphanumeric() || character == '-' || character == '_'
832 })
833}
834
835#[cfg(feature = "a3s-vec-fts")]
836fn gc_generations(root: &Path, current_generation: &str) -> WorkspaceIndexResult<()> {
837 if !safe_generation_name(current_generation) {
838 return Err(WorkspaceIndexError::InvalidConfig(
839 "persistent a3s-vec cleanup received an invalid current generation".to_owned(),
840 ));
841 }
842 for entry in fs::read_dir(root).map_err(|error| WorkspaceIndexError::ReadFailed {
843 path: root.display().to_string(),
844 message: error.to_string(),
845 })? {
846 let path = entry
847 .map_err(|error| WorkspaceIndexError::ReadFailed {
848 path: root.display().to_string(),
849 message: error.to_string(),
850 })?
851 .path();
852 let name = path
853 .file_name()
854 .and_then(|name| name.to_str())
855 .unwrap_or_default();
856 if name == current_generation {
857 continue;
858 }
859 if name.starts_with("generation-") || name.starts_with(".generation-") {
860 remove_path_if_exists(&path)?;
861 }
862 }
863 Ok(())
864}
865
866#[cfg(feature = "a3s-vec-fts")]
867fn remove_path_if_exists(path: &Path) -> WorkspaceIndexResult<()> {
868 match fs::remove_dir_all(path) {
869 Ok(()) => Ok(()),
870 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
871 Err(error) => Err(WorkspaceIndexError::ReadFailed {
872 path: path.display().to_string(),
873 message: error.to_string(),
874 }),
875 }
876}
877
878fn strip_windows_verbatim_prefix(path: PathBuf) -> PathBuf {
883 #[cfg(windows)]
884 {
885 let value = path.to_string_lossy();
886 if let Some(stripped) = value.strip_prefix(r"\\?\UNC\") {
887 return PathBuf::from(format!(r"\\{stripped}"));
888 }
889 if let Some(stripped) = value.strip_prefix(r"\\?\") {
890 return PathBuf::from(stripped);
891 }
892 }
893 path
894}
895
896#[cfg(feature = "a3s-vec-fts")]
897fn write_current(root: &Path, generation: &str) -> WorkspaceIndexResult<()> {
898 let temporary = root.join(".CURRENT.tmp");
899 fs::write(&temporary, format!("{generation}\n")).map_err(|error| {
900 WorkspaceIndexError::ReadFailed {
901 path: temporary.display().to_string(),
902 message: error.to_string(),
903 }
904 })?;
905 #[cfg(windows)]
906 let _ = fs::remove_file(root.join(CURRENT_FILE));
907 fs::rename(&temporary, root.join(CURRENT_FILE)).map_err(|error| {
908 WorkspaceIndexError::ReadFailed {
909 path: root.join(CURRENT_FILE).display().to_string(),
910 message: error.to_string(),
911 }
912 })
913}
914
915#[cfg(feature = "a3s-vec-fts")]
916fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> WorkspaceIndexResult<()> {
917 let temporary = path.with_extension("json.tmp");
918 let bytes = serde_json::to_vec(value).map_err(|error| {
919 WorkspaceIndexError::InvalidConfig(format!(
920 "persistent a3s-vec manifest serialization failed: {error}"
921 ))
922 })?;
923 fs::write(&temporary, bytes).map_err(|error| WorkspaceIndexError::ReadFailed {
924 path: temporary.display().to_string(),
925 message: error.to_string(),
926 })?;
927 fs::rename(&temporary, path).map_err(|error| WorkspaceIndexError::ReadFailed {
928 path: path.display().to_string(),
929 message: error.to_string(),
930 })
931}
932
933#[cfg(feature = "a3s-vec-fts")]
934fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> WorkspaceIndexResult<T> {
935 const MAX_MANIFEST_BYTES: u64 = 256 * 1024 * 1024;
936 let file = fs::File::open(path).map_err(|error| WorkspaceIndexError::ReadFailed {
937 path: path.display().to_string(),
938 message: error.to_string(),
939 })?;
940 let declared_len = file
941 .metadata()
942 .map_err(|error| WorkspaceIndexError::ReadFailed {
943 path: path.display().to_string(),
944 message: error.to_string(),
945 })?
946 .len();
947 if declared_len > MAX_MANIFEST_BYTES {
948 return Err(WorkspaceIndexError::InvalidConfig(format!(
949 "persistent a3s-vec manifest exceeds the {MAX_MANIFEST_BYTES} byte limit"
950 )));
951 }
952 let mut reader = file.take(MAX_MANIFEST_BYTES + 1);
953 let mut bytes = Vec::with_capacity(
954 usize::try_from(declared_len)
955 .unwrap_or(usize::MAX)
956 .min(1024 * 1024),
957 );
958 reader
959 .read_to_end(&mut bytes)
960 .map_err(|error| WorkspaceIndexError::ReadFailed {
961 path: path.display().to_string(),
962 message: error.to_string(),
963 })?;
964 if bytes.len() as u64 > MAX_MANIFEST_BYTES {
965 return Err(WorkspaceIndexError::InvalidConfig(format!(
966 "persistent a3s-vec manifest exceeds the {MAX_MANIFEST_BYTES} byte limit"
967 )));
968 }
969 serde_json::from_slice(&bytes).map_err(|error| {
970 WorkspaceIndexError::InvalidConfig(format!(
971 "persistent a3s-vec manifest is invalid: {error}"
972 ))
973 })
974}
975
976#[cfg(feature = "a3s-vec-fts")]
977fn validate_persisted_chunks(chunks: &[PersistedChunk]) -> WorkspaceIndexResult<()> {
978 let mut ids = HashSet::with_capacity(chunks.len());
979 for (index, chunk) in chunks.iter().enumerate() {
980 if chunk.path.is_empty() {
981 return Err(WorkspaceIndexError::InvalidConfig(format!(
982 "persistent a3s-vec manifest chunk {index} has an empty path"
983 )));
984 }
985 if chunk.start_byte >= chunk.end_byte {
986 return Err(WorkspaceIndexError::InvalidConfig(format!(
987 "persistent a3s-vec manifest chunk {index} has an invalid byte range"
988 )));
989 }
990 if chunk.start_line == 0 || chunk.start_line > chunk.end_line {
991 return Err(WorkspaceIndexError::InvalidConfig(format!(
992 "persistent a3s-vec manifest chunk {index} has an invalid line range"
993 )));
994 }
995 if !valid_sha256_digest(&chunk.content_digest) || !valid_sha256_digest(&chunk.text_digest) {
996 return Err(WorkspaceIndexError::InvalidConfig(format!(
997 "persistent a3s-vec manifest chunk {index} has a non-canonical digest"
998 )));
999 }
1000 let expected_text_digest = digest_content(&chunk.text);
1001 if chunk.text.is_empty() || expected_text_digest.as_ref() != chunk.text_digest {
1002 return Err(WorkspaceIndexError::InvalidConfig(format!(
1003 "persistent a3s-vec manifest chunk {index} text digest does not match its payload"
1004 )));
1005 }
1006 let expected_id = chunk_id(
1007 &chunk.path,
1008 &chunk.content_digest,
1009 chunk.start_byte,
1010 chunk.end_byte,
1011 );
1012 if chunk.id != expected_id.as_str() {
1013 return Err(WorkspaceIndexError::InvalidConfig(format!(
1014 "persistent a3s-vec manifest chunk {index} id does not bind its metadata"
1015 )));
1016 }
1017 if !ids.insert(chunk.id.as_str()) {
1018 return Err(WorkspaceIndexError::InvalidConfig(format!(
1019 "persistent a3s-vec manifest contains duplicate chunk id at index {index}"
1020 )));
1021 }
1022 }
1023 Ok(())
1024}
1025
1026#[cfg(feature = "a3s-vec-fts")]
1027fn valid_sha256_digest(value: &str) -> bool {
1028 value.strip_prefix("sha256:").is_some_and(|hex| {
1029 hex.len() == 64
1030 && hex
1031 .bytes()
1032 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
1033 })
1034}
1035
1036#[cfg(feature = "a3s-vec-fts")]
1037fn indexed_chunks_match(left: &[Arc<WorkspaceChunk>], right: &[Arc<WorkspaceChunk>]) -> bool {
1038 left.len() == right.len()
1039 && left.iter().zip(right).all(|(left, right)| {
1040 left.id.as_str() == right.id.as_str()
1041 && left.path == right.path
1042 && left.language == right.language
1043 && left.start_line == right.start_line
1044 && left.end_line == right.end_line
1045 && left.start_byte == right.start_byte
1046 && left.end_byte == right.end_byte
1047 && left.content_digest == right.content_digest
1048 })
1049}
1050
1051#[cfg(feature = "a3s-vec-fts")]
1052fn distinct_path_count(chunks: &[Arc<WorkspaceChunk>]) -> usize {
1053 chunks
1054 .iter()
1055 .map(|chunk| Arc::clone(&chunk.path))
1056 .collect::<HashSet<_>>()
1057 .len()
1058}
1059
1060#[cfg(all(test, feature = "a3s-vec-fts"))]
1061mod tests {
1062 use super::{strip_windows_verbatim_prefix, WorkspacePersistentIndex, MANIFEST_FILE};
1063 use crate::workspace::retrieval::{
1064 ChunkCatalogLimits, ChunkingConfig, LexicalSearchRequest, WorkspaceChunkCatalog,
1065 WorkspaceLexicalEngine,
1066 };
1067 use crate::workspace::WorkspacePath;
1068 use std::path::PathBuf;
1069
1070 #[test]
1071 fn strip_windows_verbatim_prefix_removes_extended_length_form() {
1072 let stripped = strip_windows_verbatim_prefix(PathBuf::from(
1073 r"\\?\C:\Users\runneradmin\AppData\Local\Temp\.tmpIndex\.a3s-code\index",
1074 ));
1075 #[cfg(windows)]
1076 assert_eq!(
1077 stripped,
1078 PathBuf::from(r"C:\Users\runneradmin\AppData\Local\Temp\.tmpIndex\.a3s-code\index")
1079 );
1080 #[cfg(not(windows))]
1081 assert_eq!(
1082 stripped,
1083 PathBuf::from(r"\\?\C:\Users\runneradmin\AppData\Local\Temp\.tmpIndex\.a3s-code\index")
1084 );
1085 }
1086
1087 #[cfg(feature = "a3s-vec-fts")]
1088 #[test]
1089 fn persistent_index_opens_under_windows_verbatim_prefix() {
1090 let directory = tempfile::tempdir().expect("temporary index directory");
1091 let root = directory.path().join(".a3s-code").join("index");
1092 std::fs::create_dir_all(&root).expect("index root");
1093 let open_root = {
1094 #[cfg(windows)]
1095 {
1096 let canonical = std::fs::canonicalize(&root).expect("canonicalize index root");
1097 if canonical.to_string_lossy().starts_with(r"\\?\") {
1098 canonical
1099 } else {
1100 PathBuf::from(format!(r"\\?\{}", canonical.display()))
1101 }
1102 }
1103 #[cfg(not(windows))]
1104 {
1105 root.clone()
1106 }
1107 };
1108 let catalog = WorkspaceChunkCatalog::new_with_engine(
1109 ChunkingConfig::default(),
1110 ChunkCatalogLimits::default(),
1111 WorkspaceLexicalEngine::A3sVec,
1112 )
1113 .expect("catalog");
1114 catalog
1115 .replace_file(
1116 &WorkspacePath::from_normalized("src/lib.rs"),
1117 Some("rust"),
1118 1,
1119 "verbatim path persistent sentinel\n",
1120 )
1121 .expect("catalog replacement");
1122 let index = WorkspacePersistentIndex::open(open_root, WorkspaceLexicalEngine::A3sVec)
1123 .expect("persistent index under verbatim prefix");
1124 index
1125 .sync_snapshot(&catalog.snapshot().expect("snapshot"))
1126 .expect("generation write under verbatim prefix");
1127 assert!(index.is_ready());
1128 assert!(
1129 !index.root().to_string_lossy().starts_with(r"\\?\"),
1130 "persistent index root should strip the Windows verbatim prefix"
1131 );
1132 }
1133
1134 #[test]
1135 fn persistent_generation_survives_reopen_and_replaces_removed_content() {
1136 let directory = tempfile::tempdir().expect("temporary index directory");
1137 let catalog = WorkspaceChunkCatalog::new_with_engine(
1138 ChunkingConfig::default(),
1139 ChunkCatalogLimits::default(),
1140 WorkspaceLexicalEngine::A3sVec,
1141 )
1142 .expect("catalog");
1143 catalog
1144 .replace_file(
1145 &WorkspacePath::from_normalized("src/lib.rs"),
1146 Some("rust"),
1147 1,
1148 "persistent workspace search sentinel\n",
1149 )
1150 .expect("catalog replacement");
1151
1152 let index =
1153 WorkspacePersistentIndex::open(directory.path(), WorkspaceLexicalEngine::A3sVec)
1154 .expect("persistent index");
1155 index
1156 .sync_snapshot(&catalog.snapshot().expect("snapshot"))
1157 .expect("generation write");
1158 assert!(index.is_ready());
1159
1160 let request = LexicalSearchRequest::new("workspace sentinel");
1161 let result = index.search(&request).expect("persistent query");
1162 assert_eq!(result.hits.len(), 1);
1163 assert_eq!(result.hits[0].chunk.path.as_ref(), "src/lib.rs");
1164 drop(index);
1165
1166 let reopened =
1167 WorkspacePersistentIndex::open(directory.path(), WorkspaceLexicalEngine::A3sVec)
1168 .expect("reopen persistent index");
1169 let result = reopened.search(&request).expect("reopened query");
1170 assert_eq!(result.hits.len(), 1);
1171 assert_eq!(reopened.status().indexed_chunks, 1);
1172
1173 catalog
1174 .remove_file(&WorkspacePath::from_normalized("src/lib.rs"), 2)
1175 .expect("catalog removal");
1176 reopened
1177 .sync_snapshot(&catalog.snapshot().expect("empty snapshot"))
1178 .expect("replacement generation");
1179 assert!(reopened
1180 .search(&request)
1181 .expect("empty query")
1182 .hits
1183 .is_empty());
1184 reopened.drop_index().expect("drop persistent index");
1185 assert_eq!(
1186 reopened.status().phase,
1187 super::WorkspacePersistentIndexPhase::Absent
1188 );
1189 }
1190
1191 #[test]
1192 fn metadata_only_revision_reuses_the_published_generation() {
1193 let directory = tempfile::tempdir().expect("temporary index directory");
1194 let catalog = WorkspaceChunkCatalog::new_with_engine(
1195 ChunkingConfig::default(),
1196 ChunkCatalogLimits::default(),
1197 WorkspaceLexicalEngine::A3sVec,
1198 )
1199 .expect("catalog");
1200 let path = WorkspacePath::from_normalized("src/lib.rs");
1201 catalog
1202 .replace_file(&path, Some("rust"), 1, "stable content sentinel\n")
1203 .expect("first replacement");
1204 let index =
1205 WorkspacePersistentIndex::open(directory.path(), WorkspaceLexicalEngine::A3sVec)
1206 .expect("persistent index");
1207 index
1208 .sync_snapshot(&catalog.snapshot().expect("first snapshot"))
1209 .expect("first generation");
1210 let first = index.status();
1211
1212 catalog
1216 .replace_file(&path, Some("rust"), 2, "stable content sentinel\n")
1217 .expect("same-content replacement");
1218 index
1219 .sync_snapshot(&catalog.snapshot().expect("second snapshot"))
1220 .expect("metadata-only update");
1221 let second = index.status();
1222 assert_eq!(second.generation, first.generation);
1223 assert_eq!(second.source_revision, 2);
1224 assert_eq!(second.catalog_revision, first.catalog_revision + 1);
1225 assert_eq!(second.indexed_chunks, first.indexed_chunks);
1226 assert_eq!(
1227 index
1228 .search(&LexicalSearchRequest::new("stable sentinel"))
1229 .expect("reused query")
1230 .hits
1231 .len(),
1232 1
1233 );
1234 }
1235
1236 #[test]
1237 fn published_replacement_collects_old_generations() {
1238 let directory = tempfile::tempdir().expect("temporary index directory");
1239 let catalog = WorkspaceChunkCatalog::new_with_engine(
1240 ChunkingConfig::default(),
1241 ChunkCatalogLimits::default(),
1242 WorkspaceLexicalEngine::A3sVec,
1243 )
1244 .expect("catalog");
1245 let path = WorkspacePath::from_normalized("src/lib.rs");
1246 let index =
1247 WorkspacePersistentIndex::open(directory.path(), WorkspaceLexicalEngine::A3sVec)
1248 .expect("persistent index");
1249
1250 catalog
1251 .replace_file(&path, Some("rust"), 1, "generation one sentinel\n")
1252 .expect("first replacement");
1253 index
1254 .sync_snapshot(&catalog.snapshot().expect("first snapshot"))
1255 .expect("first generation");
1256 let first_generation = index.status().generation.expect("first generation name");
1257
1258 catalog
1259 .replace_file(&path, Some("rust"), 2, "generation two sentinel\n")
1260 .expect("second replacement");
1261 index
1262 .sync_snapshot(&catalog.snapshot().expect("second snapshot"))
1263 .expect("second generation");
1264 let second_generation = index.status().generation.expect("second generation name");
1265 assert_ne!(first_generation, second_generation);
1266
1267 let generations = std::fs::read_dir(directory.path())
1268 .expect("persistent index directory")
1269 .filter_map(Result::ok)
1270 .filter(|entry| {
1271 entry
1272 .file_name()
1273 .to_str()
1274 .is_some_and(|name| name.starts_with("generation-"))
1275 })
1276 .map(|entry| entry.file_name())
1277 .collect::<Vec<_>>();
1278 assert_eq!(
1279 generations,
1280 vec![std::ffi::OsString::from(&second_generation)]
1281 );
1282 assert_eq!(
1283 std::fs::read_to_string(directory.path().join("CURRENT")).expect("CURRENT"),
1284 format!("{}\n", second_generation)
1285 );
1286 }
1287
1288 #[test]
1289 fn corrupted_generation_manifest_is_rejected_before_query() {
1290 let directory = tempfile::tempdir().expect("temporary index directory");
1291 let catalog = WorkspaceChunkCatalog::new_with_engine(
1292 ChunkingConfig::default(),
1293 ChunkCatalogLimits::default(),
1294 WorkspaceLexicalEngine::A3sVec,
1295 )
1296 .expect("catalog");
1297 catalog
1298 .replace_file(
1299 &WorkspacePath::from_normalized("src/lib.rs"),
1300 Some("rust"),
1301 1,
1302 "durable manifest integrity sentinel\n",
1303 )
1304 .expect("catalog replacement");
1305
1306 let index =
1307 WorkspacePersistentIndex::open(directory.path(), WorkspaceLexicalEngine::A3sVec)
1308 .expect("persistent index");
1309 let snapshot = catalog.snapshot().expect("snapshot");
1310 index.sync_snapshot(&snapshot).expect("generation write");
1311 let generation = index.status().generation.expect("generation");
1312 drop(index);
1313
1314 let manifest_path = directory.path().join(generation).join(MANIFEST_FILE);
1315 let bytes = std::fs::read(&manifest_path).expect("manifest");
1316 let mut manifest: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1317 manifest["chunks"][0]["text"] = serde_json::Value::String("tampered\n".to_owned());
1318 std::fs::write(
1319 &manifest_path,
1320 serde_json::to_vec(&manifest).expect("manifest json"),
1321 )
1322 .expect("tampered manifest");
1323
1324 let reopened =
1325 WorkspacePersistentIndex::open(directory.path(), WorkspaceLexicalEngine::A3sVec)
1326 .expect("reopen should remain recoverable");
1327 assert!(!reopened.is_ready());
1328 assert_eq!(
1329 reopened.status().phase,
1330 super::WorkspacePersistentIndexPhase::Absent
1331 );
1332
1333 reopened
1334 .sync_snapshot(&snapshot)
1335 .expect("catalog snapshot rebuilds corrupted generation");
1336 let result = reopened
1337 .search(&LexicalSearchRequest::new("manifest integrity sentinel"))
1338 .expect("rebuilt query");
1339 assert_eq!(result.hits.len(), 1);
1340 assert_eq!(
1341 result.hits[0].chunk.text.as_ref(),
1342 "durable manifest integrity sentinel\n"
1343 );
1344 }
1345
1346 #[test]
1347 fn status_reports_building_without_hiding_the_last_published_generation() {
1348 let directory = tempfile::tempdir().expect("temporary index directory");
1349 let catalog = WorkspaceChunkCatalog::new_with_engine(
1350 ChunkingConfig::default(),
1351 ChunkCatalogLimits::default(),
1352 WorkspaceLexicalEngine::A3sVec,
1353 )
1354 .expect("catalog");
1355 catalog
1356 .replace_file(
1357 &WorkspacePath::from_normalized("src/lib.rs"),
1358 Some("rust"),
1359 1,
1360 "published status sentinel\n",
1361 )
1362 .expect("catalog replacement");
1363 let index =
1364 WorkspacePersistentIndex::open(directory.path(), WorkspaceLexicalEngine::A3sVec)
1365 .expect("persistent index");
1366 index
1367 .sync_snapshot(&catalog.snapshot().expect("snapshot"))
1368 .expect("generation");
1369 let ready = index.status();
1370 index
1371 .building
1372 .store(true, std::sync::atomic::Ordering::Release);
1373 let building = index.status();
1374 assert_eq!(
1375 building.phase,
1376 super::WorkspacePersistentIndexPhase::Building
1377 );
1378 assert_eq!(building.generation, ready.generation);
1379 assert_eq!(building.indexed_chunks, ready.indexed_chunks);
1380 index
1381 .building
1382 .store(false, std::sync::atomic::Ordering::Release);
1383 }
1384}