1use 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#[derive(Clone, Debug)]
46pub struct SemanticMigrationRequest {
47 pub storage: PathBuf,
49 pub project_root: PathBuf,
51 pub family: String,
53 pub view: String,
55 pub configured_model_fingerprint: String,
57 pub chunker_version: String,
59 pub embed_template_version: String,
60}
61
62impl SemanticMigrationRequest {
63 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#[derive(Clone, Debug, Eq, PartialEq)]
92pub enum SemanticMigrationOutcome {
93 NotNeeded,
95 Imported,
97 AlreadyPublished,
99 RebuildRequired { reason: String },
101 RebuildAlreadyScheduled { reason: String },
103 PublishConflict { current_generation: Option<String> },
105}
106
107#[derive(Clone, Debug, Eq, PartialEq)]
109pub struct SemanticMigrationReport {
110 pub outcome: SemanticMigrationOutcome,
111 pub imported_rows: usize,
113 pub skipped_rows: usize,
115 pub outside_root_rows: usize,
117 pub stale_rows: usize,
119 pub reembedded_rows: usize,
121 pub semantic_keys: Vec<String>,
123 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
148pub enum CallgraphMigrationOutcome {
149 Rebuilt,
150 AlreadyCurrent,
151}
152
153#[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
244pub(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
342pub 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 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
531pub 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 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}