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} — run `memstead mem-repo init` first")]
78 NotInitialised { path: PathBuf },
79 #[error(
81 "workspace store io error at {path}: {source} — no memstead command repairs this; \
82 check filesystem permissions and disk state"
83 )]
84 Io {
85 path: PathBuf,
86 #[source]
87 source: std::io::Error,
88 },
89 #[error(
93 "workspace store parse error at {path}: {message} — no memstead command repairs this; \
94 fix the named file by hand or restore it from version control"
95 )]
96 Parse { path: PathBuf, message: String },
97 #[error(
100 "workspace store format mismatch at {path}: expected {expected}, found {found} — \
101 no memstead command repairs this; use an engine version whose format matches the file"
102 )]
103 FormatMismatch {
104 path: PathBuf,
105 expected: String,
106 found: String,
107 },
108 #[error(
113 "pre-rename workspace layout at {path} (found format {found}): migrate the workspace \
114 state in place — rewrite mounts.json to memstead-mounts-3 (record field `mem`, storage \
115 paths under mem-repo/), workspace.toml to memstead-git-branch-2 (tables `mem_management`, \
116 `cross_mem_links`), rename the gitdir container to mem-repo/, and move the metadata \
117 branch tree to mems/ — then retry"
118 )]
119 LegacyLayout { path: PathBuf, found: String },
120 #[error(
128 "legacy (pre-v2) projection config at {path}: this workspace predates the single-record \
129 binding format v2 — run `memstead projection migrate` to convert it in place once"
130 )]
131 LegacyProjectionStore { path: PathBuf },
132 #[error(
136 "unsupported binding format version {version} at {path}: this engine understands v2 (version 2)"
137 )]
138 UnknownBindingVersion { path: PathBuf, version: i64 },
139 #[error("workspace store error: {0}")]
142 Other(String),
143}
144
145impl StoreError {
146 pub fn code(&self) -> &'static str {
153 match self {
154 StoreError::NotInitialised { .. } => "WORKSPACE_NOT_INITIALISED",
158 StoreError::Io { .. } => "WORKSPACE_STORE_IO",
159 StoreError::Parse { .. } => "WORKSPACE_STORE_PARSE",
160 StoreError::FormatMismatch { .. } => "WORKSPACE_STORE_FORMAT_MISMATCH",
161 StoreError::LegacyLayout { .. } => "LEGACY_WORKSPACE_LAYOUT",
162 StoreError::LegacyProjectionStore { .. } => "PROJECTION_STORE_LEGACY",
163 StoreError::UnknownBindingVersion { .. } => "UNKNOWN_BINDING_VERSION",
164 StoreError::Other(_) => "WORKSPACE_STORE_ERROR",
165 }
166 }
167}
168
169pub trait WorkspaceStoreAdapter: Send + Sync {
175 fn load(&self, workspace_root: &Path) -> Result<Workspace, StoreError>;
181
182 fn save_state(&self, workspace_root: &Path, workspace: &Workspace) -> Result<(), StoreError>;
188}
189
190#[derive(Debug, Default, Clone, Copy)]
196pub struct FileWorkspaceStore;
197
198impl FileWorkspaceStore {
199 pub fn new() -> Self {
202 Self
203 }
204
205 pub fn workspace_toml_path(workspace_root: &Path) -> PathBuf {
207 workspace_root
208 .join(WORKSPACE_STORE_DIR)
209 .join("workspace.toml")
210 }
211
212 pub fn mounts_json_path(workspace_root: &Path) -> PathBuf {
214 workspace_root
215 .join(WORKSPACE_STORE_DIR)
216 .join("state")
217 .join("mounts.json")
218 }
219}
220
221const WORKSPACE_TOML_FORMAT: &str = "memstead-git-branch-2";
222const WORKSPACE_TOML_FORMAT_LEGACY: &str = "memstead-git-branch-1";
226const MOUNTS_JSON_FORMAT_V3: &str = "memstead-mounts-3";
236const MOUNTS_JSON_FORMAT_LEGACY: [&str; 2] = ["memstead-mounts-1", "memstead-mounts-2"];
241
242#[derive(Deserialize)]
246struct MountsFormatProbe {
247 format: String,
248}
249
250fn check_workspace_toml_format(format: &str, toml_path: &Path) -> Result<(), StoreError> {
254 if format == WORKSPACE_TOML_FORMAT {
255 return Ok(());
256 }
257 if format == WORKSPACE_TOML_FORMAT_LEGACY {
258 return Err(StoreError::LegacyLayout {
259 path: toml_path.to_path_buf(),
260 found: format.to_string(),
261 });
262 }
263 Err(StoreError::FormatMismatch {
264 path: toml_path.to_path_buf(),
265 expected: WORKSPACE_TOML_FORMAT.to_string(),
266 found: format.to_string(),
267 })
268}
269
270fn absolutize_mount_path(value: PathBuf, workspace_root: &Path) -> PathBuf {
276 if value.is_absolute() {
277 value
278 } else {
279 workspace_root.join(value)
280 }
281}
282
283fn relativize_mount_path(value: &Path, workspace_root: &Path) -> PathBuf {
289 match value.strip_prefix(workspace_root) {
290 Ok(rel) => rel.to_path_buf(),
291 Err(_) => value.to_path_buf(),
292 }
293}
294
295pub fn is_workspace_root(dir: &Path) -> bool {
300 FileWorkspaceStore::workspace_toml_path(dir).is_file()
301}
302
303pub fn is_mem_repo_shaped(workspace_root: &Path) -> bool {
312 workspace_root.join("mem-repo").join(".git").is_dir()
313}
314
315pub fn workspace_shape_label(workspace_root: &Path) -> &'static str {
321 if is_mem_repo_shaped(workspace_root) {
322 "mem-repo"
323 } else {
324 "filesystem-mem"
325 }
326}
327
328impl WorkspaceStoreAdapter for FileWorkspaceStore {
329 fn load(&self, workspace_root: &Path) -> Result<Workspace, StoreError> {
330 let memstead_dir = workspace_root.join(WORKSPACE_STORE_DIR);
331 if !memstead_dir.is_dir() {
332 return Err(StoreError::NotInitialised {
333 path: workspace_root.to_path_buf(),
334 });
335 }
336
337 let toml_path = Self::workspace_toml_path(workspace_root);
339 let toml_text = std::fs::read_to_string(&toml_path).map_err(|e| {
340 if e.kind() == std::io::ErrorKind::NotFound {
341 StoreError::NotInitialised {
342 path: workspace_root.to_path_buf(),
343 }
344 } else {
345 StoreError::Io {
346 path: toml_path.clone(),
347 source: e,
348 }
349 }
350 })?;
351 let toml_doc: WorkspaceTomlDoc =
352 toml::from_str(&toml_text).map_err(|e| StoreError::Parse {
353 path: toml_path.clone(),
354 message: e.to_string(),
355 })?;
356 check_workspace_toml_format(&toml_doc.format, &toml_path)?;
357
358 let mounts_path = Self::mounts_json_path(workspace_root);
362 let mounts: Vec<Mount> = match std::fs::read_to_string(&mounts_path) {
363 Ok(text) => {
364 let probe: MountsFormatProbe =
369 serde_json::from_str(&text).map_err(|e| StoreError::Parse {
370 path: mounts_path.clone(),
371 message: e.to_string(),
372 })?;
373 if MOUNTS_JSON_FORMAT_LEGACY.contains(&probe.format.as_str()) {
374 return Err(StoreError::LegacyLayout {
375 path: mounts_path,
376 found: probe.format,
377 });
378 }
379 if probe.format != MOUNTS_JSON_FORMAT_V3 {
380 return Err(StoreError::FormatMismatch {
381 path: mounts_path,
382 expected: MOUNTS_JSON_FORMAT_V3.to_string(),
383 found: probe.format,
384 });
385 }
386 let doc: MountsJsonDoc =
387 serde_json::from_str(&text).map_err(|e| StoreError::Parse {
388 path: mounts_path.clone(),
389 message: e.to_string(),
390 })?;
391 doc.mounts
392 .into_iter()
393 .map(|w| w.into_mount(workspace_root))
394 .collect()
395 }
396 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
397 Err(e) => {
398 return Err(StoreError::Io {
399 path: mounts_path,
400 source: e,
401 });
402 }
403 };
404
405 warn_if_legacy_schemas_dir(toml_doc.schemas_dir.as_deref());
406 let settings = build_settings(
407 toml_doc.mem_management,
408 toml_doc.cross_mem_links,
409 toml_doc.mcp,
410 toml_doc.mutations,
411 toml_doc.plugin,
412 )?;
413 Ok(Workspace { mounts, settings })
414 }
415
416 fn save_state(&self, workspace_root: &Path, workspace: &Workspace) -> Result<(), StoreError> {
417 let mounts_path = Self::mounts_json_path(workspace_root);
418 if let Some(parent) = mounts_path.parent() {
419 std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
420 path: parent.to_path_buf(),
421 source: e,
422 })?;
423 }
424 let doc = MountsJsonDoc {
425 format: MOUNTS_JSON_FORMAT_V3.to_string(),
426 mounts: workspace
427 .mounts
428 .iter()
429 .map(|m| MountWire::from_mount(m, workspace_root))
430 .collect(),
431 };
432 let text = serde_json::to_string_pretty(&doc).map_err(|e| StoreError::Parse {
433 path: mounts_path.clone(),
434 message: e.to_string(),
435 })?;
436 std::fs::write(&mounts_path, text).map_err(|e| StoreError::Io {
437 path: mounts_path,
438 source: e,
439 })?;
440 Ok(())
441 }
442}
443
444#[derive(Debug, Serialize, Deserialize)]
449#[serde(deny_unknown_fields)]
450struct WorkspaceTomlDoc {
451 format: String,
455 #[serde(default)]
459 persistence_adapter: PersistenceAdapterDecl,
460 #[serde(default)]
465 mem_management: MemManagementWire,
466 #[serde(default)]
473 cross_mem_links: toml::Table,
474 #[serde(default)]
481 schemas_dir: Option<std::path::PathBuf>,
482 #[serde(default)]
486 mcp: McpSection,
487 #[serde(default)]
490 mutations: MutationsSection,
491 #[serde(default)]
495 plugin: std::collections::HashMap<String, toml::Table>,
496}
497
498#[derive(Debug, Default, Serialize, Deserialize)]
502struct MemManagementWire {
503 #[serde(default)]
504 create: Vec<CreateRuleWire>,
505 #[serde(default)]
506 delete: Vec<DeleteRuleWire>,
507}
508
509#[derive(Debug, Serialize, Deserialize)]
519struct CreateRuleWire {
520 pattern: String,
521 #[serde(default)]
522 schemas: Vec<String>,
523 #[serde(default)]
524 default_cross_links: Option<toml::Value>,
525}
526
527#[derive(Debug, Serialize, Deserialize)]
530struct DeleteRuleWire {
531 pattern: String,
532}
533
534pub fn parse_workspace_settings(
552 workspace_root: &Path,
553) -> Result<crate::workspace::WorkspaceSettings, StoreError> {
554 let memstead_dir = workspace_root.join(WORKSPACE_STORE_DIR);
555 if !memstead_dir.is_dir() {
556 return Err(StoreError::NotInitialised {
557 path: workspace_root.to_path_buf(),
558 });
559 }
560 let toml_path = FileWorkspaceStore::workspace_toml_path(workspace_root);
561 let toml_text = std::fs::read_to_string(&toml_path).map_err(|e| {
562 if e.kind() == std::io::ErrorKind::NotFound {
563 StoreError::NotInitialised {
564 path: workspace_root.to_path_buf(),
565 }
566 } else {
567 StoreError::Io {
568 path: toml_path.clone(),
569 source: e,
570 }
571 }
572 })?;
573 let toml_doc: WorkspaceTomlDoc = toml::from_str(&toml_text).map_err(|e| StoreError::Parse {
574 path: toml_path.clone(),
575 message: e.to_string(),
576 })?;
577 check_workspace_toml_format(&toml_doc.format, &toml_path)?;
578 warn_if_legacy_schemas_dir(toml_doc.schemas_dir.as_deref());
579 build_settings(
580 toml_doc.mem_management,
581 toml_doc.cross_mem_links,
582 toml_doc.mcp,
583 toml_doc.mutations,
584 toml_doc.plugin,
585 )
586}
587
588fn build_settings(
594 vm: MemManagementWire,
595 cross_mem_links_raw: toml::Table,
596 mcp: McpSection,
597 mutations: MutationsSection,
598 plugin: std::collections::HashMap<String, toml::Table>,
599) -> Result<WorkspaceSettings, StoreError> {
600 let mut create_rules = Vec::with_capacity(vm.create.len());
601 for r in vm.create {
602 let default_cross_links = match r.default_cross_links {
603 None => None,
604 Some(value) => {
605 let location = format!(
606 "[[mem_management.create]] pattern={}.default_cross_links",
607 r.pattern
608 );
609 Some(parse_cross_link_value(&location, &value)?)
610 }
611 };
612 create_rules.push(crate::workspace::CreateRuleSetting {
613 pattern: r.pattern,
614 schemas: r.schemas,
615 default_cross_links,
616 });
617 }
618
619 let mut cross_mem_links = std::collections::BTreeMap::new();
620 for (mem, value) in &cross_mem_links_raw {
621 let location = format!("[cross_mem_links].{mem}");
622 let parsed = parse_cross_link_value(&location, value)?;
623 cross_mem_links.insert(mem.clone(), parsed);
624 }
625
626 Ok(WorkspaceSettings {
627 mem_create_rules: create_rules,
628 mem_delete_rules: vm
629 .delete
630 .into_iter()
631 .map(|r| crate::workspace::DeleteRuleSetting { pattern: r.pattern })
632 .collect(),
633 cross_mem_links,
634 mcp,
635 mutations,
636 plugin,
637 })
638}
639
640fn warn_if_legacy_schemas_dir(schemas_dir: Option<&std::path::Path>) {
647 if let Some(dir) = schemas_dir {
648 tracing::warn!(
649 "`schemas_dir` (= {:?}) in workspace.toml is retired and ignored — \
650 authored schemas are read from the fixed `<workspace>/.memstead/schemas/`. \
651 Remove the key to silence this warning.",
652 dir
653 );
654 }
655}
656
657fn parse_cross_link_value(
661 location: &str,
662 value: &toml::Value,
663) -> Result<memstead_schema::workspace_config::CrossLinkValue, StoreError> {
664 memstead_schema::workspace_config::CrossLinkValue::parse_toml(location, value).map_err(|e| {
665 StoreError::Parse {
666 path: std::path::PathBuf::from("workspace.toml"),
667 message: e.to_string(),
668 }
669 })
670}
671
672#[derive(Debug, Serialize, Deserialize)]
675struct PersistenceAdapterDecl {
676 name: String,
677}
678
679impl Default for PersistenceAdapterDecl {
680 fn default() -> Self {
681 Self {
682 name: "file-two-layer".to_string(),
683 }
684 }
685}
686
687#[derive(Debug, Serialize, Deserialize)]
691struct MountsJsonDoc {
692 format: String,
693 mounts: Vec<MountWire>,
694}
695
696#[derive(Debug, Serialize, Deserialize)]
701struct MountWire {
702 mem: String,
703 #[serde(default, skip_serializing_if = "Option::is_none")]
709 schema: Option<String>,
710 #[serde(default, skip_serializing_if = "Option::is_none")]
715 migration_target: Option<String>,
716 storage: MountStorageWire,
717 capability: CapabilityWire,
718 lifecycle: LifecycleWire,
719 cross_linkable: bool,
720}
721
722#[derive(Debug, Serialize, Deserialize)]
723#[serde(tag = "type", rename_all = "kebab-case")]
724enum MountStorageWire {
725 Folder {
726 path: PathBuf,
727 },
728 GitBranch {
729 gitdir: PathBuf,
730 branch: String,
731 },
732 Archive {
733 path: PathBuf,
734 },
735 InMemory,
743}
744
745#[derive(Debug, Serialize, Deserialize)]
746#[serde(rename_all = "kebab-case")]
747enum CapabilityWire {
748 ReadOnly,
749 Write,
750}
751
752#[derive(Debug, Serialize, Deserialize)]
753#[serde(rename_all = "kebab-case")]
754enum LifecycleWire {
755 Eager,
756 Lazy,
757}
758
759impl MountWire {
760 fn from_mount(m: &Mount, workspace_root: &Path) -> Self {
761 Self {
762 mem: m.mem.clone(),
763 schema: m.schema.as_ref().map(|s| s.to_string()),
764 migration_target: m.migration_target.as_ref().map(|t| t.to_string()),
765 storage: match &m.storage {
766 MountStorage::Folder { path } => MountStorageWire::Folder {
767 path: relativize_mount_path(path, workspace_root),
768 },
769 MountStorage::GitBranch { gitdir, branch } => MountStorageWire::GitBranch {
770 gitdir: relativize_mount_path(gitdir, workspace_root),
771 branch: branch.clone(),
772 },
773 MountStorage::Archive { path } => MountStorageWire::Archive {
774 path: relativize_mount_path(path, workspace_root),
775 },
776 MountStorage::InMemory => MountStorageWire::InMemory,
777 },
778 capability: match m.capability {
779 MountCapability::ReadOnly => CapabilityWire::ReadOnly,
780 MountCapability::Write => CapabilityWire::Write,
781 },
782 lifecycle: match m.lifecycle {
783 MountLifecycle::Eager => LifecycleWire::Eager,
784 MountLifecycle::Lazy => LifecycleWire::Lazy,
785 },
786 cross_linkable: m.cross_linkable,
787 }
788 }
789
790 fn into_mount(self, workspace_root: &Path) -> Mount {
791 Mount {
792 mem: self.mem,
793 schema: self.schema.map(|s| {
794 s.parse()
795 .expect("schema pin on disk must be `<name>@<version>`")
796 }),
797 migration_target: self.migration_target.map(|t| {
798 t.parse()
799 .expect("migration_target on disk must be `<name>@<version>`")
800 }),
801 storage: match self.storage {
802 MountStorageWire::Folder { path } => MountStorage::Folder {
803 path: absolutize_mount_path(path, workspace_root),
804 },
805 MountStorageWire::GitBranch { gitdir, branch } => MountStorage::GitBranch {
806 gitdir: absolutize_mount_path(gitdir, workspace_root),
807 branch,
808 },
809 MountStorageWire::Archive { path } => MountStorage::Archive {
810 path: absolutize_mount_path(path, workspace_root),
811 },
812 MountStorageWire::InMemory => MountStorage::InMemory,
813 },
814 capability: match self.capability {
815 CapabilityWire::ReadOnly => MountCapability::ReadOnly,
816 CapabilityWire::Write => MountCapability::Write,
817 },
818 lifecycle: match self.lifecycle {
819 LifecycleWire::Eager => MountLifecycle::Eager,
820 LifecycleWire::Lazy => MountLifecycle::Lazy,
821 },
822 cross_linkable: self.cross_linkable,
823 }
824 }
825}
826
827#[derive(Debug, thiserror::Error)]
829pub enum InstantiateError {
830 #[error(
837 "mem {mem}: git-branch backend requires the `mem-repo` feature; \
838 use `instantiate_full_backend` from memstead-git-branch, or rebuild with --features mem-repo"
839 )]
840 GitBranchRequiresMemRepoFeature { mem: String },
841}
842
843impl InstantiateError {
844 pub fn code(&self) -> &'static str {
850 match self {
851 InstantiateError::GitBranchRequiresMemRepoFeature { .. } => {
852 "UNSUPPORTED_WORKSPACE_SHAPE"
853 }
854 }
855 }
856}
857
858pub fn instantiate_lean_backend(mount: &Mount) -> Result<Box<dyn MemBackend>, InstantiateError> {
868 match &mount.storage {
869 MountStorage::Folder { path } => Ok(Box::new(FilesystemMemWriter::new(path.clone()))),
870 MountStorage::Archive { path } => Ok(Box::new(ArchiveBackend::new(path.clone()))),
871 MountStorage::InMemory => Ok(Box::new(InMemoryBackend::new())),
872 MountStorage::GitBranch { .. } => Err(InstantiateError::GitBranchRequiresMemRepoFeature {
873 mem: mount.mem.clone(),
874 }),
875 }
876}
877
878#[derive(Debug, Clone, Copy, PartialEq, Eq)]
886pub enum Layout {
887 Empty,
890 New,
893}
894
895pub fn detect_layout(workspace_root: &Path) -> Layout {
899 if is_workspace_root(workspace_root) {
900 Layout::New
901 } else {
902 Layout::Empty
903 }
904}
905
906pub fn standalone_workspace(workspace_root: &Path) -> Option<Workspace> {
926 let config = memstead_schema::config::load_and_validate(workspace_root).ok()?;
927 let schema = config.schema.clone()?;
928 let name = config.name.clone().unwrap_or_else(|| {
929 workspace_root
930 .file_name()
931 .map(|n| n.to_string_lossy().to_string())
932 .unwrap_or_else(|| "mem".to_string())
933 });
934 let mount = Mount {
935 mem: name,
936 schema: Some(schema),
937 storage: MountStorage::Folder {
938 path: workspace_root.to_path_buf(),
939 },
940 capability: MountCapability::Write,
941 lifecycle: MountLifecycle::Eager,
942 cross_linkable: false,
943 migration_target: None,
944 };
945 Some(Workspace {
946 mounts: vec![mount],
947 settings: WorkspaceSettings::default(),
948 })
949}
950
951#[cfg(test)]
952mod tests {
953 use super::*;
954 use memstead_schema::SchemaRef;
955 use std::io::Write as _;
956 use tempfile::TempDir;
957
958 fn pin(s: &str) -> SchemaRef {
959 s.parse().unwrap()
960 }
961
962 fn folder_mount(mem: &str, path: PathBuf) -> Mount {
963 Mount {
964 mem: mem.to_string(),
965 schema: Some(pin("default@1.0.0")),
966 storage: MountStorage::Folder { path },
967 capability: MountCapability::Write,
968 lifecycle: MountLifecycle::Eager,
969 cross_linkable: true,
970 migration_target: None,
971 }
972 }
973
974 fn write_workspace_toml(workspace_root: &Path, body: &str) {
975 let path = FileWorkspaceStore::workspace_toml_path(workspace_root);
976 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
977 std::fs::write(path, body).unwrap();
978 }
979
980 #[test]
981 fn load_returns_not_initialised_when_memstead_dir_absent() {
982 let tmp = TempDir::new().unwrap();
983 let store = FileWorkspaceStore::new();
984 let err = store.load(tmp.path()).unwrap_err();
985 assert!(matches!(err, StoreError::NotInitialised { .. }));
986 }
987
988 #[test]
994 fn parse_workspace_settings_reflects_cross_mem_links_edit() {
995 let tmp = TempDir::new().unwrap();
996 write_workspace_toml(
997 tmp.path(),
998 r#"
999format = "memstead-git-branch-2"
1000
1001[persistence_adapter]
1002name = "file-two-layer"
1003
1004[cross_mem_links]
1005team-a = ["team-b"]
1006"#,
1007 );
1008 let settings = super::parse_workspace_settings(tmp.path()).unwrap();
1009 assert!(
1010 settings.cross_mem_links.contains_key("team-a"),
1011 "initial parse must surface the team-a grant; got {:?}",
1012 settings.cross_mem_links
1013 );
1014
1015 write_workspace_toml(
1017 tmp.path(),
1018 r#"
1019format = "memstead-git-branch-2"
1020
1021[persistence_adapter]
1022name = "file-two-layer"
1023
1024[cross_mem_links]
1025"#,
1026 );
1027 let refreshed = super::parse_workspace_settings(tmp.path()).unwrap();
1028 assert!(
1029 refreshed.cross_mem_links.is_empty(),
1030 "refreshed parse must drop the team-a grant; got {:?}",
1031 refreshed.cross_mem_links
1032 );
1033 }
1034
1035 #[test]
1040 fn parse_workspace_settings_reflects_allowlist_edit() {
1041 let tmp = TempDir::new().unwrap();
1042 write_workspace_toml(
1043 tmp.path(),
1044 r#"
1045format = "memstead-git-branch-2"
1046
1047[persistence_adapter]
1048name = "file-two-layer"
1049"#,
1050 );
1051 let initial = super::parse_workspace_settings(tmp.path()).unwrap();
1052 assert!(initial.mem_create_rules.is_empty());
1053
1054 write_workspace_toml(
1056 tmp.path(),
1057 r#"
1058format = "memstead-git-branch-2"
1059
1060[persistence_adapter]
1061name = "file-two-layer"
1062
1063[[mem_management.create]]
1064pattern = "test-*"
1065schemas = ["default@1.0.0"]
1066"#,
1067 );
1068 let refreshed = super::parse_workspace_settings(tmp.path()).unwrap();
1069 assert_eq!(refreshed.mem_create_rules.len(), 1);
1070 assert_eq!(refreshed.mem_create_rules[0].pattern, "test-*");
1071 }
1072
1073 #[test]
1074 fn load_returns_not_initialised_when_workspace_toml_missing() {
1075 let tmp = TempDir::new().unwrap();
1076 std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
1077 let store = FileWorkspaceStore::new();
1078 let err = store.load(tmp.path()).unwrap_err();
1079 assert!(matches!(err, StoreError::NotInitialised { .. }));
1080 }
1081
1082 #[test]
1083 fn load_with_no_mounts_yields_empty_mount_list() {
1084 let tmp = TempDir::new().unwrap();
1085 write_workspace_toml(
1086 tmp.path(),
1087 r#"
1088format = "memstead-git-branch-2"
1089
1090[persistence_adapter]
1091name = "file-two-layer"
1092"#,
1093 );
1094 let store = FileWorkspaceStore::new();
1095 let workspace = store.load(tmp.path()).unwrap();
1096 assert!(workspace.mounts.is_empty());
1097 }
1098
1099 #[test]
1100 fn load_with_no_mem_management_yields_empty_settings() {
1101 let tmp = TempDir::new().unwrap();
1106 write_workspace_toml(
1107 tmp.path(),
1108 r#"
1109format = "memstead-git-branch-2"
1110
1111[persistence_adapter]
1112name = "file-two-layer"
1113"#,
1114 );
1115 let store = FileWorkspaceStore::new();
1116 let workspace = store.load(tmp.path()).unwrap();
1117 assert!(workspace.settings.mem_create_rules.is_empty());
1118 assert!(workspace.settings.mem_delete_rules.is_empty());
1119 assert!(workspace.settings.cross_mem_links.is_empty());
1120 }
1121
1122 #[test]
1123 fn load_picks_up_cross_mem_links_wildcard_and_list() {
1124 use memstead_schema::workspace_config::CrossLinkValue;
1129 let tmp = TempDir::new().unwrap();
1130 write_workspace_toml(
1131 tmp.path(),
1132 r#"
1133format = "memstead-git-branch-2"
1134
1135[persistence_adapter]
1136name = "file-two-layer"
1137
1138[cross_mem_links]
1139specs = "*"
1140engine = ["specs", "macos"]
1141locked = []
1142"#,
1143 );
1144 let store = FileWorkspaceStore::new();
1145 let workspace = store.load(tmp.path()).unwrap();
1146 let cvl = &workspace.settings.cross_mem_links;
1147 assert_eq!(cvl.len(), 3);
1148 assert_eq!(cvl.get("specs"), Some(&CrossLinkValue::Wildcard));
1149 assert_eq!(
1150 cvl.get("engine"),
1151 Some(&CrossLinkValue::List(vec![
1152 "specs".to_string(),
1153 "macos".to_string()
1154 ]))
1155 );
1156 assert_eq!(cvl.get("locked"), Some(&CrossLinkValue::List(vec![])));
1157 }
1158
1159 #[test]
1160 fn load_rejects_cross_mem_links_mixed_wildcard_and_names() {
1161 let tmp = TempDir::new().unwrap();
1166 write_workspace_toml(
1167 tmp.path(),
1168 r#"
1169format = "memstead-git-branch-2"
1170
1171[persistence_adapter]
1172name = "file-two-layer"
1173
1174[cross_mem_links]
1175specs = ["*", "engine"]
1176"#,
1177 );
1178 let store = FileWorkspaceStore::new();
1179 let err = store.load(tmp.path()).unwrap_err();
1180 match err {
1181 StoreError::Parse { message, .. } => {
1182 assert!(message.contains("[cross_mem_links].specs"));
1183 assert!(message.contains("wildcard"));
1184 }
1185 other => panic!("expected StoreError::Parse, got {other:?}"),
1186 }
1187 }
1188
1189 #[test]
1190 fn load_picks_up_default_cross_links_on_create_rule() {
1191 use memstead_schema::workspace_config::CrossLinkValue;
1195 let tmp = TempDir::new().unwrap();
1196 write_workspace_toml(
1197 tmp.path(),
1198 r#"
1199format = "memstead-git-branch-2"
1200
1201[persistence_adapter]
1202name = "file-two-layer"
1203
1204[[mem_management.create]]
1205pattern = "exec-*"
1206schemas = ["default"]
1207default_cross_links = "*"
1208"#,
1209 );
1210 let store = FileWorkspaceStore::new();
1211 let workspace = store.load(tmp.path()).unwrap();
1212 let rule = &workspace.settings.mem_create_rules[0];
1213 assert_eq!(rule.pattern, "exec-*");
1214 assert_eq!(rule.default_cross_links, Some(CrossLinkValue::Wildcard));
1215 }
1216
1217 #[test]
1218 fn load_picks_up_mem_management_create_and_delete_rules() {
1219 let tmp = TempDir::new().unwrap();
1223 write_workspace_toml(
1224 tmp.path(),
1225 r#"
1226format = "memstead-git-branch-2"
1227
1228[persistence_adapter]
1229name = "file-two-layer"
1230
1231[[mem_management.create]]
1232pattern = "exec-*"
1233schemas = ["default@1.0.0", "*"]
1234
1235[[mem_management.create]]
1236pattern = "scratch-*"
1237schemas = ["default"]
1238
1239[[mem_management.delete]]
1240pattern = "exec-*"
1241"#,
1242 );
1243 let store = FileWorkspaceStore::new();
1244 let workspace = store.load(tmp.path()).unwrap();
1245 assert_eq!(workspace.settings.mem_create_rules.len(), 2);
1246 assert_eq!(workspace.settings.mem_create_rules[0].pattern, "exec-*");
1247 assert_eq!(
1248 workspace.settings.mem_create_rules[0].schemas,
1249 vec!["default@1.0.0".to_string(), "*".to_string()]
1250 );
1251 assert_eq!(workspace.settings.mem_create_rules[1].pattern, "scratch-*");
1252 assert_eq!(workspace.settings.mem_delete_rules.len(), 1);
1253 assert_eq!(workspace.settings.mem_delete_rules[0].pattern, "exec-*");
1254 }
1255
1256 #[test]
1261 fn save_state_round_trips_migration_target() {
1262 let tmp = TempDir::new().unwrap();
1263 write_workspace_toml(
1264 tmp.path(),
1265 "\nformat = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1266 );
1267 let store = FileWorkspaceStore::new();
1268 let mut migrating = folder_mount("specs", PathBuf::from("/work/mem"));
1269 migrating.migration_target = Some(pin("mig-b@0.1.0"));
1270 let settled = folder_mount("other", PathBuf::from("/work/other"));
1271 let original = Workspace {
1272 mounts: vec![migrating, settled],
1273 settings: WorkspaceSettings::default(),
1274 };
1275 store.save_state(tmp.path(), &original).unwrap();
1276 let raw =
1277 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1278 assert!(
1279 raw.contains("mig-b@0.1.0"),
1280 "migration_target must persist: {raw}"
1281 );
1282 assert_eq!(
1283 raw.matches("migration_target").count(),
1284 1,
1285 "settled mounts must omit the key entirely: {raw}"
1286 );
1287 let loaded = store.load(tmp.path()).unwrap();
1288 assert_eq!(loaded.mounts[0].migration_target, Some(pin("mig-b@0.1.0")));
1289 assert_eq!(loaded.mounts[1].migration_target, None);
1290 }
1291
1292 #[test]
1293 fn save_state_then_load_round_trips_mount_list() {
1294 let tmp = TempDir::new().unwrap();
1295 write_workspace_toml(
1296 tmp.path(),
1297 r#"
1298format = "memstead-git-branch-2"
1299
1300[persistence_adapter]
1301name = "file-two-layer"
1302"#,
1303 );
1304 let store = FileWorkspaceStore::new();
1305 let original = Workspace {
1306 mounts: vec![
1307 folder_mount("specs", PathBuf::from("/work/mem")),
1308 Mount {
1309 mem: "engine".to_string(),
1310 schema: Some(pin("default@1.0.0")),
1311 storage: MountStorage::GitBranch {
1312 gitdir: PathBuf::from("/work/mem-repo/.git"),
1313 branch: "engine".to_string(),
1314 },
1315 capability: MountCapability::Write,
1316 lifecycle: MountLifecycle::Eager,
1317 cross_linkable: true,
1318 migration_target: None,
1319 },
1320 Mount {
1321 mem: "external".to_string(),
1322 schema: Some(pin("default@1.0.0")),
1323 storage: MountStorage::Archive {
1324 path: PathBuf::from("/deps/external.mem"),
1325 },
1326 capability: MountCapability::ReadOnly,
1327 lifecycle: MountLifecycle::Lazy,
1328 cross_linkable: false,
1329 migration_target: None,
1330 },
1331 ],
1332 settings: WorkspaceSettings::default(),
1333 };
1334 store.save_state(tmp.path(), &original).unwrap();
1335
1336 assert!(FileWorkspaceStore::mounts_json_path(tmp.path()).is_file());
1338
1339 let reloaded = store.load(tmp.path()).unwrap();
1340 assert_eq!(reloaded.mounts.len(), original.mounts.len());
1341 for (a, b) in reloaded.mounts.iter().zip(original.mounts.iter()) {
1342 assert_eq!(a.mem, b.mem);
1343 assert_eq!(a.schema, b.schema);
1344 assert_eq!(a.capability, b.capability);
1345 assert_eq!(a.lifecycle, b.lifecycle);
1346 assert_eq!(a.cross_linkable, b.cross_linkable);
1347 assert_eq!(a.storage, b.storage);
1348 }
1349 }
1350
1351 #[test]
1352 fn save_state_round_trips_unset_schema_assertion() {
1353 let tmp = TempDir::new().unwrap();
1358 write_workspace_toml(
1359 tmp.path(),
1360 r#"
1361format = "memstead-git-branch-2"
1362
1363[persistence_adapter]
1364name = "file-two-layer"
1365"#,
1366 );
1367 let store = FileWorkspaceStore::new();
1368 let original = Workspace {
1369 mounts: vec![Mount {
1370 mem: "foreign".to_string(),
1371 schema: None,
1372 storage: MountStorage::Folder {
1373 path: tmp.path().join("foreign"),
1374 },
1375 capability: MountCapability::ReadOnly,
1376 lifecycle: MountLifecycle::Eager,
1377 cross_linkable: false,
1378 migration_target: None,
1379 }],
1380 settings: WorkspaceSettings::default(),
1381 };
1382 store.save_state(tmp.path(), &original).unwrap();
1383
1384 let raw =
1386 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1387 assert!(
1388 !raw.contains("\"schema\""),
1389 "unset schema assertion must omit the key on the wire; got:\n{raw}"
1390 );
1391
1392 let reloaded = store.load(tmp.path()).unwrap();
1394 assert_eq!(reloaded.mounts.len(), 1);
1395 assert_eq!(reloaded.mounts[0].schema, None);
1396 }
1397
1398 #[test]
1399 fn save_state_does_not_touch_workspace_toml() {
1400 let tmp = TempDir::new().unwrap();
1401 let original_body = r#"
1402format = "memstead-git-branch-2"
1403
1404[persistence_adapter]
1405name = "file-two-layer"
1406"#;
1407 write_workspace_toml(tmp.path(), original_body);
1408 let store = FileWorkspaceStore::new();
1409 let workspace = Workspace::default();
1410 store.save_state(tmp.path(), &workspace).unwrap();
1411 let toml_after =
1413 std::fs::read_to_string(FileWorkspaceStore::workspace_toml_path(tmp.path())).unwrap();
1414 assert_eq!(toml_after, original_body);
1415 }
1416
1417 #[test]
1418 fn save_state_writes_paths_relative_to_workspace_root() {
1419 let tmp = TempDir::new().unwrap();
1420 write_workspace_toml(
1421 tmp.path(),
1422 r#"
1423format = "memstead-git-branch-2"
1424
1425[persistence_adapter]
1426name = "file-two-layer"
1427"#,
1428 );
1429 let store = FileWorkspaceStore::new();
1430 let workspace = Workspace {
1431 mounts: vec![
1432 Mount {
1433 mem: "engine".to_string(),
1434 schema: Some(pin("default@1.0.0")),
1435 storage: MountStorage::GitBranch {
1436 gitdir: tmp.path().join("mem-repo").join(".git"),
1437 branch: "engine".to_string(),
1438 },
1439 capability: MountCapability::Write,
1440 lifecycle: MountLifecycle::Eager,
1441 cross_linkable: true,
1442 migration_target: None,
1443 },
1444 Mount {
1445 mem: "external".to_string(),
1446 schema: Some(pin("default@1.0.0")),
1447 storage: MountStorage::Archive {
1448 path: PathBuf::from("/global/cache/external.mem"),
1449 },
1450 capability: MountCapability::ReadOnly,
1451 lifecycle: MountLifecycle::Lazy,
1452 cross_linkable: false,
1453 migration_target: None,
1454 },
1455 ],
1456 settings: WorkspaceSettings::default(),
1457 };
1458 store.save_state(tmp.path(), &workspace).unwrap();
1459
1460 let on_disk =
1461 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1462 assert!(on_disk.contains("\"memstead-mounts-3\""));
1464 assert!(
1466 on_disk.contains("\"mem-repo/.git\""),
1467 "expected relative gitdir, got: {on_disk}"
1468 );
1469 assert!(
1470 !on_disk.contains(tmp.path().to_str().unwrap()),
1471 "in-workspace path should not include the absolute tmp prefix: {on_disk}"
1472 );
1473 assert!(on_disk.contains("\"/global/cache/external.mem\""));
1475
1476 let reloaded = store.load(tmp.path()).unwrap();
1478 match &reloaded.mounts[0].storage {
1479 MountStorage::GitBranch { gitdir, .. } => {
1480 assert_eq!(gitdir, &tmp.path().join("mem-repo").join(".git"));
1481 }
1482 other => panic!("expected GitBranch storage, got {other:?}"),
1483 }
1484 match &reloaded.mounts[1].storage {
1485 MountStorage::Archive { path } => {
1486 assert_eq!(path, &PathBuf::from("/global/cache/external.mem"));
1487 }
1488 other => panic!("expected Archive storage, got {other:?}"),
1489 }
1490 }
1491
1492 #[test]
1493 fn load_absolute_inside_root_path_then_save_rewrites_relative() {
1494 let tmp = TempDir::new().unwrap();
1495 write_workspace_toml(
1496 tmp.path(),
1497 r#"
1498format = "memstead-git-branch-2"
1499
1500[persistence_adapter]
1501name = "file-two-layer"
1502"#,
1503 );
1504 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1509 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1510 let abs_gitdir = tmp.path().join("mem-repo").join(".git");
1511 let mounts_body = format!(
1512 r#"{{
1513 "format": "memstead-mounts-3",
1514 "mounts": [
1515 {{
1516 "mem": "engine",
1517 "schema": "default@1.0.0",
1518 "storage": {{
1519 "type": "git-branch",
1520 "gitdir": "{}",
1521 "branch": "engine"
1522 }},
1523 "capability": "write",
1524 "lifecycle": "eager",
1525 "cross_linkable": true
1526 }}
1527 ]
1528}}"#,
1529 abs_gitdir.to_str().unwrap()
1530 );
1531 std::fs::write(&mounts_path, &mounts_body).unwrap();
1532
1533 let store = FileWorkspaceStore::new();
1534 let workspace = store.load(tmp.path()).unwrap();
1537 match &workspace.mounts[0].storage {
1538 MountStorage::GitBranch { gitdir, .. } => assert_eq!(gitdir, &abs_gitdir),
1539 other => panic!("expected GitBranch storage, got {other:?}"),
1540 }
1541
1542 store.save_state(tmp.path(), &workspace).unwrap();
1545 let on_disk = std::fs::read_to_string(&mounts_path).unwrap();
1546 assert!(on_disk.contains("\"memstead-mounts-3\""));
1547 assert!(on_disk.contains("\"mem-repo/.git\""));
1548 assert!(!on_disk.contains(tmp.path().to_str().unwrap()));
1549 }
1550
1551 #[test]
1560 fn save_state_preserves_refs_heads_branch_form() {
1561 let tmp = TempDir::new().unwrap();
1562 write_workspace_toml(
1563 tmp.path(),
1564 r#"
1565format = "memstead-git-branch-2"
1566
1567[persistence_adapter]
1568name = "file-two-layer"
1569"#,
1570 );
1571 let store = FileWorkspaceStore::new();
1572 let original = Workspace {
1573 mounts: vec![Mount {
1574 mem: "engine".to_string(),
1575 schema: Some(pin("default@1.0.0")),
1576 storage: MountStorage::GitBranch {
1577 gitdir: tmp.path().join("mem-repo").join(".git"),
1578 branch: "refs/heads/demo/engine".to_string(),
1579 },
1580 capability: MountCapability::Write,
1581 lifecycle: MountLifecycle::Eager,
1582 cross_linkable: true,
1583 migration_target: None,
1584 }],
1585 settings: WorkspaceSettings::default(),
1586 };
1587 store.save_state(tmp.path(), &original).unwrap();
1588
1589 let on_disk =
1590 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1591 assert!(
1592 on_disk.contains("\"branch\": \"refs/heads/demo/engine\""),
1593 "expected fully-qualified ref on disk, got: {on_disk}"
1594 );
1595
1596 let reloaded = store.load(tmp.path()).unwrap();
1597 match &reloaded.mounts[0].storage {
1598 MountStorage::GitBranch { branch, .. } => {
1599 assert_eq!(branch, "refs/heads/demo/engine");
1600 }
1601 other => panic!("expected GitBranch storage, got {other:?}"),
1602 }
1603 }
1604
1605 #[test]
1615 fn load_preserves_short_form_branch_without_rewrite() {
1616 let tmp = TempDir::new().unwrap();
1617 write_workspace_toml(
1618 tmp.path(),
1619 r#"
1620format = "memstead-git-branch-2"
1621
1622[persistence_adapter]
1623name = "file-two-layer"
1624"#,
1625 );
1626 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1627 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1628 std::fs::write(
1629 &mounts_path,
1630 r#"{
1631 "format": "memstead-mounts-3",
1632 "mounts": [
1633 {
1634 "mem": "engine",
1635 "schema": "default@1.0.0",
1636 "storage": {
1637 "type": "git-branch",
1638 "gitdir": "mem-repo/.git",
1639 "branch": "demo/engine"
1640 },
1641 "capability": "write",
1642 "lifecycle": "eager",
1643 "cross_linkable": true
1644 }
1645 ]
1646}"#,
1647 )
1648 .unwrap();
1649
1650 let store = FileWorkspaceStore::new();
1651 let workspace = store.load(tmp.path()).unwrap();
1652 match &workspace.mounts[0].storage {
1653 MountStorage::GitBranch { branch, .. } => {
1654 assert_eq!(
1655 branch, "demo/engine",
1656 "reader must not silently rewrite short-form branch"
1657 );
1658 }
1659 other => panic!("expected GitBranch storage, got {other:?}"),
1660 }
1661 }
1662
1663 #[test]
1664 fn load_rejects_format_version_mismatch_on_toml() {
1665 let tmp = TempDir::new().unwrap();
1666 write_workspace_toml(
1667 tmp.path(),
1668 r#"
1669format = "memstead-git-branch-99"
1670
1671[persistence_adapter]
1672name = "file-two-layer"
1673"#,
1674 );
1675 let store = FileWorkspaceStore::new();
1676 let err = store.load(tmp.path()).unwrap_err();
1677 match err {
1678 StoreError::FormatMismatch {
1679 expected, found, ..
1680 } => {
1681 assert_eq!(expected, "memstead-git-branch-2");
1682 assert_eq!(found, "memstead-git-branch-99");
1683 }
1684 other => panic!("expected FormatMismatch, got {other:?}"),
1685 }
1686 }
1687
1688 #[test]
1689 fn load_rejects_format_version_mismatch_on_mounts_json() {
1690 let tmp = TempDir::new().unwrap();
1691 write_workspace_toml(
1692 tmp.path(),
1693 r#"
1694format = "memstead-git-branch-2"
1695
1696[persistence_adapter]
1697name = "file-two-layer"
1698"#,
1699 );
1700 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1701 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1702 std::fs::write(
1703 &mounts_path,
1704 r#"{ "format": "memstead-mounts-99", "mounts": [] }"#,
1705 )
1706 .unwrap();
1707 let store = FileWorkspaceStore::new();
1708 let err = store.load(tmp.path()).unwrap_err();
1709 assert!(matches!(err, StoreError::FormatMismatch { .. }));
1710 }
1711
1712 #[test]
1715 fn load_refuses_pre_rename_toml_as_legacy_layout() {
1716 let tmp = TempDir::new().unwrap();
1717 write_workspace_toml(
1718 tmp.path(),
1719 r#"
1720format = "memstead-git-branch-1"
1721
1722[persistence_adapter]
1723name = "file-two-layer"
1724"#,
1725 );
1726 let store = FileWorkspaceStore::new();
1727 let err = store.load(tmp.path()).unwrap_err();
1728 match err {
1729 StoreError::LegacyLayout { found, .. } => {
1730 assert_eq!(found, "memstead-git-branch-1");
1731 }
1732 other => panic!("expected LegacyLayout, got {other:?}"),
1733 }
1734 }
1735
1736 #[test]
1743 fn load_refuses_pre_rename_mounts_json_as_legacy_layout() {
1744 for legacy in ["memstead-mounts-1", "memstead-mounts-2"] {
1745 let tmp = TempDir::new().unwrap();
1746 write_workspace_toml(
1747 tmp.path(),
1748 r#"
1749format = "memstead-git-branch-2"
1750
1751[persistence_adapter]
1752name = "file-two-layer"
1753"#,
1754 );
1755 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1756 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1757 std::fs::write(
1758 &mounts_path,
1759 format!(
1760 r#"{{ "format": "{legacy}", "mounts": [{{ "unit": "notes", "storage": {{ "type": "folder", "path": "notes" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#
1761 ),
1762 )
1763 .unwrap();
1764 let store = FileWorkspaceStore::new();
1765 let err = store.load(tmp.path()).unwrap_err();
1766 match err {
1767 StoreError::LegacyLayout { found, .. } => assert_eq!(found, legacy),
1768 other => panic!("expected LegacyLayout for {legacy}, got {other:?}"),
1769 }
1770 }
1771 }
1772
1773 #[test]
1774 fn load_rejects_invalid_toml() {
1775 let tmp = TempDir::new().unwrap();
1776 write_workspace_toml(tmp.path(), "this is not = valid = toml");
1777 let store = FileWorkspaceStore::new();
1778 let err = store.load(tmp.path()).unwrap_err();
1779 assert!(matches!(err, StoreError::Parse { .. }));
1780 }
1781
1782 #[test]
1783 fn load_rejects_unknown_top_level_key() {
1784 let tmp = TempDir::new().unwrap();
1788 write_workspace_toml(
1789 tmp.path(),
1790 "format = \"memstead-git-branch-2\"\nnonexistent_key = true\n",
1791 );
1792 let store = FileWorkspaceStore::new();
1793 let err = store.load(tmp.path()).unwrap_err();
1794 match err {
1795 StoreError::Parse { message, .. } => {
1796 assert!(
1797 message.contains("nonexistent_key"),
1798 "refusal must name the unknown key: {message}"
1799 );
1800 }
1801 other => panic!("expected Parse error, got {other:?}"),
1802 }
1803 }
1804
1805 #[test]
1806 fn instantiate_lean_backend_handles_folder_archive_and_in_memory() {
1807 let tmp = TempDir::new().unwrap();
1808 let folder = folder_mount("local", tmp.path().to_path_buf());
1809 let archive_path = tmp.path().join("ext.mem");
1810 let f = std::fs::File::create(&archive_path).unwrap();
1812 let mut w = zip::ZipWriter::new(f);
1813 w.start_file("a.md", zip::write::SimpleFileOptions::default())
1814 .unwrap();
1815 w.write_all(b"# a").unwrap();
1816 w.finish().unwrap();
1817 let archive = Mount {
1818 mem: "external".to_string(),
1819 schema: Some(pin("default@1.0.0")),
1820 storage: MountStorage::Archive { path: archive_path },
1821 capability: MountCapability::ReadOnly,
1822 lifecycle: MountLifecycle::Lazy,
1823 cross_linkable: false,
1824 migration_target: None,
1825 };
1826 let in_memory = Mount {
1827 mem: "session".to_string(),
1828 schema: Some(pin("default@1.0.0")),
1829 storage: MountStorage::InMemory,
1830 capability: MountCapability::Write,
1831 lifecycle: MountLifecycle::Eager,
1832 cross_linkable: true,
1833 migration_target: None,
1834 };
1835
1836 let _: Box<dyn MemBackend> = instantiate_lean_backend(&folder).unwrap();
1837 let _: Box<dyn MemBackend> = instantiate_lean_backend(&archive).unwrap();
1838 let _: Box<dyn MemBackend> = instantiate_lean_backend(&in_memory).unwrap();
1841 }
1842
1843 #[test]
1848 fn save_state_round_trips_in_memory_variant_unambiguously() {
1849 let tmp = TempDir::new().unwrap();
1850 write_workspace_toml(
1851 tmp.path(),
1852 r#"
1853format = "memstead-git-branch-2"
1854
1855[persistence_adapter]
1856name = "file-two-layer"
1857"#,
1858 );
1859 let store = FileWorkspaceStore::new();
1860 let original = Workspace {
1861 mounts: vec![
1862 folder_mount("local", PathBuf::from("/work/mem")),
1863 Mount {
1864 mem: "session".to_string(),
1865 schema: Some(pin("default@1.0.0")),
1866 storage: MountStorage::InMemory,
1867 capability: MountCapability::Write,
1868 lifecycle: MountLifecycle::Eager,
1869 cross_linkable: true,
1870 migration_target: None,
1871 },
1872 ],
1873 settings: WorkspaceSettings::default(),
1874 };
1875 store.save_state(tmp.path(), &original).unwrap();
1876
1877 let raw =
1879 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1880 assert!(raw.contains("\"type\": \"in-memory\""), "got: {raw}");
1881
1882 let reloaded = store.load(tmp.path()).unwrap();
1883 assert_eq!(reloaded.mounts.len(), 2);
1884 let session = reloaded
1887 .mounts
1888 .iter()
1889 .find(|m| m.mem == "session")
1890 .expect("session mount survives reload");
1891 assert_eq!(session.storage, MountStorage::InMemory);
1892 let local = reloaded.mounts.iter().find(|m| m.mem == "local").unwrap();
1895 assert!(matches!(local.storage, MountStorage::Folder { .. }));
1896 }
1897
1898 #[test]
1899 fn instantiate_lean_backend_rejects_git_branch_with_typed_error() {
1900 let mount = Mount {
1901 mem: "engine".to_string(),
1902 schema: Some(pin("default@1.0.0")),
1903 storage: MountStorage::GitBranch {
1904 gitdir: PathBuf::from("/some/path/.git"),
1905 branch: "engine".to_string(),
1906 },
1907 capability: MountCapability::Write,
1908 lifecycle: MountLifecycle::Eager,
1909 cross_linkable: true,
1910 migration_target: None,
1911 };
1912 match instantiate_lean_backend(&mount) {
1916 Err(InstantiateError::GitBranchRequiresMemRepoFeature { mem }) => {
1917 assert_eq!(mem, "engine");
1918 }
1919 Ok(_) => panic!("expected GitBranchRequiresMemRepoFeature, got Ok"),
1920 }
1921 }
1922
1923 #[test]
1924 fn detect_layout_returns_empty_for_unrecognised_workspace() {
1925 let tmp = TempDir::new().unwrap();
1926 assert_eq!(detect_layout(tmp.path()), Layout::Empty);
1927 }
1928 #[test]
1929 fn detect_layout_returns_new_when_workspace_toml_present() {
1930 let tmp = TempDir::new().unwrap();
1931 write_workspace_toml(
1932 tmp.path(),
1933 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1934 );
1935 assert_eq!(detect_layout(tmp.path()), Layout::New);
1936 }
1937}