1use super::{
4 AtlasStore, DbError, DbResult, ProjectRootMismatchIdentities, normalize_metadata_path,
5 set_metadata,
6};
7use crate::schema::{self, PROJECT_ROOT_KEY, SchemaState};
8use projectatlas_core::graph::ProjectInstanceId;
9use projectatlas_core::{CanonicalProjectRoot, IndexGeneration};
10use rusqlite::{Connection, OptionalExtension};
11use std::fs;
12use std::path::Path;
13
14const PROJECT_IDENTITY_GENERATION_ATTEMPTS: usize = 8;
16
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub enum ProjectRootTransition {
20 Bind,
22 Move,
24 Detach,
26 AdoptLegacy,
28}
29
30#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct ProjectRootTransitionResult {
33 pub transition: ProjectRootTransition,
35 pub previous_root: Option<String>,
38 pub project_root: Option<String>,
43 pub project_instance_id: ProjectInstanceId,
45 pub identity_changed: bool,
47 pub publication_invalidated: bool,
49}
50
51impl AtlasStore {
52 pub fn repair_missing_project_root_identity(
71 database_path: &Path,
72 destination: &Path,
73 ) -> DbResult<()> {
74 let destination_identity = validate_project_root_destination(destination)?;
75 let (preflight, _) = schema::preflight(database_path, None)?;
76 if preflight.state != SchemaState::Current {
77 return Ok(());
78 }
79 if let Some(found) = read_current_project_root_identity(database_path)? {
80 prove_existing_root_equivalence(destination_identity.as_path(), found.as_path())?;
81 return Ok(());
82 }
83 let legacy = preflight
84 .project_root
85 .as_deref()
86 .ok_or(DbError::ProjectRootMissing)?;
87 prove_existing_root_equivalence(destination_identity.as_path(), Path::new(legacy))?;
88
89 let store = Self::open_with_binding_requirement(
90 database_path,
91 None,
92 None,
93 super::ProjectIdentityRequirement::TransitionOwned,
94 super::StoreInitializationPolicy::AllowFresh,
95 )?;
96 let transaction = rusqlite::Transaction::new_unchecked(
97 &store.connection,
98 rusqlite::TransactionBehavior::Immediate,
99 )?;
100 let result = (|| {
101 schema::validate_current_schema_version(&transaction)?;
102 if load_project_identity(&transaction)?.is_none() {
103 return Err(DbError::ProjectInstanceIdentityMissing);
104 }
105 let found_identity = load_project_root_identity(&transaction)?;
106 let selected = if let Some(found) = found_identity.as_ref() {
107 prove_existing_root_equivalence(destination_identity.as_path(), found.as_path())?
108 } else {
109 let legacy = transaction
110 .query_row(
111 "SELECT value FROM metadata WHERE key = ?1",
112 [PROJECT_ROOT_KEY],
113 |row| row.get::<_, String>(0),
114 )
115 .optional()?
116 .ok_or(DbError::ProjectRootMissing)?;
117 prove_existing_root_equivalence(destination_identity.as_path(), Path::new(&legacy))?
118 };
119 set_project_root_identity(&transaction, &selected)?;
120 set_project_root_metadata(&transaction, &selected)?;
121 Ok(())
122 })();
123 match result {
124 Ok(()) => {
125 transaction.commit()?;
126 Ok(())
127 }
128 Err(operation) => match transaction.rollback() {
129 Ok(()) => Err(operation),
130 Err(rollback) => Err(DbError::TransactionRollback {
131 operation: Box::new(operation),
132 rollback,
133 }),
134 },
135 }
136 }
137
138 pub fn transition_project_root(
154 database_path: &Path,
155 destination: &Path,
156 transition: ProjectRootTransition,
157 ) -> DbResult<ProjectRootTransitionResult> {
158 let destination_identity = validate_project_root_destination(destination)?;
159 let destination = destination_identity.display_string().ok();
160 if transition == ProjectRootTransition::AdoptLegacy {
161 let previous = schema::adopt_legacy_project_root(database_path, &destination_identity)?;
162 return Ok(ProjectRootTransitionResult {
163 transition,
164 previous_root: None,
165 project_root: destination,
166 project_instance_id: previous
167 .project_instance_id
168 .ok_or(DbError::ProjectInstanceIdentityMissing)?,
169 identity_changed: false,
170 publication_invalidated: false,
171 });
172 }
173 let (preflight, _) = schema::preflight(database_path, None)?;
174 let previous_root = preflight.project_root.clone();
175 let previous_identity = preflight.project_instance_id;
176 if preflight.state == SchemaState::UpgradeRequired
177 && schema::legacy_root_requires_native_authority(previous_root.as_deref())
178 {
179 return Err(DbError::ProjectRootIdentityMissing);
180 }
181 let previous_root_identity = if preflight.state == SchemaState::Current {
182 read_current_project_root_identity(database_path)?
183 } else if (transition == ProjectRootTransition::Bind
184 || transition == ProjectRootTransition::Detach)
185 && preflight.state == SchemaState::UpgradeRequired
186 {
187 previous_root
188 .as_deref()
189 .map(Path::new)
190 .map(CanonicalProjectRoot::from_path)
191 .transpose()?
192 } else {
193 None
194 };
195 match transition {
196 ProjectRootTransition::AdoptLegacy => Err(DbError::LegacyRootAdoptionUnavailable),
197 ProjectRootTransition::Bind => {
198 if let Some(found) = previous_root_identity.as_ref() {
199 prove_existing_root_equivalence(
200 destination_identity.as_path(),
201 found.as_path(),
202 )?;
203 } else if let Some(found) = previous_root.as_deref() {
204 prove_existing_root_equivalence(
205 destination_identity.as_path(),
206 Path::new(found),
207 )?;
208 }
209 let store = Self::open_for_project(database_path, destination_identity.as_path())?;
210 let project_instance_id = store
211 .project_instance_id()?
212 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
213 Ok(ProjectRootTransitionResult {
214 transition,
215 previous_root: previous_root_identity
216 .as_ref()
217 .and_then(|root| root.display_string().ok()),
218 project_root: destination,
219 project_instance_id,
220 identity_changed: previous_identity != Some(project_instance_id),
221 publication_invalidated: false,
222 })
223 }
224 ProjectRootTransition::Move | ProjectRootTransition::Detach => {
225 let previous_root_identity = match previous_root_identity.as_ref() {
226 Some(identity) => identity,
227 None if preflight.state == SchemaState::UpgradeRequired => {
228 return Err(DbError::ProjectRootIdentityMissing);
229 }
230 None => return Err(DbError::ProjectRootTransitionRequiresExistingRoot),
231 };
232 if transition == ProjectRootTransition::Move {
233 if previous_root_identity == &destination_identity {
234 return Err(DbError::ProjectRootTransitionRequiresDifferentRoot {
235 root: destination_identity.display_string_lossy(),
236 });
237 }
238 verify_root_absent(previous_root_identity)?;
239 }
240
241 let mut store = Self::open_for_root_transition(database_path)?;
242 let opened_identity = store.project_instance_id()?;
243 let upgrade_transaction = !store.connection.is_autocommit();
244 if previous_identity.is_some() && opened_identity != previous_identity {
245 let error = project_transition_changed(
246 previous_root_identity.display_string().ok(),
247 store.project_root()?,
248 previous_identity,
249 opened_identity,
250 );
251 return Err(if upgrade_transaction {
252 schema::rollback_after_error(&store.connection, error)
253 } else {
254 error
255 });
256 }
257 let mut result = if upgrade_transaction {
258 let operation = apply_root_transition_in_transaction(
259 &mut store,
260 transition,
261 Some(previous_root_identity),
262 opened_identity,
263 &destination_identity,
264 );
265 match operation {
266 Ok(result) => {
267 if let Err(source) = store.connection.execute_batch("COMMIT") {
268 return Err(schema::rollback_after_error(
269 &store.connection,
270 DbError::Sqlite(source),
271 ));
272 }
273 result
274 }
275 Err(error) => {
276 return Err(schema::rollback_after_error(&store.connection, error));
277 }
278 }
279 } else {
280 apply_root_transition(
281 &mut store,
282 transition,
283 Some(previous_root_identity),
284 opened_identity,
285 &destination_identity,
286 )?
287 };
288 result.identity_changed = previous_identity != Some(result.project_instance_id);
289 Ok(result)
290 }
291 }
292 }
293
294 pub fn project_instance_id(&self) -> DbResult<Option<ProjectInstanceId>> {
300 load_project_identity(&self.connection)
301 }
302}
303
304fn validate_project_root_destination(destination: &Path) -> DbResult<CanonicalProjectRoot> {
306 let root = normalize_metadata_path(destination);
307 if !destination.is_absolute() {
308 return Err(DbError::ProjectRootDestinationInvalid {
309 root,
310 source: std::io::Error::new(
311 std::io::ErrorKind::InvalidInput,
312 "project root destination is not absolute",
313 ),
314 });
315 }
316 CanonicalProjectRoot::from_path(destination).map_err(|error| match error {
317 projectatlas_core::CoreError::CanonicalProjectRootIo { source, .. } => {
318 DbError::ProjectRootDestinationInvalid { root, source }
319 }
320 projectatlas_core::CoreError::InvalidCanonicalProjectRoot { reason, .. } => {
321 DbError::ProjectRootDestinationInvalid {
322 root,
323 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, reason),
324 }
325 }
326 other => DbError::from(other),
327 })
328}
329
330fn apply_root_transition(
332 store: &mut AtlasStore,
333 transition: ProjectRootTransition,
334 expected_root_identity: Option<&CanonicalProjectRoot>,
335 expected_identity: Option<ProjectInstanceId>,
336 destination: &CanonicalProjectRoot,
337) -> DbResult<ProjectRootTransitionResult> {
338 store.connection.execute_batch("BEGIN IMMEDIATE")?;
339 let operation = apply_root_transition_in_transaction(
340 store,
341 transition,
342 expected_root_identity,
343 expected_identity,
344 destination,
345 );
346 match operation {
347 Ok(result) => {
348 if let Err(source) = store.connection.execute_batch("COMMIT") {
349 return Err(schema::rollback_after_error(
350 &store.connection,
351 DbError::Sqlite(source),
352 ));
353 }
354 store.validated_project_root = destination.display_string().ok();
355 store.validated_project_root_identity = Some(destination.clone());
356 store.validated_project_instance_id = Some(result.project_instance_id);
357 if result.identity_changed {
358 store.library_usage_instances.get_mut().clear();
359 }
360 Ok(result)
361 }
362 Err(error) => Err(schema::rollback_after_error(&store.connection, error)),
363 }
364}
365
366fn apply_root_transition_in_transaction(
368 store: &mut AtlasStore,
369 transition: ProjectRootTransition,
370 expected_root_identity: Option<&CanonicalProjectRoot>,
371 expected_identity: Option<ProjectInstanceId>,
372 destination: &CanonicalProjectRoot,
373) -> DbResult<ProjectRootTransitionResult> {
374 schema::validate_current_schema_version(&store.connection)?;
375 let found_root = store.project_root()?;
376 let found_root_identity = load_project_root_identity(&store.connection)?;
377 let found_identity = load_project_identity(&store.connection)?;
378 if found_root_identity.as_ref() != expected_root_identity || found_identity != expected_identity
379 {
380 return Err(project_transition_changed(
381 expected_root_identity.map(CanonicalProjectRoot::display_string_lossy),
382 found_root,
383 expected_identity,
384 found_identity,
385 ));
386 }
387
388 if transition == ProjectRootTransition::Detach
389 && let Some(previous_identity) = found_identity
390 {
391 crate::telemetry::seal_project_usage_instances(&store.connection, previous_identity)?;
392 }
393 set_project_root_metadata(&store.connection, destination)?;
394 set_project_root_identity(&store.connection, destination)?;
395 schema::invalidate_derived_publication(&store.connection)?;
396 let (project_instance_id, identity_changed) = match transition {
397 ProjectRootTransition::Bind => unreachable!("bind does not use transition mutation"),
398 ProjectRootTransition::AdoptLegacy => return Err(DbError::LegacyRootAdoptionUnavailable),
399 ProjectRootTransition::Move => {
400 let (identity, identity_changed) = ensure_project_identity(&store.connection)?;
401 set_graph_generation(&store.connection, IndexGeneration::ZERO)?;
402 (identity, identity_changed)
403 }
404 ProjectRootTransition::Detach => {
405 store
406 .connection
407 .execute("DELETE FROM graph_resolution_keys", [])?;
408 store.connection.execute("DELETE FROM graph_coverage", [])?;
409 store
410 .connection
411 .execute("DELETE FROM graph_identity_rejections", [])?;
412 store
413 .connection
414 .execute("DELETE FROM graph_relations", [])?;
415 store.connection.execute("DELETE FROM graph_entities", [])?;
416 let identity = generate_project_identity(&store.connection, found_identity)?;
417 set_project_identity(&store.connection, identity)?;
418 (identity, true)
419 }
420 };
421
422 Ok(ProjectRootTransitionResult {
423 transition,
424 previous_root: expected_root_identity.and_then(|root| root.display_string().ok()),
425 project_root: destination.display_string().ok(),
426 project_instance_id,
427 identity_changed,
428 publication_invalidated: true,
429 })
430}
431
432fn verify_root_absent(root: &CanonicalProjectRoot) -> DbResult<()> {
434 classify_root_absence(
435 &root.display_string_lossy(),
436 fs::symlink_metadata(root.as_path()).map(|_| ()),
437 )
438}
439
440fn classify_root_absence(root: &str, result: std::io::Result<()>) -> DbResult<()> {
442 match result {
443 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
444 Ok(()) => Err(DbError::ProjectRootStillPresent {
445 root: root.to_string(),
446 }),
447 Err(source) => Err(DbError::ProjectRootAbsenceUncertain {
448 root: root.to_string(),
449 source,
450 }),
451 }
452}
453
454fn read_current_project_root_identity(path: &Path) -> DbResult<Option<CanonicalProjectRoot>> {
456 let (connection, _) = schema::open_current_read_only(path, None)?;
457 load_project_root_identity(&connection)
458}
459
460pub(crate) fn load_project_identity(
462 connection: &Connection,
463) -> DbResult<Option<ProjectInstanceId>> {
464 let bytes = connection
465 .query_row(
466 "SELECT project_instance_id FROM project_identity WHERE singleton = 1",
467 [],
468 |row| row.get::<_, Vec<u8>>(0),
469 )
470 .optional()?;
471 bytes.map(project_identity_from_blob).transpose()
472}
473
474pub(crate) fn load_project_root_identity(
476 connection: &Connection,
477) -> DbResult<Option<CanonicalProjectRoot>> {
478 let encoded = connection
479 .query_row(
480 "SELECT codec_version, root FROM project_root_identity WHERE singleton = 1",
481 [],
482 |row| {
483 let version = row.get::<_, i64>(0)?;
484 let root = row.get::<_, Vec<u8>>(1)?;
485 Ok((version, root))
486 },
487 )
488 .optional()?;
489 encoded
490 .map(|(version, root)| {
491 if version
492 != i64::from(projectatlas_core::project_root::CANONICAL_PROJECT_ROOT_CODEC_VERSION)
493 {
494 return Err(DbError::ProjectRootIdentity(
495 projectatlas_core::CoreError::CanonicalProjectRootCodec {
496 reason: "unsupported codec version",
497 },
498 ));
499 }
500 CanonicalProjectRoot::decode(&root).map_err(DbError::ProjectRootIdentity)
501 })
502 .transpose()
503}
504
505pub(crate) fn set_project_root_identity(
507 connection: &Connection,
508 identity: &CanonicalProjectRoot,
509) -> DbResult<()> {
510 let encoded = identity.encode()?;
511 connection.execute(
512 "INSERT INTO project_root_identity(singleton, codec_version, root)
513 VALUES(1, ?1, ?2)
514 ON CONFLICT(singleton) DO UPDATE SET
515 codec_version = excluded.codec_version,
516 root = excluded.root",
517 rusqlite::params![
518 i64::from(projectatlas_core::project_root::CANONICAL_PROJECT_ROOT_CODEC_VERSION),
519 encoded,
520 ],
521 )?;
522 Ok(())
523}
524
525pub(crate) fn prove_existing_root_equivalence(
532 selected: &Path,
533 persisted: &Path,
534) -> DbResult<CanonicalProjectRoot> {
535 let selected = CanonicalProjectRoot::from_path(selected)?;
536 let persisted = CanonicalProjectRoot::from_path(persisted)?;
537 if selected.as_path() != persisted.as_path() {
538 return Err(DbError::ProjectRootMismatch {
539 expected: selected.display_string_lossy(),
540 found: persisted.display_string_lossy(),
541 identities: Some(Box::new(ProjectRootMismatchIdentities {
542 expected: selected,
543 found: persisted,
544 })),
545 });
546 }
547 Ok(selected)
548}
549
550pub(crate) fn ensure_project_root_identity(
552 connection: &Connection,
553 expected: &CanonicalProjectRoot,
554) -> DbResult<()> {
555 if load_project_identity(connection)?.is_none() {
556 return Err(DbError::ProjectInstanceIdentityMissing);
557 }
558 let metadata = connection
559 .query_row(
560 "SELECT value FROM metadata WHERE key = ?1",
561 [PROJECT_ROOT_KEY],
562 |row| row.get::<_, String>(0),
563 )
564 .optional()?;
565 if let Some(found) = load_project_root_identity(connection)? {
566 let selected = prove_existing_root_equivalence(expected.as_path(), found.as_path())?;
567 if metadata.as_deref() == selected.display_string().ok().as_deref() {
568 schema::validate_current_schema_version(connection)?;
569 return Ok(());
570 }
571 }
572 connection.execute_batch("BEGIN IMMEDIATE")?;
573 let result = ensure_project_root_identity_after_lock(connection, expected);
574 match result {
575 Ok(()) => connection.execute_batch("COMMIT").map_err(Into::into),
576 Err(error) => Err(schema::rollback_after_error(connection, error)),
577 }
578}
579
580fn ensure_project_root_identity_after_lock(
582 connection: &Connection,
583 expected: &CanonicalProjectRoot,
584) -> DbResult<()> {
585 schema::validate_current_schema_version(connection)?;
586 if load_project_identity(connection)?.is_none() {
587 return Err(DbError::ProjectInstanceIdentityMissing);
588 }
589 ensure_project_root_identity_in_transaction(connection, expected)
590}
591
592pub(crate) fn ensure_project_root_identity_in_transaction(
594 connection: &Connection,
595 expected: &CanonicalProjectRoot,
596) -> DbResult<()> {
597 let found_metadata = connection
598 .query_row(
599 "SELECT value FROM metadata WHERE key = ?1",
600 [PROJECT_ROOT_KEY],
601 |row| row.get::<_, String>(0),
602 )
603 .optional()?;
604 let found_identity = load_project_root_identity(connection)?;
605 let selected = if let Some(found) = found_identity.as_ref() {
606 prove_existing_root_equivalence(expected.as_path(), found.as_path())?
607 } else {
608 let native_identity_table_exists = connection
609 .query_row(
610 "SELECT 1 FROM sqlite_master
611 WHERE type = 'table' AND name = 'project_root_identity'",
612 [],
613 |row| row.get::<_, i64>(0),
614 )
615 .optional()?
616 .is_some();
617 if native_identity_table_exists {
618 return Err(DbError::ProjectRootIdentityMissing);
619 }
620 let Some(legacy) = found_metadata.as_deref() else {
621 return Err(DbError::ProjectRootMissing);
622 };
623 prove_existing_root_equivalence(expected.as_path(), Path::new(legacy))?
624 };
625 set_project_root_identity(connection, &selected)?;
626 set_project_root_metadata(connection, &selected)?;
627 Ok(())
628}
629
630pub(crate) fn set_project_root_metadata(
636 connection: &Connection,
637 identity: &CanonicalProjectRoot,
638) -> DbResult<()> {
639 if let Ok(display) = identity.display_string() {
640 set_metadata(connection, PROJECT_ROOT_KEY, &display)?;
641 } else {
642 connection.execute("DELETE FROM metadata WHERE key = ?1", [PROJECT_ROOT_KEY])?;
643 }
644 Ok(())
645}
646
647pub(crate) fn load_graph_generation(connection: &Connection) -> DbResult<Option<IndexGeneration>> {
649 let generation = connection
650 .query_row(
651 "SELECT active_generation FROM project_identity WHERE singleton = 1",
652 [],
653 |row| row.get::<_, i64>(0),
654 )
655 .optional()?;
656 generation
657 .map(|value| {
658 let value = u64::try_from(value).map_err(|source| DbError::InvalidCount {
659 field: "project_identity.active_generation",
660 value,
661 source,
662 })?;
663 Ok(IndexGeneration::new(value))
664 })
665 .transpose()
666}
667
668pub(crate) fn verify_project_identity(
670 connection: &Connection,
671 expected: ProjectInstanceId,
672) -> DbResult<bool> {
673 let Some(found) = load_project_identity(connection)? else {
674 return Ok(false);
675 };
676 require_project_identity(expected, found)?;
677 Ok(true)
678}
679
680pub(crate) fn require_bound_project_identity(
682 connection: &Connection,
683 expected: ProjectInstanceId,
684) -> DbResult<()> {
685 let found =
686 load_project_identity(connection)?.ok_or(DbError::ProjectInstanceIdentityMissing)?;
687 require_project_identity(expected, found)
688}
689
690pub(crate) fn ensure_project_identity(
692 connection: &Connection,
693) -> DbResult<(ProjectInstanceId, bool)> {
694 if let Some(identity) = load_project_identity(connection)? {
695 return Ok((identity, false));
696 }
697 let identity = generate_project_identity(connection, None)?;
698 set_project_identity(connection, identity)?;
699 Ok((identity, true))
700}
701
702pub(crate) fn set_graph_generation(
704 connection: &Connection,
705 generation: IndexGeneration,
706) -> DbResult<()> {
707 let generation =
708 i64::try_from(generation.get()).map_err(|_source| DbError::GraphCountOverflow {
709 field: "project_identity.active_generation",
710 value: generation.get(),
711 })?;
712 connection.execute(
713 "UPDATE project_identity SET active_generation = ?1 WHERE singleton = 1",
714 [generation],
715 )?;
716 Ok(())
717}
718
719fn generate_project_identity(
721 connection: &Connection,
722 predecessor: Option<ProjectInstanceId>,
723) -> DbResult<ProjectInstanceId> {
724 for _ in 0..PROJECT_IDENTITY_GENERATION_ATTEMPTS {
725 let bytes =
726 connection.query_row("SELECT randomblob(16)", [], |row| row.get::<_, Vec<u8>>(0))?;
727 if let Ok(identity) = project_identity_from_blob(bytes)
728 && Some(identity) != predecessor
729 {
730 return Ok(identity);
731 }
732 }
733 Err(DbError::ProjectInstanceIdentityGenerationFailed)
734}
735
736pub(crate) fn set_project_identity(
738 connection: &Connection,
739 identity: ProjectInstanceId,
740) -> DbResult<()> {
741 connection.execute(
742 "INSERT INTO project_identity(singleton, project_instance_id, active_generation)
743 VALUES(1, ?1, 0)
744 ON CONFLICT(singleton) DO UPDATE SET
745 project_instance_id = excluded.project_instance_id,
746 active_generation = 0",
747 [&identity.as_bytes()[..]],
748 )?;
749 Ok(())
750}
751
752fn project_identity_from_blob(value: Vec<u8>) -> DbResult<ProjectInstanceId> {
754 let found = value.len();
755 let bytes: [u8; 16] = value
756 .try_into()
757 .map_err(|_value| DbError::InvalidBlobLength {
758 field: "project_identity.project_instance_id",
759 expected: 16,
760 found,
761 })?;
762 ProjectInstanceId::from_bytes(bytes).map_err(Into::into)
763}
764
765fn require_project_identity(expected: ProjectInstanceId, found: ProjectInstanceId) -> DbResult<()> {
767 if expected != found {
768 return Err(DbError::GraphProjectIdentityMismatch {
769 expected: expected.to_string(),
770 found: found.to_string(),
771 });
772 }
773 Ok(())
774}
775
776fn project_transition_changed(
778 expected_root: Option<String>,
779 found_root: Option<String>,
780 expected_identity: Option<ProjectInstanceId>,
781 found_identity: Option<ProjectInstanceId>,
782) -> DbError {
783 DbError::ProjectRootTransitionChanged {
784 expected_root,
785 found_root,
786 expected_identity: expected_identity.map(|identity| identity.to_string()),
787 found_identity: found_identity.map(|identity| identity.to_string()),
788 }
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794 use crate::HealthResolution;
795 use projectatlas_core::graph::{
796 CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
797 CoverageState, EntityResolutionKey, EntitySelector, GraphEntity, GraphIdentityText,
798 GraphRelationKind, LogicalRelation, RelationDependencyKey, RelationOccurrence,
799 RelationResolution, RepositoryFilePath, ResolutionKeyDomain, SourceSpan,
800 };
801 use projectatlas_core::symbols::RelationKind;
802 use projectatlas_core::telemetry::usage_from_estimates;
803 use std::error::Error;
804 use std::fmt::Debug;
805 use std::io;
806
807 #[cfg(unix)]
808 #[test]
809 #[allow(clippy::panic_in_result_fn)]
810 fn canonical_root_missing_identity_refuses_even_for_equivalent_alias()
811 -> Result<(), Box<dyn Error>> {
812 use std::os::unix::fs::symlink;
813
814 let temp = tempfile::tempdir()?;
815 let target = temp.path().join("private-var");
816 let alias = temp.path().join("var");
817 fs::create_dir(&target)?;
818 symlink(&target, &alias)?;
819 let database = temp.path().join("projectatlas.db");
820 drop(AtlasStore::open_for_project(&database, &target)?);
821
822 let connection = Connection::open(&database)?;
823 connection.execute(
824 "UPDATE metadata SET value = ?1 WHERE key = ?2",
825 rusqlite::params![normalize_metadata_path(&alias), PROJECT_ROOT_KEY],
826 )?;
827 connection.execute("DELETE FROM project_root_identity", [])?;
828 drop(connection);
829
830 let database_before = fs::read(&database)?;
831 let error = AtlasStore::open_for_project(&database, &alias)
832 .err()
833 .ok_or_else(|| io::Error::other("missing native identity unexpectedly repaired"))?;
834 require(
835 matches!(error, DbError::ProjectRootIdentityMissing),
836 "missing native identity returned the wrong error",
837 )?;
838 require_eq(
839 &fs::read(&database)?,
840 &database_before,
841 "missing native identity database bytes",
842 )?;
843 let connection = Connection::open(&database)?;
844 require_eq(
845 &connection.query_row(
846 "SELECT COUNT(*) FROM project_root_identity WHERE singleton = 1",
847 [],
848 |row| row.get::<_, i64>(0),
849 )?,
850 &0,
851 "missing native identity row",
852 )?;
853 require_eq(
854 &connection.query_row(
855 "SELECT value FROM metadata WHERE key = ?1",
856 [PROJECT_ROOT_KEY],
857 |row| row.get::<_, String>(0),
858 )?,
859 &normalize_metadata_path(&alias),
860 "missing native identity legacy metadata",
861 )?;
862 Ok(())
863 }
864
865 #[cfg(unix)]
866 #[test]
867 #[allow(clippy::panic_in_result_fn)]
868 fn canonical_root_repair_rolls_back_when_identity_write_fails() -> Result<(), Box<dyn Error>> {
869 use std::os::unix::fs::symlink;
870
871 let temp = tempfile::tempdir()?;
872 let target = temp.path().join("private-var");
873 let alias = temp.path().join("var");
874 fs::create_dir(&target)?;
875 symlink(&target, &alias)?;
876 let database = temp.path().join("projectatlas.db");
877 drop(AtlasStore::open_for_project(&database, &target)?);
878 let connection = Connection::open(&database)?;
879 connection.execute(
880 "UPDATE metadata SET value = ?1 WHERE key = ?2",
881 rusqlite::params![normalize_metadata_path(&alias), PROJECT_ROOT_KEY],
882 )?;
883 connection.execute_batch(
884 "CREATE TEMP TRIGGER fail_project_root_identity_update
885 BEFORE UPDATE OF root ON project_root_identity
886 BEGIN SELECT RAISE(ABORT, 'injected root identity failure'); END;",
887 )?;
888 let expected = CanonicalProjectRoot::from_path(&alias)?;
889 let error = ensure_project_root_identity(&connection, &expected)
890 .err()
891 .ok_or_else(|| io::Error::other("repair unexpectedly succeeded"))?;
892 assert!(matches!(error, DbError::Sqlite(_)));
893 let metadata = connection.query_row(
894 "SELECT value FROM metadata WHERE key = ?1",
895 [PROJECT_ROOT_KEY],
896 |row| row.get::<_, String>(0),
897 )?;
898 let identity_rows = connection.query_row(
899 "SELECT COUNT(*) FROM project_root_identity WHERE singleton = 1",
900 [],
901 |row| row.get::<_, i64>(0),
902 )?;
903 assert_eq!(metadata, normalize_metadata_path(&alias));
904 assert_eq!(identity_rows, 1);
905 drop(connection);
906
907 let connection = Connection::open(&database)?;
908 let reopened_metadata = connection.query_row(
909 "SELECT value FROM metadata WHERE key = ?1",
910 [PROJECT_ROOT_KEY],
911 |row| row.get::<_, String>(0),
912 )?;
913 let reopened_identity_rows = connection.query_row(
914 "SELECT COUNT(*) FROM project_root_identity WHERE singleton = 1",
915 [],
916 |row| row.get::<_, i64>(0),
917 )?;
918 assert_eq!(reopened_metadata, normalize_metadata_path(&alias));
919 assert_eq!(reopened_identity_rows, 1);
920 Ok(())
921 }
922
923 #[cfg(unix)]
924 #[test]
925 fn root_repair_rechecks_identity_after_lock_without_mutation() -> Result<(), Box<dyn Error>> {
926 use std::os::unix::fs::symlink;
927
928 let temp = tempfile::tempdir()?;
929 let target = temp.path().join("private-var");
930 let alias = temp.path().join("var");
931 fs::create_dir(&target)?;
932 symlink(&target, &alias)?;
933 let database = temp.path().join("repair-after-lock.db");
934
935 let mut initial = AtlasStore::open_for_project(&database, &target)?;
936 let project = initial
937 .project_instance_id()?
938 .ok_or_else(|| io::Error::other("repair-after-lock fixture identity is missing"))?;
939 seed_authored_and_graph_state(&mut initial, project)?;
940 let publication_before = initial.index_publication()?;
941 let usage_before = initial.usage_events(Some("identity-test"))?;
942 let overview_before = initial.token_overview(Some("identity-test"))?;
943 let generation_before = load_graph_generation(&initial.connection)?;
944 assert_authored_state(&initial)?;
945 assert_usage_report(&initial, true)?;
946 assert_runtime_scope(&initial, project, 1, 0, 1)?;
947 assert_graph_counts(&initial, [2, 1, 1, 1, 1, 1, 1])?;
948
949 initial.connection.execute(
950 "UPDATE metadata SET value = ?1 WHERE key = ?2",
951 rusqlite::params![normalize_metadata_path(&alias), PROJECT_ROOT_KEY],
952 )?;
953 initial
954 .connection
955 .execute_batch("DELETE FROM project_root_identity; PRAGMA wal_checkpoint(TRUNCATE);")?;
956 drop(initial);
957
958 drop(AtlasStore::open_read_only(&database)?);
961 let baseline = AtlasStore::open_read_only(&database)?;
962 let metadata_before = baseline.project_root()?;
963 let native_before = baseline.project_root_identity()?;
964 let instance_before = baseline.project_instance_id()?;
965 require_eq(
966 &metadata_before,
967 &Some(normalize_metadata_path(&alias)),
968 "repair-after-lock legacy metadata before fault",
969 )?;
970 require_eq(
971 &native_before,
972 &None,
973 "repair-after-lock native identity before fault",
974 )?;
975 require_eq(
976 &instance_before,
977 &Some(project),
978 "repair-after-lock instance before fault",
979 )?;
980 require_eq(
981 &load_graph_generation(&baseline.connection)?,
982 &generation_before,
983 "repair-after-lock generation before fault",
984 )?;
985 require_eq(
986 &baseline.index_publication()?,
987 &publication_before,
988 "repair-after-lock publication before fault",
989 )?;
990 require_eq(
991 &baseline.usage_events(Some("identity-test"))?,
992 &usage_before,
993 "repair-after-lock usage before fault",
994 )?;
995 require_eq(
996 &baseline.token_overview(Some("identity-test"))?,
997 &overview_before,
998 "repair-after-lock overview before fault",
999 )?;
1000 assert_authored_state(&baseline)?;
1001 assert_usage_report(&baseline, true)?;
1002 assert_runtime_scope(&baseline, project, 1, 0, 1)?;
1003 assert_graph_counts(&baseline, [2, 1, 1, 1, 1, 1, 1])?;
1004 drop(baseline);
1005
1006 let database_before = fs::read(&database)?;
1007 let is_sqlite_sidecar = |name: &str| {
1008 [
1009 "repair-after-lock.db-wal",
1010 "repair-after-lock.db-shm",
1011 "repair-after-lock.db-journal",
1012 ]
1013 .contains(&name)
1014 };
1015 let inventory_before = {
1016 let mut entries = fs::read_dir(temp.path())?
1017 .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
1018 .filter(|entry| match entry {
1019 Ok(name) => !is_sqlite_sidecar(name),
1020 Err(_) => true,
1021 })
1022 .collect::<Result<Vec<_>, _>>()?;
1023 entries.sort();
1024 entries
1025 };
1026
1027 let connection = Connection::open(&database)?;
1028 connection.execute_batch("BEGIN IMMEDIATE; PRAGMA defer_foreign_keys = ON;")?;
1029 connection.execute("DELETE FROM project_identity", [])?;
1030 let expected = CanonicalProjectRoot::from_path(&alias)?;
1031 let error = ensure_project_root_identity_after_lock(&connection, &expected)
1032 .err()
1033 .ok_or_else(|| io::Error::other("post-lock root repair unexpectedly succeeded"))?;
1034 require(
1035 matches!(error, DbError::ProjectInstanceIdentityMissing),
1036 "post-lock root repair returned the wrong error",
1037 )?;
1038 let rollback_error = schema::rollback_after_error(&connection, error);
1039 require(
1040 matches!(rollback_error, DbError::ProjectInstanceIdentityMissing),
1041 "post-lock rollback changed the initiating error",
1042 )?;
1043 drop(connection);
1044
1045 require_eq(
1046 &fs::read(&database)?,
1047 &database_before,
1048 "post-lock root repair database bytes",
1049 )?;
1050 let mut inventory_after = fs::read_dir(temp.path())?
1051 .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
1052 .filter(|entry| match entry {
1053 Ok(name) => !is_sqlite_sidecar(name),
1054 Err(_) => true,
1055 })
1056 .collect::<Result<Vec<_>, _>>()?;
1057 inventory_after.sort();
1058 require_eq(
1059 &inventory_after,
1060 &inventory_before,
1061 "post-lock root repair inventory",
1062 )?;
1063
1064 let reopened = AtlasStore::open_read_only(&database)?;
1065 require_eq(
1066 &reopened.project_root()?,
1067 &metadata_before,
1068 "post-lock root repair legacy metadata",
1069 )?;
1070 require_eq(
1071 &reopened.project_root_identity()?,
1072 &native_before,
1073 "post-lock root repair native identity",
1074 )?;
1075 require_eq(
1076 &reopened.project_instance_id()?,
1077 &instance_before,
1078 "post-lock root repair instance",
1079 )?;
1080 require_eq(
1081 &load_graph_generation(&reopened.connection)?,
1082 &generation_before,
1083 "post-lock root repair generation",
1084 )?;
1085 require_eq(
1086 &reopened.index_publication()?,
1087 &publication_before,
1088 "post-lock root repair publication",
1089 )?;
1090 require_eq(
1091 &reopened.usage_events(Some("identity-test"))?,
1092 &usage_before,
1093 "post-lock root repair usage",
1094 )?;
1095 require_eq(
1096 &reopened.token_overview(Some("identity-test"))?,
1097 &overview_before,
1098 "post-lock root repair overview",
1099 )?;
1100 assert_authored_state(&reopened)?;
1101 assert_usage_report(&reopened, true)?;
1102 assert_runtime_scope(&reopened, project, 1, 0, 1)?;
1103 assert_graph_counts(&reopened, [2, 1, 1, 1, 1, 1, 1])?;
1104 Ok(())
1105 }
1106
1107 #[test]
1108 fn current_no_repair_open_rechecks_schema_after_preflight() -> Result<(), Box<dyn Error>> {
1109 let temp = tempfile::tempdir()?;
1110 let root = temp.path().join("schema-race-root");
1111 fs::create_dir(&root)?;
1112 let database = temp.path().join("schema-race.db");
1113 drop(AtlasStore::open_for_project(&database, &root)?);
1114 let expected = CanonicalProjectRoot::from_path(&root)?;
1115
1116 let stale = Connection::open(&database)?;
1117 let (preflight, _) = schema::preflight_for_project(&database, &expected)?;
1118 require_eq(
1119 &preflight.state,
1120 &SchemaState::Current,
1121 "schema-race preflight",
1122 )?;
1123 let updater = Connection::open(&database)?;
1124 let future_schema = schema::SCHEMA_VERSION + 1;
1125 updater.execute(
1126 "UPDATE metadata SET value = ?2 WHERE key = ?1",
1127 rusqlite::params![schema::SCHEMA_VERSION_KEY, future_schema.to_string()],
1128 )?;
1129 drop(updater);
1130 let before = fs::read(&database)?;
1131
1132 let repair_error = ensure_project_root_identity(&stale, &expected)
1133 .err()
1134 .ok_or_else(|| io::Error::other("stale no-repair open unexpectedly succeeded"))?;
1135 require(
1136 matches!(
1137 repair_error,
1138 DbError::SchemaVersion { found, expected }
1139 if found == future_schema && expected == schema::SCHEMA_VERSION
1140 ),
1141 "stale no-repair open returned the wrong error",
1142 )?;
1143 require_eq(
1144 &fs::read(&database)?,
1145 &before,
1146 "stale no-repair repair mutation",
1147 )?;
1148
1149 let revalidation_error = schema::revalidate_current_native_binding(&stale, &expected, true)
1150 .err()
1151 .ok_or_else(|| io::Error::other("stale revalidation unexpectedly succeeded"))?;
1152 require(
1153 matches!(
1154 revalidation_error,
1155 DbError::SchemaVersion { found, expected }
1156 if found == future_schema && expected == schema::SCHEMA_VERSION
1157 ),
1158 "stale revalidation returned the wrong error",
1159 )?;
1160 require_eq(
1161 &fs::read(&database)?,
1162 &before,
1163 "stale revalidation mutation",
1164 )?;
1165 Ok(())
1166 }
1167
1168 #[test]
1169 fn current_root_repair_and_transitions_recheck_schema_before_mutation()
1170 -> Result<(), Box<dyn Error>> {
1171 let temp = tempfile::tempdir()?;
1172 let snapshot = |database: &Path| {
1173 let database_name = database
1174 .file_name()
1175 .ok_or_else(|| io::Error::other("schema snapshot database name is missing"))?
1176 .to_string_lossy()
1177 .into_owned();
1178 let sidecars =
1179 ["-wal", "-shm", "-journal"].map(|suffix| format!("{database_name}{suffix}"));
1180 let mut inventory = fs::read_dir(temp.path())?
1181 .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
1182 .filter(|entry| match entry {
1183 Ok(name) => !sidecars.iter().any(|sidecar| sidecar == name),
1184 Err(_) => true,
1185 })
1186 .collect::<Result<Vec<_>, _>>()?;
1187 inventory.sort();
1188 Ok::<_, Box<dyn Error>>((fs::read(database)?, inventory))
1189 };
1190
1191 let repair_root = temp.path().join("repair-root");
1192 fs::create_dir(&repair_root)?;
1193 let repair_database = temp.path().join("repair-schema.db");
1194 let repair_store = AtlasStore::open_for_project(&repair_database, &repair_root)?;
1195 let repair_project = repair_store
1196 .project_instance_id()?
1197 .ok_or_else(|| io::Error::other("repair schema fixture identity is missing"))?;
1198 drop(repair_store);
1199 let repair_connection = Connection::open(&repair_database)?;
1200 repair_connection.execute("DELETE FROM project_root_identity", [])?;
1201 drop(repair_connection);
1202 let repair_expected = CanonicalProjectRoot::from_path(&repair_root)?;
1203 let repair_stale = Connection::open(&repair_database)?;
1204 let (repair_preflight, _) = schema::preflight(&repair_database, None)?;
1205 require_eq(
1206 &repair_preflight.state,
1207 &SchemaState::Current,
1208 "repair schema preflight",
1209 )?;
1210 let repair_updater = Connection::open(&repair_database)?;
1211 let future_schema = schema::SCHEMA_VERSION + 1;
1212 repair_updater.execute(
1213 "UPDATE metadata SET value = ?2 WHERE key = ?1",
1214 rusqlite::params![schema::SCHEMA_VERSION_KEY, future_schema.to_string()],
1215 )?;
1216 drop(repair_updater);
1217 let repair_before = snapshot(&repair_database)?;
1218 let repair_error = ensure_project_root_identity(&repair_stale, &repair_expected)
1219 .err()
1220 .ok_or_else(|| io::Error::other("stale root repair unexpectedly succeeded"))?;
1221 require(
1222 matches!(
1223 repair_error,
1224 DbError::SchemaVersion { found, expected }
1225 if found == future_schema && expected == schema::SCHEMA_VERSION
1226 ),
1227 "stale root repair returned the wrong error",
1228 )?;
1229 require_eq(
1230 &snapshot(&repair_database)?,
1231 &repair_before,
1232 "stale root repair mutation",
1233 )?;
1234 drop(repair_stale);
1235 let repair_restore = Connection::open(&repair_database)?;
1236 repair_restore.execute(
1237 "UPDATE metadata SET value = ?2 WHERE key = ?1",
1238 rusqlite::params![
1239 schema::SCHEMA_VERSION_KEY,
1240 schema::SCHEMA_VERSION.to_string()
1241 ],
1242 )?;
1243 drop(repair_restore);
1244 let repair_reopened = AtlasStore::open_read_only(&repair_database)?;
1245 require_eq(
1246 &repair_reopened.project_root_identity()?,
1247 &None,
1248 "stale root repair native identity",
1249 )?;
1250 require_eq(
1251 &repair_reopened.project_instance_id()?,
1252 &Some(repair_project),
1253 "stale root repair project identity",
1254 )?;
1255
1256 for (name, transition) in [
1257 ("detach", ProjectRootTransition::Detach),
1258 ("move", ProjectRootTransition::Move),
1259 ] {
1260 let source = temp.path().join(format!("{name}-source"));
1261 let destination = temp.path().join(format!("{name}-destination"));
1262 fs::create_dir(&source)?;
1263 fs::create_dir(&destination)?;
1264 let database = temp.path().join(format!("{name}-schema.db"));
1265 let mut seeded = AtlasStore::open_for_project(&database, &source)?;
1266 let project = seeded
1267 .project_instance_id()?
1268 .ok_or_else(|| io::Error::other("transition schema fixture identity is missing"))?;
1269 seed_authored_and_graph_state(&mut seeded, project)?;
1270 let source_identity = seeded
1271 .project_root_identity()?
1272 .ok_or_else(|| io::Error::other("transition schema fixture root is missing"))?;
1273 drop(seeded);
1274 let mut stale = AtlasStore::open_for_project(&database, &source)?;
1275 if transition == ProjectRootTransition::Move {
1276 fs::remove_dir(&source)?;
1277 }
1278 let updater = Connection::open(&database)?;
1279 updater.execute(
1280 "UPDATE metadata SET value = ?2 WHERE key = ?1",
1281 rusqlite::params![schema::SCHEMA_VERSION_KEY, future_schema.to_string()],
1282 )?;
1283 drop(updater);
1284 let before = snapshot(&database)?;
1285 let error = apply_root_transition(
1286 &mut stale,
1287 transition,
1288 Some(&source_identity),
1289 Some(project),
1290 &CanonicalProjectRoot::from_path(&destination)?,
1291 )
1292 .err()
1293 .ok_or_else(|| io::Error::other("stale transition unexpectedly succeeded"))?;
1294 require(
1295 matches!(
1296 error,
1297 DbError::SchemaVersion { found, expected }
1298 if found == future_schema && expected == schema::SCHEMA_VERSION
1299 ),
1300 "stale transition returned the wrong error",
1301 )?;
1302 require_eq(&snapshot(&database)?, &before, "stale transition mutation")?;
1303 drop(stale);
1304 let restore = Connection::open(&database)?;
1305 restore.execute(
1306 "UPDATE metadata SET value = ?2 WHERE key = ?1",
1307 rusqlite::params![
1308 schema::SCHEMA_VERSION_KEY,
1309 schema::SCHEMA_VERSION.to_string()
1310 ],
1311 )?;
1312 drop(restore);
1313 let reopened = AtlasStore::open_read_only(&database)?;
1314 require_eq(
1315 &reopened.project_root_identity()?,
1316 &Some(source_identity),
1317 "stale transition native identity",
1318 )?;
1319 require_eq(
1320 &reopened.project_instance_id()?,
1321 &Some(project),
1322 "stale transition project identity",
1323 )?;
1324 assert_authored_state(&reopened)?;
1325 assert_usage_report(&reopened, true)?;
1326 assert_graph_counts(&reopened, [2, 1, 1, 1, 1, 1, 1])?;
1327 }
1328 Ok(())
1329 }
1330
1331 #[cfg(unix)]
1332 #[test]
1333 #[allow(clippy::panic_in_result_fn)]
1334 fn root_bind_accepts_equivalent_native_alias() -> Result<(), Box<dyn Error>> {
1335 use std::os::unix::fs::symlink;
1336
1337 let temp = tempfile::tempdir()?;
1338 let target = temp.path().join("private-var");
1339 let alias = temp.path().join("var");
1340 fs::create_dir(&target)?;
1341 symlink(&target, &alias)?;
1342 let database = temp.path().join("projectatlas.db");
1343 let initial = AtlasStore::open_for_project(&database, &target)?;
1344 let project = initial
1345 .project_instance_id()?
1346 .ok_or_else(|| io::Error::other("project identity missing"))?;
1347 drop(initial);
1348 let connection = Connection::open(&database)?;
1349 connection.execute(
1350 "UPDATE metadata SET value = ?1 WHERE key = ?2",
1351 rusqlite::params![normalize_metadata_path(&alias), PROJECT_ROOT_KEY],
1352 )?;
1353 drop(connection);
1354
1355 let rebound =
1356 AtlasStore::transition_project_root(&database, &alias, ProjectRootTransition::Bind)?;
1357 assert_eq!(rebound.project_instance_id, project);
1358 assert!(!rebound.identity_changed);
1359 Ok(())
1360 }
1361
1362 #[test]
1363 fn root_transitions_preserve_authored_state_and_isolate_copies() -> Result<(), Box<dyn Error>> {
1364 let temp = tempfile::tempdir()?;
1365 let root_a = temp.path().join("source-Δ");
1366 let root_b = temp.path().join("detached-copy");
1367 let root_c = temp.path().join("moved-source");
1368 fs::create_dir_all(&root_a)?;
1369 fs::create_dir_all(&root_b)?;
1370 fs::create_dir_all(&root_c)?;
1371 let source_db = temp.path().join("source.db");
1372 let copy_db = temp.path().join("copy.db");
1373 let rollback_db = temp.path().join("rollback.db");
1374
1375 let bound =
1376 AtlasStore::transition_project_root(&source_db, &root_a, ProjectRootTransition::Bind)?;
1377 require(
1378 bound.previous_root.is_none(),
1379 "fresh bind had a previous root",
1380 )?;
1381 require(bound.identity_changed, "fresh bind did not create identity")?;
1382 require(
1383 !bound.publication_invalidated,
1384 "fresh bind invalidated publication",
1385 )?;
1386 let source_identity = bound.project_instance_id;
1387 let rebound =
1388 AtlasStore::transition_project_root(&source_db, &root_a, ProjectRootTransition::Bind)?;
1389 require_eq(
1390 &rebound.project_instance_id,
1391 &source_identity,
1392 "same-root bind identity",
1393 )?;
1394 require(!rebound.identity_changed, "same-root bind changed identity")?;
1395
1396 let mut source = AtlasStore::open_for_project(&source_db, &root_a)?;
1397 seed_authored_and_graph_state(&mut source, source_identity)?;
1398 let foreign_identity = ProjectInstanceId::from_bytes([0x7a; 16])?;
1399 let foreign_project = GraphEntity::new(
1400 foreign_identity,
1401 EntitySelector::Project,
1402 IndexGeneration::new(2),
1403 )?;
1404 let mut rejected_publication = source.begin_index_publication("foreign-project")?;
1405 let foreign_error = require_error(
1406 rejected_publication.replace_repository_graph(
1407 foreign_identity,
1408 &[foreign_project],
1409 &[],
1410 &[],
1411 &[],
1412 ),
1413 "foreign graph publication replaced destination identity",
1414 )?;
1415 require(
1416 matches!(foreign_error, DbError::GraphProjectIdentityMismatch { .. }),
1417 "foreign graph publication returned the wrong error",
1418 )?;
1419 drop(rejected_publication);
1420 source
1421 .connection
1422 .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
1423 drop(source);
1424 fs::copy(&source_db, ©_db)?;
1425 fs::copy(&source_db, &rollback_db)?;
1426 let copied_bytes = fs::read(©_db)?;
1427
1428 let invalid_file_root = temp.path().join("not-a-project-directory");
1429 let missing_root = temp.path().join("missing-project-root");
1430 fs::write(&invalid_file_root, "not a directory")?;
1431 for (destination, label) in [
1432 (Path::new("relative-project-root"), "relative destination"),
1433 (missing_root.as_path(), "missing destination"),
1434 (invalid_file_root.as_path(), "file destination"),
1435 ] {
1436 let invalid_error = require_error(
1437 AtlasStore::transition_project_root(
1438 ©_db,
1439 destination,
1440 ProjectRootTransition::Detach,
1441 ),
1442 "invalid transition destination was accepted",
1443 )?;
1444 require(
1445 matches!(invalid_error, DbError::ProjectRootDestinationInvalid { .. }),
1446 "invalid destination returned the wrong error",
1447 )?;
1448 assert_database_unchanged(©_db, &copied_bytes, label)?;
1449 }
1450
1451 let bind_error = require_error(
1452 AtlasStore::transition_project_root(©_db, &root_b, ProjectRootTransition::Bind),
1453 "copied database accepted an implicit rebind",
1454 )?;
1455 require(
1456 matches!(bind_error, DbError::ProjectRootMismatch { .. }),
1457 "copied database bind returned the wrong error",
1458 )?;
1459 assert_database_unchanged(©_db, &copied_bytes, "rejected bind database")?;
1460 let move_error = require_error(
1461 AtlasStore::transition_project_root(©_db, &root_b, ProjectRootTransition::Move),
1462 "copy preserved identity while original root was accessible",
1463 )?;
1464 require(
1465 matches!(move_error, DbError::ProjectRootStillPresent { .. }),
1466 "accessible-root move returned the wrong error",
1467 )?;
1468 assert_database_unchanged(©_db, &copied_bytes, "rejected move database")?;
1469 let rejected_store = AtlasStore::open_read_only_for_project(©_db, &root_a)?;
1470 require_eq(
1471 &rejected_store.project_instance_id()?,
1472 &Some(source_identity),
1473 "rejected transition identity",
1474 )?;
1475 assert_authored_state(&rejected_store)?;
1476 assert_usage_report(&rejected_store, true)?;
1477 assert_runtime_scope(&rejected_store, source_identity, 1, 0, 1)?;
1478 assert_graph_counts(&rejected_store, [2, 1, 1, 1, 1, 1, 1])?;
1479 require(
1480 rejected_store.index_publication()?.is_some(),
1481 "rejected transition invalidated publication",
1482 )?;
1483 drop(rejected_store);
1484
1485 let regular_root = temp.path().join("former-root-now-file");
1486 let regular_db = temp.path().join("regular-root.db");
1487 fs::create_dir(®ular_root)?;
1488 AtlasStore::transition_project_root(
1489 ®ular_db,
1490 ®ular_root,
1491 ProjectRootTransition::Bind,
1492 )?;
1493 fs::remove_dir(®ular_root)?;
1494 fs::write(®ular_root, "the old root path still exists")?;
1495 let regular_bytes = fs::read(®ular_db)?;
1496 let regular_error = require_error(
1497 AtlasStore::transition_project_root(®ular_db, &root_c, ProjectRootTransition::Move),
1498 "regular file at the old root was treated as absent",
1499 )?;
1500 require(
1501 matches!(regular_error, DbError::ProjectRootStillPresent { .. }),
1502 "regular-file move returned the wrong error",
1503 )?;
1504 assert_database_unchanged(®ular_db, ®ular_bytes, "regular-file move database")?;
1505
1506 let link_root = temp.path().join("former-root-now-link");
1507 let missing_link_target = temp.path().join("missing-link-target");
1508 let link_db = temp.path().join("linked-root.db");
1509 fs::create_dir(&link_root)?;
1510 AtlasStore::transition_project_root(&link_db, &link_root, ProjectRootTransition::Bind)?;
1511 fs::remove_dir(&link_root)?;
1512 if create_dangling_directory_link(&missing_link_target, &link_root)? {
1513 let link_bytes = fs::read(&link_db)?;
1514 let link_error = require_error(
1515 AtlasStore::transition_project_root(&link_db, &root_c, ProjectRootTransition::Move),
1516 "dangling root link was treated as absent",
1517 )?;
1518 require(
1519 matches!(link_error, DbError::ProjectRootStillPresent { .. }),
1520 "dangling-link move returned the wrong error",
1521 )?;
1522 assert_database_unchanged(&link_db, &link_bytes, "dangling-link move database")?;
1523 fs::remove_file(&link_root)?;
1524 }
1525
1526 let relative_root = temp.path().join("relative-root-source");
1527 let relative_db = temp.path().join("relative-root.db");
1528 fs::create_dir(&relative_root)?;
1529 AtlasStore::transition_project_root(
1530 &relative_db,
1531 &relative_root,
1532 ProjectRootTransition::Bind,
1533 )?;
1534 {
1535 let relative_store = AtlasStore::open(&relative_db)?;
1536 set_metadata(
1537 &relative_store.connection,
1538 PROJECT_ROOT_KEY,
1539 "relative/stored/root",
1540 )?;
1541 relative_store
1542 .connection
1543 .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
1544 }
1545 let relative_bytes = fs::read(&relative_db)?;
1546 let relative_error = require_error(
1547 AtlasStore::transition_project_root(&relative_db, &root_c, ProjectRootTransition::Move),
1548 "non-absolute stored root was treated as a verified move",
1549 )?;
1550 require(
1551 matches!(relative_error, DbError::ProjectRootStillPresent { .. }),
1552 "native stored root returned the wrong error",
1553 )?;
1554 assert_database_unchanged(&relative_db, &relative_bytes, "relative-root move database")?;
1555
1556 let uncertain_error = require_error(
1557 classify_root_absence(
1558 "C:/uncertain-project-root",
1559 Err(std::io::Error::new(
1560 std::io::ErrorKind::PermissionDenied,
1561 "injected permission denial",
1562 )),
1563 ),
1564 "permission uncertainty was treated as absence",
1565 )?;
1566 require(
1567 matches!(uncertain_error, DbError::ProjectRootAbsenceUncertain { .. }),
1568 "permission uncertainty returned the wrong error",
1569 )?;
1570
1571 let legacy_db = temp.path().join("legacy-root.db");
1572 let legacy_old_root = temp.path().join("legacy-old-root");
1573 let legacy_destination = temp.path().join("legacy-destination");
1574 fs::create_dir(&legacy_destination)?;
1575 {
1576 let legacy = Connection::open(&legacy_db)?;
1577 schema::create_released_schema_eight(&legacy)?;
1578 set_metadata(
1579 &legacy,
1580 PROJECT_ROOT_KEY,
1581 &normalize_metadata_path(&legacy_old_root),
1582 )?;
1583 }
1584 let legacy_bytes = fs::read(&legacy_db)?;
1585 let legacy_error = require_error(
1586 AtlasStore::transition_project_root(
1587 &legacy_db,
1588 &legacy_destination,
1589 ProjectRootTransition::Move,
1590 ),
1591 "legacy move repaired a missing root without native identity proof",
1592 )?;
1593 require(
1594 matches!(legacy_error, DbError::ProjectRootIdentityMissing),
1595 "legacy missing-root move returned the wrong error",
1596 )?;
1597 assert_database_unchanged(&legacy_db, &legacy_bytes, "legacy missing-root move")?;
1598
1599 let current_missing_identity_db = temp.path().join("current-missing-identity.db");
1600 let current_missing_identity_root = temp.path().join("current-missing-identity-root");
1601 let current_missing_identity_destination =
1602 temp.path().join("current-missing-identity-destination");
1603 fs::create_dir(¤t_missing_identity_root)?;
1604 fs::create_dir(¤t_missing_identity_destination)?;
1605 AtlasStore::transition_project_root(
1606 ¤t_missing_identity_db,
1607 ¤t_missing_identity_root,
1608 ProjectRootTransition::Bind,
1609 )?;
1610 {
1611 let current = AtlasStore::open(¤t_missing_identity_db)?;
1612 current
1613 .connection
1614 .execute("DELETE FROM project_identity", [])?;
1615 current
1616 .connection
1617 .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
1618 }
1619 fs::remove_dir(¤t_missing_identity_root)?;
1620 let repaired_current_move = AtlasStore::transition_project_root(
1621 ¤t_missing_identity_db,
1622 ¤t_missing_identity_destination,
1623 ProjectRootTransition::Move,
1624 )?;
1625 require(
1626 repaired_current_move.identity_changed,
1627 "current bound database move did not report its repaired identity",
1628 )?;
1629 let repaired_current = AtlasStore::open_read_only_for_project(
1630 ¤t_missing_identity_db,
1631 ¤t_missing_identity_destination,
1632 )?;
1633 require_eq(
1634 &repaired_current.project_instance_id()?,
1635 &Some(repaired_current_move.project_instance_id),
1636 "current bound database repaired identity",
1637 )?;
1638
1639 let detached =
1640 AtlasStore::transition_project_root(©_db, &root_b, ProjectRootTransition::Detach)?;
1641 require(
1642 detached.project_instance_id != source_identity,
1643 "detach preserved copied identity",
1644 )?;
1645 require(detached.identity_changed, "detach did not change identity")?;
1646 require(
1647 detached.publication_invalidated,
1648 "detach did not invalidate publication",
1649 )?;
1650 let detached_store = AtlasStore::open_read_only_for_project(©_db, &root_b)?;
1651 require_eq(
1652 &detached_store.project_instance_id()?,
1653 &Some(detached.project_instance_id),
1654 "detached identity",
1655 )?;
1656 assert_authored_state(&detached_store)?;
1657 assert_usage_report(&detached_store, false)?;
1658 assert_runtime_scope(&detached_store, source_identity, 0, 1, 0)?;
1659 assert_graph_counts(&detached_store, [0, 0, 0, 0, 0, 0, 0])?;
1660 require(
1661 detached_store.index_publication()?.is_none(),
1662 "detach retained publication",
1663 )?;
1664
1665 let source_store = AtlasStore::open_read_only_for_project(&source_db, &root_a)?;
1666 require_eq(
1667 &source_store.project_instance_id()?,
1668 &Some(source_identity),
1669 "source identity after copy detach",
1670 )?;
1671 assert_authored_state(&source_store)?;
1672 assert_usage_report(&source_store, true)?;
1673 assert_runtime_scope(&source_store, source_identity, 1, 0, 1)?;
1674 assert_graph_counts(&source_store, [2, 1, 1, 1, 1, 1, 1])?;
1675 drop(source_store);
1676
1677 let mut rollback_store = AtlasStore::open(&rollback_db)?;
1678 let rollback_source_root = CanonicalProjectRoot::from_path(&root_a)?;
1679 let rollback_destination_root = CanonicalProjectRoot::from_path(&root_b)?;
1680 rollback_store.connection.execute_batch(
1681 "CREATE TEMP TRIGGER fail_detach_graph
1682 BEFORE DELETE ON graph_entities
1683 BEGIN SELECT RAISE(ABORT, 'injected detach failure'); END;",
1684 )?;
1685 let rollback_error = require_error(
1686 apply_root_transition(
1687 &mut rollback_store,
1688 ProjectRootTransition::Detach,
1689 Some(&rollback_source_root),
1690 Some(source_identity),
1691 &rollback_destination_root,
1692 ),
1693 "late detach failure committed partial identity state",
1694 )?;
1695 require(
1696 matches!(rollback_error, DbError::Sqlite(_)),
1697 "late detach failure returned the wrong error",
1698 )?;
1699 require_eq(
1700 &rollback_store.project_root()?,
1701 &Some(normalize_metadata_path(&root_a)),
1702 "rollback project root",
1703 )?;
1704 require_eq(
1705 &rollback_store.project_instance_id()?,
1706 &Some(source_identity),
1707 "rollback project identity",
1708 )?;
1709 assert_authored_state(&rollback_store)?;
1710 assert_usage_report(&rollback_store, true)?;
1711 assert_runtime_scope(&rollback_store, source_identity, 1, 0, 1)?;
1712 assert_graph_counts(&rollback_store, [2, 1, 1, 1, 1, 1, 1])?;
1713 require(
1714 rollback_store.index_publication()?.is_some(),
1715 "rollback invalidated prior publication",
1716 )?;
1717 drop(rollback_store);
1718
1719 fs::remove_dir(&root_a)?;
1720 let moved =
1721 AtlasStore::transition_project_root(&source_db, &root_c, ProjectRootTransition::Move)?;
1722 require_eq(
1723 &moved.project_instance_id,
1724 &source_identity,
1725 "moved identity",
1726 )?;
1727 require(!moved.identity_changed, "move changed identity")?;
1728 require(
1729 moved.publication_invalidated,
1730 "move did not invalidate publication",
1731 )?;
1732 let moved_store = AtlasStore::open_read_only_for_project(&source_db, &root_c)?;
1733 require_eq(
1734 &moved_store.project_instance_id()?,
1735 &Some(source_identity),
1736 "stored moved identity",
1737 )?;
1738 assert_authored_state(&moved_store)?;
1739 assert_usage_report(&moved_store, true)?;
1740 assert_runtime_scope(&moved_store, source_identity, 1, 0, 1)?;
1741 assert_graph_counts(&moved_store, [2, 1, 1, 1, 1, 1, 1])?;
1742 require(
1743 moved_store.index_publication()?.is_none(),
1744 "move retained publication",
1745 )?;
1746 let active_generation = moved_store.connection.query_row(
1747 "SELECT active_generation FROM project_identity WHERE singleton = 1",
1748 [],
1749 |row| row.get::<_, i64>(0),
1750 )?;
1751 require_eq(&active_generation, &0, "moved graph generation")?;
1752 Ok(())
1753 }
1754
1755 #[cfg(windows)]
1756 #[test]
1757 fn schema_nineteen_detach_migrates_legacy_identity_before_transition()
1758 -> Result<(), Box<dyn Error>> {
1759 let temp = tempfile::tempdir()?;
1760 let source_root = temp.path().join("schema-19-detach-source");
1761 let destination_root = temp.path().join("schema-19-detach-destination");
1762 fs::create_dir(&source_root)?;
1763 fs::create_dir(&destination_root)?;
1764 let database = temp.path().join("schema-19-detach.db");
1765
1766 let mut store = AtlasStore::open_for_project(&database, &source_root)?;
1767 let previous_project = store
1768 .project_instance_id()?
1769 .ok_or_else(|| io::Error::other("schema-19 detach fixture identity is missing"))?;
1770 seed_authored_and_graph_state(&mut store, previous_project)?;
1771 crate::schema::drop_worktree_native_identity_schema(&store.connection)?;
1772 store.connection.execute_batch(
1773 "DROP TABLE project_root_identity;
1774 DROP TABLE IF EXISTS graph_identity_rejections;
1775 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
1776 )?;
1777 drop(store);
1778
1779 let detached = AtlasStore::transition_project_root(
1780 &database,
1781 &destination_root,
1782 ProjectRootTransition::Detach,
1783 )?;
1784 require(
1785 detached.identity_changed && detached.project_instance_id != previous_project,
1786 "schema-19 detach did not rotate the project identity",
1787 )?;
1788 require_eq(
1789 &detached.project_root,
1790 &Some(normalize_metadata_path(&destination_root)),
1791 "schema-19 detach destination",
1792 )?;
1793
1794 let reopened = AtlasStore::open_read_only_for_project(&database, &destination_root)?;
1795 let schema_version = reopened.connection.query_row(
1796 "SELECT value FROM metadata WHERE key = 'schema_version'",
1797 [],
1798 |row| row.get::<_, String>(0),
1799 )?;
1800 require_eq(
1801 &schema_version,
1802 &schema::SCHEMA_VERSION.to_string(),
1803 "schema-19 detach schema",
1804 )?;
1805 require_eq(
1806 &reopened.project_root_identity()?,
1807 &Some(CanonicalProjectRoot::from_path(&destination_root)?),
1808 "schema-19 detach native destination identity",
1809 )?;
1810 require_eq(
1811 &reopened.project_instance_id()?,
1812 &Some(detached.project_instance_id),
1813 "schema-19 detach project identity",
1814 )?;
1815 assert_authored_state(&reopened)?;
1816 assert_usage_report(&reopened, false)?;
1817 assert_runtime_scope(&reopened, previous_project, 0, 1, 0)?;
1818 assert_graph_counts(&reopened, [0, 0, 0, 0, 0, 0, 0])?;
1819 require(
1820 reopened.index_publication()?.is_none(),
1821 "schema-19 detach retained derived publication",
1822 )?;
1823 drop(reopened);
1824
1825 let missing_database = temp.path().join("schema-19-detach-missing.db");
1826 let missing_store = AtlasStore::open_for_project(&missing_database, &source_root)?;
1827 crate::schema::drop_worktree_native_identity_schema(&missing_store.connection)?;
1828 missing_store
1829 .connection
1830 .execute_batch("DROP TABLE project_root_identity; DROP TABLE IF EXISTS graph_identity_rejections; UPDATE metadata SET value = '19' WHERE key = 'schema_version';")?;
1831 drop(missing_store);
1832 let missing_before = fs::read(&missing_database)?;
1833 fs::remove_dir(&source_root)?;
1834 let missing_error = require_error(
1835 AtlasStore::transition_project_root(
1836 &missing_database,
1837 &destination_root,
1838 ProjectRootTransition::Detach,
1839 ),
1840 "schema-19 detach repaired a missing legacy root",
1841 )?;
1842 require(
1843 !matches!(missing_error, DbError::ProjectRootTransitionChanged { .. }),
1844 "schema-19 missing-root detach reached mutable transition validation",
1845 )?;
1846 assert_database_unchanged(
1847 &missing_database,
1848 &missing_before,
1849 "schema-19 missing-root detach",
1850 )?;
1851 Ok(())
1852 }
1853
1854 #[cfg(windows)]
1855 #[test]
1856 fn schema_nineteen_detach_rolls_back_migration_when_transition_fails()
1857 -> Result<(), Box<dyn Error>> {
1858 let temp = tempfile::tempdir()?;
1859 let source_root = temp.path().join("schema-19-atomic-source");
1860 let destination_root = temp.path().join("schema-19-atomic-destination");
1861 fs::create_dir(&source_root)?;
1862 fs::create_dir(&destination_root)?;
1863 let database = temp.path().join("schema-19-atomic.db");
1864
1865 let mut store = AtlasStore::open_for_project(&database, &source_root)?;
1866 let previous_project = store
1867 .project_instance_id()?
1868 .ok_or_else(|| io::Error::other("schema-19 atomic fixture identity is missing"))?;
1869 seed_authored_and_graph_state(&mut store, previous_project)?;
1870 let publication_before = store.index_publication()?;
1871 let usage_before = store.usage_events(Some("identity-test"))?;
1872 let overview_before = store.token_overview(Some("identity-test"))?;
1873 crate::schema::drop_worktree_native_identity_schema(&store.connection)?;
1874 store.connection.execute_batch(
1875 "DROP TABLE project_root_identity;
1876 DROP TABLE IF EXISTS graph_identity_rejections;
1877 UPDATE metadata SET value = '19' WHERE key = 'schema_version';
1878 PRAGMA wal_checkpoint(TRUNCATE);",
1879 )?;
1880
1881 drop(store);
1882 let database_before = fs::read(&database)?;
1883 let sidecars_before = ["-wal", "-shm", "-journal"].map(|suffix| {
1884 fs::read(database.with_file_name(format!("schema-19-atomic.db{suffix}"))).ok()
1885 });
1886 let mut inventory_before = fs::read_dir(temp.path())?
1887 .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
1888 .collect::<Result<Vec<_>, _>>()?;
1889 inventory_before.sort();
1890
1891 let source_identity = CanonicalProjectRoot::from_path(&source_root)?;
1892 let destination_identity = CanonicalProjectRoot::from_path(&destination_root)?;
1893 let mut store = AtlasStore::open_for_root_transition(&database)?;
1894 require(
1895 !store.connection.is_autocommit(),
1896 "schema-19 transition opener committed migration before transition",
1897 )?;
1898 store.connection.execute_batch(
1899 "CREATE TRIGGER fail_destination_root_metadata_update
1900 BEFORE UPDATE OF value ON metadata
1901 WHEN OLD.key = 'project_root' AND NEW.value <> OLD.value
1902 BEGIN SELECT RAISE(ABORT, 'injected destination metadata failure'); END;",
1903 )?;
1904 let transition_error = require_error(
1905 apply_root_transition_in_transaction(
1906 &mut store,
1907 ProjectRootTransition::Detach,
1908 Some(&source_identity),
1909 Some(previous_project),
1910 &destination_identity,
1911 ),
1912 "schema-19 detach committed migration before its transition failure",
1913 )?;
1914 require(
1915 matches!(transition_error, DbError::Sqlite(_)),
1916 "schema-19 detach failure returned the wrong error",
1917 )?;
1918 let rollback_error = schema::rollback_after_error(&store.connection, transition_error);
1919 require(
1920 matches!(rollback_error, DbError::Sqlite(_)),
1921 "schema-19 detach rollback changed the initiating error",
1922 )?;
1923 let schema_version = store.connection.query_row(
1924 "SELECT value FROM metadata WHERE key = 'schema_version'",
1925 [],
1926 |row| row.get::<_, String>(0),
1927 )?;
1928 require_eq(
1929 &schema_version,
1930 &"19".to_string(),
1931 "schema-19 detach failed schema marker",
1932 )?;
1933 let native_identity_table = store.connection.query_row(
1934 "SELECT COUNT(*) FROM sqlite_master
1935 WHERE type = 'table' AND name = 'project_root_identity'",
1936 [],
1937 |row| row.get::<_, i64>(0),
1938 )?;
1939 require_eq(
1940 &native_identity_table,
1941 &0,
1942 "schema-19 detach failed native identity table",
1943 )?;
1944 require_eq(
1945 &store.project_root()?,
1946 &Some(normalize_metadata_path(&source_root)),
1947 "schema-19 detach failed source metadata",
1948 )?;
1949 require_eq(
1950 &store.project_instance_id()?,
1951 &Some(previous_project),
1952 "schema-19 detach failed project identity",
1953 )?;
1954 require_eq(
1955 &store.index_publication()?,
1956 &publication_before,
1957 "schema-19 detach failed publication",
1958 )?;
1959 require_eq(
1960 &store.usage_events(Some("identity-test"))?,
1961 &usage_before,
1962 "schema-19 detach failed usage",
1963 )?;
1964 require_eq(
1965 &store.token_overview(Some("identity-test"))?,
1966 &overview_before,
1967 "schema-19 detach failed telemetry overview",
1968 )?;
1969 assert_authored_state(&store)?;
1970 assert_usage_report(&store, true)?;
1971 assert_runtime_scope(&store, previous_project, 1, 0, 1)?;
1972 assert_graph_counts(&store, [2, 1, 1, 1, 1, 1, 1])?;
1973 let trigger_count = store.connection.query_row(
1974 "SELECT COUNT(*) FROM sqlite_master
1975 WHERE type = 'trigger' AND name = 'fail_destination_root_metadata_update'",
1976 [],
1977 |row| row.get::<_, i64>(0),
1978 )?;
1979 require_eq(&trigger_count, &0, "schema-19 detach rollback trigger")?;
1980 drop(store);
1981 require_eq(
1982 &fs::read(&database)?,
1983 &database_before,
1984 "schema-19 detach failed database bytes",
1985 )?;
1986 let sidecars_after = ["-wal", "-shm", "-journal"].map(|suffix| {
1987 fs::read(database.with_file_name(format!("schema-19-atomic.db{suffix}"))).ok()
1988 });
1989 require_eq(
1990 &sidecars_after,
1991 &sidecars_before,
1992 "schema-19 detach failed sidecars",
1993 )?;
1994 let mut inventory_after = fs::read_dir(temp.path())?
1995 .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
1996 .collect::<Result<Vec<_>, _>>()?;
1997 inventory_after.sort();
1998 require_eq(
1999 &inventory_after,
2000 &inventory_before,
2001 "schema-19 detach failed inventory",
2002 )?;
2003
2004 let detached = AtlasStore::transition_project_root(
2005 &database,
2006 &destination_root,
2007 ProjectRootTransition::Detach,
2008 )?;
2009 require(
2010 detached.identity_changed && detached.project_instance_id != previous_project,
2011 "schema-19 detach retry did not rotate the project identity",
2012 )?;
2013 let reopened = AtlasStore::open_read_only_for_project(&database, &destination_root)?;
2014 require_eq(
2015 &reopened.project_root_identity()?,
2016 &Some(CanonicalProjectRoot::from_path(&destination_root)?),
2017 "schema-19 detach retry native destination",
2018 )?;
2019 Ok(())
2020 }
2021
2022 #[cfg(windows)]
2023 #[test]
2024 fn schema_nineteen_same_root_bind_reports_preserved_identity_and_state()
2025 -> Result<(), Box<dyn Error>> {
2026 let temp = tempfile::tempdir()?;
2027 let root = temp.path().join("schema-19-bind-root");
2028 let database = temp.path().join("schema-19-bind.db");
2029 fs::create_dir(&root)?;
2030
2031 let mut store = AtlasStore::open_for_project(&database, &root)?;
2032 let project = store
2033 .project_instance_id()?
2034 .ok_or_else(|| io::Error::other("schema-19 bind fixture identity is missing"))?;
2035 seed_authored_and_graph_state(&mut store, project)?;
2036 let publication = store.index_publication()?;
2037 assert_authored_state(&store)?;
2038 assert_usage_report(&store, true)?;
2039 assert_runtime_scope(&store, project, 1, 0, 1)?;
2040 assert_graph_counts(&store, [2, 1, 1, 1, 1, 1, 1])?;
2041 crate::schema::drop_worktree_native_identity_schema(&store.connection)?;
2042 store.connection.execute_batch(
2043 "DROP TABLE project_root_identity;
2044 DROP TABLE IF EXISTS graph_identity_rejections;
2045 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
2046 )?;
2047 drop(store);
2048
2049 let bound =
2050 AtlasStore::transition_project_root(&database, &root, ProjectRootTransition::Bind)?;
2051 let expected_display = normalize_metadata_path(&root);
2052 require_eq(
2053 &bound.previous_root,
2054 &Some(expected_display.clone()),
2055 "schema-19 bind previous root",
2056 )?;
2057 require_eq(
2058 &bound.project_root,
2059 &Some(expected_display),
2060 "schema-19 bind destination root",
2061 )?;
2062 require_eq(
2063 &bound.project_instance_id,
2064 &project,
2065 "schema-19 bind identity",
2066 )?;
2067 require(!bound.identity_changed, "schema-19 bind changed identity")?;
2068 require(
2069 !bound.publication_invalidated,
2070 "schema-19 bind invalidated publication",
2071 )?;
2072
2073 let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
2074 require_eq(
2075 &reopened.project_root_identity()?,
2076 &Some(CanonicalProjectRoot::from_path(&root)?),
2077 "schema-19 bind native root",
2078 )?;
2079 require_eq(
2080 &reopened.project_instance_id()?,
2081 &Some(project),
2082 "schema-19 bind reopened identity",
2083 )?;
2084 require_eq(
2085 &reopened.index_publication()?,
2086 &publication,
2087 "schema-19 bind publication",
2088 )?;
2089 assert_authored_state(&reopened)?;
2090 assert_usage_report(&reopened, true)?;
2091 assert_runtime_scope(&reopened, project, 1, 0, 1)?;
2092 assert_graph_counts(&reopened, [2, 1, 1, 1, 1, 1, 1])?;
2093 Ok(())
2094 }
2095
2096 #[cfg(windows)]
2097 #[test]
2098 fn case_only_root_rename_reopens_same_persisted_identity() -> Result<(), Box<dyn Error>> {
2099 let temp = tempfile::tempdir()?;
2100 let original_path = temp.path().join("CaseOnlyRoot");
2101 let staging_path = temp.path().join("CaseOnlyRootStaging");
2102 let renamed_path = temp.path().join("caseonlyroot");
2103 fs::create_dir(&original_path)?;
2104 let database = temp.path().join("case-only-root.db");
2105
2106 let initial = AtlasStore::open_for_project(&database, &original_path)?;
2107 let project = initial
2108 .project_instance_id()?
2109 .ok_or_else(|| io::Error::other("case-only root fixture identity is missing"))?;
2110 let initial_root = initial
2111 .project_root_identity()?
2112 .ok_or_else(|| io::Error::other("case-only root fixture native identity is missing"))?;
2113 drop(initial);
2114
2115 fs::rename(&original_path, &staging_path)?;
2116 fs::rename(&staging_path, &renamed_path)?;
2117 let renamed_root = CanonicalProjectRoot::from_path(&renamed_path)?;
2118 if initial_root.encode()? == renamed_root.encode()? {
2119 return Err("case-only root rename did not retain distinct native spelling".into());
2120 }
2121 let Ok(recanonicalized_root) = CanonicalProjectRoot::from_path(&original_path) else {
2124 return Ok(());
2125 };
2126 require_eq(
2127 &recanonicalized_root,
2128 &renamed_root,
2129 "re-canonicalized case-only root",
2130 )?;
2131
2132 let reopened = AtlasStore::open_for_project(&database, &renamed_path)?;
2133 require_eq(
2134 &reopened.project_instance_id()?,
2135 &Some(project),
2136 "case-only root project identity",
2137 )?;
2138 require_eq(
2139 &reopened.project_root_identity()?,
2140 &Some(renamed_root),
2141 "case-only root native identity",
2142 )?;
2143 require_eq(
2144 &reopened.project_root()?,
2145 &Some(normalize_metadata_path(&renamed_path)),
2146 "case-only root display metadata",
2147 )?;
2148 Ok(())
2149 }
2150
2151 #[cfg(windows)]
2152 #[test]
2153 fn verbatim_root_reopens_file_backed_store_without_identity_drift() -> Result<(), Box<dyn Error>>
2154 {
2155 let temp = tempfile::tempdir()?;
2156 let base = temp
2157 .path()
2158 .to_str()
2159 .ok_or("temporary directory was not UTF-8")?;
2160 let long_component = "a".repeat(220);
2161 let root = std::path::PathBuf::from(format!(r"\\?\{base}\{long_component}"));
2162 fs::create_dir(&root)?;
2163 let database = root.join(".projectatlas/projectatlas.db");
2164 fs::create_dir_all(
2165 database
2166 .parent()
2167 .ok_or("verbatim root database has no parent")?,
2168 )?;
2169
2170 let initial = AtlasStore::open_for_project(&database, &root)?;
2171 let project = initial
2172 .project_instance_id()?
2173 .ok_or_else(|| io::Error::other("verbatim root project identity is missing"))?;
2174 let native_root = initial
2175 .project_root_identity()?
2176 .ok_or_else(|| io::Error::other("verbatim root native identity is missing"))?;
2177 let display = native_root.display_string()?;
2178 if !display.starts_with(r"\\?\") {
2179 return Err("verbatim root display lost its extended prefix".into());
2180 }
2181 drop(initial);
2182
2183 let reopened = AtlasStore::open_for_project(&database, &root)?;
2184 require_eq(
2185 &reopened.project_instance_id()?,
2186 &Some(project),
2187 "verbatim root project identity",
2188 )?;
2189 require_eq(
2190 &reopened.project_root_identity()?,
2191 &Some(native_root),
2192 "verbatim root native identity",
2193 )?;
2194 Ok(())
2195 }
2196
2197 #[cfg(windows)]
2198 #[test]
2199 fn case_sensitive_root_namespace_rejects_distinct_binding_without_mutation()
2200 -> Result<(), Box<dyn Error>> {
2201 use std::process::Command;
2202
2203 let temp = tempfile::tempdir()?;
2204 let case_sensitive_parent = temp.path().join("case-sensitive-parent");
2205 fs::create_dir(&case_sensitive_parent)?;
2206 let enabled = Command::new("fsutil")
2207 .args(["file", "SetCaseSensitiveInfo"])
2208 .arg(&case_sensitive_parent)
2209 .arg("enable")
2210 .status()
2211 .is_ok_and(|status| status.success());
2212 if !enabled {
2213 return Ok(());
2216 }
2217
2218 let upper_path = case_sensitive_parent.join("Repo");
2219 let lower_path = case_sensitive_parent.join("repo");
2220 if fs::create_dir(&upper_path).is_err() || fs::create_dir(&lower_path).is_err() {
2221 return Ok(());
2222 }
2223 let upper_identity = CanonicalProjectRoot::from_path(&upper_path)?;
2224 let lower_identity = CanonicalProjectRoot::from_path(&lower_path)?;
2225 if upper_identity == lower_identity {
2226 return Ok(());
2227 }
2228
2229 let database = temp.path().join("case-sensitive.db");
2230 let mut store = AtlasStore::open_for_project(&database, &upper_path)?;
2231 let project = store
2232 .project_instance_id()?
2233 .ok_or_else(|| io::Error::other("case-sensitive root identity is missing"))?;
2234 seed_authored_and_graph_state(&mut store, project)?;
2235 drop(store);
2236
2237 let read_guard = AtlasStore::open_read_only_for_project(&database, &upper_path)?;
2241 let database_before = fs::read(&database)?;
2242 let sidecars_before = ["-wal", "-shm", "-journal"].map(|suffix| {
2243 fs::read(database.with_file_name(format!("case-sensitive.db{suffix}"))).ok()
2244 });
2245 let mut inventory_before = fs::read_dir(temp.path())?
2246 .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
2247 .collect::<Result<Vec<_>, _>>()?;
2248 inventory_before.sort();
2249
2250 let Some(error) = AtlasStore::open_for_project(&database, &lower_path).err() else {
2251 return Err(io::Error::other(
2252 "case-sensitive sibling root was admitted as the persisted binding",
2253 )
2254 .into());
2255 };
2256 if !matches!(error, DbError::ProjectRootMismatch { .. }) {
2257 return Err(io::Error::other(format!(
2258 "case-sensitive sibling returned the wrong error: {error}"
2259 ))
2260 .into());
2261 }
2262 let database_unchanged = fs::read(&database)? == database_before;
2263 let sidecars_unchanged = ["-wal", "-shm", "-journal"].map(|suffix| {
2264 fs::read(database.with_file_name(format!("case-sensitive.db{suffix}"))).ok()
2265 }) == sidecars_before;
2266 if !database_unchanged || !sidecars_unchanged {
2267 return Err(io::Error::other(format!(
2268 "case-sensitive sibling refusal changed database or sidecar bytes (database_unchanged={database_unchanged}, sidecars_unchanged={sidecars_unchanged})"
2269 ))
2270 .into());
2271 }
2272 let mut inventory_after = fs::read_dir(temp.path())?
2273 .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
2274 .collect::<Result<Vec<_>, _>>()?;
2275 inventory_after.sort();
2276 if inventory_after != inventory_before {
2277 return Err(io::Error::other(
2278 "case-sensitive sibling refusal changed sidecar inventory",
2279 )
2280 .into());
2281 }
2282
2283 let reopened = AtlasStore::open_read_only_for_project(&database, &upper_path)?;
2284 require_eq(
2285 &reopened.project_instance_id()?,
2286 &Some(project),
2287 "case-sensitive root project identity",
2288 )?;
2289 require_eq(
2290 &reopened.project_root_identity()?,
2291 &Some(upper_identity),
2292 "case-sensitive root native identity",
2293 )?;
2294 assert_authored_state(&reopened)?;
2295 assert_usage_report(&reopened, true)?;
2296 assert_runtime_scope(&reopened, project, 1, 0, 1)?;
2297 assert_graph_counts(&reopened, [2, 1, 1, 1, 1, 1, 1])?;
2298 require(
2299 reopened.index_publication()?.is_some(),
2300 "case-sensitive sibling refusal changed publication",
2301 )?;
2302 drop(read_guard);
2303 Ok(())
2304 }
2305
2306 #[cfg(windows)]
2307 #[test]
2308 fn schema_nineteen_case_sensitive_sibling_refuses_before_migration_without_mutation()
2309 -> Result<(), Box<dyn Error>> {
2310 use rusqlite::OpenFlags;
2311 use std::process::Command;
2312
2313 let temp = tempfile::tempdir()?;
2314 let case_sensitive_parent = temp.path().join("schema-19-case-sensitive-parent");
2315 fs::create_dir(&case_sensitive_parent)?;
2316 let enabled = Command::new("fsutil")
2317 .args(["file", "SetCaseSensitiveInfo"])
2318 .arg(&case_sensitive_parent)
2319 .arg("enable")
2320 .status()
2321 .is_ok_and(|status| status.success());
2322 if !enabled {
2323 return Ok(());
2326 }
2327
2328 let upper_path = case_sensitive_parent.join("Repo");
2329 let lower_path = case_sensitive_parent.join("repo");
2330 if fs::create_dir(&upper_path).is_err() || fs::create_dir(&lower_path).is_err() {
2331 return Ok(());
2332 }
2333 let upper_identity = CanonicalProjectRoot::from_path(&upper_path)?;
2334 let lower_identity = CanonicalProjectRoot::from_path(&lower_path)?;
2335 if upper_identity == lower_identity {
2336 return Ok(());
2337 }
2338
2339 let database = temp.path().join("schema-19-case-sensitive.db");
2340 let mut store = AtlasStore::open_for_project(&database, &upper_path)?;
2341 let project = store
2342 .project_instance_id()?
2343 .ok_or_else(|| io::Error::other("schema-19 root fixture identity is missing"))?;
2344 seed_authored_and_graph_state(&mut store, project)?;
2345 let publication_before = store.index_publication()?;
2346 let usage_before = store.usage_events(Some("identity-test"))?;
2347 let overview_before = store.token_overview(Some("identity-test"))?;
2348 assert_authored_state(&store)?;
2349 assert_usage_report(&store, true)?;
2350 assert_runtime_scope(&store, project, 1, 0, 1)?;
2351 assert_graph_counts(&store, [2, 1, 1, 1, 1, 1, 1])?;
2352
2353 crate::schema::drop_worktree_native_identity_schema(&store.connection)?;
2354 store.connection.execute_batch(
2355 "DROP TABLE project_root_identity;
2356 DROP TABLE IF EXISTS graph_identity_rejections;
2357 UPDATE metadata SET value = '19' WHERE key = 'schema_version';
2358 PRAGMA wal_checkpoint(TRUNCATE);",
2359 )?;
2360
2361 let schema_before = store.connection.query_row(
2362 "SELECT value FROM metadata WHERE key = 'schema_version'",
2363 [],
2364 |row| row.get::<_, String>(0),
2365 )?;
2366 let legacy_root_before = store
2367 .connection
2368 .query_row(
2369 "SELECT value FROM metadata WHERE key = 'project_root'",
2370 [],
2371 |row| row.get::<_, String>(0),
2372 )
2373 .optional()?;
2374 let (project_bytes_before, generation_before) = store.connection.query_row(
2375 "SELECT project_instance_id, active_generation
2376 FROM project_identity WHERE singleton = 1",
2377 [],
2378 |row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, i64>(1)?)),
2379 )?;
2380 let identity_table_before = store.connection.query_row(
2381 "SELECT COUNT(*) FROM sqlite_master WHERE name = 'project_root_identity'",
2382 [],
2383 |row| row.get::<_, i64>(0),
2384 )?;
2385 require_eq(
2386 &schema_before,
2387 &"19".to_string(),
2388 "schema-19 fixture marker",
2389 )?;
2390 require_eq(
2391 &identity_table_before,
2392 &0,
2393 "schema-19 fixture identity table absence",
2394 )?;
2395
2396 let read_guard = Connection::open_with_flags(&database, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
2400 let database_before = fs::read(&database)?;
2401 let sidecars_before = ["-wal", "-shm", "-journal"].map(|suffix| {
2402 fs::read(database.with_file_name(format!("schema-19-case-sensitive.db{suffix}"))).ok()
2403 });
2404 let mut inventory_before = fs::read_dir(temp.path())?
2405 .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
2406 .collect::<Result<Vec<_>, _>>()?;
2407 inventory_before.sort();
2408
2409 let Some(error) = AtlasStore::open_for_project(&database, &lower_path).err() else {
2410 return Err(io::Error::other(
2411 "schema-19 case-sensitive sibling reached migration or was admitted",
2412 )
2413 .into());
2414 };
2415 if !matches!(error, DbError::ProjectRootMismatch { .. }) {
2416 return Err(io::Error::other(format!(
2417 "schema-19 case-sensitive sibling returned the wrong error: {error}"
2418 ))
2419 .into());
2420 }
2421
2422 require_eq(
2423 &fs::read(&database)?,
2424 &database_before,
2425 "schema-19 case-sensitive refusal database bytes",
2426 )?;
2427 let sidecars_after = ["-wal", "-shm", "-journal"].map(|suffix| {
2428 fs::read(database.with_file_name(format!("schema-19-case-sensitive.db{suffix}"))).ok()
2429 });
2430 require_eq(
2431 &sidecars_after,
2432 &sidecars_before,
2433 "schema-19 case-sensitive refusal sidecar bytes",
2434 )?;
2435 let mut inventory_after = fs::read_dir(temp.path())?
2436 .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
2437 .collect::<Result<Vec<_>, _>>()?;
2438 inventory_after.sort();
2439 require_eq(
2440 &inventory_after,
2441 &inventory_before,
2442 "schema-19 case-sensitive refusal inventory",
2443 )?;
2444
2445 require_eq(
2446 &store.connection.query_row(
2447 "SELECT value FROM metadata WHERE key = 'schema_version'",
2448 [],
2449 |row| row.get::<_, String>(0),
2450 )?,
2451 &schema_before,
2452 "schema-19 refusal schema marker",
2453 )?;
2454 require_eq(
2455 &store.connection.query_row(
2456 "SELECT value FROM metadata WHERE key = 'project_root'",
2457 [],
2458 |row| row.get::<_, Option<String>>(0),
2459 )?,
2460 &legacy_root_before,
2461 "schema-19 refusal legacy root metadata",
2462 )?;
2463 let (project_bytes_after, generation_after) = store.connection.query_row(
2464 "SELECT project_instance_id, active_generation
2465 FROM project_identity WHERE singleton = 1",
2466 [],
2467 |row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, i64>(1)?)),
2468 )?;
2469 require_eq(
2470 &project_bytes_after,
2471 &project_bytes_before,
2472 "schema-19 refusal project instance",
2473 )?;
2474 require_eq(
2475 &generation_after,
2476 &generation_before,
2477 "schema-19 refusal generation",
2478 )?;
2479 require_eq(
2480 &store.index_publication()?,
2481 &publication_before,
2482 "schema-19 refusal publication",
2483 )?;
2484 require_eq(
2485 &store.usage_events(Some("identity-test"))?,
2486 &usage_before,
2487 "schema-19 refusal usage events",
2488 )?;
2489 require_eq(
2490 &store.token_overview(Some("identity-test"))?,
2491 &overview_before,
2492 "schema-19 refusal usage overview",
2493 )?;
2494 let identity_table_after = store.connection.query_row(
2495 "SELECT COUNT(*) FROM sqlite_master WHERE name = 'project_root_identity'",
2496 [],
2497 |row| row.get::<_, i64>(0),
2498 )?;
2499 require_eq(
2500 &identity_table_after,
2501 &identity_table_before,
2502 "schema-19 refusal identity table",
2503 )?;
2504 assert_authored_state(&store)?;
2505 assert_usage_report(&store, true)?;
2506 assert_runtime_scope(&store, project, 1, 0, 1)?;
2507 assert_graph_counts(&store, [2, 1, 1, 1, 1, 1, 1])?;
2508 drop(read_guard);
2509 Ok(())
2510 }
2511
2512 #[cfg(unix)]
2513 #[test]
2514 fn non_utf8_root_transitions_use_native_identity_not_display_projection()
2515 -> Result<(), Box<dyn Error>> {
2516 use std::os::unix::ffi::OsStringExt;
2517
2518 let temp = tempfile::tempdir()?;
2519 let native_name = std::ffi::OsString::from_vec(vec![b'r', b'o', b'o', b't', 0x80]);
2520 let root = temp.path().join(&native_name);
2521 let display_collision = temp.path().join("root-�");
2522 let destination_collision = temp.path().join("dest-�");
2523 let destination_native_name =
2524 std::ffi::OsString::from_vec(vec![b'd', b'e', b's', b't', 0x81]);
2525 let destination_native = temp.path().join(&destination_native_name);
2526 fs::create_dir(&root)?;
2527 fs::create_dir(&display_collision)?;
2528 fs::create_dir(&destination_collision)?;
2529 fs::create_dir(&destination_native)?;
2530
2531 let database = temp.path().join("non-utf8-root.db");
2532 let bound =
2533 AtlasStore::transition_project_root(&database, &root, ProjectRootTransition::Bind)?;
2534 require(
2535 bound.project_root.is_none(),
2536 "raw root exposed a lossy transition display",
2537 )?;
2538 let native_identity = CanonicalProjectRoot::from_path(&root)?;
2539 let opened = AtlasStore::open_read_only_for_project(&database, &root)?;
2540 require_eq(
2541 &opened.project_root_identity()?,
2542 &Some(native_identity),
2543 "non-UTF-8 bound native identity",
2544 )?;
2545 require_eq(
2546 &opened.captured_project_binding()?.project_root,
2547 &None,
2548 "non-UTF-8 typed display availability",
2549 )?;
2550 require_eq(
2551 &opened.project_root()?,
2552 &None,
2553 "non-UTF-8 compatibility metadata",
2554 )?;
2555 drop(opened);
2556 let before_collision = fs::read(&database)?;
2557 let collision_error = require_error(
2558 AtlasStore::transition_project_root(
2559 &database,
2560 &display_collision,
2561 ProjectRootTransition::Bind,
2562 ),
2563 "replacement-character root collision was accepted",
2564 )?;
2565 require(
2566 matches!(collision_error, DbError::ProjectRootMismatch { .. }),
2567 "non-UTF-8 root collision returned the wrong error",
2568 )?;
2569 assert_database_unchanged(&database, &before_collision, "non-UTF-8 root collision")?;
2570 crate::verify_project_database(&database, &root)?;
2571
2572 let mut stale = AtlasStore::open_for_project(&database, &root)?;
2573 seed_authored_and_graph_state(&mut stale, bound.project_instance_id)?;
2574 stale
2575 .begin_index_publication("non-utf8-before-move")?
2576 .complete()?;
2577 fs::remove_dir(&root)?;
2578 let moved = AtlasStore::transition_project_root(
2579 &database,
2580 &display_collision,
2581 ProjectRootTransition::Move,
2582 )?;
2583 require_eq(
2584 &moved.project_instance_id,
2585 &bound.project_instance_id,
2586 "non-UTF-8 move identity",
2587 )?;
2588 require_eq(
2589 &moved.previous_root,
2590 &None,
2591 "non-UTF-8 move previous display availability",
2592 )?;
2593 let destination_identity = CanonicalProjectRoot::from_path(&display_collision)?;
2594 let moved_store = AtlasStore::open_read_only_for_project(&database, &display_collision)?;
2595 require_eq(
2596 &moved_store.project_root_identity()?,
2597 &Some(destination_identity),
2598 "non-UTF-8 moved native identity",
2599 )?;
2600 assert_authored_state(&moved_store)?;
2601 assert_usage_report(&moved_store, true)?;
2602 drop(moved_store);
2603 crate::verify_project_database(&database, &display_collision)?;
2604
2605 let before_stale_publication = fs::read(&database)?;
2606 let publication_error = require_error(
2607 stale.begin_index_publication("stale-non-utf8-after-move"),
2608 "stale non-UTF-8 store entered publication after native root move",
2609 )?;
2610 require(
2611 matches!(
2612 publication_error,
2613 DbError::ProjectRootTransitionChanged { .. }
2614 ),
2615 "stale non-UTF-8 publication returned the wrong error",
2616 )?;
2617 assert_database_unchanged(
2618 &database,
2619 &before_stale_publication,
2620 "stale non-UTF-8 publication",
2621 )?;
2622 let destination_state =
2623 AtlasStore::open_read_only_for_project(&database, &display_collision)?;
2624 require(
2625 destination_state.index_publication()?.is_none(),
2626 "stale non-UTF-8 publication mutated derived state",
2627 )?;
2628 drop(destination_state);
2629 drop(stale);
2630
2631 let before_destination_collision = fs::read(&database)?;
2632 let destination_collision_error = require_error(
2633 AtlasStore::transition_project_root(
2634 &database,
2635 &destination_collision,
2636 ProjectRootTransition::Bind,
2637 ),
2638 "replacement-character destination collision was accepted",
2639 )?;
2640 require(
2641 matches!(
2642 destination_collision_error,
2643 DbError::ProjectRootMismatch { .. }
2644 ),
2645 "non-UTF-8 destination collision returned the wrong error",
2646 )?;
2647 assert_database_unchanged(
2648 &database,
2649 &before_destination_collision,
2650 "non-UTF-8 destination collision",
2651 )?;
2652
2653 let detached = AtlasStore::transition_project_root(
2654 &database,
2655 &destination_native,
2656 ProjectRootTransition::Detach,
2657 )?;
2658 require(
2659 detached.project_instance_id != bound.project_instance_id,
2660 "non-UTF-8 detach did not rotate project identity",
2661 )?;
2662 require_eq(
2663 &detached.previous_root,
2664 &Some(normalize_metadata_path(&display_collision)),
2665 "non-UTF-8 detach previous display",
2666 )?;
2667 require(
2668 detached.project_root.is_none(),
2669 "non-UTF-8 detach exposed a lossy destination display",
2670 )?;
2671 let detached_store =
2672 AtlasStore::open_read_only_for_project(&database, &destination_native)?;
2673 require_eq(
2674 &detached_store.project_root_identity()?,
2675 &Some(CanonicalProjectRoot::from_path(&destination_native)?),
2676 "non-UTF-8 detached native identity",
2677 )?;
2678 require_eq(
2679 &detached_store.captured_project_binding()?.project_root,
2680 &None,
2681 "non-UTF-8 detached display availability",
2682 )?;
2683 assert_authored_state(&detached_store)?;
2684 assert_usage_report(&detached_store, false)?;
2685 assert_graph_counts(&detached_store, [0, 0, 0, 0, 0, 0, 0])?;
2686 Ok(())
2687 }
2688
2689 #[cfg(unix)]
2690 #[test]
2691 fn set_project_root_preserves_non_utf8_binding_against_replacement_sibling()
2692 -> Result<(), Box<dyn Error>> {
2693 use std::os::unix::ffi::OsStringExt;
2694
2695 let temp = tempfile::tempdir()?;
2696 let raw_root = temp.path().join(std::ffi::OsString::from_vec(vec![
2697 b'r', b'e', b'p', b'o', 0x80,
2698 ]));
2699 let replacement_root = temp.path().join("repo-�");
2700 fs::create_dir(&raw_root)?;
2701 fs::create_dir(&replacement_root)?;
2702 let database = temp.path().join("set-project-root-non-utf8.db");
2703 let bound =
2704 AtlasStore::transition_project_root(&database, &raw_root, ProjectRootTransition::Bind)?;
2705 let native_identity = CanonicalProjectRoot::from_path(&raw_root)?;
2706 let mut store = AtlasStore::open_for_project(&database, &raw_root)?;
2707 require_eq(
2708 &store.project_root_identity()?,
2709 &Some(native_identity.clone()),
2710 "non-UTF-8 set_project_root native identity",
2711 )?;
2712 require_eq(
2713 &store.project_root()?,
2714 &None,
2715 "non-UTF-8 set_project_root compatibility metadata",
2716 )?;
2717 seed_authored_and_graph_state(&mut store, bound.project_instance_id)?;
2718 let before_database = fs::read(&database)?;
2719 let same_store_error = require_error(
2720 store.set_project_root(&replacement_root),
2721 "set_project_root rebound a non-UTF-8 root to its replacement sibling",
2722 )?;
2723 require(
2724 matches!(same_store_error, DbError::ProjectRootMismatch { .. }),
2725 "same-store non-UTF-8 set_project_root returned the wrong error",
2726 )?;
2727 require_eq(
2728 &store.project_root_identity()?,
2729 &Some(native_identity.clone()),
2730 "same-store native identity after rejected set_project_root",
2731 )?;
2732 require_eq(
2733 &store.project_root()?,
2734 &None,
2735 "same-store metadata after rejected set_project_root",
2736 )?;
2737 require_eq(
2738 &store.project_instance_id()?,
2739 &Some(bound.project_instance_id),
2740 "same-store project identity after rejected set_project_root",
2741 )?;
2742 assert_authored_state(&store)?;
2743 assert_usage_report(&store, true)?;
2744 assert_graph_counts(&store, [2, 1, 1, 1, 1, 1, 1])?;
2745 assert_database_unchanged(
2746 &database,
2747 &before_database,
2748 "same-store rejected non-UTF-8 set_project_root",
2749 )?;
2750 drop(store);
2751
2752 let mut reopened = AtlasStore::open_for_project(&database, &raw_root)?;
2753 let before_reopened_attempt = fs::read(&database)?;
2754 let reopened_error = require_error(
2755 reopened.set_project_root(&replacement_root),
2756 "reopened store rebound a non-UTF-8 root to its replacement sibling",
2757 )?;
2758 require(
2759 matches!(reopened_error, DbError::ProjectRootMismatch { .. }),
2760 "reopened non-UTF-8 set_project_root returned the wrong error",
2761 )?;
2762 require_eq(
2763 &reopened.project_root_identity()?,
2764 &Some(native_identity),
2765 "reopened native identity after rejected set_project_root",
2766 )?;
2767 require_eq(
2768 &reopened.project_root()?,
2769 &None,
2770 "reopened metadata after rejected set_project_root",
2771 )?;
2772 require_eq(
2773 &reopened.project_instance_id()?,
2774 &Some(bound.project_instance_id),
2775 "reopened project identity after rejected set_project_root",
2776 )?;
2777 assert_authored_state(&reopened)?;
2778 assert_usage_report(&reopened, true)?;
2779 assert_graph_counts(&reopened, [2, 1, 1, 1, 1, 1, 1])?;
2780 assert_database_unchanged(
2781 &database,
2782 &before_reopened_attempt,
2783 "reopened rejected non-UTF-8 set_project_root",
2784 )?;
2785 Ok(())
2786 }
2787
2788 #[test]
2789 fn stale_stores_cannot_write_after_binding_transitions() -> Result<(), Box<dyn Error>> {
2790 let temp = tempfile::tempdir()?;
2791 let root = temp.path().join("same-root");
2792 fs::create_dir(&root)?;
2793 let database = temp.path().join("same-root.db");
2794 let initial =
2795 AtlasStore::transition_project_root(&database, &root, ProjectRootTransition::Bind)?;
2796 let mut stale = AtlasStore::open_for_project(&database, &root)?;
2797 seed_authored_and_graph_state(&mut stale, initial.project_instance_id)?;
2798
2799 let detached =
2800 AtlasStore::transition_project_root(&database, &root, ProjectRootTransition::Detach)?;
2801 require(
2802 detached.project_instance_id != initial.project_instance_id,
2803 "same-root detach did not rotate identity",
2804 )?;
2805 let purpose_error = require_error(
2806 stale.set_purpose(
2807 "src/lib.rs",
2808 "Stale store must not replace this purpose.",
2809 projectatlas_core::PurposeSource::Agent,
2810 ),
2811 "stale store wrote purpose after same-root detach",
2812 )?;
2813 require(
2814 matches!(purpose_error, DbError::ProjectRootTransitionChanged { .. }),
2815 "same-root stale purpose returned the wrong error",
2816 )?;
2817 let scan_error = require_error(
2818 stale.replace_scan(&[]),
2819 "stale store replaced scan state after same-root detach",
2820 )?;
2821 require(
2822 matches!(scan_error, DbError::ProjectRootTransitionChanged { .. }),
2823 "same-root stale scan returned the wrong error",
2824 )?;
2825 let telemetry_error = require_error(
2826 stale.record_usage(&usage_from_estimates(
2827 "stale-after-detach",
2828 "summary",
2829 Some("src/lib.rs".to_string()),
2830 None,
2831 100,
2832 20,
2833 )),
2834 "stale store recorded telemetry after same-root detach",
2835 )?;
2836 require(
2837 matches!(
2838 telemetry_error,
2839 DbError::ProjectRootTransitionChanged { .. }
2840 ),
2841 "same-root stale telemetry returned the wrong error",
2842 )?;
2843 let health_error = require_error(
2844 stale.resolve_health_finding(&HealthResolution {
2845 finding_id: "stale-resolution".to_string(),
2846 category: "missing-purpose".to_string(),
2847 path: "src/lib.rs".to_string(),
2848 related_path: None,
2849 rationale: "A stale store must not persist this resolution.".to_string(),
2850 }),
2851 "stale store resolved health state after same-root detach",
2852 )?;
2853 require(
2854 matches!(health_error, DbError::ProjectRootTransitionChanged { .. }),
2855 "same-root stale health resolution returned the wrong error",
2856 )?;
2857 let publication_error = require_error(
2858 stale.begin_index_publication("stale-after-detach"),
2859 "stale store began publication after same-root detach",
2860 )?;
2861 require(
2862 matches!(
2863 publication_error,
2864 DbError::ProjectRootTransitionChanged { .. }
2865 ),
2866 "same-root stale publication returned the wrong error",
2867 )?;
2868 let detached_store = AtlasStore::open_read_only_for_project(&database, &root)?;
2869 assert_authored_state(&detached_store)?;
2870 assert_usage_report(&detached_store, false)?;
2871 assert_runtime_scope(&detached_store, initial.project_instance_id, 0, 1, 0)?;
2872 assert_graph_counts(&detached_store, [0, 0, 0, 0, 0, 0, 0])?;
2873 require(
2874 detached_store.index_publication()?.is_none(),
2875 "stale writes restored invalidated publication state",
2876 )?;
2877 let active_generation = detached_store.connection.query_row(
2878 "SELECT active_generation FROM project_identity WHERE singleton = 1",
2879 [],
2880 |row| row.get::<_, i64>(0),
2881 )?;
2882 require_eq(
2883 &active_generation,
2884 &0,
2885 "active generation after rejected stale writes",
2886 )?;
2887 drop(detached_store);
2888 drop(stale);
2889
2890 let move_root = temp.path().join("move-source");
2891 let destination = temp.path().join("move-destination");
2892 fs::create_dir(&move_root)?;
2893 fs::create_dir(&destination)?;
2894 let move_database = temp.path().join("move.db");
2895 AtlasStore::transition_project_root(
2896 &move_database,
2897 &move_root,
2898 ProjectRootTransition::Bind,
2899 )?;
2900 let mut stale_move = AtlasStore::open_for_project(&move_database, &move_root)?;
2901 fs::remove_dir(&move_root)?;
2902 AtlasStore::transition_project_root(
2903 &move_database,
2904 &destination,
2905 ProjectRootTransition::Move,
2906 )?;
2907 let move_error = require_error(
2908 stale_move.begin_index_publication("stale-after-move"),
2909 "stale store began publication after root move",
2910 )?;
2911 require(
2912 matches!(
2913 move_error,
2914 DbError::ProjectRootMismatch { .. } | DbError::ProjectRootTransitionChanged { .. }
2915 ),
2916 "moved stale publication returned the wrong error",
2917 )?;
2918 drop(AtlasStore::open_read_only_for_project(
2919 &move_database,
2920 &destination,
2921 )?);
2922 Ok(())
2923 }
2924
2925 fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
2927 if condition {
2928 Ok(())
2929 } else {
2930 Err(io::Error::other(message).into())
2931 }
2932 }
2933
2934 fn require_eq<T: Debug + PartialEq>(
2936 actual: &T,
2937 expected: &T,
2938 label: &str,
2939 ) -> Result<(), Box<dyn Error>> {
2940 if actual == expected {
2941 Ok(())
2942 } else {
2943 Err(io::Error::other(format!(
2944 "{label} mismatch: expected {expected:?}, got {actual:?}"
2945 ))
2946 .into())
2947 }
2948 }
2949
2950 fn require_error<T>(result: DbResult<T>, message: &str) -> Result<DbError, Box<dyn Error>> {
2952 match result {
2953 Ok(_) => Err(io::Error::other(message).into()),
2954 Err(error) => Ok(error),
2955 }
2956 }
2957
2958 #[cfg(unix)]
2960 fn create_dangling_directory_link(target: &Path, link: &Path) -> Result<bool, Box<dyn Error>> {
2961 std::os::unix::fs::symlink(target, link)?;
2962 Ok(true)
2963 }
2964
2965 #[cfg(windows)]
2967 fn create_dangling_directory_link(target: &Path, link: &Path) -> Result<bool, Box<dyn Error>> {
2968 match std::os::windows::fs::symlink_dir(target, link) {
2969 Ok(()) => Ok(true),
2970 Err(error)
2971 if error.kind() == std::io::ErrorKind::PermissionDenied
2972 || error.raw_os_error() == Some(1314) =>
2973 {
2974 Ok(false)
2975 }
2976 Err(error) => Err(error.into()),
2977 }
2978 }
2979
2980 #[cfg(not(any(unix, windows)))]
2982 fn create_dangling_directory_link(
2983 _target: &Path,
2984 _link: &Path,
2985 ) -> Result<bool, Box<dyn Error>> {
2986 Ok(false)
2987 }
2988
2989 fn seed_authored_and_graph_state(
2990 store: &mut AtlasStore,
2991 project: ProjectInstanceId,
2992 ) -> Result<(), Box<dyn Error>> {
2993 store.connection.execute_batch(
2994 "INSERT INTO nodes(path, kind, parent_path) VALUES('.', 'folder', NULL);
2995 INSERT INTO nodes(path, kind, parent_path) VALUES('src/lib.rs', 'file', '.');
2996 INSERT INTO file_content_classifications(path, classification)
2997 VALUES('src/lib.rs', 'source');
2998 INSERT INTO purposes(node_id, purpose, source, status, updated_by)
2999 SELECT id, 'Own the local source entry point.', 'agent', 'approved', 'agent'
3000 FROM nodes WHERE path = 'src/lib.rs';
3001 INSERT INTO metadata(key, value) VALUES('custom.identity-test-setting', 'retain-me');
3002 INSERT INTO health_resolutions(
3003 finding_id, category, path, related_path, rationale, resolved_by
3004 ) VALUES(
3005 'resolved-purpose', 'duplicate-purpose', 'src/lib.rs', 'src/main.rs',
3006 'The responsibilities are intentionally distinct.', 'agent'
3007 );",
3008 )?;
3009 store.record_usage(&identity_transition_usage_event())?;
3010 let generation = IndexGeneration::new(1);
3011 let project_entity = GraphEntity::new(project, EntitySelector::Project, generation)?;
3012 let file_entity = GraphEntity::new(
3013 project,
3014 EntitySelector::File {
3015 path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
3016 },
3017 generation,
3018 )?;
3019 let relation = LogicalRelation::new(
3020 &file_entity,
3021 GraphRelationKind::Legacy(RelationKind::DependsOn),
3022 RelationResolution::Unresolved {
3023 reference: GraphIdentityText::new("src/lib.rs")?,
3024 },
3025 ConfidenceClass::Exact,
3026 Completeness::Complete,
3027 generation,
3028 )?;
3029 let occurrence = RelationOccurrence::new(
3030 &relation,
3031 RepositoryFilePath::new(Path::new("src/lib.rs"))?,
3032 SourceSpan::new(1, 0, 1, 1)?,
3033 generation,
3034 )?;
3035 let coverage = CoverageRecord::new(
3036 CoverageScope::Project,
3037 None,
3038 CoverageState::Complete,
3039 1,
3040 0,
3041 generation,
3042 None,
3043 None,
3044 )?;
3045 let resolution_key = CanonicalResolutionKey::new(
3046 project,
3047 ResolutionKeyDomain::Declaration,
3048 &GraphIdentityText::new("identity-transition")?,
3049 &GraphIdentityText::new("rust")?,
3050 None,
3051 None,
3052 Some(GraphRelationKind::Legacy(RelationKind::DependsOn)),
3053 &GraphIdentityText::new("src/lib.rs")?,
3054 );
3055 let entity_export =
3056 EntityResolutionKey::new(file_entity.key().clone(), resolution_key.clone())?;
3057 let relation_dependency =
3058 RelationDependencyKey::new(relation.key().clone(), resolution_key)?;
3059 let mut publication = store.begin_index_publication("identity-transition")?;
3060 publication.replace_repository_graph_with_resolution_keys(
3061 project,
3062 &[project_entity, file_entity],
3063 &[relation],
3064 &[occurrence],
3065 &[coverage],
3066 &[entity_export],
3067 &[relation_dependency],
3068 )?;
3069 publication.complete()?;
3070 Ok(())
3071 }
3072
3073 fn assert_authored_state(store: &AtlasStore) -> Result<(), Box<dyn Error>> {
3074 let mut nodes = store
3075 .connection
3076 .prepare("SELECT path, kind, parent_path FROM nodes ORDER BY path")?;
3077 let nodes = nodes
3078 .query_map([], |row| {
3079 Ok((
3080 row.get::<_, String>(0)?,
3081 row.get::<_, String>(1)?,
3082 row.get::<_, Option<String>>(2)?,
3083 ))
3084 })?
3085 .collect::<Result<Vec<_>, _>>()?;
3086 require_eq(
3087 &nodes,
3088 &vec![
3089 (".".to_string(), "folder".to_string(), None),
3090 (
3091 "src/lib.rs".to_string(),
3092 "file".to_string(),
3093 Some(".".to_string()),
3094 ),
3095 ],
3096 "node anchors",
3097 )?;
3098
3099 let purpose = store.connection.query_row(
3100 "SELECT n.path, p.purpose, p.source, p.status, p.updated_by
3101 FROM purposes p JOIN nodes n ON n.id = p.node_id",
3102 [],
3103 |row| {
3104 Ok((
3105 row.get::<_, String>(0)?,
3106 row.get::<_, Option<String>>(1)?,
3107 row.get::<_, String>(2)?,
3108 row.get::<_, String>(3)?,
3109 row.get::<_, Option<String>>(4)?,
3110 ))
3111 },
3112 )?;
3113 require_eq(
3114 &purpose,
3115 &(
3116 "src/lib.rs".to_string(),
3117 Some("Own the local source entry point.".to_string()),
3118 "agent".to_string(),
3119 "approved".to_string(),
3120 Some("agent".to_string()),
3121 ),
3122 "purpose content and review ownership",
3123 )?;
3124
3125 let custom_setting = store.connection.query_row(
3126 "SELECT value FROM metadata WHERE key = 'custom.identity-test-setting'",
3127 [],
3128 |row| row.get::<_, String>(0),
3129 )?;
3130 require_eq(&custom_setting, &"retain-me".to_string(), "custom metadata")?;
3131
3132 let health_resolution = store.connection.query_row(
3133 "SELECT finding_id, category, path, related_path, rationale, resolved_by
3134 FROM health_resolutions",
3135 [],
3136 |row| {
3137 Ok((
3138 row.get::<_, String>(0)?,
3139 row.get::<_, String>(1)?,
3140 row.get::<_, String>(2)?,
3141 row.get::<_, Option<String>>(3)?,
3142 row.get::<_, String>(4)?,
3143 row.get::<_, String>(5)?,
3144 ))
3145 },
3146 )?;
3147 require_eq(
3148 &health_resolution,
3149 &(
3150 "resolved-purpose".to_string(),
3151 "duplicate-purpose".to_string(),
3152 "src/lib.rs".to_string(),
3153 Some("src/main.rs".to_string()),
3154 "The responsibilities are intentionally distinct.".to_string(),
3155 "agent".to_string(),
3156 ),
3157 "health resolution content",
3158 )?;
3159
3160 Ok(())
3161 }
3162
3163 fn identity_transition_usage_event() -> projectatlas_core::telemetry::UsageEvent {
3165 usage_from_estimates(
3166 "identity-test",
3167 "summary",
3168 Some("src/lib.rs".to_string()),
3169 Some("identity transition".to_string()),
3170 120,
3171 20,
3172 )
3173 }
3174
3175 fn assert_usage_report(store: &AtlasStore, retained: bool) -> Result<(), Box<dyn Error>> {
3177 let events = store.usage_events(Some("identity-test"))?;
3178 let overview = store.token_overview(Some("identity-test"))?;
3179 if retained {
3180 require_eq(
3181 &events,
3182 &vec![identity_transition_usage_event()],
3183 "project-scoped usage event",
3184 )?;
3185 require_eq(&overview.calls, &1, "project-scoped usage calls")?;
3186 } else {
3187 require(
3188 events.is_empty(),
3189 "detached project exposed prior raw usage",
3190 )?;
3191 require_eq(&overview.calls, &0, "detached project usage calls")?;
3192 }
3193 Ok(())
3194 }
3195
3196 fn assert_runtime_scope(
3198 store: &AtlasStore,
3199 project: ProjectInstanceId,
3200 expected_active: i64,
3201 expected_sealed: i64,
3202 expected_baselines: i64,
3203 ) -> Result<(), Box<dyn Error>> {
3204 const ACTIVE: &str = "active";
3205 const SEALED: &str = "sealed";
3206 let project_bytes = project.as_bytes();
3207 let (active, sealed) = store.connection.query_row(
3208 "SELECT
3209 COALESCE(SUM(CASE WHEN state = ?2 THEN 1 ELSE 0 END), 0),
3210 COALESCE(SUM(CASE WHEN state = ?3 THEN 1 ELSE 0 END), 0)
3211 FROM usage_instances
3212 WHERE project_instance_id = ?1",
3213 rusqlite::params![project_bytes.as_slice(), ACTIVE, SEALED],
3214 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
3215 )?;
3216 let baselines = store.connection.query_row(
3217 "SELECT COUNT(*)
3218 FROM usage_instance_baselines AS b
3219 JOIN usage_instances AS i USING(instance_row_id)
3220 WHERE i.project_instance_id = ?1",
3221 [project_bytes.as_slice()],
3222 |row| row.get::<_, i64>(0),
3223 )?;
3224 require_eq(&active, &expected_active, "active runtime instances")?;
3225 require_eq(&sealed, &expected_sealed, "sealed runtime instances")?;
3226 require_eq(&baselines, &expected_baselines, "active baseline witnesses")
3227 }
3228
3229 fn assert_database_unchanged(
3231 database_path: &Path,
3232 expected: &[u8],
3233 label: &str,
3234 ) -> Result<(), Box<dyn Error>> {
3235 require_eq(&fs::read(database_path)?, &expected.to_vec(), label)
3236 }
3237
3238 fn assert_graph_counts(store: &AtlasStore, expected: [i64; 7]) -> Result<(), Box<dyn Error>> {
3239 let mut counts = Vec::new();
3240 for table in [
3241 "graph_entities",
3242 "graph_relations",
3243 "graph_relation_occurrences",
3244 "graph_coverage",
3245 "graph_resolution_keys",
3246 "graph_entity_exports",
3247 "graph_relation_dependencies",
3248 ] {
3249 counts.push(store.connection.query_row(
3250 &format!("SELECT COUNT(*) FROM {table}"),
3251 [],
3252 |row| row.get::<_, i64>(0),
3253 )?);
3254 }
3255 require_eq(&counts, &expected.to_vec(), "graph row counts")?;
3256 let quick_check = store
3257 .connection
3258 .query_row("PRAGMA quick_check(1)", [], |row| row.get::<_, String>(0))?;
3259 require_eq(&quick_check, &"ok".to_string(), "database integrity")?;
3260 let foreign_key_failures = store.connection.query_row(
3261 "SELECT COUNT(*) FROM pragma_foreign_key_check",
3262 [],
3263 |row| row.get::<_, i64>(0),
3264 )?;
3265 require_eq(&foreign_key_failures, &0, "foreign key integrity")?;
3266 Ok(())
3267 }
3268}