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