Skip to main content

a3s_code_core/workspace/retrieval/
persistent.rs

1//! Workspace-owned persistent lexical index.
2//!
3//! The session catalog remains the source of truth for admission and source
4//! verification. This module adds an optional durable a3s-vec generation that can
5//! be reopened after a process restart. The generation is replaced atomically
6//! after a catalog snapshot has been fully built.
7
8use 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    /// Digest of the exact chunk text retained in the durable manifest.
53    /// `content_digest` identifies the complete source file and therefore
54    /// cannot by itself detect a damaged chunk payload after restart.
55    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
149/// Durable workspace FTS state. A missing `CURRENT` file is a valid empty
150/// state and is populated by the catalog coordinator's first reconciliation.
151pub 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    /// One-shot sync failure owned by this index. A process-global switch is
163    /// stolen by whichever durable worker runs first under parallel tests.
164    #[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        // a3s-vec rejects Windows verbatim (`\\?\`) paths from `canonicalize()`.
194        // Workspace roots are commonly canonicalized before the index is
195        // configured, so strip the prefix at the durable-index boundary.
196        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            // CURRENT publication and generation collection are shared by
228            // every session that points at this workspace. Serialize those
229            // operations across processes before inspecting the directory.
230            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    /// Fail the next `sync_snapshot` once with a non-retryable query error.
252    #[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    /// Whether [`Self::fail_next_sync_for_test`] is still waiting to fire.
258    #[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(&current) {
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            // The directory contains one shared CURRENT pointer and shared
401            // generation names. The instance-local writer is insufficient
402            // when multiple sessions/processes use the same workspace.
403            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            // Manifest versions can advance when the source watcher observes
458            // metadata-only changes (or when a file is rewritten with the
459            // same bytes). The native postings are content-addressed by chunk
460            // identity, so rebuilding the whole collection would waste the
461            // dominant indexing cost. Reuse the published generation and
462            // advance only the in-memory freshness fence. A restart may read
463            // the prior manifest revision briefly, but the shared manifest
464            // coordinator reconciles the current snapshot before normal
465            // indexed results are considered fresh.
466            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            // Keep the shared generation alive while the native query is
579            // executing. Writers take the exclusive counterpart before
580            // replacing/collecting generations, including from another
581            // process that points at the same workspace.
582            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    /// Wait until all index work owned by this workspace has finished.
674    ///
675    /// Runtime task cancellation cannot interrupt `spawn_blocking`, so backend
676    /// teardown uses this boundary before allowing a temporary workspace or
677    /// caller-owned root to disappear underneath an open collection.
678    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(&current) {
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
878/// Strip Windows extended-length prefixes that `canonicalize()` adds.
879///
880/// Native a3s-vec rejects `\\?\C:\...` paths as invalid. Persistent index roots
881/// often inherit a canonicalized workspace path, so normalize at this boundary.
882fn 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        // Rechunk the same bytes under a newer source revision. The chunk
1213        // identity and FTS postings are unchanged, so a full native rebuild
1214        // would be pure update amplification.
1215        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}