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
303impl WorkspaceStoreAdapter for FileWorkspaceStore {
304 fn load(&self, workspace_root: &Path) -> Result<Workspace, StoreError> {
305 let memstead_dir = workspace_root.join(WORKSPACE_STORE_DIR);
306 if !memstead_dir.is_dir() {
307 return Err(StoreError::NotInitialised {
308 path: workspace_root.to_path_buf(),
309 });
310 }
311
312 let toml_path = Self::workspace_toml_path(workspace_root);
314 let toml_text = std::fs::read_to_string(&toml_path).map_err(|e| {
315 if e.kind() == std::io::ErrorKind::NotFound {
316 StoreError::NotInitialised {
317 path: workspace_root.to_path_buf(),
318 }
319 } else {
320 StoreError::Io {
321 path: toml_path.clone(),
322 source: e,
323 }
324 }
325 })?;
326 let toml_doc: WorkspaceTomlDoc =
327 toml::from_str(&toml_text).map_err(|e| StoreError::Parse {
328 path: toml_path.clone(),
329 message: e.to_string(),
330 })?;
331 check_workspace_toml_format(&toml_doc.format, &toml_path)?;
332
333 let mounts_path = Self::mounts_json_path(workspace_root);
337 let mounts: Vec<Mount> = match std::fs::read_to_string(&mounts_path) {
338 Ok(text) => {
339 let probe: MountsFormatProbe =
344 serde_json::from_str(&text).map_err(|e| StoreError::Parse {
345 path: mounts_path.clone(),
346 message: e.to_string(),
347 })?;
348 if MOUNTS_JSON_FORMAT_LEGACY.contains(&probe.format.as_str()) {
349 return Err(StoreError::LegacyLayout {
350 path: mounts_path,
351 found: probe.format,
352 });
353 }
354 if probe.format != MOUNTS_JSON_FORMAT_V3 {
355 return Err(StoreError::FormatMismatch {
356 path: mounts_path,
357 expected: MOUNTS_JSON_FORMAT_V3.to_string(),
358 found: probe.format,
359 });
360 }
361 let doc: MountsJsonDoc =
362 serde_json::from_str(&text).map_err(|e| StoreError::Parse {
363 path: mounts_path.clone(),
364 message: e.to_string(),
365 })?;
366 doc.mounts
367 .into_iter()
368 .map(|w| w.into_mount(workspace_root))
369 .collect()
370 }
371 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
372 Err(e) => {
373 return Err(StoreError::Io {
374 path: mounts_path,
375 source: e,
376 });
377 }
378 };
379
380 warn_if_legacy_schemas_dir(toml_doc.schemas_dir.as_deref());
381 let settings = build_settings(
382 toml_doc.mem_management,
383 toml_doc.cross_mem_links,
384 toml_doc.mcp,
385 toml_doc.mutations,
386 toml_doc.plugin,
387 )?;
388 Ok(Workspace { mounts, settings })
389 }
390
391 fn save_state(&self, workspace_root: &Path, workspace: &Workspace) -> Result<(), StoreError> {
392 let mounts_path = Self::mounts_json_path(workspace_root);
393 if let Some(parent) = mounts_path.parent() {
394 std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
395 path: parent.to_path_buf(),
396 source: e,
397 })?;
398 }
399 let doc = MountsJsonDoc {
400 format: MOUNTS_JSON_FORMAT_V3.to_string(),
401 mounts: workspace
402 .mounts
403 .iter()
404 .map(|m| MountWire::from_mount(m, workspace_root))
405 .collect(),
406 };
407 let text = serde_json::to_string_pretty(&doc).map_err(|e| StoreError::Parse {
408 path: mounts_path.clone(),
409 message: e.to_string(),
410 })?;
411 std::fs::write(&mounts_path, text).map_err(|e| StoreError::Io {
412 path: mounts_path,
413 source: e,
414 })?;
415 Ok(())
416 }
417}
418
419#[derive(Debug, Serialize, Deserialize)]
424#[serde(deny_unknown_fields)]
425struct WorkspaceTomlDoc {
426 format: String,
430 #[serde(default)]
434 persistence_adapter: PersistenceAdapterDecl,
435 #[serde(default)]
440 mem_management: MemManagementWire,
441 #[serde(default)]
448 cross_mem_links: toml::Table,
449 #[serde(default)]
456 schemas_dir: Option<std::path::PathBuf>,
457 #[serde(default)]
461 mcp: McpSection,
462 #[serde(default)]
465 mutations: MutationsSection,
466 #[serde(default)]
470 plugin: std::collections::HashMap<String, toml::Table>,
471}
472
473#[derive(Debug, Default, Serialize, Deserialize)]
477struct MemManagementWire {
478 #[serde(default)]
479 create: Vec<CreateRuleWire>,
480 #[serde(default)]
481 delete: Vec<DeleteRuleWire>,
482}
483
484#[derive(Debug, Serialize, Deserialize)]
494struct CreateRuleWire {
495 pattern: String,
496 #[serde(default)]
497 schemas: Vec<String>,
498 #[serde(default)]
499 default_cross_links: Option<toml::Value>,
500}
501
502#[derive(Debug, Serialize, Deserialize)]
505struct DeleteRuleWire {
506 pattern: String,
507}
508
509pub fn parse_workspace_settings(
527 workspace_root: &Path,
528) -> Result<crate::workspace::WorkspaceSettings, StoreError> {
529 let memstead_dir = workspace_root.join(WORKSPACE_STORE_DIR);
530 if !memstead_dir.is_dir() {
531 return Err(StoreError::NotInitialised {
532 path: workspace_root.to_path_buf(),
533 });
534 }
535 let toml_path = FileWorkspaceStore::workspace_toml_path(workspace_root);
536 let toml_text = std::fs::read_to_string(&toml_path).map_err(|e| {
537 if e.kind() == std::io::ErrorKind::NotFound {
538 StoreError::NotInitialised {
539 path: workspace_root.to_path_buf(),
540 }
541 } else {
542 StoreError::Io {
543 path: toml_path.clone(),
544 source: e,
545 }
546 }
547 })?;
548 let toml_doc: WorkspaceTomlDoc = toml::from_str(&toml_text).map_err(|e| StoreError::Parse {
549 path: toml_path.clone(),
550 message: e.to_string(),
551 })?;
552 check_workspace_toml_format(&toml_doc.format, &toml_path)?;
553 warn_if_legacy_schemas_dir(toml_doc.schemas_dir.as_deref());
554 build_settings(
555 toml_doc.mem_management,
556 toml_doc.cross_mem_links,
557 toml_doc.mcp,
558 toml_doc.mutations,
559 toml_doc.plugin,
560 )
561}
562
563fn build_settings(
569 vm: MemManagementWire,
570 cross_mem_links_raw: toml::Table,
571 mcp: McpSection,
572 mutations: MutationsSection,
573 plugin: std::collections::HashMap<String, toml::Table>,
574) -> Result<WorkspaceSettings, StoreError> {
575 let mut create_rules = Vec::with_capacity(vm.create.len());
576 for r in vm.create {
577 let default_cross_links = match r.default_cross_links {
578 None => None,
579 Some(value) => {
580 let location = format!(
581 "[[mem_management.create]] pattern={}.default_cross_links",
582 r.pattern
583 );
584 Some(parse_cross_link_value(&location, &value)?)
585 }
586 };
587 create_rules.push(crate::workspace::CreateRuleSetting {
588 pattern: r.pattern,
589 schemas: r.schemas,
590 default_cross_links,
591 });
592 }
593
594 let mut cross_mem_links = std::collections::BTreeMap::new();
595 for (mem, value) in &cross_mem_links_raw {
596 let location = format!("[cross_mem_links].{mem}");
597 let parsed = parse_cross_link_value(&location, value)?;
598 cross_mem_links.insert(mem.clone(), parsed);
599 }
600
601 Ok(WorkspaceSettings {
602 mem_create_rules: create_rules,
603 mem_delete_rules: vm
604 .delete
605 .into_iter()
606 .map(|r| crate::workspace::DeleteRuleSetting { pattern: r.pattern })
607 .collect(),
608 cross_mem_links,
609 mcp,
610 mutations,
611 plugin,
612 })
613}
614
615fn warn_if_legacy_schemas_dir(schemas_dir: Option<&std::path::Path>) {
622 if let Some(dir) = schemas_dir {
623 tracing::warn!(
624 "`schemas_dir` (= {:?}) in workspace.toml is retired and ignored — \
625 authored schemas are read from the fixed `<workspace>/.memstead/schemas/`. \
626 Remove the key to silence this warning.",
627 dir
628 );
629 }
630}
631
632fn parse_cross_link_value(
636 location: &str,
637 value: &toml::Value,
638) -> Result<memstead_schema::workspace_config::CrossLinkValue, StoreError> {
639 memstead_schema::workspace_config::CrossLinkValue::parse_toml(location, value).map_err(|e| {
640 StoreError::Parse {
641 path: std::path::PathBuf::from("workspace.toml"),
642 message: e.to_string(),
643 }
644 })
645}
646
647#[derive(Debug, Serialize, Deserialize)]
650struct PersistenceAdapterDecl {
651 name: String,
652}
653
654impl Default for PersistenceAdapterDecl {
655 fn default() -> Self {
656 Self {
657 name: "file-two-layer".to_string(),
658 }
659 }
660}
661
662#[derive(Debug, Serialize, Deserialize)]
666struct MountsJsonDoc {
667 format: String,
668 mounts: Vec<MountWire>,
669}
670
671#[derive(Debug, Serialize, Deserialize)]
676struct MountWire {
677 mem: String,
678 #[serde(default, skip_serializing_if = "Option::is_none")]
684 schema: Option<String>,
685 #[serde(default, skip_serializing_if = "Option::is_none")]
690 migration_target: Option<String>,
691 storage: MountStorageWire,
692 capability: CapabilityWire,
693 lifecycle: LifecycleWire,
694 cross_linkable: bool,
695}
696
697#[derive(Debug, Serialize, Deserialize)]
698#[serde(tag = "type", rename_all = "kebab-case")]
699enum MountStorageWire {
700 Folder {
701 path: PathBuf,
702 },
703 GitBranch {
704 gitdir: PathBuf,
705 branch: String,
706 },
707 Archive {
708 path: PathBuf,
709 },
710 InMemory,
718}
719
720#[derive(Debug, Serialize, Deserialize)]
721#[serde(rename_all = "kebab-case")]
722enum CapabilityWire {
723 ReadOnly,
724 Write,
725}
726
727#[derive(Debug, Serialize, Deserialize)]
728#[serde(rename_all = "kebab-case")]
729enum LifecycleWire {
730 Eager,
731 Lazy,
732}
733
734impl MountWire {
735 fn from_mount(m: &Mount, workspace_root: &Path) -> Self {
736 Self {
737 mem: m.mem.clone(),
738 schema: m.schema.as_ref().map(|s| s.to_string()),
739 migration_target: m.migration_target.as_ref().map(|t| t.to_string()),
740 storage: match &m.storage {
741 MountStorage::Folder { path } => MountStorageWire::Folder {
742 path: relativize_mount_path(path, workspace_root),
743 },
744 MountStorage::GitBranch { gitdir, branch } => MountStorageWire::GitBranch {
745 gitdir: relativize_mount_path(gitdir, workspace_root),
746 branch: branch.clone(),
747 },
748 MountStorage::Archive { path } => MountStorageWire::Archive {
749 path: relativize_mount_path(path, workspace_root),
750 },
751 MountStorage::InMemory => MountStorageWire::InMemory,
752 },
753 capability: match m.capability {
754 MountCapability::ReadOnly => CapabilityWire::ReadOnly,
755 MountCapability::Write => CapabilityWire::Write,
756 },
757 lifecycle: match m.lifecycle {
758 MountLifecycle::Eager => LifecycleWire::Eager,
759 MountLifecycle::Lazy => LifecycleWire::Lazy,
760 },
761 cross_linkable: m.cross_linkable,
762 }
763 }
764
765 fn into_mount(self, workspace_root: &Path) -> Mount {
766 Mount {
767 mem: self.mem,
768 schema: self.schema.map(|s| {
769 s.parse()
770 .expect("schema pin on disk must be `<name>@<version>`")
771 }),
772 migration_target: self.migration_target.map(|t| {
773 t.parse()
774 .expect("migration_target on disk must be `<name>@<version>`")
775 }),
776 storage: match self.storage {
777 MountStorageWire::Folder { path } => MountStorage::Folder {
778 path: absolutize_mount_path(path, workspace_root),
779 },
780 MountStorageWire::GitBranch { gitdir, branch } => MountStorage::GitBranch {
781 gitdir: absolutize_mount_path(gitdir, workspace_root),
782 branch,
783 },
784 MountStorageWire::Archive { path } => MountStorage::Archive {
785 path: absolutize_mount_path(path, workspace_root),
786 },
787 MountStorageWire::InMemory => MountStorage::InMemory,
788 },
789 capability: match self.capability {
790 CapabilityWire::ReadOnly => MountCapability::ReadOnly,
791 CapabilityWire::Write => MountCapability::Write,
792 },
793 lifecycle: match self.lifecycle {
794 LifecycleWire::Eager => MountLifecycle::Eager,
795 LifecycleWire::Lazy => MountLifecycle::Lazy,
796 },
797 cross_linkable: self.cross_linkable,
798 }
799 }
800}
801
802#[derive(Debug, thiserror::Error)]
804pub enum InstantiateError {
805 #[error(
812 "mem {mem}: git-branch backend requires the `mem-repo` feature; \
813 use `instantiate_full_backend` from memstead-git-branch, or rebuild with --features mem-repo"
814 )]
815 GitBranchRequiresMemRepoFeature { mem: String },
816}
817
818impl InstantiateError {
819 pub fn code(&self) -> &'static str {
825 match self {
826 InstantiateError::GitBranchRequiresMemRepoFeature { .. } => {
827 "UNSUPPORTED_WORKSPACE_SHAPE"
828 }
829 }
830 }
831}
832
833pub fn instantiate_lean_backend(mount: &Mount) -> Result<Box<dyn MemBackend>, InstantiateError> {
843 match &mount.storage {
844 MountStorage::Folder { path } => Ok(Box::new(FilesystemMemWriter::new(path.clone()))),
845 MountStorage::Archive { path } => Ok(Box::new(ArchiveBackend::new(path.clone()))),
846 MountStorage::InMemory => Ok(Box::new(InMemoryBackend::new())),
847 MountStorage::GitBranch { .. } => Err(InstantiateError::GitBranchRequiresMemRepoFeature {
848 mem: mount.mem.clone(),
849 }),
850 }
851}
852
853#[derive(Debug, Clone, Copy, PartialEq, Eq)]
861pub enum Layout {
862 Empty,
865 New,
868}
869
870pub fn detect_layout(workspace_root: &Path) -> Layout {
874 if is_workspace_root(workspace_root) {
875 Layout::New
876 } else {
877 Layout::Empty
878 }
879}
880
881pub fn standalone_workspace(workspace_root: &Path) -> Option<Workspace> {
901 let config = memstead_schema::config::load_and_validate(workspace_root).ok()?;
902 let schema = config.schema.clone()?;
903 let name = config.name.clone().unwrap_or_else(|| {
904 workspace_root
905 .file_name()
906 .map(|n| n.to_string_lossy().to_string())
907 .unwrap_or_else(|| "mem".to_string())
908 });
909 let mount = Mount {
910 mem: name,
911 schema: Some(schema),
912 storage: MountStorage::Folder {
913 path: workspace_root.to_path_buf(),
914 },
915 capability: MountCapability::Write,
916 lifecycle: MountLifecycle::Eager,
917 cross_linkable: false,
918 migration_target: None,
919 };
920 Some(Workspace {
921 mounts: vec![mount],
922 settings: WorkspaceSettings::default(),
923 })
924}
925
926#[cfg(test)]
927mod tests {
928 use super::*;
929 use memstead_schema::SchemaRef;
930 use std::io::Write as _;
931 use tempfile::TempDir;
932
933 fn pin(s: &str) -> SchemaRef {
934 s.parse().unwrap()
935 }
936
937 fn folder_mount(mem: &str, path: PathBuf) -> Mount {
938 Mount {
939 mem: mem.to_string(),
940 schema: Some(pin("default@1.0.0")),
941 storage: MountStorage::Folder { path },
942 capability: MountCapability::Write,
943 lifecycle: MountLifecycle::Eager,
944 cross_linkable: true,
945 migration_target: None,
946 }
947 }
948
949 fn write_workspace_toml(workspace_root: &Path, body: &str) {
950 let path = FileWorkspaceStore::workspace_toml_path(workspace_root);
951 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
952 std::fs::write(path, body).unwrap();
953 }
954
955 #[test]
956 fn load_returns_not_initialised_when_memstead_dir_absent() {
957 let tmp = TempDir::new().unwrap();
958 let store = FileWorkspaceStore::new();
959 let err = store.load(tmp.path()).unwrap_err();
960 assert!(matches!(err, StoreError::NotInitialised { .. }));
961 }
962
963 #[test]
969 fn parse_workspace_settings_reflects_cross_mem_links_edit() {
970 let tmp = TempDir::new().unwrap();
971 write_workspace_toml(
972 tmp.path(),
973 r#"
974format = "memstead-git-branch-2"
975
976[persistence_adapter]
977name = "file-two-layer"
978
979[cross_mem_links]
980team-a = ["team-b"]
981"#,
982 );
983 let settings = super::parse_workspace_settings(tmp.path()).unwrap();
984 assert!(
985 settings.cross_mem_links.contains_key("team-a"),
986 "initial parse must surface the team-a grant; got {:?}",
987 settings.cross_mem_links
988 );
989
990 write_workspace_toml(
992 tmp.path(),
993 r#"
994format = "memstead-git-branch-2"
995
996[persistence_adapter]
997name = "file-two-layer"
998
999[cross_mem_links]
1000"#,
1001 );
1002 let refreshed = super::parse_workspace_settings(tmp.path()).unwrap();
1003 assert!(
1004 refreshed.cross_mem_links.is_empty(),
1005 "refreshed parse must drop the team-a grant; got {:?}",
1006 refreshed.cross_mem_links
1007 );
1008 }
1009
1010 #[test]
1015 fn parse_workspace_settings_reflects_allowlist_edit() {
1016 let tmp = TempDir::new().unwrap();
1017 write_workspace_toml(
1018 tmp.path(),
1019 r#"
1020format = "memstead-git-branch-2"
1021
1022[persistence_adapter]
1023name = "file-two-layer"
1024"#,
1025 );
1026 let initial = super::parse_workspace_settings(tmp.path()).unwrap();
1027 assert!(initial.mem_create_rules.is_empty());
1028
1029 write_workspace_toml(
1031 tmp.path(),
1032 r#"
1033format = "memstead-git-branch-2"
1034
1035[persistence_adapter]
1036name = "file-two-layer"
1037
1038[[mem_management.create]]
1039pattern = "test-*"
1040schemas = ["default@1.0.0"]
1041"#,
1042 );
1043 let refreshed = super::parse_workspace_settings(tmp.path()).unwrap();
1044 assert_eq!(refreshed.mem_create_rules.len(), 1);
1045 assert_eq!(refreshed.mem_create_rules[0].pattern, "test-*");
1046 }
1047
1048 #[test]
1049 fn load_returns_not_initialised_when_workspace_toml_missing() {
1050 let tmp = TempDir::new().unwrap();
1051 std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
1052 let store = FileWorkspaceStore::new();
1053 let err = store.load(tmp.path()).unwrap_err();
1054 assert!(matches!(err, StoreError::NotInitialised { .. }));
1055 }
1056
1057 #[test]
1058 fn load_with_no_mounts_yields_empty_mount_list() {
1059 let tmp = TempDir::new().unwrap();
1060 write_workspace_toml(
1061 tmp.path(),
1062 r#"
1063format = "memstead-git-branch-2"
1064
1065[persistence_adapter]
1066name = "file-two-layer"
1067"#,
1068 );
1069 let store = FileWorkspaceStore::new();
1070 let workspace = store.load(tmp.path()).unwrap();
1071 assert!(workspace.mounts.is_empty());
1072 }
1073
1074 #[test]
1075 fn load_with_no_mem_management_yields_empty_settings() {
1076 let tmp = TempDir::new().unwrap();
1081 write_workspace_toml(
1082 tmp.path(),
1083 r#"
1084format = "memstead-git-branch-2"
1085
1086[persistence_adapter]
1087name = "file-two-layer"
1088"#,
1089 );
1090 let store = FileWorkspaceStore::new();
1091 let workspace = store.load(tmp.path()).unwrap();
1092 assert!(workspace.settings.mem_create_rules.is_empty());
1093 assert!(workspace.settings.mem_delete_rules.is_empty());
1094 assert!(workspace.settings.cross_mem_links.is_empty());
1095 }
1096
1097 #[test]
1098 fn load_picks_up_cross_mem_links_wildcard_and_list() {
1099 use memstead_schema::workspace_config::CrossLinkValue;
1104 let tmp = TempDir::new().unwrap();
1105 write_workspace_toml(
1106 tmp.path(),
1107 r#"
1108format = "memstead-git-branch-2"
1109
1110[persistence_adapter]
1111name = "file-two-layer"
1112
1113[cross_mem_links]
1114specs = "*"
1115engine = ["specs", "macos"]
1116locked = []
1117"#,
1118 );
1119 let store = FileWorkspaceStore::new();
1120 let workspace = store.load(tmp.path()).unwrap();
1121 let cvl = &workspace.settings.cross_mem_links;
1122 assert_eq!(cvl.len(), 3);
1123 assert_eq!(cvl.get("specs"), Some(&CrossLinkValue::Wildcard));
1124 assert_eq!(
1125 cvl.get("engine"),
1126 Some(&CrossLinkValue::List(vec![
1127 "specs".to_string(),
1128 "macos".to_string()
1129 ]))
1130 );
1131 assert_eq!(cvl.get("locked"), Some(&CrossLinkValue::List(vec![])));
1132 }
1133
1134 #[test]
1135 fn load_rejects_cross_mem_links_mixed_wildcard_and_names() {
1136 let tmp = TempDir::new().unwrap();
1141 write_workspace_toml(
1142 tmp.path(),
1143 r#"
1144format = "memstead-git-branch-2"
1145
1146[persistence_adapter]
1147name = "file-two-layer"
1148
1149[cross_mem_links]
1150specs = ["*", "engine"]
1151"#,
1152 );
1153 let store = FileWorkspaceStore::new();
1154 let err = store.load(tmp.path()).unwrap_err();
1155 match err {
1156 StoreError::Parse { message, .. } => {
1157 assert!(message.contains("[cross_mem_links].specs"));
1158 assert!(message.contains("wildcard"));
1159 }
1160 other => panic!("expected StoreError::Parse, got {other:?}"),
1161 }
1162 }
1163
1164 #[test]
1165 fn load_picks_up_default_cross_links_on_create_rule() {
1166 use memstead_schema::workspace_config::CrossLinkValue;
1170 let tmp = TempDir::new().unwrap();
1171 write_workspace_toml(
1172 tmp.path(),
1173 r#"
1174format = "memstead-git-branch-2"
1175
1176[persistence_adapter]
1177name = "file-two-layer"
1178
1179[[mem_management.create]]
1180pattern = "exec-*"
1181schemas = ["default"]
1182default_cross_links = "*"
1183"#,
1184 );
1185 let store = FileWorkspaceStore::new();
1186 let workspace = store.load(tmp.path()).unwrap();
1187 let rule = &workspace.settings.mem_create_rules[0];
1188 assert_eq!(rule.pattern, "exec-*");
1189 assert_eq!(rule.default_cross_links, Some(CrossLinkValue::Wildcard));
1190 }
1191
1192 #[test]
1193 fn load_picks_up_mem_management_create_and_delete_rules() {
1194 let tmp = TempDir::new().unwrap();
1198 write_workspace_toml(
1199 tmp.path(),
1200 r#"
1201format = "memstead-git-branch-2"
1202
1203[persistence_adapter]
1204name = "file-two-layer"
1205
1206[[mem_management.create]]
1207pattern = "exec-*"
1208schemas = ["default@1.0.0", "*"]
1209
1210[[mem_management.create]]
1211pattern = "scratch-*"
1212schemas = ["default"]
1213
1214[[mem_management.delete]]
1215pattern = "exec-*"
1216"#,
1217 );
1218 let store = FileWorkspaceStore::new();
1219 let workspace = store.load(tmp.path()).unwrap();
1220 assert_eq!(workspace.settings.mem_create_rules.len(), 2);
1221 assert_eq!(workspace.settings.mem_create_rules[0].pattern, "exec-*");
1222 assert_eq!(
1223 workspace.settings.mem_create_rules[0].schemas,
1224 vec!["default@1.0.0".to_string(), "*".to_string()]
1225 );
1226 assert_eq!(workspace.settings.mem_create_rules[1].pattern, "scratch-*");
1227 assert_eq!(workspace.settings.mem_delete_rules.len(), 1);
1228 assert_eq!(workspace.settings.mem_delete_rules[0].pattern, "exec-*");
1229 }
1230
1231 #[test]
1236 fn save_state_round_trips_migration_target() {
1237 let tmp = TempDir::new().unwrap();
1238 write_workspace_toml(
1239 tmp.path(),
1240 "\nformat = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1241 );
1242 let store = FileWorkspaceStore::new();
1243 let mut migrating = folder_mount("specs", PathBuf::from("/work/mem"));
1244 migrating.migration_target = Some(pin("mig-b@0.1.0"));
1245 let settled = folder_mount("other", PathBuf::from("/work/other"));
1246 let original = Workspace {
1247 mounts: vec![migrating, settled],
1248 settings: WorkspaceSettings::default(),
1249 };
1250 store.save_state(tmp.path(), &original).unwrap();
1251 let raw =
1252 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1253 assert!(
1254 raw.contains("mig-b@0.1.0"),
1255 "migration_target must persist: {raw}"
1256 );
1257 assert_eq!(
1258 raw.matches("migration_target").count(),
1259 1,
1260 "settled mounts must omit the key entirely: {raw}"
1261 );
1262 let loaded = store.load(tmp.path()).unwrap();
1263 assert_eq!(loaded.mounts[0].migration_target, Some(pin("mig-b@0.1.0")));
1264 assert_eq!(loaded.mounts[1].migration_target, None);
1265 }
1266
1267 #[test]
1268 fn save_state_then_load_round_trips_mount_list() {
1269 let tmp = TempDir::new().unwrap();
1270 write_workspace_toml(
1271 tmp.path(),
1272 r#"
1273format = "memstead-git-branch-2"
1274
1275[persistence_adapter]
1276name = "file-two-layer"
1277"#,
1278 );
1279 let store = FileWorkspaceStore::new();
1280 let original = Workspace {
1281 mounts: vec![
1282 folder_mount("specs", PathBuf::from("/work/mem")),
1283 Mount {
1284 mem: "engine".to_string(),
1285 schema: Some(pin("default@1.0.0")),
1286 storage: MountStorage::GitBranch {
1287 gitdir: PathBuf::from("/work/mem-repo/.git"),
1288 branch: "engine".to_string(),
1289 },
1290 capability: MountCapability::Write,
1291 lifecycle: MountLifecycle::Eager,
1292 cross_linkable: true,
1293 migration_target: None,
1294 },
1295 Mount {
1296 mem: "external".to_string(),
1297 schema: Some(pin("default@1.0.0")),
1298 storage: MountStorage::Archive {
1299 path: PathBuf::from("/deps/external.mem"),
1300 },
1301 capability: MountCapability::ReadOnly,
1302 lifecycle: MountLifecycle::Lazy,
1303 cross_linkable: false,
1304 migration_target: None,
1305 },
1306 ],
1307 settings: WorkspaceSettings::default(),
1308 };
1309 store.save_state(tmp.path(), &original).unwrap();
1310
1311 assert!(FileWorkspaceStore::mounts_json_path(tmp.path()).is_file());
1313
1314 let reloaded = store.load(tmp.path()).unwrap();
1315 assert_eq!(reloaded.mounts.len(), original.mounts.len());
1316 for (a, b) in reloaded.mounts.iter().zip(original.mounts.iter()) {
1317 assert_eq!(a.mem, b.mem);
1318 assert_eq!(a.schema, b.schema);
1319 assert_eq!(a.capability, b.capability);
1320 assert_eq!(a.lifecycle, b.lifecycle);
1321 assert_eq!(a.cross_linkable, b.cross_linkable);
1322 assert_eq!(a.storage, b.storage);
1323 }
1324 }
1325
1326 #[test]
1327 fn save_state_round_trips_unset_schema_assertion() {
1328 let tmp = TempDir::new().unwrap();
1333 write_workspace_toml(
1334 tmp.path(),
1335 r#"
1336format = "memstead-git-branch-2"
1337
1338[persistence_adapter]
1339name = "file-two-layer"
1340"#,
1341 );
1342 let store = FileWorkspaceStore::new();
1343 let original = Workspace {
1344 mounts: vec![Mount {
1345 mem: "foreign".to_string(),
1346 schema: None,
1347 storage: MountStorage::Folder {
1348 path: tmp.path().join("foreign"),
1349 },
1350 capability: MountCapability::ReadOnly,
1351 lifecycle: MountLifecycle::Eager,
1352 cross_linkable: false,
1353 migration_target: None,
1354 }],
1355 settings: WorkspaceSettings::default(),
1356 };
1357 store.save_state(tmp.path(), &original).unwrap();
1358
1359 let raw =
1361 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1362 assert!(
1363 !raw.contains("\"schema\""),
1364 "unset schema assertion must omit the key on the wire; got:\n{raw}"
1365 );
1366
1367 let reloaded = store.load(tmp.path()).unwrap();
1369 assert_eq!(reloaded.mounts.len(), 1);
1370 assert_eq!(reloaded.mounts[0].schema, None);
1371 }
1372
1373 #[test]
1374 fn save_state_does_not_touch_workspace_toml() {
1375 let tmp = TempDir::new().unwrap();
1376 let original_body = r#"
1377format = "memstead-git-branch-2"
1378
1379[persistence_adapter]
1380name = "file-two-layer"
1381"#;
1382 write_workspace_toml(tmp.path(), original_body);
1383 let store = FileWorkspaceStore::new();
1384 let workspace = Workspace::default();
1385 store.save_state(tmp.path(), &workspace).unwrap();
1386 let toml_after =
1388 std::fs::read_to_string(FileWorkspaceStore::workspace_toml_path(tmp.path())).unwrap();
1389 assert_eq!(toml_after, original_body);
1390 }
1391
1392 #[test]
1393 fn save_state_writes_paths_relative_to_workspace_root() {
1394 let tmp = TempDir::new().unwrap();
1395 write_workspace_toml(
1396 tmp.path(),
1397 r#"
1398format = "memstead-git-branch-2"
1399
1400[persistence_adapter]
1401name = "file-two-layer"
1402"#,
1403 );
1404 let store = FileWorkspaceStore::new();
1405 let workspace = Workspace {
1406 mounts: vec![
1407 Mount {
1408 mem: "engine".to_string(),
1409 schema: Some(pin("default@1.0.0")),
1410 storage: MountStorage::GitBranch {
1411 gitdir: tmp.path().join("mem-repo").join(".git"),
1412 branch: "engine".to_string(),
1413 },
1414 capability: MountCapability::Write,
1415 lifecycle: MountLifecycle::Eager,
1416 cross_linkable: true,
1417 migration_target: None,
1418 },
1419 Mount {
1420 mem: "external".to_string(),
1421 schema: Some(pin("default@1.0.0")),
1422 storage: MountStorage::Archive {
1423 path: PathBuf::from("/global/cache/external.mem"),
1424 },
1425 capability: MountCapability::ReadOnly,
1426 lifecycle: MountLifecycle::Lazy,
1427 cross_linkable: false,
1428 migration_target: None,
1429 },
1430 ],
1431 settings: WorkspaceSettings::default(),
1432 };
1433 store.save_state(tmp.path(), &workspace).unwrap();
1434
1435 let on_disk =
1436 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1437 assert!(on_disk.contains("\"memstead-mounts-3\""));
1439 assert!(
1441 on_disk.contains("\"mem-repo/.git\""),
1442 "expected relative gitdir, got: {on_disk}"
1443 );
1444 assert!(
1445 !on_disk.contains(tmp.path().to_str().unwrap()),
1446 "in-workspace path should not include the absolute tmp prefix: {on_disk}"
1447 );
1448 assert!(on_disk.contains("\"/global/cache/external.mem\""));
1450
1451 let reloaded = store.load(tmp.path()).unwrap();
1453 match &reloaded.mounts[0].storage {
1454 MountStorage::GitBranch { gitdir, .. } => {
1455 assert_eq!(gitdir, &tmp.path().join("mem-repo").join(".git"));
1456 }
1457 other => panic!("expected GitBranch storage, got {other:?}"),
1458 }
1459 match &reloaded.mounts[1].storage {
1460 MountStorage::Archive { path } => {
1461 assert_eq!(path, &PathBuf::from("/global/cache/external.mem"));
1462 }
1463 other => panic!("expected Archive storage, got {other:?}"),
1464 }
1465 }
1466
1467 #[test]
1468 fn load_absolute_inside_root_path_then_save_rewrites_relative() {
1469 let tmp = TempDir::new().unwrap();
1470 write_workspace_toml(
1471 tmp.path(),
1472 r#"
1473format = "memstead-git-branch-2"
1474
1475[persistence_adapter]
1476name = "file-two-layer"
1477"#,
1478 );
1479 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1484 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1485 let abs_gitdir = tmp.path().join("mem-repo").join(".git");
1486 let mounts_body = format!(
1487 r#"{{
1488 "format": "memstead-mounts-3",
1489 "mounts": [
1490 {{
1491 "mem": "engine",
1492 "schema": "default@1.0.0",
1493 "storage": {{
1494 "type": "git-branch",
1495 "gitdir": "{}",
1496 "branch": "engine"
1497 }},
1498 "capability": "write",
1499 "lifecycle": "eager",
1500 "cross_linkable": true
1501 }}
1502 ]
1503}}"#,
1504 abs_gitdir.to_str().unwrap()
1505 );
1506 std::fs::write(&mounts_path, &mounts_body).unwrap();
1507
1508 let store = FileWorkspaceStore::new();
1509 let workspace = store.load(tmp.path()).unwrap();
1512 match &workspace.mounts[0].storage {
1513 MountStorage::GitBranch { gitdir, .. } => assert_eq!(gitdir, &abs_gitdir),
1514 other => panic!("expected GitBranch storage, got {other:?}"),
1515 }
1516
1517 store.save_state(tmp.path(), &workspace).unwrap();
1520 let on_disk = std::fs::read_to_string(&mounts_path).unwrap();
1521 assert!(on_disk.contains("\"memstead-mounts-3\""));
1522 assert!(on_disk.contains("\"mem-repo/.git\""));
1523 assert!(!on_disk.contains(tmp.path().to_str().unwrap()));
1524 }
1525
1526 #[test]
1535 fn save_state_preserves_refs_heads_branch_form() {
1536 let tmp = TempDir::new().unwrap();
1537 write_workspace_toml(
1538 tmp.path(),
1539 r#"
1540format = "memstead-git-branch-2"
1541
1542[persistence_adapter]
1543name = "file-two-layer"
1544"#,
1545 );
1546 let store = FileWorkspaceStore::new();
1547 let original = Workspace {
1548 mounts: vec![Mount {
1549 mem: "engine".to_string(),
1550 schema: Some(pin("default@1.0.0")),
1551 storage: MountStorage::GitBranch {
1552 gitdir: tmp.path().join("mem-repo").join(".git"),
1553 branch: "refs/heads/demo/engine".to_string(),
1554 },
1555 capability: MountCapability::Write,
1556 lifecycle: MountLifecycle::Eager,
1557 cross_linkable: true,
1558 migration_target: None,
1559 }],
1560 settings: WorkspaceSettings::default(),
1561 };
1562 store.save_state(tmp.path(), &original).unwrap();
1563
1564 let on_disk =
1565 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1566 assert!(
1567 on_disk.contains("\"branch\": \"refs/heads/demo/engine\""),
1568 "expected fully-qualified ref on disk, got: {on_disk}"
1569 );
1570
1571 let reloaded = store.load(tmp.path()).unwrap();
1572 match &reloaded.mounts[0].storage {
1573 MountStorage::GitBranch { branch, .. } => {
1574 assert_eq!(branch, "refs/heads/demo/engine");
1575 }
1576 other => panic!("expected GitBranch storage, got {other:?}"),
1577 }
1578 }
1579
1580 #[test]
1590 fn load_preserves_short_form_branch_without_rewrite() {
1591 let tmp = TempDir::new().unwrap();
1592 write_workspace_toml(
1593 tmp.path(),
1594 r#"
1595format = "memstead-git-branch-2"
1596
1597[persistence_adapter]
1598name = "file-two-layer"
1599"#,
1600 );
1601 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1602 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1603 std::fs::write(
1604 &mounts_path,
1605 r#"{
1606 "format": "memstead-mounts-3",
1607 "mounts": [
1608 {
1609 "mem": "engine",
1610 "schema": "default@1.0.0",
1611 "storage": {
1612 "type": "git-branch",
1613 "gitdir": "mem-repo/.git",
1614 "branch": "demo/engine"
1615 },
1616 "capability": "write",
1617 "lifecycle": "eager",
1618 "cross_linkable": true
1619 }
1620 ]
1621}"#,
1622 )
1623 .unwrap();
1624
1625 let store = FileWorkspaceStore::new();
1626 let workspace = store.load(tmp.path()).unwrap();
1627 match &workspace.mounts[0].storage {
1628 MountStorage::GitBranch { branch, .. } => {
1629 assert_eq!(
1630 branch, "demo/engine",
1631 "reader must not silently rewrite short-form branch"
1632 );
1633 }
1634 other => panic!("expected GitBranch storage, got {other:?}"),
1635 }
1636 }
1637
1638 #[test]
1639 fn load_rejects_format_version_mismatch_on_toml() {
1640 let tmp = TempDir::new().unwrap();
1641 write_workspace_toml(
1642 tmp.path(),
1643 r#"
1644format = "memstead-git-branch-99"
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::FormatMismatch {
1654 expected, found, ..
1655 } => {
1656 assert_eq!(expected, "memstead-git-branch-2");
1657 assert_eq!(found, "memstead-git-branch-99");
1658 }
1659 other => panic!("expected FormatMismatch, got {other:?}"),
1660 }
1661 }
1662
1663 #[test]
1664 fn load_rejects_format_version_mismatch_on_mounts_json() {
1665 let tmp = TempDir::new().unwrap();
1666 write_workspace_toml(
1667 tmp.path(),
1668 r#"
1669format = "memstead-git-branch-2"
1670
1671[persistence_adapter]
1672name = "file-two-layer"
1673"#,
1674 );
1675 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1676 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1677 std::fs::write(
1678 &mounts_path,
1679 r#"{ "format": "memstead-mounts-99", "mounts": [] }"#,
1680 )
1681 .unwrap();
1682 let store = FileWorkspaceStore::new();
1683 let err = store.load(tmp.path()).unwrap_err();
1684 assert!(matches!(err, StoreError::FormatMismatch { .. }));
1685 }
1686
1687 #[test]
1690 fn load_refuses_pre_rename_toml_as_legacy_layout() {
1691 let tmp = TempDir::new().unwrap();
1692 write_workspace_toml(
1693 tmp.path(),
1694 r#"
1695format = "memstead-git-branch-1"
1696
1697[persistence_adapter]
1698name = "file-two-layer"
1699"#,
1700 );
1701 let store = FileWorkspaceStore::new();
1702 let err = store.load(tmp.path()).unwrap_err();
1703 match err {
1704 StoreError::LegacyLayout { found, .. } => {
1705 assert_eq!(found, "memstead-git-branch-1");
1706 }
1707 other => panic!("expected LegacyLayout, got {other:?}"),
1708 }
1709 }
1710
1711 #[test]
1718 fn load_refuses_pre_rename_mounts_json_as_legacy_layout() {
1719 for legacy in ["memstead-mounts-1", "memstead-mounts-2"] {
1720 let tmp = TempDir::new().unwrap();
1721 write_workspace_toml(
1722 tmp.path(),
1723 r#"
1724format = "memstead-git-branch-2"
1725
1726[persistence_adapter]
1727name = "file-two-layer"
1728"#,
1729 );
1730 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1731 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1732 std::fs::write(
1733 &mounts_path,
1734 format!(
1735 r#"{{ "format": "{legacy}", "mounts": [{{ "unit": "notes", "storage": {{ "type": "folder", "path": "notes" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#
1736 ),
1737 )
1738 .unwrap();
1739 let store = FileWorkspaceStore::new();
1740 let err = store.load(tmp.path()).unwrap_err();
1741 match err {
1742 StoreError::LegacyLayout { found, .. } => assert_eq!(found, legacy),
1743 other => panic!("expected LegacyLayout for {legacy}, got {other:?}"),
1744 }
1745 }
1746 }
1747
1748 #[test]
1749 fn load_rejects_invalid_toml() {
1750 let tmp = TempDir::new().unwrap();
1751 write_workspace_toml(tmp.path(), "this is not = valid = toml");
1752 let store = FileWorkspaceStore::new();
1753 let err = store.load(tmp.path()).unwrap_err();
1754 assert!(matches!(err, StoreError::Parse { .. }));
1755 }
1756
1757 #[test]
1758 fn load_rejects_unknown_top_level_key() {
1759 let tmp = TempDir::new().unwrap();
1763 write_workspace_toml(
1764 tmp.path(),
1765 "format = \"memstead-git-branch-2\"\nnonexistent_key = true\n",
1766 );
1767 let store = FileWorkspaceStore::new();
1768 let err = store.load(tmp.path()).unwrap_err();
1769 match err {
1770 StoreError::Parse { message, .. } => {
1771 assert!(
1772 message.contains("nonexistent_key"),
1773 "refusal must name the unknown key: {message}"
1774 );
1775 }
1776 other => panic!("expected Parse error, got {other:?}"),
1777 }
1778 }
1779
1780 #[test]
1781 fn instantiate_lean_backend_handles_folder_archive_and_in_memory() {
1782 let tmp = TempDir::new().unwrap();
1783 let folder = folder_mount("local", tmp.path().to_path_buf());
1784 let archive_path = tmp.path().join("ext.mem");
1785 let f = std::fs::File::create(&archive_path).unwrap();
1787 let mut w = zip::ZipWriter::new(f);
1788 w.start_file("a.md", zip::write::SimpleFileOptions::default())
1789 .unwrap();
1790 w.write_all(b"# a").unwrap();
1791 w.finish().unwrap();
1792 let archive = Mount {
1793 mem: "external".to_string(),
1794 schema: Some(pin("default@1.0.0")),
1795 storage: MountStorage::Archive { path: archive_path },
1796 capability: MountCapability::ReadOnly,
1797 lifecycle: MountLifecycle::Lazy,
1798 cross_linkable: false,
1799 migration_target: None,
1800 };
1801 let in_memory = Mount {
1802 mem: "session".to_string(),
1803 schema: Some(pin("default@1.0.0")),
1804 storage: MountStorage::InMemory,
1805 capability: MountCapability::Write,
1806 lifecycle: MountLifecycle::Eager,
1807 cross_linkable: true,
1808 migration_target: None,
1809 };
1810
1811 let _: Box<dyn MemBackend> = instantiate_lean_backend(&folder).unwrap();
1812 let _: Box<dyn MemBackend> = instantiate_lean_backend(&archive).unwrap();
1813 let _: Box<dyn MemBackend> = instantiate_lean_backend(&in_memory).unwrap();
1816 }
1817
1818 #[test]
1823 fn save_state_round_trips_in_memory_variant_unambiguously() {
1824 let tmp = TempDir::new().unwrap();
1825 write_workspace_toml(
1826 tmp.path(),
1827 r#"
1828format = "memstead-git-branch-2"
1829
1830[persistence_adapter]
1831name = "file-two-layer"
1832"#,
1833 );
1834 let store = FileWorkspaceStore::new();
1835 let original = Workspace {
1836 mounts: vec![
1837 folder_mount("local", PathBuf::from("/work/mem")),
1838 Mount {
1839 mem: "session".to_string(),
1840 schema: Some(pin("default@1.0.0")),
1841 storage: MountStorage::InMemory,
1842 capability: MountCapability::Write,
1843 lifecycle: MountLifecycle::Eager,
1844 cross_linkable: true,
1845 migration_target: None,
1846 },
1847 ],
1848 settings: WorkspaceSettings::default(),
1849 };
1850 store.save_state(tmp.path(), &original).unwrap();
1851
1852 let raw =
1854 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1855 assert!(raw.contains("\"type\": \"in-memory\""), "got: {raw}");
1856
1857 let reloaded = store.load(tmp.path()).unwrap();
1858 assert_eq!(reloaded.mounts.len(), 2);
1859 let session = reloaded
1862 .mounts
1863 .iter()
1864 .find(|m| m.mem == "session")
1865 .expect("session mount survives reload");
1866 assert_eq!(session.storage, MountStorage::InMemory);
1867 let local = reloaded.mounts.iter().find(|m| m.mem == "local").unwrap();
1870 assert!(matches!(local.storage, MountStorage::Folder { .. }));
1871 }
1872
1873 #[test]
1874 fn instantiate_lean_backend_rejects_git_branch_with_typed_error() {
1875 let mount = Mount {
1876 mem: "engine".to_string(),
1877 schema: Some(pin("default@1.0.0")),
1878 storage: MountStorage::GitBranch {
1879 gitdir: PathBuf::from("/some/path/.git"),
1880 branch: "engine".to_string(),
1881 },
1882 capability: MountCapability::Write,
1883 lifecycle: MountLifecycle::Eager,
1884 cross_linkable: true,
1885 migration_target: None,
1886 };
1887 match instantiate_lean_backend(&mount) {
1891 Err(InstantiateError::GitBranchRequiresMemRepoFeature { mem }) => {
1892 assert_eq!(mem, "engine");
1893 }
1894 Ok(_) => panic!("expected GitBranchRequiresMemRepoFeature, got Ok"),
1895 }
1896 }
1897
1898 #[test]
1899 fn detect_layout_returns_empty_for_unrecognised_workspace() {
1900 let tmp = TempDir::new().unwrap();
1901 assert_eq!(detect_layout(tmp.path()), Layout::Empty);
1902 }
1903 #[test]
1904 fn detect_layout_returns_new_when_workspace_toml_present() {
1905 let tmp = TempDir::new().unwrap();
1906 write_workspace_toml(
1907 tmp.path(),
1908 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1909 );
1910 assert_eq!(detect_layout(tmp.path()), Layout::New);
1911 }
1912}