Skip to main content

aft/migration/
mod.rs

1//! One-way import of the pre-view semantic snapshot format.
2//!
3//! The importer deliberately leaves `semantic.bin` untouched until a complete
4//! manifest is visible.  A caller can therefore roll back by removing the view
5//! directory and continue to use the legacy snapshot.
6//!
7//! Configure maintenance calls [`import_legacy_semantic`] once when views are
8//! enabled, the checkout owns shared artifact writes, and no manifest exists.
9//! Admission uses the shared cold-build limiter and runs after the configure
10//! acknowledgement. The importer reads the whole snapshot, then re-reads and
11//! hashes every eligible source row before writing compatible rows, so its cost
12//! scales with the corpus. A `RebuildRequired` outcome records
13//! `migration-state.json`; later attempts observe that marker rather than
14//! repeatedly scheduling the same rebuild.
15
16use std::collections::BTreeMap;
17use std::fmt;
18use std::fs::{self, File};
19use std::io;
20use std::path::{Path, PathBuf};
21
22use rusqlite::{params, Connection, OptionalExtension};
23use serde::Serialize;
24
25use crate::alias::AliasStore;
26use crate::blob_store::{
27    BlobStore, BlobStoreError, FullKey, PutOutcome, SemanticKey, SEMANTIC_PRODUCER_VERSION,
28};
29use crate::callgraph_store::CallGraphStoreError;
30use crate::pins::{AssemblyPin, PinError};
31use crate::views::{
32    ArtifactPlane, ClosureRequirements, Manifest, ManifestEntry, PublicationArtifacts,
33    PublicationClosure, PublicationRequest, PublishOutcome, RegularPlanes, RelPath, ViewError,
34    ViewStore,
35};
36
37const LEGACY_SEMANTIC_V6: u8 = 6;
38const LEGACY_SEMANTIC_V7: u8 = 7;
39const LEGACY_CHUNKING_VERSION: u64 = 2;
40const MAX_LEGACY_ROWS: usize = 2_000_000;
41const MAX_LEGACY_DIMENSION: usize = 16_384;
42const IMPORTED_PAYLOAD_VERSION: u8 = 1;
43
44/// Input for importing the semantic snapshot that predates view assembly.
45#[derive(Clone, Debug)]
46pub struct SemanticMigrationRequest {
47    /// Shared AFT storage root.
48    pub storage: PathBuf,
49    /// Existing checkout whose legacy snapshot is eligible for import.
50    pub project_root: PathBuf,
51    /// Repository-family key used by both the old semantic cache and new blob store.
52    pub family: String,
53    /// Checkout-local view key. Different worktrees share `family` but not `view`.
54    pub view: String,
55    /// Canonical JSON fingerprint for the currently configured embedding model.
56    pub configured_model_fingerprint: String,
57    /// The producer stamps assigned to imported blobs by this binary.
58    pub chunker_version: String,
59    pub embed_template_version: String,
60}
61
62impl SemanticMigrationRequest {
63    /// Builds a request using the repository and checkout identities already used by
64    /// the legacy semantic cache.
65    pub fn for_root(
66        storage: impl Into<PathBuf>,
67        project_root: impl Into<PathBuf>,
68        configured_model_fingerprint: impl Into<String>,
69    ) -> Self {
70        let project_root = project_root.into();
71        Self {
72            storage: storage.into(),
73            family: crate::search_index::artifact_cache_key(&project_root),
74            view: crate::path_identity::project_scope_key(&project_root),
75            project_root,
76            configured_model_fingerprint: configured_model_fingerprint.into(),
77            chunker_version: SEMANTIC_PRODUCER_VERSION.to_string(),
78            embed_template_version: SEMANTIC_PRODUCER_VERSION.to_string(),
79        }
80    }
81
82    pub fn legacy_semantic_path(&self) -> PathBuf {
83        self.storage
84            .join("semantic")
85            .join(&self.family)
86            .join("semantic.bin")
87    }
88}
89
90/// Terminal classification of one import attempt.
91#[derive(Clone, Debug, Eq, PartialEq)]
92pub enum SemanticMigrationOutcome {
93    /// No old snapshot exists for this root.
94    NotNeeded,
95    /// A compatible snapshot was transformed into semantic blobs and published.
96    Imported,
97    /// A view already has a current generation, so the one-way import has finished.
98    AlreadyPublished,
99    /// The legacy snapshot cannot be proven compatible and must be rebuilt once.
100    RebuildRequired { reason: String },
101    /// A rebuild was already requested for this view and must not be scheduled again.
102    RebuildAlreadyScheduled { reason: String },
103    /// Another publisher won the initial pointer compare-and-swap.
104    PublishConflict { current_generation: Option<String> },
105}
106
107/// Observable result of a semantic migration attempt.
108#[derive(Clone, Debug, Eq, PartialEq)]
109pub struct SemanticMigrationReport {
110    pub outcome: SemanticMigrationOutcome,
111    /// Number of legacy file rows imported without embedding work.
112    pub imported_rows: usize,
113    /// Total rows omitted from the imported manifest.
114    pub skipped_rows: usize,
115    /// Rows omitted because their path is invalid for, or outside, the current root.
116    pub outside_root_rows: usize,
117    /// Rows omitted because source bytes were unreadable or no longer match the snapshot.
118    pub stale_rows: usize,
119    /// Semantic embedding work performed by this importer. Compatible imports are zero.
120    pub reembedded_rows: usize,
121    /// Full keys written or reused by this import, in bytewise path order.
122    pub semantic_keys: Vec<String>,
123    /// The importer stamps every imported row with these producer values.
124    pub chunker_version: String,
125    pub embed_template_version: String,
126    pub model_fingerprint: String,
127}
128
129impl SemanticMigrationReport {
130    fn empty(request: &SemanticMigrationRequest, outcome: SemanticMigrationOutcome) -> Self {
131        Self {
132            outcome,
133            imported_rows: 0,
134            skipped_rows: 0,
135            outside_root_rows: 0,
136            stale_rows: 0,
137            reembedded_rows: 0,
138            semantic_keys: Vec::new(),
139            chunker_version: request.chunker_version.clone(),
140            embed_template_version: request.embed_template_version.clone(),
141            model_fingerprint: request.configured_model_fingerprint.clone(),
142        }
143    }
144}
145
146/// Whether the existing staged callgraph build had to do cold work.
147#[derive(Clone, Copy, Debug, Eq, PartialEq)]
148pub enum CallgraphMigrationOutcome {
149    Rebuilt,
150    AlreadyCurrent,
151}
152
153/// Errors returned before a migration has a terminal report.
154#[derive(Debug)]
155pub enum MigrationError {
156    Io(io::Error),
157    Blob(BlobStoreError),
158    Sqlite(rusqlite::Error),
159    Alias(crate::alias::AliasError),
160    View(ViewError),
161    Pin(PinError),
162    Callgraph(CallGraphStoreError),
163    InvalidLegacySnapshot(String),
164    UndurablePut(PutOutcome),
165}
166
167impl fmt::Display for MigrationError {
168    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
169        match self {
170            Self::Io(error) => write!(formatter, "migration I/O error: {error}"),
171            Self::Blob(error) => write!(formatter, "migration blob-store error: {error}"),
172            Self::Sqlite(error) => write!(formatter, "migration SQLite error: {error}"),
173            Self::Alias(error) => write!(formatter, "migration alias-store error: {error}"),
174            Self::View(error) => write!(formatter, "migration view error: {error}"),
175            Self::Pin(error) => write!(formatter, "migration pin error: {error}"),
176            Self::Callgraph(error) => write!(formatter, "migration callgraph error: {error}"),
177            Self::InvalidLegacySnapshot(reason) => {
178                write!(formatter, "invalid legacy semantic snapshot: {reason}")
179            }
180            Self::UndurablePut(outcome) => {
181                write!(formatter, "semantic blob put was not durable: {outcome:?}")
182            }
183        }
184    }
185}
186
187impl std::error::Error for MigrationError {
188    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
189        match self {
190            Self::Io(error) => Some(error),
191            Self::Blob(error) => Some(error),
192            Self::Sqlite(error) => Some(error),
193            Self::Alias(error) => Some(error),
194            Self::View(error) => Some(error),
195            Self::Pin(error) => Some(error),
196            Self::Callgraph(error) => Some(error),
197            Self::InvalidLegacySnapshot(_) | Self::UndurablePut(_) => None,
198        }
199    }
200}
201
202impl From<io::Error> for MigrationError {
203    fn from(error: io::Error) -> Self {
204        Self::Io(error)
205    }
206}
207
208impl From<BlobStoreError> for MigrationError {
209    fn from(error: BlobStoreError) -> Self {
210        Self::Blob(error)
211    }
212}
213
214impl From<rusqlite::Error> for MigrationError {
215    fn from(error: rusqlite::Error) -> Self {
216        Self::Sqlite(error)
217    }
218}
219
220impl From<crate::alias::AliasError> for MigrationError {
221    fn from(error: crate::alias::AliasError) -> Self {
222        Self::Alias(error)
223    }
224}
225
226impl From<ViewError> for MigrationError {
227    fn from(error: ViewError) -> Self {
228        Self::View(error)
229    }
230}
231
232impl From<PinError> for MigrationError {
233    fn from(error: PinError) -> Self {
234        Self::Pin(error)
235    }
236}
237
238impl From<CallGraphStoreError> for MigrationError {
239    fn from(error: CallGraphStoreError) -> Self {
240        Self::Callgraph(error)
241    }
242}
243
244/// Imports an eligible root's old `semantic.bin` without making embedding requests.
245///
246/// Compatibility is intentionally strict: V6/V7 snapshots must carry the exact
247/// configured model fingerprint and the current legacy chunking version. Earlier
248/// or unstamped snapshots are recorded as a rebuild request rather than treated as
249/// an import failure. The source snapshot is never deleted or rewritten here.
250pub(crate) fn configured_fingerprint_for_legacy(
251    path: &Path,
252    config: &crate::config::SemanticBackendConfig,
253) -> Result<String, MigrationError> {
254    let bytes = fs::read(path)?;
255    let mut reader = LegacyReader::new(&bytes);
256    let version = reader
257        .read_u8()
258        .map_err(MigrationError::InvalidLegacySnapshot)?;
259    if version != LEGACY_SEMANTIC_V6 && version != LEGACY_SEMANTIC_V7 {
260        return Err(MigrationError::InvalidLegacySnapshot(format!(
261            "semantic.bin version {version} has no established producer stamps"
262        )));
263    }
264    let dimension = reader
265        .read_u32()
266        .map_err(MigrationError::InvalidLegacySnapshot)? as usize;
267    Ok(
268        crate::semantic_index::SemanticIndexFingerprint::for_config_dimension(config, dimension)
269            .as_string(),
270    )
271}
272
273pub(crate) fn store_live_semantic_blobs(
274    request: &SemanticMigrationRequest,
275    index: &crate::semantic_index::SemanticIndex,
276) -> Result<BTreeMap<Vec<u8>, String>, MigrationError> {
277    let raw = index.to_bytes();
278    let parsed = parse_legacy_snapshot(&raw, &request.configured_model_fingerprint)
279        .map_err(MigrationError::InvalidLegacySnapshot)?;
280    let root = fs::canonicalize(&request.project_root)?;
281    let view = ViewStore::open(&request.storage, &request.view)?;
282    let mut candidates = Vec::new();
283    for file in parsed.files {
284        let Some(rel_path) = legacy_path_to_rel_path(&file.path, &root) else {
285            continue;
286        };
287        let source_path = root.join(os_path_from_bytes(rel_path.as_bytes()));
288        let Ok(source) = fs::read(&source_path) else {
289            continue;
290        };
291        if blake3::hash(&source).as_bytes() != &file.content_hash {
292            continue;
293        }
294        let key = SemanticKey::from_bytes(
295            &source,
296            rel_path.as_bytes(),
297            &request.chunker_version,
298            &request.embed_template_version,
299            &request.configured_model_fingerprint,
300        )
301        .full_key();
302        candidates.push(ImportCandidate {
303            rel_path,
304            mode: regular_mode(&source_path)?,
305            key,
306            payload: encode_imported_payload(request, &file.entries),
307        });
308    }
309    let keys = candidates
310        .iter()
311        .map(|candidate| candidate.key.clone())
312        .collect::<Vec<_>>();
313    let generation = format!("semantic-live-{}", &blake3::hash(&raw).to_hex()[..16]);
314    let mut pin = AssemblyPin::create(
315        view.view_dir(),
316        request.family.clone(),
317        request.view.clone(),
318        generation,
319        &keys,
320    )?;
321    let mut store = BlobStore::open(
322        &request.storage,
323        request.family.clone(),
324        crate::blob_store::BlobPlane::Semantic,
325    )?;
326    let mut semantic_keys = BTreeMap::new();
327    for candidate in candidates {
328        pin.renew_if_due()?;
329        let put = store.put(&candidate.key, &candidate.payload)?;
330        if !put.durable {
331            return Err(MigrationError::UndurablePut(put.outcome));
332        }
333        semantic_keys.insert(
334            candidate.rel_path.as_bytes().to_vec(),
335            candidate.key.to_hex(),
336        );
337    }
338    pin.release();
339    Ok(semantic_keys)
340}
341
342/// Imports an eligible root's old `semantic.bin` without making embedding requests.
343///
344/// Compatibility is intentionally strict: V6/V7 snapshots must carry the exact
345/// configured model fingerprint and the current legacy chunking version. Earlier
346/// or unstamped snapshots are recorded as a rebuild request rather than treated as
347/// an import failure. The source snapshot is never deleted or rewritten here.
348pub fn import_legacy_semantic(
349    request: &SemanticMigrationRequest,
350) -> Result<SemanticMigrationReport, MigrationError> {
351    let source_path = request.legacy_semantic_path();
352    if !source_path.is_file() {
353        return Ok(SemanticMigrationReport::empty(
354            request,
355            SemanticMigrationOutcome::NotNeeded,
356        ));
357    }
358
359    let root = fs::canonicalize(&request.project_root)?;
360    let view = ViewStore::open(&request.storage, &request.view)?;
361    if view.current_generation()?.is_some() {
362        return Ok(SemanticMigrationReport::empty(
363            request,
364            SemanticMigrationOutcome::AlreadyPublished,
365        ));
366    }
367
368    let raw = fs::read(&source_path)?;
369    let parsed = match parse_legacy_snapshot(&raw, &request.configured_model_fingerprint) {
370        Ok(parsed) => parsed,
371        Err(reason) => return record_rebuild_request(request, &view, reason),
372    };
373
374    let mut candidates = Vec::new();
375    let mut skipped_rows = 0;
376    let mut outside_root_rows = 0;
377    let mut stale_rows = 0;
378    for file in parsed.files {
379        let Some(rel_path) = legacy_path_to_rel_path(&file.path, &root) else {
380            outside_root_rows += 1;
381            skipped_rows += 1;
382            crate::slog_warn!(
383                "semantic migration skipped legacy row outside root {}: {}",
384                root.display(),
385                legacy_path_display(&file.path)
386            );
387            continue;
388        };
389        let source_path = root.join(os_path_from_bytes(rel_path.as_bytes()));
390        let source = match fs::read(&source_path) {
391            Ok(source) => source,
392            Err(error) => {
393                skipped_rows += 1;
394                stale_rows += 1;
395                crate::slog_warn!(
396                    "semantic migration skipped unreadable legacy row {}: {}",
397                    source_path.display(),
398                    error
399                );
400                continue;
401            }
402        };
403        if file.content_hash.iter().all(|byte| *byte == 0)
404            || blake3::hash(&source).as_bytes() != &file.content_hash
405        {
406            skipped_rows += 1;
407            stale_rows += 1;
408            crate::slog_warn!(
409                "semantic migration skipped changed legacy row {}",
410                source_path.display()
411            );
412            continue;
413        }
414
415        let key = SemanticKey::from_bytes(
416            &source,
417            rel_path.as_bytes(),
418            &request.chunker_version,
419            &request.embed_template_version,
420            &request.configured_model_fingerprint,
421        )
422        .full_key();
423        candidates.push(ImportCandidate {
424            rel_path,
425            mode: regular_mode(&source_path)?,
426            key,
427            payload: encode_imported_payload(request, &file.entries),
428        });
429    }
430    candidates.sort_by(|left, right| left.rel_path.cmp(&right.rel_path));
431
432    let mut store = BlobStore::open(
433        &request.storage,
434        request.family.clone(),
435        crate::blob_store::BlobPlane::Semantic,
436    )?;
437    let alias_store = AliasStore::open(&request.storage, &request.family)?;
438    let artifacts =
439        create_publication_artifacts(view.view_dir(), store.path(), alias_store.path())?;
440
441    let keys = candidates
442        .iter()
443        .map(|candidate| candidate.key.clone())
444        .collect::<Vec<_>>();
445    let generation = migration_generation(&raw);
446    let mut pin = (!keys.is_empty())
447        .then(|| {
448            crate::pins::AssemblyPin::create(
449                view.view_dir(),
450                request.family.clone(),
451                request.view.clone(),
452                generation.clone(),
453                &keys,
454            )
455        })
456        .transpose()?;
457
458    let mut entries = Vec::with_capacity(candidates.len());
459    let mut semantic_keys = Vec::with_capacity(candidates.len());
460    for candidate in candidates {
461        if let Some(pin) = pin.as_mut() {
462            // Renewal happens before each put; failure leaves the legacy source and
463            // current pointer untouched so a later attempt can restart safely.
464            pin.renew_if_due()?;
465        }
466        let put = store.put(&candidate.key, &candidate.payload)?;
467        if !put.durable {
468            return Err(MigrationError::UndurablePut(put.outcome));
469        }
470        let key = candidate.key.to_hex();
471        semantic_keys.push(key.clone());
472        entries.push((
473            candidate.rel_path,
474            ManifestEntry::Regular {
475                mode: candidate.mode,
476                planes: RegularPlanes {
477                    semantic: Some(key),
478                    callgraph: None,
479                },
480                resolution_input: false,
481            },
482        ));
483    }
484
485    let manifest = Manifest::new(entries)?;
486    let closure = MigrationClosure {
487        semantic_database: store.path().to_path_buf(),
488        trigram: artifacts.trigram_artifact.clone(),
489    };
490    let publication = view.publish(
491        &PublicationRequest {
492            generation: &generation,
493            base_generation: None,
494            manifest: &manifest,
495            artifacts,
496            closure_requirements: ClosureRequirements::default(),
497        },
498        &closure,
499    )?;
500    if let Some(pin) = pin.as_mut() {
501        pin.release();
502    }
503
504    let outcome = match publication {
505        PublishOutcome::Published => SemanticMigrationOutcome::Imported,
506        PublishOutcome::Conflict { current_generation } => {
507            SemanticMigrationOutcome::PublishConflict { current_generation }
508        }
509    };
510    crate::slog_info!(
511        "semantic migration outcome={:?} imported_rows={} skipped_rows={} outside_root_rows={} reembeds=0",
512        outcome,
513        semantic_keys.len(),
514        skipped_rows,
515        outside_root_rows
516    );
517    Ok(SemanticMigrationReport {
518        outcome,
519        imported_rows: semantic_keys.len(),
520        skipped_rows,
521        outside_root_rows,
522        stale_rows,
523        reembedded_rows: 0,
524        semantic_keys,
525        chunker_version: request.chunker_version.clone(),
526        embed_template_version: request.embed_template_version.clone(),
527        model_fingerprint: request.configured_model_fingerprint.clone(),
528    })
529}
530
531/// Runs the existing staged cold callgraph build exactly when no usable store exists.
532/// A later rebind opens the published store and returns [`CallgraphMigrationOutcome::AlreadyCurrent`].
533pub fn rebuild_legacy_callgraph_once(
534    storage: &Path,
535    project_root: &Path,
536    chunk_size: usize,
537) -> Result<CallgraphMigrationOutcome, MigrationError> {
538    let project_root = fs::canonicalize(project_root)?;
539    let family = crate::search_index::artifact_cache_key(&project_root);
540    let callgraph_dir = storage.join("callgraph").join(family);
541    let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
542    let (_store, rebuilt) =
543        crate::callgraph_store::CallGraphStore::ensure_built_with_lease_chunked(
544            callgraph_dir,
545            project_root,
546            &files,
547            chunk_size,
548        )?;
549    Ok(if rebuilt.is_some() {
550        CallgraphMigrationOutcome::Rebuilt
551    } else {
552        CallgraphMigrationOutcome::AlreadyCurrent
553    })
554}
555
556fn record_rebuild_request(
557    request: &SemanticMigrationRequest,
558    view: &ViewStore,
559    reason: String,
560) -> Result<SemanticMigrationReport, MigrationError> {
561    let state_path = view.view_dir().join("migration-state.json");
562    if state_path.is_file() {
563        return Ok(SemanticMigrationReport::empty(
564            request,
565            SemanticMigrationOutcome::RebuildAlreadyScheduled { reason },
566        ));
567    }
568    let state = RebuildState {
569        disposition: "rebuild".to_string(),
570        reason: reason.clone(),
571    };
572    write_rebuild_state(&state_path, &state)?;
573    crate::slog_info!(
574        "semantic migration rebuild required for {}: {}",
575        request.project_root.display(),
576        reason
577    );
578    Ok(SemanticMigrationReport::empty(
579        request,
580        SemanticMigrationOutcome::RebuildRequired { reason },
581    ))
582}
583
584#[derive(Serialize)]
585struct RebuildState {
586    disposition: String,
587    reason: String,
588}
589
590fn write_rebuild_state(path: &Path, state: &RebuildState) -> Result<(), MigrationError> {
591    let temporary = path.with_extension(format!("json.tmp.{}", std::process::id()));
592    let result = (|| -> Result<(), MigrationError> {
593        let mut file = File::create(&temporary)?;
594        serde_json::to_writer(&mut file, state)
595            .map_err(|error| MigrationError::InvalidLegacySnapshot(error.to_string()))?;
596        use std::io::Write as _;
597        file.write_all(b"\n")?;
598        file.sync_all()?;
599        drop(file);
600        crate::fs_lock::rename_over(&temporary, path)?;
601        crate::fs_lock::sync_parent(path);
602        Ok(())
603    })();
604    if result.is_err() {
605        let _ = fs::remove_file(&temporary);
606    }
607    result
608}
609
610struct ImportCandidate {
611    rel_path: RelPath,
612    mode: u32,
613    key: FullKey,
614    payload: Vec<u8>,
615}
616
617struct MigrationClosure {
618    semantic_database: PathBuf,
619    trigram: PathBuf,
620}
621
622impl PublicationClosure for MigrationClosure {
623    fn contains_blob(&self, plane: ArtifactPlane, full_key: &str) -> crate::views::Result<bool> {
624        if plane != ArtifactPlane::Semantic {
625            return Ok(false);
626        }
627        let Some(key) = decode_hex_key(full_key) else {
628            return Ok(false);
629        };
630        Ok(Connection::open(&self.semantic_database)?
631            .query_row(
632                "SELECT 1 FROM blob_payloads WHERE full_key = ?1",
633                params![key.as_slice()],
634                |_| Ok(()),
635            )
636            .optional()?
637            .is_some())
638    }
639
640    fn trigram_is_present(&self) -> crate::views::Result<bool> {
641        Ok(self.trigram.is_file())
642    }
643
644    fn contains_alias(&self, _git_oid: &str) -> crate::views::Result<bool> {
645        Ok(false)
646    }
647}
648
649fn create_publication_artifacts(
650    view_dir: &Path,
651    semantic_database: &Path,
652    alias_database: &Path,
653) -> Result<PublicationArtifacts, MigrationError> {
654    let derived_database = view_dir.join("derived.sqlite");
655    let derived = Connection::open(&derived_database)?;
656    derived.execute_batch(
657        "PRAGMA journal_mode=WAL;
658         PRAGMA synchronous=NORMAL;
659         CREATE TABLE IF NOT EXISTS migration_derived (singleton INTEGER PRIMARY KEY);",
660    )?;
661    drop(derived);
662
663    let trigram_artifact = view_dir.join("trigram.bin");
664    let trigram = File::create(&trigram_artifact)?;
665    trigram.sync_all()?;
666    drop(trigram);
667    crate::fs_lock::sync_parent(&trigram_artifact);
668
669    Ok(PublicationArtifacts {
670        blob_databases: vec![semantic_database.to_path_buf()],
671        derived_database,
672        trigram_artifact,
673        alias_database: alias_database.to_path_buf(),
674    })
675}
676
677fn migration_generation(raw: &[u8]) -> String {
678    let digest = blake3::hash(raw).to_hex();
679    format!("migration-{}", &digest[..16])
680}
681
682fn regular_mode(path: &Path) -> Result<u32, io::Error> {
683    let metadata = fs::metadata(path)?;
684    #[cfg(unix)]
685    {
686        use std::os::unix::fs::PermissionsExt as _;
687        return Ok(if metadata.permissions().mode() & 0o111 == 0 {
688            0o100644
689        } else {
690            0o100755
691        });
692    }
693    #[cfg(not(unix))]
694    {
695        let _ = metadata;
696        Ok(0o100644)
697    }
698}
699
700fn legacy_path_to_rel_path(raw: &[u8], root: &Path) -> Option<RelPath> {
701    let legacy = os_path_from_bytes(raw);
702    if legacy.is_absolute() {
703        let relative = legacy.strip_prefix(root).ok()?;
704        RelPath::from_os_path(relative).ok()
705    } else {
706        RelPath::from_os_path(&legacy).ok()
707    }
708}
709
710fn os_path_from_bytes(bytes: &[u8]) -> PathBuf {
711    #[cfg(unix)]
712    {
713        use std::os::unix::ffi::OsStringExt as _;
714        PathBuf::from(std::ffi::OsString::from_vec(bytes.to_vec()))
715    }
716    #[cfg(not(unix))]
717    {
718        PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
719    }
720}
721
722fn legacy_path_display(path: &[u8]) -> String {
723    String::from_utf8_lossy(path).into_owned()
724}
725
726fn decode_hex_key(value: &str) -> Option<Vec<u8>> {
727    if value.len() != 64 {
728        return None;
729    }
730    (0..64)
731        .step_by(2)
732        .map(|index| u8::from_str_radix(&value[index..index + 2], 16).ok())
733        .collect()
734}
735
736fn encode_imported_payload(request: &SemanticMigrationRequest, entries: &[LegacyEntry]) -> Vec<u8> {
737    let mut payload = Vec::new();
738    payload.push(IMPORTED_PAYLOAD_VERSION);
739    push_bytes(&mut payload, request.chunker_version.as_bytes());
740    push_bytes(&mut payload, request.embed_template_version.as_bytes());
741    push_bytes(
742        &mut payload,
743        request.configured_model_fingerprint.as_bytes(),
744    );
745    payload.extend_from_slice(&(entries.len() as u32).to_le_bytes());
746    for entry in entries {
747        push_bytes(&mut payload, entry.name.as_bytes());
748        push_bytes(
749            &mut payload,
750            entry
751                .qualified_name
752                .as_deref()
753                .unwrap_or_default()
754                .as_bytes(),
755        );
756        payload.push(entry.kind);
757        payload.extend_from_slice(&entry.start_line.to_le_bytes());
758        payload.extend_from_slice(&entry.end_line.to_le_bytes());
759        payload.push(u8::from(entry.exported));
760        push_bytes(&mut payload, entry.snippet.as_bytes());
761        push_bytes(&mut payload, entry.embed_text.as_bytes());
762        push_bytes(&mut payload, &entry.vector);
763    }
764    payload
765}
766
767fn push_bytes(output: &mut Vec<u8>, bytes: &[u8]) {
768    output.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
769    output.extend_from_slice(bytes);
770}
771
772struct ParsedLegacySnapshot {
773    files: Vec<LegacyFile>,
774}
775
776struct LegacyFile {
777    path: Vec<u8>,
778    content_hash: [u8; 32],
779    entries: Vec<LegacyEntry>,
780}
781
782struct LegacyEntry {
783    name: String,
784    qualified_name: Option<String>,
785    kind: u8,
786    start_line: u32,
787    end_line: u32,
788    exported: bool,
789    snippet: String,
790    embed_text: String,
791    vector: Vec<u8>,
792}
793
794fn parse_legacy_snapshot(
795    bytes: &[u8],
796    configured_model_fingerprint: &str,
797) -> Result<ParsedLegacySnapshot, String> {
798    let mut reader = LegacyReader::new(bytes);
799    let version = reader.read_u8()?;
800    if version != LEGACY_SEMANTIC_V6 && version != LEGACY_SEMANTIC_V7 {
801        return Err(format!(
802            "semantic.bin version {version} has no established producer stamps"
803        ));
804    }
805    let dimension = reader.read_u32()? as usize;
806    if dimension == 0 || dimension > MAX_LEGACY_DIMENSION {
807        return Err(format!("invalid semantic dimension {dimension}"));
808    }
809    let entry_count = reader.read_u32()? as usize;
810    if entry_count > MAX_LEGACY_ROWS {
811        return Err(format!("too many semantic entries {entry_count}"));
812    }
813    let fingerprint = reader.read_string()?;
814    if !fingerprint_is_compatible(&fingerprint, configured_model_fingerprint) {
815        return Err("model fingerprint or chunker version is incompatible".to_string());
816    }
817
818    let metadata_count = reader.read_u32()? as usize;
819    if metadata_count > MAX_LEGACY_ROWS {
820        return Err(format!("too many semantic metadata rows {metadata_count}"));
821    }
822    let mut metadata = BTreeMap::new();
823    for _ in 0..metadata_count {
824        let path = reader.read_bytes()?;
825        let _seconds = reader.read_u64()?;
826        let nanos = reader.read_u32()?;
827        if nanos >= 1_000_000_000 {
828            return Err(format!("invalid semantic mtime nanos {nanos}"));
829        }
830        let _size = reader.read_u64()?;
831        let content_hash = reader.read_array_32()?;
832        if metadata.insert(path.clone(), content_hash).is_some() {
833            return Err(format!(
834                "duplicate semantic metadata path {}",
835                legacy_path_display(&path)
836            ));
837        }
838    }
839
840    let mut entries_by_path = BTreeMap::<Vec<u8>, Vec<LegacyEntry>>::new();
841    for _ in 0..entry_count {
842        let path = reader.read_bytes()?;
843        let name = reader.read_string()?;
844        let qualified_name = if version == LEGACY_SEMANTIC_V7 {
845            let qualified = reader.read_string()?;
846            (!qualified.is_empty()).then_some(qualified)
847        } else {
848            None
849        };
850        let kind = reader.read_u8()?;
851        let start_line = reader.read_u32()?;
852        let end_line = reader.read_u32()?;
853        let exported = reader.read_u8()? != 0;
854        let snippet = reader.read_string()?;
855        let embed_text = reader.read_string()?;
856        let vector_len = dimension
857            .checked_mul(std::mem::size_of::<f32>())
858            .ok_or_else(|| "semantic vector length overflow".to_string())?;
859        let vector = reader.read_exact(vector_len)?.to_vec();
860        entries_by_path.entry(path).or_default().push(LegacyEntry {
861            name,
862            qualified_name,
863            kind,
864            start_line,
865            end_line,
866            exported,
867            snippet,
868            embed_text,
869            vector,
870        });
871    }
872    if !reader.is_exhausted() {
873        return Err("trailing bytes after semantic snapshot".to_string());
874    }
875
876    let mut files = Vec::with_capacity(metadata.len());
877    for (path, content_hash) in metadata {
878        files.push(LegacyFile {
879            entries: entries_by_path.remove(&path).unwrap_or_default(),
880            path,
881            content_hash,
882        });
883    }
884    // An entry without metadata cannot prove that it belongs to this root. It is
885    // intentionally not imported rather than attaching an unverifiable vector.
886    if !entries_by_path.is_empty() {
887        crate::slog_warn!(
888            "semantic migration skipped {} entry path(s) without file metadata",
889            entries_by_path.len()
890        );
891    }
892    Ok(ParsedLegacySnapshot { files })
893}
894
895fn fingerprint_is_compatible(legacy: &str, configured: &str) -> bool {
896    let Ok(legacy) = serde_json::from_str::<serde_json::Value>(legacy) else {
897        return false;
898    };
899    let Ok(configured) = serde_json::from_str::<serde_json::Value>(configured) else {
900        return false;
901    };
902    legacy == configured
903        && legacy
904            .get("chunking_version")
905            .and_then(serde_json::Value::as_u64)
906            == Some(LEGACY_CHUNKING_VERSION)
907}
908
909struct LegacyReader<'a> {
910    bytes: &'a [u8],
911    offset: usize,
912}
913
914impl<'a> LegacyReader<'a> {
915    fn new(bytes: &'a [u8]) -> Self {
916        Self { bytes, offset: 0 }
917    }
918
919    fn is_exhausted(&self) -> bool {
920        self.offset == self.bytes.len()
921    }
922
923    fn read_exact(&mut self, len: usize) -> Result<&'a [u8], String> {
924        let end = self
925            .offset
926            .checked_add(len)
927            .ok_or_else(|| "semantic snapshot offset overflow".to_string())?;
928        let bytes = self
929            .bytes
930            .get(self.offset..end)
931            .ok_or_else(|| "unexpected end of semantic snapshot".to_string())?;
932        self.offset = end;
933        Ok(bytes)
934    }
935
936    fn read_u8(&mut self) -> Result<u8, String> {
937        Ok(self.read_exact(1)?[0])
938    }
939
940    fn read_u32(&mut self) -> Result<u32, String> {
941        let bytes: [u8; 4] = self
942            .read_exact(4)?
943            .try_into()
944            .map_err(|_| "invalid u32 field".to_string())?;
945        Ok(u32::from_le_bytes(bytes))
946    }
947
948    fn read_u64(&mut self) -> Result<u64, String> {
949        let bytes: [u8; 8] = self
950            .read_exact(8)?
951            .try_into()
952            .map_err(|_| "invalid u64 field".to_string())?;
953        Ok(u64::from_le_bytes(bytes))
954    }
955
956    fn read_array_32(&mut self) -> Result<[u8; 32], String> {
957        self.read_exact(32)?
958            .try_into()
959            .map_err(|_| "invalid 32-byte field".to_string())
960    }
961
962    fn read_bytes(&mut self) -> Result<Vec<u8>, String> {
963        let len = self.read_u32()? as usize;
964        if len > self.bytes.len().saturating_sub(self.offset) {
965            return Err("semantic string exceeds remaining snapshot bytes".to_string());
966        }
967        Ok(self.read_exact(len)?.to_vec())
968    }
969
970    fn read_string(&mut self) -> Result<String, String> {
971        String::from_utf8(self.read_bytes()?)
972            .map_err(|_| "semantic string is not UTF-8".to_string())
973    }
974}
975
976#[cfg(test)]
977mod tests {
978    use super::*;
979
980    #[test]
981    fn compatible_fingerprint_requires_the_current_chunker() {
982        let fingerprint = serde_json::json!({
983            "backend": "test",
984            "model": "test",
985            "base_url": "test",
986            "dimension": 3,
987            "chunking_version": 2,
988        })
989        .to_string();
990        assert!(fingerprint_is_compatible(&fingerprint, &fingerprint));
991        let older = fingerprint.replace("\"chunking_version\":2", "\"chunking_version\":1");
992        assert!(!fingerprint_is_compatible(&older, &older));
993    }
994
995    #[test]
996    fn imported_payload_carries_the_importing_producer_stamps() {
997        let request = SemanticMigrationRequest {
998            storage: PathBuf::new(),
999            project_root: PathBuf::new(),
1000            family: "family".to_string(),
1001            view: "view".to_string(),
1002            configured_model_fingerprint: "model".to_string(),
1003            chunker_version: "chunker".to_string(),
1004            embed_template_version: "template".to_string(),
1005        };
1006        let payload = encode_imported_payload(&request, &[]);
1007        assert_eq!(payload[0], IMPORTED_PAYLOAD_VERSION);
1008        assert!(payload
1009            .windows(b"chunker".len())
1010            .any(|part| part == b"chunker"));
1011        assert!(payload
1012            .windows(b"template".len())
1013            .any(|part| part == b"template"));
1014    }
1015}