1use std::path::{Path, PathBuf};
48
49use serde::{Deserialize, Serialize};
50
51use crate::backend::MemBackend;
52use crate::storage::{ArchiveBackend, FilesystemMemWriter, InMemoryBackend};
53use crate::workspace::{
54 McpSection, Mount, MountCapability, MountLifecycle, MountStorage, MutationsSection, Workspace,
55 WorkspaceSettings,
56};
57
58pub const WORKSPACE_STORE_DIR: &str = ".memstead";
67
68#[derive(Debug, thiserror::Error)]
73pub enum StoreError {
74 #[error("workspace store not found at {path}")]
78 NotInitialised { path: PathBuf },
79 #[error("workspace store io error at {path}: {source}")]
81 Io {
82 path: PathBuf,
83 #[source]
84 source: std::io::Error,
85 },
86 #[error("workspace store parse error at {path}: {message}")]
90 Parse { path: PathBuf, message: String },
91 #[error("workspace store format mismatch at {path}: expected {expected}, found {found}")]
94 FormatMismatch {
95 path: PathBuf,
96 expected: String,
97 found: String,
98 },
99 #[error(
104 "pre-rename workspace layout at {path} (found format {found}): migrate the workspace \
105 state in place — rewrite mounts.json to memstead-mounts-3 (record field `mem`, storage \
106 paths under mem-repo/), workspace.toml to memstead-git-branch-2 (tables `mem_management`, \
107 `cross_mem_links`), rename the gitdir container to mem-repo/, and move the metadata \
108 branch tree to mems/ — then retry"
109 )]
110 LegacyLayout { path: PathBuf, found: String },
111 #[error(
117 "legacy (pre-v1) projection config at {path}: this workspace predates binding format v1 \
118 — run `memstead projection migrate` to promote it to v1 bindings once"
119 )]
120 LegacyProjectionStore { path: PathBuf },
121 #[error(
124 "unsupported binding format version {version} at {path}: this engine understands v1 (version 1)"
125 )]
126 UnknownBindingVersion { path: PathBuf, version: i64 },
127 #[error("workspace store error: {0}")]
130 Other(String),
131}
132
133pub trait WorkspaceStoreAdapter: Send + Sync {
139 fn load(&self, workspace_root: &Path) -> Result<Workspace, StoreError>;
145
146 fn save_state(&self, workspace_root: &Path, workspace: &Workspace) -> Result<(), StoreError>;
152}
153
154#[derive(Debug, Default, Clone, Copy)]
160pub struct FileWorkspaceStore;
161
162impl FileWorkspaceStore {
163 pub fn new() -> Self {
166 Self
167 }
168
169 pub fn workspace_toml_path(workspace_root: &Path) -> PathBuf {
171 workspace_root
172 .join(WORKSPACE_STORE_DIR)
173 .join("workspace.toml")
174 }
175
176 pub fn mounts_json_path(workspace_root: &Path) -> PathBuf {
178 workspace_root
179 .join(WORKSPACE_STORE_DIR)
180 .join("state")
181 .join("mounts.json")
182 }
183}
184
185const WORKSPACE_TOML_FORMAT: &str = "memstead-git-branch-2";
186const WORKSPACE_TOML_FORMAT_LEGACY: &str = "memstead-git-branch-1";
190const MOUNTS_JSON_FORMAT_V3: &str = "memstead-mounts-3";
200const MOUNTS_JSON_FORMAT_LEGACY: [&str; 2] = ["memstead-mounts-1", "memstead-mounts-2"];
205
206#[derive(Deserialize)]
210struct MountsFormatProbe {
211 format: String,
212}
213
214fn check_workspace_toml_format(format: &str, toml_path: &Path) -> Result<(), StoreError> {
218 if format == WORKSPACE_TOML_FORMAT {
219 return Ok(());
220 }
221 if format == WORKSPACE_TOML_FORMAT_LEGACY {
222 return Err(StoreError::LegacyLayout {
223 path: toml_path.to_path_buf(),
224 found: format.to_string(),
225 });
226 }
227 Err(StoreError::FormatMismatch {
228 path: toml_path.to_path_buf(),
229 expected: WORKSPACE_TOML_FORMAT.to_string(),
230 found: format.to_string(),
231 })
232}
233
234fn absolutize_mount_path(value: PathBuf, workspace_root: &Path) -> PathBuf {
240 if value.is_absolute() {
241 value
242 } else {
243 workspace_root.join(value)
244 }
245}
246
247fn relativize_mount_path(value: &Path, workspace_root: &Path) -> PathBuf {
253 match value.strip_prefix(workspace_root) {
254 Ok(rel) => rel.to_path_buf(),
255 Err(_) => value.to_path_buf(),
256 }
257}
258
259pub fn is_workspace_root(dir: &Path) -> bool {
264 FileWorkspaceStore::workspace_toml_path(dir).is_file()
265}
266
267impl WorkspaceStoreAdapter for FileWorkspaceStore {
268 fn load(&self, workspace_root: &Path) -> Result<Workspace, StoreError> {
269 let memstead_dir = workspace_root.join(WORKSPACE_STORE_DIR);
270 if !memstead_dir.is_dir() {
271 return Err(StoreError::NotInitialised {
272 path: workspace_root.to_path_buf(),
273 });
274 }
275
276 let toml_path = Self::workspace_toml_path(workspace_root);
278 let toml_text = std::fs::read_to_string(&toml_path).map_err(|e| {
279 if e.kind() == std::io::ErrorKind::NotFound {
280 StoreError::NotInitialised {
281 path: workspace_root.to_path_buf(),
282 }
283 } else {
284 StoreError::Io {
285 path: toml_path.clone(),
286 source: e,
287 }
288 }
289 })?;
290 let toml_doc: WorkspaceTomlDoc =
291 toml::from_str(&toml_text).map_err(|e| StoreError::Parse {
292 path: toml_path.clone(),
293 message: e.to_string(),
294 })?;
295 check_workspace_toml_format(&toml_doc.format, &toml_path)?;
296
297 let mounts_path = Self::mounts_json_path(workspace_root);
301 let mounts: Vec<Mount> = match std::fs::read_to_string(&mounts_path) {
302 Ok(text) => {
303 let probe: MountsFormatProbe =
308 serde_json::from_str(&text).map_err(|e| StoreError::Parse {
309 path: mounts_path.clone(),
310 message: e.to_string(),
311 })?;
312 if MOUNTS_JSON_FORMAT_LEGACY.contains(&probe.format.as_str()) {
313 return Err(StoreError::LegacyLayout {
314 path: mounts_path,
315 found: probe.format,
316 });
317 }
318 if probe.format != MOUNTS_JSON_FORMAT_V3 {
319 return Err(StoreError::FormatMismatch {
320 path: mounts_path,
321 expected: MOUNTS_JSON_FORMAT_V3.to_string(),
322 found: probe.format,
323 });
324 }
325 let doc: MountsJsonDoc =
326 serde_json::from_str(&text).map_err(|e| StoreError::Parse {
327 path: mounts_path.clone(),
328 message: e.to_string(),
329 })?;
330 doc.mounts
331 .into_iter()
332 .map(|w| w.into_mount(workspace_root))
333 .collect()
334 }
335 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
336 Err(e) => {
337 return Err(StoreError::Io {
338 path: mounts_path,
339 source: e,
340 });
341 }
342 };
343
344 warn_if_legacy_schemas_dir(toml_doc.schemas_dir.as_deref());
345 let settings = build_settings(
346 toml_doc.mem_management,
347 toml_doc.cross_mem_links,
348 toml_doc.mcp,
349 toml_doc.mutations,
350 toml_doc.plugin,
351 )?;
352 Ok(Workspace { mounts, settings })
353 }
354
355 fn save_state(&self, workspace_root: &Path, workspace: &Workspace) -> Result<(), StoreError> {
356 let mounts_path = Self::mounts_json_path(workspace_root);
357 if let Some(parent) = mounts_path.parent() {
358 std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
359 path: parent.to_path_buf(),
360 source: e,
361 })?;
362 }
363 let doc = MountsJsonDoc {
364 format: MOUNTS_JSON_FORMAT_V3.to_string(),
365 mounts: workspace
366 .mounts
367 .iter()
368 .map(|m| MountWire::from_mount(m, workspace_root))
369 .collect(),
370 };
371 let text = serde_json::to_string_pretty(&doc).map_err(|e| StoreError::Parse {
372 path: mounts_path.clone(),
373 message: e.to_string(),
374 })?;
375 std::fs::write(&mounts_path, text).map_err(|e| StoreError::Io {
376 path: mounts_path,
377 source: e,
378 })?;
379 Ok(())
380 }
381}
382
383#[derive(Debug, Serialize, Deserialize)]
388#[serde(deny_unknown_fields)]
389struct WorkspaceTomlDoc {
390 format: String,
394 #[serde(default)]
398 persistence_adapter: PersistenceAdapterDecl,
399 #[serde(default)]
404 mem_management: MemManagementWire,
405 #[serde(default)]
412 cross_mem_links: toml::Table,
413 #[serde(default)]
420 schemas_dir: Option<std::path::PathBuf>,
421 #[serde(default)]
425 mcp: McpSection,
426 #[serde(default)]
429 mutations: MutationsSection,
430 #[serde(default)]
434 plugin: std::collections::HashMap<String, toml::Table>,
435}
436
437#[derive(Debug, Default, Serialize, Deserialize)]
441struct MemManagementWire {
442 #[serde(default)]
443 create: Vec<CreateRuleWire>,
444 #[serde(default)]
445 delete: Vec<DeleteRuleWire>,
446}
447
448#[derive(Debug, Serialize, Deserialize)]
458struct CreateRuleWire {
459 pattern: String,
460 #[serde(default)]
461 schemas: Vec<String>,
462 #[serde(default)]
463 default_cross_links: Option<toml::Value>,
464}
465
466#[derive(Debug, Serialize, Deserialize)]
469struct DeleteRuleWire {
470 pattern: String,
471}
472
473pub fn parse_workspace_settings(
491 workspace_root: &Path,
492) -> Result<crate::workspace::WorkspaceSettings, StoreError> {
493 let memstead_dir = workspace_root.join(WORKSPACE_STORE_DIR);
494 if !memstead_dir.is_dir() {
495 return Err(StoreError::NotInitialised {
496 path: workspace_root.to_path_buf(),
497 });
498 }
499 let toml_path = FileWorkspaceStore::workspace_toml_path(workspace_root);
500 let toml_text = std::fs::read_to_string(&toml_path).map_err(|e| {
501 if e.kind() == std::io::ErrorKind::NotFound {
502 StoreError::NotInitialised {
503 path: workspace_root.to_path_buf(),
504 }
505 } else {
506 StoreError::Io {
507 path: toml_path.clone(),
508 source: e,
509 }
510 }
511 })?;
512 let toml_doc: WorkspaceTomlDoc = toml::from_str(&toml_text).map_err(|e| StoreError::Parse {
513 path: toml_path.clone(),
514 message: e.to_string(),
515 })?;
516 check_workspace_toml_format(&toml_doc.format, &toml_path)?;
517 warn_if_legacy_schemas_dir(toml_doc.schemas_dir.as_deref());
518 build_settings(
519 toml_doc.mem_management,
520 toml_doc.cross_mem_links,
521 toml_doc.mcp,
522 toml_doc.mutations,
523 toml_doc.plugin,
524 )
525}
526
527fn build_settings(
533 vm: MemManagementWire,
534 cross_mem_links_raw: toml::Table,
535 mcp: McpSection,
536 mutations: MutationsSection,
537 plugin: std::collections::HashMap<String, toml::Table>,
538) -> Result<WorkspaceSettings, StoreError> {
539 let mut create_rules = Vec::with_capacity(vm.create.len());
540 for r in vm.create {
541 let default_cross_links = match r.default_cross_links {
542 None => None,
543 Some(value) => {
544 let location = format!(
545 "[[mem_management.create]] pattern={}.default_cross_links",
546 r.pattern
547 );
548 Some(parse_cross_link_value(&location, &value)?)
549 }
550 };
551 create_rules.push(crate::workspace::CreateRuleSetting {
552 pattern: r.pattern,
553 schemas: r.schemas,
554 default_cross_links,
555 });
556 }
557
558 let mut cross_mem_links = std::collections::BTreeMap::new();
559 for (mem, value) in &cross_mem_links_raw {
560 let location = format!("[cross_mem_links].{mem}");
561 let parsed = parse_cross_link_value(&location, value)?;
562 cross_mem_links.insert(mem.clone(), parsed);
563 }
564
565 Ok(WorkspaceSettings {
566 mem_create_rules: create_rules,
567 mem_delete_rules: vm
568 .delete
569 .into_iter()
570 .map(|r| crate::workspace::DeleteRuleSetting { pattern: r.pattern })
571 .collect(),
572 cross_mem_links,
573 mcp,
574 mutations,
575 plugin,
576 })
577}
578
579fn warn_if_legacy_schemas_dir(schemas_dir: Option<&std::path::Path>) {
586 if let Some(dir) = schemas_dir {
587 tracing::warn!(
588 "`schemas_dir` (= {:?}) in workspace.toml is retired and ignored — \
589 authored schemas are read from the fixed `<workspace>/.memstead/schemas/`. \
590 Remove the key to silence this warning.",
591 dir
592 );
593 }
594}
595
596fn parse_cross_link_value(
600 location: &str,
601 value: &toml::Value,
602) -> Result<memstead_schema::workspace_config::CrossLinkValue, StoreError> {
603 memstead_schema::workspace_config::CrossLinkValue::parse_toml(location, value).map_err(|e| {
604 StoreError::Parse {
605 path: std::path::PathBuf::from("workspace.toml"),
606 message: e.to_string(),
607 }
608 })
609}
610
611#[derive(Debug, Serialize, Deserialize)]
614struct PersistenceAdapterDecl {
615 name: String,
616}
617
618impl Default for PersistenceAdapterDecl {
619 fn default() -> Self {
620 Self {
621 name: "file-two-layer".to_string(),
622 }
623 }
624}
625
626#[derive(Debug, Serialize, Deserialize)]
630struct MountsJsonDoc {
631 format: String,
632 mounts: Vec<MountWire>,
633}
634
635#[derive(Debug, Serialize, Deserialize)]
640struct MountWire {
641 mem: String,
642 #[serde(default, skip_serializing_if = "Option::is_none")]
648 schema: Option<String>,
649 #[serde(default, skip_serializing_if = "Option::is_none")]
654 migration_target: Option<String>,
655 storage: MountStorageWire,
656 capability: CapabilityWire,
657 lifecycle: LifecycleWire,
658 cross_linkable: bool,
659}
660
661#[derive(Debug, Serialize, Deserialize)]
662#[serde(tag = "type", rename_all = "kebab-case")]
663enum MountStorageWire {
664 Folder {
665 path: PathBuf,
666 },
667 GitBranch {
668 gitdir: PathBuf,
669 branch: String,
670 },
671 Archive {
672 path: PathBuf,
673 },
674 InMemory,
682}
683
684#[derive(Debug, Serialize, Deserialize)]
685#[serde(rename_all = "kebab-case")]
686enum CapabilityWire {
687 ReadOnly,
688 Write,
689}
690
691#[derive(Debug, Serialize, Deserialize)]
692#[serde(rename_all = "kebab-case")]
693enum LifecycleWire {
694 Eager,
695 Lazy,
696}
697
698impl MountWire {
699 fn from_mount(m: &Mount, workspace_root: &Path) -> Self {
700 Self {
701 mem: m.mem.clone(),
702 schema: m.schema.as_ref().map(|s| s.to_string()),
703 migration_target: m.migration_target.as_ref().map(|t| t.to_string()),
704 storage: match &m.storage {
705 MountStorage::Folder { path } => MountStorageWire::Folder {
706 path: relativize_mount_path(path, workspace_root),
707 },
708 MountStorage::GitBranch { gitdir, branch } => MountStorageWire::GitBranch {
709 gitdir: relativize_mount_path(gitdir, workspace_root),
710 branch: branch.clone(),
711 },
712 MountStorage::Archive { path } => MountStorageWire::Archive {
713 path: relativize_mount_path(path, workspace_root),
714 },
715 MountStorage::InMemory => MountStorageWire::InMemory,
716 },
717 capability: match m.capability {
718 MountCapability::ReadOnly => CapabilityWire::ReadOnly,
719 MountCapability::Write => CapabilityWire::Write,
720 },
721 lifecycle: match m.lifecycle {
722 MountLifecycle::Eager => LifecycleWire::Eager,
723 MountLifecycle::Lazy => LifecycleWire::Lazy,
724 },
725 cross_linkable: m.cross_linkable,
726 }
727 }
728
729 fn into_mount(self, workspace_root: &Path) -> Mount {
730 Mount {
731 mem: self.mem,
732 schema: self.schema.map(|s| {
733 s.parse()
734 .expect("schema pin on disk must be `<name>@<version>`")
735 }),
736 migration_target: self.migration_target.map(|t| {
737 t.parse()
738 .expect("migration_target on disk must be `<name>@<version>`")
739 }),
740 storage: match self.storage {
741 MountStorageWire::Folder { path } => MountStorage::Folder {
742 path: absolutize_mount_path(path, workspace_root),
743 },
744 MountStorageWire::GitBranch { gitdir, branch } => MountStorage::GitBranch {
745 gitdir: absolutize_mount_path(gitdir, workspace_root),
746 branch,
747 },
748 MountStorageWire::Archive { path } => MountStorage::Archive {
749 path: absolutize_mount_path(path, workspace_root),
750 },
751 MountStorageWire::InMemory => MountStorage::InMemory,
752 },
753 capability: match self.capability {
754 CapabilityWire::ReadOnly => MountCapability::ReadOnly,
755 CapabilityWire::Write => MountCapability::Write,
756 },
757 lifecycle: match self.lifecycle {
758 LifecycleWire::Eager => MountLifecycle::Eager,
759 LifecycleWire::Lazy => MountLifecycle::Lazy,
760 },
761 cross_linkable: self.cross_linkable,
762 }
763 }
764}
765
766#[derive(Debug, thiserror::Error)]
768pub enum InstantiateError {
769 #[error(
776 "mem {mem}: git-branch backend requires the `mem-repo` feature; \
777 use `instantiate_full_backend` from memstead-git-branch, or rebuild with --features mem-repo"
778 )]
779 GitBranchRequiresMemRepoFeature { mem: String },
780}
781
782pub fn instantiate_lean_backend(mount: &Mount) -> Result<Box<dyn MemBackend>, InstantiateError> {
792 match &mount.storage {
793 MountStorage::Folder { path } => Ok(Box::new(FilesystemMemWriter::new(path.clone()))),
794 MountStorage::Archive { path } => Ok(Box::new(ArchiveBackend::new(path.clone()))),
795 MountStorage::InMemory => Ok(Box::new(InMemoryBackend::new())),
796 MountStorage::GitBranch { .. } => Err(InstantiateError::GitBranchRequiresMemRepoFeature {
797 mem: mount.mem.clone(),
798 }),
799 }
800}
801
802#[derive(Debug, Clone, Copy, PartialEq, Eq)]
810pub enum Layout {
811 Empty,
814 New,
817}
818
819pub fn detect_layout(workspace_root: &Path) -> Layout {
823 if is_workspace_root(workspace_root) {
824 Layout::New
825 } else {
826 Layout::Empty
827 }
828}
829
830pub fn standalone_workspace(workspace_root: &Path) -> Option<Workspace> {
850 let config = memstead_schema::config::load_and_validate(workspace_root).ok()?;
851 let schema = config.schema.clone()?;
852 let name = config.name.clone().unwrap_or_else(|| {
853 workspace_root
854 .file_name()
855 .map(|n| n.to_string_lossy().to_string())
856 .unwrap_or_else(|| "mem".to_string())
857 });
858 let mount = Mount {
859 mem: name,
860 schema: Some(schema),
861 storage: MountStorage::Folder {
862 path: workspace_root.to_path_buf(),
863 },
864 capability: MountCapability::Write,
865 lifecycle: MountLifecycle::Eager,
866 cross_linkable: false,
867 migration_target: None,
868 };
869 Some(Workspace {
870 mounts: vec![mount],
871 settings: WorkspaceSettings::default(),
872 })
873}
874
875#[cfg(test)]
876mod tests {
877 use super::*;
878 use memstead_schema::SchemaRef;
879 use std::io::Write as _;
880 use tempfile::TempDir;
881
882 fn pin(s: &str) -> SchemaRef {
883 s.parse().unwrap()
884 }
885
886 fn folder_mount(mem: &str, path: PathBuf) -> Mount {
887 Mount {
888 mem: mem.to_string(),
889 schema: Some(pin("default@1.0.0")),
890 storage: MountStorage::Folder { path },
891 capability: MountCapability::Write,
892 lifecycle: MountLifecycle::Eager,
893 cross_linkable: true,
894 migration_target: None,
895 }
896 }
897
898 fn write_workspace_toml(workspace_root: &Path, body: &str) {
899 let path = FileWorkspaceStore::workspace_toml_path(workspace_root);
900 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
901 std::fs::write(path, body).unwrap();
902 }
903
904 #[test]
905 fn load_returns_not_initialised_when_memstead_dir_absent() {
906 let tmp = TempDir::new().unwrap();
907 let store = FileWorkspaceStore::new();
908 let err = store.load(tmp.path()).unwrap_err();
909 assert!(matches!(err, StoreError::NotInitialised { .. }));
910 }
911
912 #[test]
918 fn parse_workspace_settings_reflects_cross_mem_links_edit() {
919 let tmp = TempDir::new().unwrap();
920 write_workspace_toml(
921 tmp.path(),
922 r#"
923format = "memstead-git-branch-2"
924
925[persistence_adapter]
926name = "file-two-layer"
927
928[cross_mem_links]
929team-a = ["team-b"]
930"#,
931 );
932 let settings = super::parse_workspace_settings(tmp.path()).unwrap();
933 assert!(
934 settings.cross_mem_links.contains_key("team-a"),
935 "initial parse must surface the team-a grant; got {:?}",
936 settings.cross_mem_links
937 );
938
939 write_workspace_toml(
941 tmp.path(),
942 r#"
943format = "memstead-git-branch-2"
944
945[persistence_adapter]
946name = "file-two-layer"
947
948[cross_mem_links]
949"#,
950 );
951 let refreshed = super::parse_workspace_settings(tmp.path()).unwrap();
952 assert!(
953 refreshed.cross_mem_links.is_empty(),
954 "refreshed parse must drop the team-a grant; got {:?}",
955 refreshed.cross_mem_links
956 );
957 }
958
959 #[test]
964 fn parse_workspace_settings_reflects_allowlist_edit() {
965 let tmp = TempDir::new().unwrap();
966 write_workspace_toml(
967 tmp.path(),
968 r#"
969format = "memstead-git-branch-2"
970
971[persistence_adapter]
972name = "file-two-layer"
973"#,
974 );
975 let initial = super::parse_workspace_settings(tmp.path()).unwrap();
976 assert!(initial.mem_create_rules.is_empty());
977
978 write_workspace_toml(
980 tmp.path(),
981 r#"
982format = "memstead-git-branch-2"
983
984[persistence_adapter]
985name = "file-two-layer"
986
987[[mem_management.create]]
988pattern = "test-*"
989schemas = ["default@1.0.0"]
990"#,
991 );
992 let refreshed = super::parse_workspace_settings(tmp.path()).unwrap();
993 assert_eq!(refreshed.mem_create_rules.len(), 1);
994 assert_eq!(refreshed.mem_create_rules[0].pattern, "test-*");
995 }
996
997 #[test]
998 fn load_returns_not_initialised_when_workspace_toml_missing() {
999 let tmp = TempDir::new().unwrap();
1000 std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
1001 let store = FileWorkspaceStore::new();
1002 let err = store.load(tmp.path()).unwrap_err();
1003 assert!(matches!(err, StoreError::NotInitialised { .. }));
1004 }
1005
1006 #[test]
1007 fn load_with_no_mounts_yields_empty_mount_list() {
1008 let tmp = TempDir::new().unwrap();
1009 write_workspace_toml(
1010 tmp.path(),
1011 r#"
1012format = "memstead-git-branch-2"
1013
1014[persistence_adapter]
1015name = "file-two-layer"
1016"#,
1017 );
1018 let store = FileWorkspaceStore::new();
1019 let workspace = store.load(tmp.path()).unwrap();
1020 assert!(workspace.mounts.is_empty());
1021 }
1022
1023 #[test]
1024 fn load_with_no_mem_management_yields_empty_settings() {
1025 let tmp = TempDir::new().unwrap();
1030 write_workspace_toml(
1031 tmp.path(),
1032 r#"
1033format = "memstead-git-branch-2"
1034
1035[persistence_adapter]
1036name = "file-two-layer"
1037"#,
1038 );
1039 let store = FileWorkspaceStore::new();
1040 let workspace = store.load(tmp.path()).unwrap();
1041 assert!(workspace.settings.mem_create_rules.is_empty());
1042 assert!(workspace.settings.mem_delete_rules.is_empty());
1043 assert!(workspace.settings.cross_mem_links.is_empty());
1044 }
1045
1046 #[test]
1047 fn load_picks_up_cross_mem_links_wildcard_and_list() {
1048 use memstead_schema::workspace_config::CrossLinkValue;
1053 let tmp = TempDir::new().unwrap();
1054 write_workspace_toml(
1055 tmp.path(),
1056 r#"
1057format = "memstead-git-branch-2"
1058
1059[persistence_adapter]
1060name = "file-two-layer"
1061
1062[cross_mem_links]
1063specs = "*"
1064engine = ["specs", "macos"]
1065locked = []
1066"#,
1067 );
1068 let store = FileWorkspaceStore::new();
1069 let workspace = store.load(tmp.path()).unwrap();
1070 let cvl = &workspace.settings.cross_mem_links;
1071 assert_eq!(cvl.len(), 3);
1072 assert_eq!(cvl.get("specs"), Some(&CrossLinkValue::Wildcard));
1073 assert_eq!(
1074 cvl.get("engine"),
1075 Some(&CrossLinkValue::List(vec![
1076 "specs".to_string(),
1077 "macos".to_string()
1078 ]))
1079 );
1080 assert_eq!(cvl.get("locked"), Some(&CrossLinkValue::List(vec![])));
1081 }
1082
1083 #[test]
1084 fn load_rejects_cross_mem_links_mixed_wildcard_and_names() {
1085 let tmp = TempDir::new().unwrap();
1090 write_workspace_toml(
1091 tmp.path(),
1092 r#"
1093format = "memstead-git-branch-2"
1094
1095[persistence_adapter]
1096name = "file-two-layer"
1097
1098[cross_mem_links]
1099specs = ["*", "engine"]
1100"#,
1101 );
1102 let store = FileWorkspaceStore::new();
1103 let err = store.load(tmp.path()).unwrap_err();
1104 match err {
1105 StoreError::Parse { message, .. } => {
1106 assert!(message.contains("[cross_mem_links].specs"));
1107 assert!(message.contains("wildcard"));
1108 }
1109 other => panic!("expected StoreError::Parse, got {other:?}"),
1110 }
1111 }
1112
1113 #[test]
1114 fn load_picks_up_default_cross_links_on_create_rule() {
1115 use memstead_schema::workspace_config::CrossLinkValue;
1119 let tmp = TempDir::new().unwrap();
1120 write_workspace_toml(
1121 tmp.path(),
1122 r#"
1123format = "memstead-git-branch-2"
1124
1125[persistence_adapter]
1126name = "file-two-layer"
1127
1128[[mem_management.create]]
1129pattern = "exec-*"
1130schemas = ["default"]
1131default_cross_links = "*"
1132"#,
1133 );
1134 let store = FileWorkspaceStore::new();
1135 let workspace = store.load(tmp.path()).unwrap();
1136 let rule = &workspace.settings.mem_create_rules[0];
1137 assert_eq!(rule.pattern, "exec-*");
1138 assert_eq!(rule.default_cross_links, Some(CrossLinkValue::Wildcard));
1139 }
1140
1141 #[test]
1142 fn load_picks_up_mem_management_create_and_delete_rules() {
1143 let tmp = TempDir::new().unwrap();
1147 write_workspace_toml(
1148 tmp.path(),
1149 r#"
1150format = "memstead-git-branch-2"
1151
1152[persistence_adapter]
1153name = "file-two-layer"
1154
1155[[mem_management.create]]
1156pattern = "exec-*"
1157schemas = ["default@1.0.0", "*"]
1158
1159[[mem_management.create]]
1160pattern = "scratch-*"
1161schemas = ["default"]
1162
1163[[mem_management.delete]]
1164pattern = "exec-*"
1165"#,
1166 );
1167 let store = FileWorkspaceStore::new();
1168 let workspace = store.load(tmp.path()).unwrap();
1169 assert_eq!(workspace.settings.mem_create_rules.len(), 2);
1170 assert_eq!(workspace.settings.mem_create_rules[0].pattern, "exec-*");
1171 assert_eq!(
1172 workspace.settings.mem_create_rules[0].schemas,
1173 vec!["default@1.0.0".to_string(), "*".to_string()]
1174 );
1175 assert_eq!(workspace.settings.mem_create_rules[1].pattern, "scratch-*");
1176 assert_eq!(workspace.settings.mem_delete_rules.len(), 1);
1177 assert_eq!(workspace.settings.mem_delete_rules[0].pattern, "exec-*");
1178 }
1179
1180 #[test]
1185 fn save_state_round_trips_migration_target() {
1186 let tmp = TempDir::new().unwrap();
1187 write_workspace_toml(
1188 tmp.path(),
1189 "\nformat = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1190 );
1191 let store = FileWorkspaceStore::new();
1192 let mut migrating = folder_mount("specs", PathBuf::from("/work/mem"));
1193 migrating.migration_target = Some(pin("mig-b@0.1.0"));
1194 let settled = folder_mount("other", PathBuf::from("/work/other"));
1195 let original = Workspace {
1196 mounts: vec![migrating, settled],
1197 settings: WorkspaceSettings::default(),
1198 };
1199 store.save_state(tmp.path(), &original).unwrap();
1200 let raw =
1201 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1202 assert!(
1203 raw.contains("mig-b@0.1.0"),
1204 "migration_target must persist: {raw}"
1205 );
1206 assert_eq!(
1207 raw.matches("migration_target").count(),
1208 1,
1209 "settled mounts must omit the key entirely: {raw}"
1210 );
1211 let loaded = store.load(tmp.path()).unwrap();
1212 assert_eq!(loaded.mounts[0].migration_target, Some(pin("mig-b@0.1.0")));
1213 assert_eq!(loaded.mounts[1].migration_target, None);
1214 }
1215
1216 #[test]
1217 fn save_state_then_load_round_trips_mount_list() {
1218 let tmp = TempDir::new().unwrap();
1219 write_workspace_toml(
1220 tmp.path(),
1221 r#"
1222format = "memstead-git-branch-2"
1223
1224[persistence_adapter]
1225name = "file-two-layer"
1226"#,
1227 );
1228 let store = FileWorkspaceStore::new();
1229 let original = Workspace {
1230 mounts: vec![
1231 folder_mount("specs", PathBuf::from("/work/mem")),
1232 Mount {
1233 mem: "engine".to_string(),
1234 schema: Some(pin("default@1.0.0")),
1235 storage: MountStorage::GitBranch {
1236 gitdir: PathBuf::from("/work/mem-repo/.git"),
1237 branch: "engine".to_string(),
1238 },
1239 capability: MountCapability::Write,
1240 lifecycle: MountLifecycle::Eager,
1241 cross_linkable: true,
1242 migration_target: None,
1243 },
1244 Mount {
1245 mem: "external".to_string(),
1246 schema: Some(pin("default@1.0.0")),
1247 storage: MountStorage::Archive {
1248 path: PathBuf::from("/deps/external.mem"),
1249 },
1250 capability: MountCapability::ReadOnly,
1251 lifecycle: MountLifecycle::Lazy,
1252 cross_linkable: false,
1253 migration_target: None,
1254 },
1255 ],
1256 settings: WorkspaceSettings::default(),
1257 };
1258 store.save_state(tmp.path(), &original).unwrap();
1259
1260 assert!(FileWorkspaceStore::mounts_json_path(tmp.path()).is_file());
1262
1263 let reloaded = store.load(tmp.path()).unwrap();
1264 assert_eq!(reloaded.mounts.len(), original.mounts.len());
1265 for (a, b) in reloaded.mounts.iter().zip(original.mounts.iter()) {
1266 assert_eq!(a.mem, b.mem);
1267 assert_eq!(a.schema, b.schema);
1268 assert_eq!(a.capability, b.capability);
1269 assert_eq!(a.lifecycle, b.lifecycle);
1270 assert_eq!(a.cross_linkable, b.cross_linkable);
1271 assert_eq!(a.storage, b.storage);
1272 }
1273 }
1274
1275 #[test]
1276 fn save_state_round_trips_unset_schema_assertion() {
1277 let tmp = TempDir::new().unwrap();
1282 write_workspace_toml(
1283 tmp.path(),
1284 r#"
1285format = "memstead-git-branch-2"
1286
1287[persistence_adapter]
1288name = "file-two-layer"
1289"#,
1290 );
1291 let store = FileWorkspaceStore::new();
1292 let original = Workspace {
1293 mounts: vec![Mount {
1294 mem: "foreign".to_string(),
1295 schema: None,
1296 storage: MountStorage::Folder {
1297 path: tmp.path().join("foreign"),
1298 },
1299 capability: MountCapability::ReadOnly,
1300 lifecycle: MountLifecycle::Eager,
1301 cross_linkable: false,
1302 migration_target: None,
1303 }],
1304 settings: WorkspaceSettings::default(),
1305 };
1306 store.save_state(tmp.path(), &original).unwrap();
1307
1308 let raw =
1310 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1311 assert!(
1312 !raw.contains("\"schema\""),
1313 "unset schema assertion must omit the key on the wire; got:\n{raw}"
1314 );
1315
1316 let reloaded = store.load(tmp.path()).unwrap();
1318 assert_eq!(reloaded.mounts.len(), 1);
1319 assert_eq!(reloaded.mounts[0].schema, None);
1320 }
1321
1322 #[test]
1323 fn save_state_does_not_touch_workspace_toml() {
1324 let tmp = TempDir::new().unwrap();
1325 let original_body = r#"
1326format = "memstead-git-branch-2"
1327
1328[persistence_adapter]
1329name = "file-two-layer"
1330"#;
1331 write_workspace_toml(tmp.path(), original_body);
1332 let store = FileWorkspaceStore::new();
1333 let workspace = Workspace::default();
1334 store.save_state(tmp.path(), &workspace).unwrap();
1335 let toml_after =
1337 std::fs::read_to_string(FileWorkspaceStore::workspace_toml_path(tmp.path())).unwrap();
1338 assert_eq!(toml_after, original_body);
1339 }
1340
1341 #[test]
1342 fn save_state_writes_paths_relative_to_workspace_root() {
1343 let tmp = TempDir::new().unwrap();
1344 write_workspace_toml(
1345 tmp.path(),
1346 r#"
1347format = "memstead-git-branch-2"
1348
1349[persistence_adapter]
1350name = "file-two-layer"
1351"#,
1352 );
1353 let store = FileWorkspaceStore::new();
1354 let workspace = Workspace {
1355 mounts: vec![
1356 Mount {
1357 mem: "engine".to_string(),
1358 schema: Some(pin("default@1.0.0")),
1359 storage: MountStorage::GitBranch {
1360 gitdir: tmp.path().join("mem-repo").join(".git"),
1361 branch: "engine".to_string(),
1362 },
1363 capability: MountCapability::Write,
1364 lifecycle: MountLifecycle::Eager,
1365 cross_linkable: true,
1366 migration_target: None,
1367 },
1368 Mount {
1369 mem: "external".to_string(),
1370 schema: Some(pin("default@1.0.0")),
1371 storage: MountStorage::Archive {
1372 path: PathBuf::from("/global/cache/external.mem"),
1373 },
1374 capability: MountCapability::ReadOnly,
1375 lifecycle: MountLifecycle::Lazy,
1376 cross_linkable: false,
1377 migration_target: None,
1378 },
1379 ],
1380 settings: WorkspaceSettings::default(),
1381 };
1382 store.save_state(tmp.path(), &workspace).unwrap();
1383
1384 let on_disk =
1385 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1386 assert!(on_disk.contains("\"memstead-mounts-3\""));
1388 assert!(
1390 on_disk.contains("\"mem-repo/.git\""),
1391 "expected relative gitdir, got: {on_disk}"
1392 );
1393 assert!(
1394 !on_disk.contains(tmp.path().to_str().unwrap()),
1395 "in-workspace path should not include the absolute tmp prefix: {on_disk}"
1396 );
1397 assert!(on_disk.contains("\"/global/cache/external.mem\""));
1399
1400 let reloaded = store.load(tmp.path()).unwrap();
1402 match &reloaded.mounts[0].storage {
1403 MountStorage::GitBranch { gitdir, .. } => {
1404 assert_eq!(gitdir, &tmp.path().join("mem-repo").join(".git"));
1405 }
1406 other => panic!("expected GitBranch storage, got {other:?}"),
1407 }
1408 match &reloaded.mounts[1].storage {
1409 MountStorage::Archive { path } => {
1410 assert_eq!(path, &PathBuf::from("/global/cache/external.mem"));
1411 }
1412 other => panic!("expected Archive storage, got {other:?}"),
1413 }
1414 }
1415
1416 #[test]
1417 fn load_absolute_inside_root_path_then_save_rewrites_relative() {
1418 let tmp = TempDir::new().unwrap();
1419 write_workspace_toml(
1420 tmp.path(),
1421 r#"
1422format = "memstead-git-branch-2"
1423
1424[persistence_adapter]
1425name = "file-two-layer"
1426"#,
1427 );
1428 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1433 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1434 let abs_gitdir = tmp.path().join("mem-repo").join(".git");
1435 let mounts_body = format!(
1436 r#"{{
1437 "format": "memstead-mounts-3",
1438 "mounts": [
1439 {{
1440 "mem": "engine",
1441 "schema": "default@1.0.0",
1442 "storage": {{
1443 "type": "git-branch",
1444 "gitdir": "{}",
1445 "branch": "engine"
1446 }},
1447 "capability": "write",
1448 "lifecycle": "eager",
1449 "cross_linkable": true
1450 }}
1451 ]
1452}}"#,
1453 abs_gitdir.to_str().unwrap()
1454 );
1455 std::fs::write(&mounts_path, &mounts_body).unwrap();
1456
1457 let store = FileWorkspaceStore::new();
1458 let workspace = store.load(tmp.path()).unwrap();
1461 match &workspace.mounts[0].storage {
1462 MountStorage::GitBranch { gitdir, .. } => assert_eq!(gitdir, &abs_gitdir),
1463 other => panic!("expected GitBranch storage, got {other:?}"),
1464 }
1465
1466 store.save_state(tmp.path(), &workspace).unwrap();
1469 let on_disk = std::fs::read_to_string(&mounts_path).unwrap();
1470 assert!(on_disk.contains("\"memstead-mounts-3\""));
1471 assert!(on_disk.contains("\"mem-repo/.git\""));
1472 assert!(!on_disk.contains(tmp.path().to_str().unwrap()));
1473 }
1474
1475 #[test]
1484 fn save_state_preserves_refs_heads_branch_form() {
1485 let tmp = TempDir::new().unwrap();
1486 write_workspace_toml(
1487 tmp.path(),
1488 r#"
1489format = "memstead-git-branch-2"
1490
1491[persistence_adapter]
1492name = "file-two-layer"
1493"#,
1494 );
1495 let store = FileWorkspaceStore::new();
1496 let original = Workspace {
1497 mounts: vec![Mount {
1498 mem: "engine".to_string(),
1499 schema: Some(pin("default@1.0.0")),
1500 storage: MountStorage::GitBranch {
1501 gitdir: tmp.path().join("mem-repo").join(".git"),
1502 branch: "refs/heads/demo/engine".to_string(),
1503 },
1504 capability: MountCapability::Write,
1505 lifecycle: MountLifecycle::Eager,
1506 cross_linkable: true,
1507 migration_target: None,
1508 }],
1509 settings: WorkspaceSettings::default(),
1510 };
1511 store.save_state(tmp.path(), &original).unwrap();
1512
1513 let on_disk =
1514 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1515 assert!(
1516 on_disk.contains("\"branch\": \"refs/heads/demo/engine\""),
1517 "expected fully-qualified ref on disk, got: {on_disk}"
1518 );
1519
1520 let reloaded = store.load(tmp.path()).unwrap();
1521 match &reloaded.mounts[0].storage {
1522 MountStorage::GitBranch { branch, .. } => {
1523 assert_eq!(branch, "refs/heads/demo/engine");
1524 }
1525 other => panic!("expected GitBranch storage, got {other:?}"),
1526 }
1527 }
1528
1529 #[test]
1539 fn load_preserves_short_form_branch_without_rewrite() {
1540 let tmp = TempDir::new().unwrap();
1541 write_workspace_toml(
1542 tmp.path(),
1543 r#"
1544format = "memstead-git-branch-2"
1545
1546[persistence_adapter]
1547name = "file-two-layer"
1548"#,
1549 );
1550 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1551 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1552 std::fs::write(
1553 &mounts_path,
1554 r#"{
1555 "format": "memstead-mounts-3",
1556 "mounts": [
1557 {
1558 "mem": "engine",
1559 "schema": "default@1.0.0",
1560 "storage": {
1561 "type": "git-branch",
1562 "gitdir": "mem-repo/.git",
1563 "branch": "demo/engine"
1564 },
1565 "capability": "write",
1566 "lifecycle": "eager",
1567 "cross_linkable": true
1568 }
1569 ]
1570}"#,
1571 )
1572 .unwrap();
1573
1574 let store = FileWorkspaceStore::new();
1575 let workspace = store.load(tmp.path()).unwrap();
1576 match &workspace.mounts[0].storage {
1577 MountStorage::GitBranch { branch, .. } => {
1578 assert_eq!(
1579 branch, "demo/engine",
1580 "reader must not silently rewrite short-form branch"
1581 );
1582 }
1583 other => panic!("expected GitBranch storage, got {other:?}"),
1584 }
1585 }
1586
1587 #[test]
1588 fn load_rejects_format_version_mismatch_on_toml() {
1589 let tmp = TempDir::new().unwrap();
1590 write_workspace_toml(
1591 tmp.path(),
1592 r#"
1593format = "memstead-git-branch-99"
1594
1595[persistence_adapter]
1596name = "file-two-layer"
1597"#,
1598 );
1599 let store = FileWorkspaceStore::new();
1600 let err = store.load(tmp.path()).unwrap_err();
1601 match err {
1602 StoreError::FormatMismatch {
1603 expected, found, ..
1604 } => {
1605 assert_eq!(expected, "memstead-git-branch-2");
1606 assert_eq!(found, "memstead-git-branch-99");
1607 }
1608 other => panic!("expected FormatMismatch, got {other:?}"),
1609 }
1610 }
1611
1612 #[test]
1613 fn load_rejects_format_version_mismatch_on_mounts_json() {
1614 let tmp = TempDir::new().unwrap();
1615 write_workspace_toml(
1616 tmp.path(),
1617 r#"
1618format = "memstead-git-branch-2"
1619
1620[persistence_adapter]
1621name = "file-two-layer"
1622"#,
1623 );
1624 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1625 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1626 std::fs::write(
1627 &mounts_path,
1628 r#"{ "format": "memstead-mounts-99", "mounts": [] }"#,
1629 )
1630 .unwrap();
1631 let store = FileWorkspaceStore::new();
1632 let err = store.load(tmp.path()).unwrap_err();
1633 assert!(matches!(err, StoreError::FormatMismatch { .. }));
1634 }
1635
1636 #[test]
1639 fn load_refuses_pre_rename_toml_as_legacy_layout() {
1640 let tmp = TempDir::new().unwrap();
1641 write_workspace_toml(
1642 tmp.path(),
1643 r#"
1644format = "memstead-git-branch-1"
1645
1646[persistence_adapter]
1647name = "file-two-layer"
1648"#,
1649 );
1650 let store = FileWorkspaceStore::new();
1651 let err = store.load(tmp.path()).unwrap_err();
1652 match err {
1653 StoreError::LegacyLayout { found, .. } => {
1654 assert_eq!(found, "memstead-git-branch-1");
1655 }
1656 other => panic!("expected LegacyLayout, got {other:?}"),
1657 }
1658 }
1659
1660 #[test]
1667 fn load_refuses_pre_rename_mounts_json_as_legacy_layout() {
1668 for legacy in ["memstead-mounts-1", "memstead-mounts-2"] {
1669 let tmp = TempDir::new().unwrap();
1670 write_workspace_toml(
1671 tmp.path(),
1672 r#"
1673format = "memstead-git-branch-2"
1674
1675[persistence_adapter]
1676name = "file-two-layer"
1677"#,
1678 );
1679 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1680 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1681 std::fs::write(
1682 &mounts_path,
1683 format!(
1684 r#"{{ "format": "{legacy}", "mounts": [{{ "unit": "notes", "storage": {{ "type": "folder", "path": "notes" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#
1685 ),
1686 )
1687 .unwrap();
1688 let store = FileWorkspaceStore::new();
1689 let err = store.load(tmp.path()).unwrap_err();
1690 match err {
1691 StoreError::LegacyLayout { found, .. } => assert_eq!(found, legacy),
1692 other => panic!("expected LegacyLayout for {legacy}, got {other:?}"),
1693 }
1694 }
1695 }
1696
1697 #[test]
1698 fn load_rejects_invalid_toml() {
1699 let tmp = TempDir::new().unwrap();
1700 write_workspace_toml(tmp.path(), "this is not = valid = toml");
1701 let store = FileWorkspaceStore::new();
1702 let err = store.load(tmp.path()).unwrap_err();
1703 assert!(matches!(err, StoreError::Parse { .. }));
1704 }
1705
1706 #[test]
1707 fn load_rejects_unknown_top_level_key() {
1708 let tmp = TempDir::new().unwrap();
1712 write_workspace_toml(
1713 tmp.path(),
1714 "format = \"memstead-git-branch-2\"\nnonexistent_key = true\n",
1715 );
1716 let store = FileWorkspaceStore::new();
1717 let err = store.load(tmp.path()).unwrap_err();
1718 match err {
1719 StoreError::Parse { message, .. } => {
1720 assert!(
1721 message.contains("nonexistent_key"),
1722 "refusal must name the unknown key: {message}"
1723 );
1724 }
1725 other => panic!("expected Parse error, got {other:?}"),
1726 }
1727 }
1728
1729 #[test]
1730 fn instantiate_lean_backend_handles_folder_archive_and_in_memory() {
1731 let tmp = TempDir::new().unwrap();
1732 let folder = folder_mount("local", tmp.path().to_path_buf());
1733 let archive_path = tmp.path().join("ext.mem");
1734 let f = std::fs::File::create(&archive_path).unwrap();
1736 let mut w = zip::ZipWriter::new(f);
1737 w.start_file("a.md", zip::write::SimpleFileOptions::default())
1738 .unwrap();
1739 w.write_all(b"# a").unwrap();
1740 w.finish().unwrap();
1741 let archive = Mount {
1742 mem: "external".to_string(),
1743 schema: Some(pin("default@1.0.0")),
1744 storage: MountStorage::Archive { path: archive_path },
1745 capability: MountCapability::ReadOnly,
1746 lifecycle: MountLifecycle::Lazy,
1747 cross_linkable: false,
1748 migration_target: None,
1749 };
1750 let in_memory = Mount {
1751 mem: "session".to_string(),
1752 schema: Some(pin("default@1.0.0")),
1753 storage: MountStorage::InMemory,
1754 capability: MountCapability::Write,
1755 lifecycle: MountLifecycle::Eager,
1756 cross_linkable: true,
1757 migration_target: None,
1758 };
1759
1760 let _: Box<dyn MemBackend> = instantiate_lean_backend(&folder).unwrap();
1761 let _: Box<dyn MemBackend> = instantiate_lean_backend(&archive).unwrap();
1762 let _: Box<dyn MemBackend> = instantiate_lean_backend(&in_memory).unwrap();
1765 }
1766
1767 #[test]
1772 fn save_state_round_trips_in_memory_variant_unambiguously() {
1773 let tmp = TempDir::new().unwrap();
1774 write_workspace_toml(
1775 tmp.path(),
1776 r#"
1777format = "memstead-git-branch-2"
1778
1779[persistence_adapter]
1780name = "file-two-layer"
1781"#,
1782 );
1783 let store = FileWorkspaceStore::new();
1784 let original = Workspace {
1785 mounts: vec![
1786 folder_mount("local", PathBuf::from("/work/mem")),
1787 Mount {
1788 mem: "session".to_string(),
1789 schema: Some(pin("default@1.0.0")),
1790 storage: MountStorage::InMemory,
1791 capability: MountCapability::Write,
1792 lifecycle: MountLifecycle::Eager,
1793 cross_linkable: true,
1794 migration_target: None,
1795 },
1796 ],
1797 settings: WorkspaceSettings::default(),
1798 };
1799 store.save_state(tmp.path(), &original).unwrap();
1800
1801 let raw =
1803 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1804 assert!(raw.contains("\"type\": \"in-memory\""), "got: {raw}");
1805
1806 let reloaded = store.load(tmp.path()).unwrap();
1807 assert_eq!(reloaded.mounts.len(), 2);
1808 let session = reloaded
1811 .mounts
1812 .iter()
1813 .find(|m| m.mem == "session")
1814 .expect("session mount survives reload");
1815 assert_eq!(session.storage, MountStorage::InMemory);
1816 let local = reloaded.mounts.iter().find(|m| m.mem == "local").unwrap();
1819 assert!(matches!(local.storage, MountStorage::Folder { .. }));
1820 }
1821
1822 #[test]
1823 fn instantiate_lean_backend_rejects_git_branch_with_typed_error() {
1824 let mount = Mount {
1825 mem: "engine".to_string(),
1826 schema: Some(pin("default@1.0.0")),
1827 storage: MountStorage::GitBranch {
1828 gitdir: PathBuf::from("/some/path/.git"),
1829 branch: "engine".to_string(),
1830 },
1831 capability: MountCapability::Write,
1832 lifecycle: MountLifecycle::Eager,
1833 cross_linkable: true,
1834 migration_target: None,
1835 };
1836 match instantiate_lean_backend(&mount) {
1840 Err(InstantiateError::GitBranchRequiresMemRepoFeature { mem }) => {
1841 assert_eq!(mem, "engine");
1842 }
1843 Ok(_) => panic!("expected GitBranchRequiresMemRepoFeature, got Ok"),
1844 }
1845 }
1846
1847 #[test]
1848 fn detect_layout_returns_empty_for_unrecognised_workspace() {
1849 let tmp = TempDir::new().unwrap();
1850 assert_eq!(detect_layout(tmp.path()), Layout::Empty);
1851 }
1852 #[test]
1853 fn detect_layout_returns_new_when_workspace_toml_present() {
1854 let tmp = TempDir::new().unwrap();
1855 write_workspace_toml(
1856 tmp.path(),
1857 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1858 );
1859 assert_eq!(detect_layout(tmp.path()), Layout::New);
1860 }
1861}