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