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("workspace store error: {0}")]
114 Other(String),
115}
116
117pub trait WorkspaceStoreAdapter: Send + Sync {
123 fn load(&self, workspace_root: &Path) -> Result<Workspace, StoreError>;
129
130 fn save_state(&self, workspace_root: &Path, workspace: &Workspace) -> Result<(), StoreError>;
136}
137
138#[derive(Debug, Default, Clone, Copy)]
144pub struct FileWorkspaceStore;
145
146impl FileWorkspaceStore {
147 pub fn new() -> Self {
150 Self
151 }
152
153 pub fn workspace_toml_path(workspace_root: &Path) -> PathBuf {
155 workspace_root
156 .join(WORKSPACE_STORE_DIR)
157 .join("workspace.toml")
158 }
159
160 pub fn mounts_json_path(workspace_root: &Path) -> PathBuf {
162 workspace_root
163 .join(WORKSPACE_STORE_DIR)
164 .join("state")
165 .join("mounts.json")
166 }
167}
168
169const WORKSPACE_TOML_FORMAT: &str = "memstead-git-branch-2";
170const WORKSPACE_TOML_FORMAT_LEGACY: &str = "memstead-git-branch-1";
174const MOUNTS_JSON_FORMAT_V3: &str = "memstead-mounts-3";
184const MOUNTS_JSON_FORMAT_LEGACY: [&str; 2] = ["memstead-mounts-1", "memstead-mounts-2"];
189
190#[derive(Deserialize)]
194struct MountsFormatProbe {
195 format: String,
196}
197
198fn check_workspace_toml_format(format: &str, toml_path: &Path) -> Result<(), StoreError> {
202 if format == WORKSPACE_TOML_FORMAT {
203 return Ok(());
204 }
205 if format == WORKSPACE_TOML_FORMAT_LEGACY {
206 return Err(StoreError::LegacyLayout {
207 path: toml_path.to_path_buf(),
208 found: format.to_string(),
209 });
210 }
211 Err(StoreError::FormatMismatch {
212 path: toml_path.to_path_buf(),
213 expected: WORKSPACE_TOML_FORMAT.to_string(),
214 found: format.to_string(),
215 })
216}
217
218fn absolutize_mount_path(value: PathBuf, workspace_root: &Path) -> PathBuf {
224 if value.is_absolute() {
225 value
226 } else {
227 workspace_root.join(value)
228 }
229}
230
231fn relativize_mount_path(value: &Path, workspace_root: &Path) -> PathBuf {
237 match value.strip_prefix(workspace_root) {
238 Ok(rel) => rel.to_path_buf(),
239 Err(_) => value.to_path_buf(),
240 }
241}
242
243pub fn is_workspace_root(dir: &Path) -> bool {
248 FileWorkspaceStore::workspace_toml_path(dir).is_file()
249}
250
251impl WorkspaceStoreAdapter for FileWorkspaceStore {
252 fn load(&self, workspace_root: &Path) -> Result<Workspace, StoreError> {
253 let memstead_dir = workspace_root.join(WORKSPACE_STORE_DIR);
254 if !memstead_dir.is_dir() {
255 return Err(StoreError::NotInitialised {
256 path: workspace_root.to_path_buf(),
257 });
258 }
259
260 let toml_path = Self::workspace_toml_path(workspace_root);
262 let toml_text = std::fs::read_to_string(&toml_path).map_err(|e| {
263 if e.kind() == std::io::ErrorKind::NotFound {
264 StoreError::NotInitialised {
265 path: workspace_root.to_path_buf(),
266 }
267 } else {
268 StoreError::Io {
269 path: toml_path.clone(),
270 source: e,
271 }
272 }
273 })?;
274 let toml_doc: WorkspaceTomlDoc =
275 toml::from_str(&toml_text).map_err(|e| StoreError::Parse {
276 path: toml_path.clone(),
277 message: e.to_string(),
278 })?;
279 check_workspace_toml_format(&toml_doc.format, &toml_path)?;
280
281 let mounts_path = Self::mounts_json_path(workspace_root);
285 let mounts: Vec<Mount> = match std::fs::read_to_string(&mounts_path) {
286 Ok(text) => {
287 let probe: MountsFormatProbe =
292 serde_json::from_str(&text).map_err(|e| StoreError::Parse {
293 path: mounts_path.clone(),
294 message: e.to_string(),
295 })?;
296 if MOUNTS_JSON_FORMAT_LEGACY.contains(&probe.format.as_str()) {
297 return Err(StoreError::LegacyLayout {
298 path: mounts_path,
299 found: probe.format,
300 });
301 }
302 if probe.format != MOUNTS_JSON_FORMAT_V3 {
303 return Err(StoreError::FormatMismatch {
304 path: mounts_path,
305 expected: MOUNTS_JSON_FORMAT_V3.to_string(),
306 found: probe.format,
307 });
308 }
309 let doc: MountsJsonDoc =
310 serde_json::from_str(&text).map_err(|e| StoreError::Parse {
311 path: mounts_path.clone(),
312 message: e.to_string(),
313 })?;
314 doc.mounts
315 .into_iter()
316 .map(|w| w.into_mount(workspace_root))
317 .collect()
318 }
319 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
320 Err(e) => {
321 return Err(StoreError::Io {
322 path: mounts_path,
323 source: e,
324 });
325 }
326 };
327
328 warn_if_legacy_schemas_dir(toml_doc.schemas_dir.as_deref());
329 let settings = build_settings(
330 toml_doc.mem_management,
331 toml_doc.cross_mem_links,
332 toml_doc.mcp,
333 toml_doc.mutations,
334 toml_doc.plugin,
335 )?;
336 Ok(Workspace { mounts, settings })
337 }
338
339 fn save_state(&self, workspace_root: &Path, workspace: &Workspace) -> Result<(), StoreError> {
340 let mounts_path = Self::mounts_json_path(workspace_root);
341 if let Some(parent) = mounts_path.parent() {
342 std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
343 path: parent.to_path_buf(),
344 source: e,
345 })?;
346 }
347 let doc = MountsJsonDoc {
348 format: MOUNTS_JSON_FORMAT_V3.to_string(),
349 mounts: workspace
350 .mounts
351 .iter()
352 .map(|m| MountWire::from_mount(m, workspace_root))
353 .collect(),
354 };
355 let text = serde_json::to_string_pretty(&doc).map_err(|e| StoreError::Parse {
356 path: mounts_path.clone(),
357 message: e.to_string(),
358 })?;
359 std::fs::write(&mounts_path, text).map_err(|e| StoreError::Io {
360 path: mounts_path,
361 source: e,
362 })?;
363 Ok(())
364 }
365}
366
367#[derive(Debug, Serialize, Deserialize)]
372#[serde(deny_unknown_fields)]
373struct WorkspaceTomlDoc {
374 format: String,
378 #[serde(default)]
382 persistence_adapter: PersistenceAdapterDecl,
383 #[serde(default)]
388 mem_management: MemManagementWire,
389 #[serde(default)]
396 cross_mem_links: toml::Table,
397 #[serde(default)]
404 schemas_dir: Option<std::path::PathBuf>,
405 #[serde(default)]
409 mcp: McpSection,
410 #[serde(default)]
413 mutations: MutationsSection,
414 #[serde(default)]
418 plugin: std::collections::HashMap<String, toml::Table>,
419}
420
421#[derive(Debug, Default, Serialize, Deserialize)]
425struct MemManagementWire {
426 #[serde(default)]
427 create: Vec<CreateRuleWire>,
428 #[serde(default)]
429 delete: Vec<DeleteRuleWire>,
430}
431
432#[derive(Debug, Serialize, Deserialize)]
442struct CreateRuleWire {
443 pattern: String,
444 #[serde(default)]
445 schemas: Vec<String>,
446 #[serde(default)]
447 default_cross_links: Option<toml::Value>,
448}
449
450#[derive(Debug, Serialize, Deserialize)]
453struct DeleteRuleWire {
454 pattern: String,
455}
456
457pub fn parse_workspace_settings(
475 workspace_root: &Path,
476) -> Result<crate::workspace::WorkspaceSettings, StoreError> {
477 let memstead_dir = workspace_root.join(WORKSPACE_STORE_DIR);
478 if !memstead_dir.is_dir() {
479 return Err(StoreError::NotInitialised {
480 path: workspace_root.to_path_buf(),
481 });
482 }
483 let toml_path = FileWorkspaceStore::workspace_toml_path(workspace_root);
484 let toml_text = std::fs::read_to_string(&toml_path).map_err(|e| {
485 if e.kind() == std::io::ErrorKind::NotFound {
486 StoreError::NotInitialised {
487 path: workspace_root.to_path_buf(),
488 }
489 } else {
490 StoreError::Io {
491 path: toml_path.clone(),
492 source: e,
493 }
494 }
495 })?;
496 let toml_doc: WorkspaceTomlDoc = toml::from_str(&toml_text).map_err(|e| StoreError::Parse {
497 path: toml_path.clone(),
498 message: e.to_string(),
499 })?;
500 check_workspace_toml_format(&toml_doc.format, &toml_path)?;
501 warn_if_legacy_schemas_dir(toml_doc.schemas_dir.as_deref());
502 build_settings(
503 toml_doc.mem_management,
504 toml_doc.cross_mem_links,
505 toml_doc.mcp,
506 toml_doc.mutations,
507 toml_doc.plugin,
508 )
509}
510
511fn build_settings(
517 vm: MemManagementWire,
518 cross_mem_links_raw: toml::Table,
519 mcp: McpSection,
520 mutations: MutationsSection,
521 plugin: std::collections::HashMap<String, toml::Table>,
522) -> Result<WorkspaceSettings, StoreError> {
523 let mut create_rules = Vec::with_capacity(vm.create.len());
524 for r in vm.create {
525 let default_cross_links = match r.default_cross_links {
526 None => None,
527 Some(value) => {
528 let location = format!(
529 "[[mem_management.create]] pattern={}.default_cross_links",
530 r.pattern
531 );
532 Some(parse_cross_link_value(&location, &value)?)
533 }
534 };
535 create_rules.push(crate::workspace::CreateRuleSetting {
536 pattern: r.pattern,
537 schemas: r.schemas,
538 default_cross_links,
539 });
540 }
541
542 let mut cross_mem_links = std::collections::BTreeMap::new();
543 for (mem, value) in &cross_mem_links_raw {
544 let location = format!("[cross_mem_links].{mem}");
545 let parsed = parse_cross_link_value(&location, value)?;
546 cross_mem_links.insert(mem.clone(), parsed);
547 }
548
549 Ok(WorkspaceSettings {
550 mem_create_rules: create_rules,
551 mem_delete_rules: vm
552 .delete
553 .into_iter()
554 .map(|r| crate::workspace::DeleteRuleSetting { pattern: r.pattern })
555 .collect(),
556 cross_mem_links,
557 mcp,
558 mutations,
559 plugin,
560 })
561}
562
563fn warn_if_legacy_schemas_dir(schemas_dir: Option<&std::path::Path>) {
570 if let Some(dir) = schemas_dir {
571 tracing::warn!(
572 "`schemas_dir` (= {:?}) in workspace.toml is retired and ignored — \
573 authored schemas are read from the fixed `<workspace>/.memstead/schemas/`. \
574 Remove the key to silence this warning.",
575 dir
576 );
577 }
578}
579
580fn parse_cross_link_value(
584 location: &str,
585 value: &toml::Value,
586) -> Result<memstead_schema::workspace_config::CrossLinkValue, StoreError> {
587 memstead_schema::workspace_config::CrossLinkValue::parse_toml(location, value).map_err(|e| {
588 StoreError::Parse {
589 path: std::path::PathBuf::from("workspace.toml"),
590 message: e.to_string(),
591 }
592 })
593}
594
595#[derive(Debug, Serialize, Deserialize)]
598struct PersistenceAdapterDecl {
599 name: String,
600}
601
602impl Default for PersistenceAdapterDecl {
603 fn default() -> Self {
604 Self {
605 name: "file-two-layer".to_string(),
606 }
607 }
608}
609
610#[derive(Debug, Serialize, Deserialize)]
614struct MountsJsonDoc {
615 format: String,
616 mounts: Vec<MountWire>,
617}
618
619#[derive(Debug, Serialize, Deserialize)]
624struct MountWire {
625 mem: String,
626 #[serde(default, skip_serializing_if = "Option::is_none")]
632 schema: Option<String>,
633 #[serde(default, skip_serializing_if = "Option::is_none")]
638 migration_target: Option<String>,
639 storage: MountStorageWire,
640 capability: CapabilityWire,
641 lifecycle: LifecycleWire,
642 cross_linkable: bool,
643}
644
645#[derive(Debug, Serialize, Deserialize)]
646#[serde(tag = "type", rename_all = "kebab-case")]
647enum MountStorageWire {
648 Folder {
649 path: PathBuf,
650 },
651 GitBranch {
652 gitdir: PathBuf,
653 branch: String,
654 },
655 Archive {
656 path: PathBuf,
657 },
658 InMemory,
666}
667
668#[derive(Debug, Serialize, Deserialize)]
669#[serde(rename_all = "kebab-case")]
670enum CapabilityWire {
671 ReadOnly,
672 Write,
673}
674
675#[derive(Debug, Serialize, Deserialize)]
676#[serde(rename_all = "kebab-case")]
677enum LifecycleWire {
678 Eager,
679 Lazy,
680}
681
682impl MountWire {
683 fn from_mount(m: &Mount, workspace_root: &Path) -> Self {
684 Self {
685 mem: m.mem.clone(),
686 schema: m.schema.as_ref().map(|s| s.to_string()),
687 migration_target: m.migration_target.as_ref().map(|t| t.to_string()),
688 storage: match &m.storage {
689 MountStorage::Folder { path } => MountStorageWire::Folder {
690 path: relativize_mount_path(path, workspace_root),
691 },
692 MountStorage::GitBranch { gitdir, branch } => MountStorageWire::GitBranch {
693 gitdir: relativize_mount_path(gitdir, workspace_root),
694 branch: branch.clone(),
695 },
696 MountStorage::Archive { path } => MountStorageWire::Archive {
697 path: relativize_mount_path(path, workspace_root),
698 },
699 MountStorage::InMemory => MountStorageWire::InMemory,
700 },
701 capability: match m.capability {
702 MountCapability::ReadOnly => CapabilityWire::ReadOnly,
703 MountCapability::Write => CapabilityWire::Write,
704 },
705 lifecycle: match m.lifecycle {
706 MountLifecycle::Eager => LifecycleWire::Eager,
707 MountLifecycle::Lazy => LifecycleWire::Lazy,
708 },
709 cross_linkable: m.cross_linkable,
710 }
711 }
712
713 fn into_mount(self, workspace_root: &Path) -> Mount {
714 Mount {
715 mem: self.mem,
716 schema: self.schema.map(|s| {
717 s.parse()
718 .expect("schema pin on disk must be `<name>@<version>`")
719 }),
720 migration_target: self.migration_target.map(|t| {
721 t.parse()
722 .expect("migration_target on disk must be `<name>@<version>`")
723 }),
724 storage: match self.storage {
725 MountStorageWire::Folder { path } => MountStorage::Folder {
726 path: absolutize_mount_path(path, workspace_root),
727 },
728 MountStorageWire::GitBranch { gitdir, branch } => MountStorage::GitBranch {
729 gitdir: absolutize_mount_path(gitdir, workspace_root),
730 branch,
731 },
732 MountStorageWire::Archive { path } => MountStorage::Archive {
733 path: absolutize_mount_path(path, workspace_root),
734 },
735 MountStorageWire::InMemory => MountStorage::InMemory,
736 },
737 capability: match self.capability {
738 CapabilityWire::ReadOnly => MountCapability::ReadOnly,
739 CapabilityWire::Write => MountCapability::Write,
740 },
741 lifecycle: match self.lifecycle {
742 LifecycleWire::Eager => MountLifecycle::Eager,
743 LifecycleWire::Lazy => MountLifecycle::Lazy,
744 },
745 cross_linkable: self.cross_linkable,
746 }
747 }
748}
749
750#[derive(Debug, thiserror::Error)]
752pub enum InstantiateError {
753 #[error(
760 "mem {mem}: git-branch backend requires the `mem-repo` feature; \
761 use `instantiate_full_backend` from memstead-git-branch, or rebuild with --features mem-repo"
762 )]
763 GitBranchRequiresMemRepoFeature { mem: String },
764}
765
766pub fn instantiate_lean_backend(mount: &Mount) -> Result<Box<dyn MemBackend>, InstantiateError> {
776 match &mount.storage {
777 MountStorage::Folder { path } => Ok(Box::new(FilesystemMemWriter::new(path.clone()))),
778 MountStorage::Archive { path } => Ok(Box::new(ArchiveBackend::new(path.clone()))),
779 MountStorage::InMemory => Ok(Box::new(InMemoryBackend::new())),
780 MountStorage::GitBranch { .. } => Err(InstantiateError::GitBranchRequiresMemRepoFeature {
781 mem: mount.mem.clone(),
782 }),
783 }
784}
785
786#[derive(Debug, Clone, Copy, PartialEq, Eq)]
794pub enum Layout {
795 Empty,
798 New,
801}
802
803pub fn detect_layout(workspace_root: &Path) -> Layout {
807 if is_workspace_root(workspace_root) {
808 Layout::New
809 } else {
810 Layout::Empty
811 }
812}
813
814pub fn standalone_workspace(workspace_root: &Path) -> Option<Workspace> {
834 let config = memstead_schema::config::load_and_validate(workspace_root).ok()?;
835 let schema = config.schema.clone()?;
836 let name = config.name.clone().unwrap_or_else(|| {
837 workspace_root
838 .file_name()
839 .map(|n| n.to_string_lossy().to_string())
840 .unwrap_or_else(|| "mem".to_string())
841 });
842 let mount = Mount {
843 mem: name,
844 schema: Some(schema),
845 storage: MountStorage::Folder {
846 path: workspace_root.to_path_buf(),
847 },
848 capability: MountCapability::Write,
849 lifecycle: MountLifecycle::Eager,
850 cross_linkable: false,
851 migration_target: None,
852 };
853 Some(Workspace {
854 mounts: vec![mount],
855 settings: WorkspaceSettings::default(),
856 })
857}
858
859#[cfg(test)]
860mod tests {
861 use super::*;
862 use memstead_schema::SchemaRef;
863 use std::io::Write as _;
864 use tempfile::TempDir;
865
866 fn pin(s: &str) -> SchemaRef {
867 s.parse().unwrap()
868 }
869
870 fn folder_mount(mem: &str, path: PathBuf) -> Mount {
871 Mount {
872 mem: mem.to_string(),
873 schema: Some(pin("default@1.0.0")),
874 storage: MountStorage::Folder { path },
875 capability: MountCapability::Write,
876 lifecycle: MountLifecycle::Eager,
877 cross_linkable: true,
878 migration_target: None,
879 }
880 }
881
882 fn write_workspace_toml(workspace_root: &Path, body: &str) {
883 let path = FileWorkspaceStore::workspace_toml_path(workspace_root);
884 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
885 std::fs::write(path, body).unwrap();
886 }
887
888 #[test]
889 fn load_returns_not_initialised_when_memstead_dir_absent() {
890 let tmp = TempDir::new().unwrap();
891 let store = FileWorkspaceStore::new();
892 let err = store.load(tmp.path()).unwrap_err();
893 assert!(matches!(err, StoreError::NotInitialised { .. }));
894 }
895
896 #[test]
902 fn parse_workspace_settings_reflects_cross_mem_links_edit() {
903 let tmp = TempDir::new().unwrap();
904 write_workspace_toml(
905 tmp.path(),
906 r#"
907format = "memstead-git-branch-2"
908
909[persistence_adapter]
910name = "file-two-layer"
911
912[cross_mem_links]
913team-a = ["team-b"]
914"#,
915 );
916 let settings = super::parse_workspace_settings(tmp.path()).unwrap();
917 assert!(
918 settings.cross_mem_links.contains_key("team-a"),
919 "initial parse must surface the team-a grant; got {:?}",
920 settings.cross_mem_links
921 );
922
923 write_workspace_toml(
925 tmp.path(),
926 r#"
927format = "memstead-git-branch-2"
928
929[persistence_adapter]
930name = "file-two-layer"
931
932[cross_mem_links]
933"#,
934 );
935 let refreshed = super::parse_workspace_settings(tmp.path()).unwrap();
936 assert!(
937 refreshed.cross_mem_links.is_empty(),
938 "refreshed parse must drop the team-a grant; got {:?}",
939 refreshed.cross_mem_links
940 );
941 }
942
943 #[test]
948 fn parse_workspace_settings_reflects_allowlist_edit() {
949 let tmp = TempDir::new().unwrap();
950 write_workspace_toml(
951 tmp.path(),
952 r#"
953format = "memstead-git-branch-2"
954
955[persistence_adapter]
956name = "file-two-layer"
957"#,
958 );
959 let initial = super::parse_workspace_settings(tmp.path()).unwrap();
960 assert!(initial.mem_create_rules.is_empty());
961
962 write_workspace_toml(
964 tmp.path(),
965 r#"
966format = "memstead-git-branch-2"
967
968[persistence_adapter]
969name = "file-two-layer"
970
971[[mem_management.create]]
972pattern = "test-*"
973schemas = ["default@1.0.0"]
974"#,
975 );
976 let refreshed = super::parse_workspace_settings(tmp.path()).unwrap();
977 assert_eq!(refreshed.mem_create_rules.len(), 1);
978 assert_eq!(refreshed.mem_create_rules[0].pattern, "test-*");
979 }
980
981 #[test]
982 fn load_returns_not_initialised_when_workspace_toml_missing() {
983 let tmp = TempDir::new().unwrap();
984 std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
985 let store = FileWorkspaceStore::new();
986 let err = store.load(tmp.path()).unwrap_err();
987 assert!(matches!(err, StoreError::NotInitialised { .. }));
988 }
989
990 #[test]
991 fn load_with_no_mounts_yields_empty_mount_list() {
992 let tmp = TempDir::new().unwrap();
993 write_workspace_toml(
994 tmp.path(),
995 r#"
996format = "memstead-git-branch-2"
997
998[persistence_adapter]
999name = "file-two-layer"
1000"#,
1001 );
1002 let store = FileWorkspaceStore::new();
1003 let workspace = store.load(tmp.path()).unwrap();
1004 assert!(workspace.mounts.is_empty());
1005 }
1006
1007 #[test]
1008 fn load_with_no_mem_management_yields_empty_settings() {
1009 let tmp = TempDir::new().unwrap();
1014 write_workspace_toml(
1015 tmp.path(),
1016 r#"
1017format = "memstead-git-branch-2"
1018
1019[persistence_adapter]
1020name = "file-two-layer"
1021"#,
1022 );
1023 let store = FileWorkspaceStore::new();
1024 let workspace = store.load(tmp.path()).unwrap();
1025 assert!(workspace.settings.mem_create_rules.is_empty());
1026 assert!(workspace.settings.mem_delete_rules.is_empty());
1027 assert!(workspace.settings.cross_mem_links.is_empty());
1028 }
1029
1030 #[test]
1031 fn load_picks_up_cross_mem_links_wildcard_and_list() {
1032 use memstead_schema::workspace_config::CrossLinkValue;
1037 let tmp = TempDir::new().unwrap();
1038 write_workspace_toml(
1039 tmp.path(),
1040 r#"
1041format = "memstead-git-branch-2"
1042
1043[persistence_adapter]
1044name = "file-two-layer"
1045
1046[cross_mem_links]
1047specs = "*"
1048engine = ["specs", "macos"]
1049locked = []
1050"#,
1051 );
1052 let store = FileWorkspaceStore::new();
1053 let workspace = store.load(tmp.path()).unwrap();
1054 let cvl = &workspace.settings.cross_mem_links;
1055 assert_eq!(cvl.len(), 3);
1056 assert_eq!(cvl.get("specs"), Some(&CrossLinkValue::Wildcard));
1057 assert_eq!(
1058 cvl.get("engine"),
1059 Some(&CrossLinkValue::List(vec![
1060 "specs".to_string(),
1061 "macos".to_string()
1062 ]))
1063 );
1064 assert_eq!(cvl.get("locked"), Some(&CrossLinkValue::List(vec![])));
1065 }
1066
1067 #[test]
1068 fn load_rejects_cross_mem_links_mixed_wildcard_and_names() {
1069 let tmp = TempDir::new().unwrap();
1074 write_workspace_toml(
1075 tmp.path(),
1076 r#"
1077format = "memstead-git-branch-2"
1078
1079[persistence_adapter]
1080name = "file-two-layer"
1081
1082[cross_mem_links]
1083specs = ["*", "engine"]
1084"#,
1085 );
1086 let store = FileWorkspaceStore::new();
1087 let err = store.load(tmp.path()).unwrap_err();
1088 match err {
1089 StoreError::Parse { message, .. } => {
1090 assert!(message.contains("[cross_mem_links].specs"));
1091 assert!(message.contains("wildcard"));
1092 }
1093 other => panic!("expected StoreError::Parse, got {other:?}"),
1094 }
1095 }
1096
1097 #[test]
1098 fn load_picks_up_default_cross_links_on_create_rule() {
1099 use memstead_schema::workspace_config::CrossLinkValue;
1103 let tmp = TempDir::new().unwrap();
1104 write_workspace_toml(
1105 tmp.path(),
1106 r#"
1107format = "memstead-git-branch-2"
1108
1109[persistence_adapter]
1110name = "file-two-layer"
1111
1112[[mem_management.create]]
1113pattern = "exec-*"
1114schemas = ["default"]
1115default_cross_links = "*"
1116"#,
1117 );
1118 let store = FileWorkspaceStore::new();
1119 let workspace = store.load(tmp.path()).unwrap();
1120 let rule = &workspace.settings.mem_create_rules[0];
1121 assert_eq!(rule.pattern, "exec-*");
1122 assert_eq!(rule.default_cross_links, Some(CrossLinkValue::Wildcard));
1123 }
1124
1125 #[test]
1126 fn load_picks_up_mem_management_create_and_delete_rules() {
1127 let tmp = TempDir::new().unwrap();
1131 write_workspace_toml(
1132 tmp.path(),
1133 r#"
1134format = "memstead-git-branch-2"
1135
1136[persistence_adapter]
1137name = "file-two-layer"
1138
1139[[mem_management.create]]
1140pattern = "exec-*"
1141schemas = ["default@1.0.0", "*"]
1142
1143[[mem_management.create]]
1144pattern = "scratch-*"
1145schemas = ["default"]
1146
1147[[mem_management.delete]]
1148pattern = "exec-*"
1149"#,
1150 );
1151 let store = FileWorkspaceStore::new();
1152 let workspace = store.load(tmp.path()).unwrap();
1153 assert_eq!(workspace.settings.mem_create_rules.len(), 2);
1154 assert_eq!(workspace.settings.mem_create_rules[0].pattern, "exec-*");
1155 assert_eq!(
1156 workspace.settings.mem_create_rules[0].schemas,
1157 vec!["default@1.0.0".to_string(), "*".to_string()]
1158 );
1159 assert_eq!(workspace.settings.mem_create_rules[1].pattern, "scratch-*");
1160 assert_eq!(workspace.settings.mem_delete_rules.len(), 1);
1161 assert_eq!(workspace.settings.mem_delete_rules[0].pattern, "exec-*");
1162 }
1163
1164 #[test]
1169 fn save_state_round_trips_migration_target() {
1170 let tmp = TempDir::new().unwrap();
1171 write_workspace_toml(
1172 tmp.path(),
1173 "\nformat = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1174 );
1175 let store = FileWorkspaceStore::new();
1176 let mut migrating = folder_mount("specs", PathBuf::from("/work/mem"));
1177 migrating.migration_target = Some(pin("mig-b@0.1.0"));
1178 let settled = folder_mount("other", PathBuf::from("/work/other"));
1179 let original = Workspace {
1180 mounts: vec![migrating, settled],
1181 settings: WorkspaceSettings::default(),
1182 };
1183 store.save_state(tmp.path(), &original).unwrap();
1184 let raw =
1185 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1186 assert!(
1187 raw.contains("mig-b@0.1.0"),
1188 "migration_target must persist: {raw}"
1189 );
1190 assert_eq!(
1191 raw.matches("migration_target").count(),
1192 1,
1193 "settled mounts must omit the key entirely: {raw}"
1194 );
1195 let loaded = store.load(tmp.path()).unwrap();
1196 assert_eq!(loaded.mounts[0].migration_target, Some(pin("mig-b@0.1.0")));
1197 assert_eq!(loaded.mounts[1].migration_target, None);
1198 }
1199
1200 #[test]
1201 fn save_state_then_load_round_trips_mount_list() {
1202 let tmp = TempDir::new().unwrap();
1203 write_workspace_toml(
1204 tmp.path(),
1205 r#"
1206format = "memstead-git-branch-2"
1207
1208[persistence_adapter]
1209name = "file-two-layer"
1210"#,
1211 );
1212 let store = FileWorkspaceStore::new();
1213 let original = Workspace {
1214 mounts: vec![
1215 folder_mount("specs", PathBuf::from("/work/mem")),
1216 Mount {
1217 mem: "engine".to_string(),
1218 schema: Some(pin("default@1.0.0")),
1219 storage: MountStorage::GitBranch {
1220 gitdir: PathBuf::from("/work/mem-repo/.git"),
1221 branch: "engine".to_string(),
1222 },
1223 capability: MountCapability::Write,
1224 lifecycle: MountLifecycle::Eager,
1225 cross_linkable: true,
1226 migration_target: None,
1227 },
1228 Mount {
1229 mem: "external".to_string(),
1230 schema: Some(pin("default@1.0.0")),
1231 storage: MountStorage::Archive {
1232 path: PathBuf::from("/deps/external.mem"),
1233 },
1234 capability: MountCapability::ReadOnly,
1235 lifecycle: MountLifecycle::Lazy,
1236 cross_linkable: false,
1237 migration_target: None,
1238 },
1239 ],
1240 settings: WorkspaceSettings::default(),
1241 };
1242 store.save_state(tmp.path(), &original).unwrap();
1243
1244 assert!(FileWorkspaceStore::mounts_json_path(tmp.path()).is_file());
1246
1247 let reloaded = store.load(tmp.path()).unwrap();
1248 assert_eq!(reloaded.mounts.len(), original.mounts.len());
1249 for (a, b) in reloaded.mounts.iter().zip(original.mounts.iter()) {
1250 assert_eq!(a.mem, b.mem);
1251 assert_eq!(a.schema, b.schema);
1252 assert_eq!(a.capability, b.capability);
1253 assert_eq!(a.lifecycle, b.lifecycle);
1254 assert_eq!(a.cross_linkable, b.cross_linkable);
1255 assert_eq!(a.storage, b.storage);
1256 }
1257 }
1258
1259 #[test]
1260 fn save_state_round_trips_unset_schema_assertion() {
1261 let tmp = TempDir::new().unwrap();
1266 write_workspace_toml(
1267 tmp.path(),
1268 r#"
1269format = "memstead-git-branch-2"
1270
1271[persistence_adapter]
1272name = "file-two-layer"
1273"#,
1274 );
1275 let store = FileWorkspaceStore::new();
1276 let original = Workspace {
1277 mounts: vec![Mount {
1278 mem: "foreign".to_string(),
1279 schema: None,
1280 storage: MountStorage::Folder {
1281 path: tmp.path().join("foreign"),
1282 },
1283 capability: MountCapability::ReadOnly,
1284 lifecycle: MountLifecycle::Eager,
1285 cross_linkable: false,
1286 migration_target: None,
1287 }],
1288 settings: WorkspaceSettings::default(),
1289 };
1290 store.save_state(tmp.path(), &original).unwrap();
1291
1292 let raw =
1294 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1295 assert!(
1296 !raw.contains("\"schema\""),
1297 "unset schema assertion must omit the key on the wire; got:\n{raw}"
1298 );
1299
1300 let reloaded = store.load(tmp.path()).unwrap();
1302 assert_eq!(reloaded.mounts.len(), 1);
1303 assert_eq!(reloaded.mounts[0].schema, None);
1304 }
1305
1306 #[test]
1307 fn save_state_does_not_touch_workspace_toml() {
1308 let tmp = TempDir::new().unwrap();
1309 let original_body = r#"
1310format = "memstead-git-branch-2"
1311
1312[persistence_adapter]
1313name = "file-two-layer"
1314"#;
1315 write_workspace_toml(tmp.path(), original_body);
1316 let store = FileWorkspaceStore::new();
1317 let workspace = Workspace::default();
1318 store.save_state(tmp.path(), &workspace).unwrap();
1319 let toml_after =
1321 std::fs::read_to_string(FileWorkspaceStore::workspace_toml_path(tmp.path())).unwrap();
1322 assert_eq!(toml_after, original_body);
1323 }
1324
1325 #[test]
1326 fn save_state_writes_paths_relative_to_workspace_root() {
1327 let tmp = TempDir::new().unwrap();
1328 write_workspace_toml(
1329 tmp.path(),
1330 r#"
1331format = "memstead-git-branch-2"
1332
1333[persistence_adapter]
1334name = "file-two-layer"
1335"#,
1336 );
1337 let store = FileWorkspaceStore::new();
1338 let workspace = Workspace {
1339 mounts: vec![
1340 Mount {
1341 mem: "engine".to_string(),
1342 schema: Some(pin("default@1.0.0")),
1343 storage: MountStorage::GitBranch {
1344 gitdir: tmp.path().join("mem-repo").join(".git"),
1345 branch: "engine".to_string(),
1346 },
1347 capability: MountCapability::Write,
1348 lifecycle: MountLifecycle::Eager,
1349 cross_linkable: true,
1350 migration_target: None,
1351 },
1352 Mount {
1353 mem: "external".to_string(),
1354 schema: Some(pin("default@1.0.0")),
1355 storage: MountStorage::Archive {
1356 path: PathBuf::from("/global/cache/external.mem"),
1357 },
1358 capability: MountCapability::ReadOnly,
1359 lifecycle: MountLifecycle::Lazy,
1360 cross_linkable: false,
1361 migration_target: None,
1362 },
1363 ],
1364 settings: WorkspaceSettings::default(),
1365 };
1366 store.save_state(tmp.path(), &workspace).unwrap();
1367
1368 let on_disk =
1369 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1370 assert!(on_disk.contains("\"memstead-mounts-3\""));
1372 assert!(
1374 on_disk.contains("\"mem-repo/.git\""),
1375 "expected relative gitdir, got: {on_disk}"
1376 );
1377 assert!(
1378 !on_disk.contains(tmp.path().to_str().unwrap()),
1379 "in-workspace path should not include the absolute tmp prefix: {on_disk}"
1380 );
1381 assert!(on_disk.contains("\"/global/cache/external.mem\""));
1383
1384 let reloaded = store.load(tmp.path()).unwrap();
1386 match &reloaded.mounts[0].storage {
1387 MountStorage::GitBranch { gitdir, .. } => {
1388 assert_eq!(gitdir, &tmp.path().join("mem-repo").join(".git"));
1389 }
1390 other => panic!("expected GitBranch storage, got {other:?}"),
1391 }
1392 match &reloaded.mounts[1].storage {
1393 MountStorage::Archive { path } => {
1394 assert_eq!(path, &PathBuf::from("/global/cache/external.mem"));
1395 }
1396 other => panic!("expected Archive storage, got {other:?}"),
1397 }
1398 }
1399
1400 #[test]
1401 fn load_absolute_inside_root_path_then_save_rewrites_relative() {
1402 let tmp = TempDir::new().unwrap();
1403 write_workspace_toml(
1404 tmp.path(),
1405 r#"
1406format = "memstead-git-branch-2"
1407
1408[persistence_adapter]
1409name = "file-two-layer"
1410"#,
1411 );
1412 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1417 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1418 let abs_gitdir = tmp.path().join("mem-repo").join(".git");
1419 let mounts_body = format!(
1420 r#"{{
1421 "format": "memstead-mounts-3",
1422 "mounts": [
1423 {{
1424 "mem": "engine",
1425 "schema": "default@1.0.0",
1426 "storage": {{
1427 "type": "git-branch",
1428 "gitdir": "{}",
1429 "branch": "engine"
1430 }},
1431 "capability": "write",
1432 "lifecycle": "eager",
1433 "cross_linkable": true
1434 }}
1435 ]
1436}}"#,
1437 abs_gitdir.to_str().unwrap()
1438 );
1439 std::fs::write(&mounts_path, &mounts_body).unwrap();
1440
1441 let store = FileWorkspaceStore::new();
1442 let workspace = store.load(tmp.path()).unwrap();
1445 match &workspace.mounts[0].storage {
1446 MountStorage::GitBranch { gitdir, .. } => assert_eq!(gitdir, &abs_gitdir),
1447 other => panic!("expected GitBranch storage, got {other:?}"),
1448 }
1449
1450 store.save_state(tmp.path(), &workspace).unwrap();
1453 let on_disk = std::fs::read_to_string(&mounts_path).unwrap();
1454 assert!(on_disk.contains("\"memstead-mounts-3\""));
1455 assert!(on_disk.contains("\"mem-repo/.git\""));
1456 assert!(!on_disk.contains(tmp.path().to_str().unwrap()));
1457 }
1458
1459 #[test]
1468 fn save_state_preserves_refs_heads_branch_form() {
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 store = FileWorkspaceStore::new();
1480 let original = Workspace {
1481 mounts: vec![Mount {
1482 mem: "engine".to_string(),
1483 schema: Some(pin("default@1.0.0")),
1484 storage: MountStorage::GitBranch {
1485 gitdir: tmp.path().join("mem-repo").join(".git"),
1486 branch: "refs/heads/demo/engine".to_string(),
1487 },
1488 capability: MountCapability::Write,
1489 lifecycle: MountLifecycle::Eager,
1490 cross_linkable: true,
1491 migration_target: None,
1492 }],
1493 settings: WorkspaceSettings::default(),
1494 };
1495 store.save_state(tmp.path(), &original).unwrap();
1496
1497 let on_disk =
1498 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1499 assert!(
1500 on_disk.contains("\"branch\": \"refs/heads/demo/engine\""),
1501 "expected fully-qualified ref on disk, got: {on_disk}"
1502 );
1503
1504 let reloaded = store.load(tmp.path()).unwrap();
1505 match &reloaded.mounts[0].storage {
1506 MountStorage::GitBranch { branch, .. } => {
1507 assert_eq!(branch, "refs/heads/demo/engine");
1508 }
1509 other => panic!("expected GitBranch storage, got {other:?}"),
1510 }
1511 }
1512
1513 #[test]
1523 fn load_preserves_short_form_branch_without_rewrite() {
1524 let tmp = TempDir::new().unwrap();
1525 write_workspace_toml(
1526 tmp.path(),
1527 r#"
1528format = "memstead-git-branch-2"
1529
1530[persistence_adapter]
1531name = "file-two-layer"
1532"#,
1533 );
1534 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1535 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1536 std::fs::write(
1537 &mounts_path,
1538 r#"{
1539 "format": "memstead-mounts-3",
1540 "mounts": [
1541 {
1542 "mem": "engine",
1543 "schema": "default@1.0.0",
1544 "storage": {
1545 "type": "git-branch",
1546 "gitdir": "mem-repo/.git",
1547 "branch": "demo/engine"
1548 },
1549 "capability": "write",
1550 "lifecycle": "eager",
1551 "cross_linkable": true
1552 }
1553 ]
1554}"#,
1555 )
1556 .unwrap();
1557
1558 let store = FileWorkspaceStore::new();
1559 let workspace = store.load(tmp.path()).unwrap();
1560 match &workspace.mounts[0].storage {
1561 MountStorage::GitBranch { branch, .. } => {
1562 assert_eq!(
1563 branch, "demo/engine",
1564 "reader must not silently rewrite short-form branch"
1565 );
1566 }
1567 other => panic!("expected GitBranch storage, got {other:?}"),
1568 }
1569 }
1570
1571 #[test]
1572 fn load_rejects_format_version_mismatch_on_toml() {
1573 let tmp = TempDir::new().unwrap();
1574 write_workspace_toml(
1575 tmp.path(),
1576 r#"
1577format = "memstead-git-branch-99"
1578
1579[persistence_adapter]
1580name = "file-two-layer"
1581"#,
1582 );
1583 let store = FileWorkspaceStore::new();
1584 let err = store.load(tmp.path()).unwrap_err();
1585 match err {
1586 StoreError::FormatMismatch {
1587 expected, found, ..
1588 } => {
1589 assert_eq!(expected, "memstead-git-branch-2");
1590 assert_eq!(found, "memstead-git-branch-99");
1591 }
1592 other => panic!("expected FormatMismatch, got {other:?}"),
1593 }
1594 }
1595
1596 #[test]
1597 fn load_rejects_format_version_mismatch_on_mounts_json() {
1598 let tmp = TempDir::new().unwrap();
1599 write_workspace_toml(
1600 tmp.path(),
1601 r#"
1602format = "memstead-git-branch-2"
1603
1604[persistence_adapter]
1605name = "file-two-layer"
1606"#,
1607 );
1608 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1609 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1610 std::fs::write(
1611 &mounts_path,
1612 r#"{ "format": "memstead-mounts-99", "mounts": [] }"#,
1613 )
1614 .unwrap();
1615 let store = FileWorkspaceStore::new();
1616 let err = store.load(tmp.path()).unwrap_err();
1617 assert!(matches!(err, StoreError::FormatMismatch { .. }));
1618 }
1619
1620 #[test]
1623 fn load_refuses_pre_rename_toml_as_legacy_layout() {
1624 let tmp = TempDir::new().unwrap();
1625 write_workspace_toml(
1626 tmp.path(),
1627 r#"
1628format = "memstead-git-branch-1"
1629
1630[persistence_adapter]
1631name = "file-two-layer"
1632"#,
1633 );
1634 let store = FileWorkspaceStore::new();
1635 let err = store.load(tmp.path()).unwrap_err();
1636 match err {
1637 StoreError::LegacyLayout { found, .. } => {
1638 assert_eq!(found, "memstead-git-branch-1");
1639 }
1640 other => panic!("expected LegacyLayout, got {other:?}"),
1641 }
1642 }
1643
1644 #[test]
1651 fn load_refuses_pre_rename_mounts_json_as_legacy_layout() {
1652 for legacy in ["memstead-mounts-1", "memstead-mounts-2"] {
1653 let tmp = TempDir::new().unwrap();
1654 write_workspace_toml(
1655 tmp.path(),
1656 r#"
1657format = "memstead-git-branch-2"
1658
1659[persistence_adapter]
1660name = "file-two-layer"
1661"#,
1662 );
1663 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1664 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1665 std::fs::write(
1666 &mounts_path,
1667 format!(
1668 r#"{{ "format": "{legacy}", "mounts": [{{ "unit": "notes", "storage": {{ "type": "folder", "path": "notes" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#
1669 ),
1670 )
1671 .unwrap();
1672 let store = FileWorkspaceStore::new();
1673 let err = store.load(tmp.path()).unwrap_err();
1674 match err {
1675 StoreError::LegacyLayout { found, .. } => assert_eq!(found, legacy),
1676 other => panic!("expected LegacyLayout for {legacy}, got {other:?}"),
1677 }
1678 }
1679 }
1680
1681 #[test]
1682 fn load_rejects_invalid_toml() {
1683 let tmp = TempDir::new().unwrap();
1684 write_workspace_toml(tmp.path(), "this is not = valid = toml");
1685 let store = FileWorkspaceStore::new();
1686 let err = store.load(tmp.path()).unwrap_err();
1687 assert!(matches!(err, StoreError::Parse { .. }));
1688 }
1689
1690 #[test]
1691 fn load_rejects_unknown_top_level_key() {
1692 let tmp = TempDir::new().unwrap();
1696 write_workspace_toml(
1697 tmp.path(),
1698 "format = \"memstead-git-branch-2\"\nnonexistent_key = true\n",
1699 );
1700 let store = FileWorkspaceStore::new();
1701 let err = store.load(tmp.path()).unwrap_err();
1702 match err {
1703 StoreError::Parse { message, .. } => {
1704 assert!(
1705 message.contains("nonexistent_key"),
1706 "refusal must name the unknown key: {message}"
1707 );
1708 }
1709 other => panic!("expected Parse error, got {other:?}"),
1710 }
1711 }
1712
1713 #[test]
1714 fn instantiate_lean_backend_handles_folder_archive_and_in_memory() {
1715 let tmp = TempDir::new().unwrap();
1716 let folder = folder_mount("local", tmp.path().to_path_buf());
1717 let archive_path = tmp.path().join("ext.mem");
1718 let f = std::fs::File::create(&archive_path).unwrap();
1720 let mut w = zip::ZipWriter::new(f);
1721 w.start_file("a.md", zip::write::SimpleFileOptions::default())
1722 .unwrap();
1723 w.write_all(b"# a").unwrap();
1724 w.finish().unwrap();
1725 let archive = Mount {
1726 mem: "external".to_string(),
1727 schema: Some(pin("default@1.0.0")),
1728 storage: MountStorage::Archive { path: archive_path },
1729 capability: MountCapability::ReadOnly,
1730 lifecycle: MountLifecycle::Lazy,
1731 cross_linkable: false,
1732 migration_target: None,
1733 };
1734 let in_memory = Mount {
1735 mem: "session".to_string(),
1736 schema: Some(pin("default@1.0.0")),
1737 storage: MountStorage::InMemory,
1738 capability: MountCapability::Write,
1739 lifecycle: MountLifecycle::Eager,
1740 cross_linkable: true,
1741 migration_target: None,
1742 };
1743
1744 let _: Box<dyn MemBackend> = instantiate_lean_backend(&folder).unwrap();
1745 let _: Box<dyn MemBackend> = instantiate_lean_backend(&archive).unwrap();
1746 let _: Box<dyn MemBackend> = instantiate_lean_backend(&in_memory).unwrap();
1749 }
1750
1751 #[test]
1756 fn save_state_round_trips_in_memory_variant_unambiguously() {
1757 let tmp = TempDir::new().unwrap();
1758 write_workspace_toml(
1759 tmp.path(),
1760 r#"
1761format = "memstead-git-branch-2"
1762
1763[persistence_adapter]
1764name = "file-two-layer"
1765"#,
1766 );
1767 let store = FileWorkspaceStore::new();
1768 let original = Workspace {
1769 mounts: vec![
1770 folder_mount("local", PathBuf::from("/work/mem")),
1771 Mount {
1772 mem: "session".to_string(),
1773 schema: Some(pin("default@1.0.0")),
1774 storage: MountStorage::InMemory,
1775 capability: MountCapability::Write,
1776 lifecycle: MountLifecycle::Eager,
1777 cross_linkable: true,
1778 migration_target: None,
1779 },
1780 ],
1781 settings: WorkspaceSettings::default(),
1782 };
1783 store.save_state(tmp.path(), &original).unwrap();
1784
1785 let raw =
1787 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1788 assert!(raw.contains("\"type\": \"in-memory\""), "got: {raw}");
1789
1790 let reloaded = store.load(tmp.path()).unwrap();
1791 assert_eq!(reloaded.mounts.len(), 2);
1792 let session = reloaded
1795 .mounts
1796 .iter()
1797 .find(|m| m.mem == "session")
1798 .expect("session mount survives reload");
1799 assert_eq!(session.storage, MountStorage::InMemory);
1800 let local = reloaded.mounts.iter().find(|m| m.mem == "local").unwrap();
1803 assert!(matches!(local.storage, MountStorage::Folder { .. }));
1804 }
1805
1806 #[test]
1807 fn instantiate_lean_backend_rejects_git_branch_with_typed_error() {
1808 let mount = Mount {
1809 mem: "engine".to_string(),
1810 schema: Some(pin("default@1.0.0")),
1811 storage: MountStorage::GitBranch {
1812 gitdir: PathBuf::from("/some/path/.git"),
1813 branch: "engine".to_string(),
1814 },
1815 capability: MountCapability::Write,
1816 lifecycle: MountLifecycle::Eager,
1817 cross_linkable: true,
1818 migration_target: None,
1819 };
1820 match instantiate_lean_backend(&mount) {
1824 Err(InstantiateError::GitBranchRequiresMemRepoFeature { mem }) => {
1825 assert_eq!(mem, "engine");
1826 }
1827 Ok(_) => panic!("expected GitBranchRequiresMemRepoFeature, got Ok"),
1828 }
1829 }
1830
1831 #[test]
1832 fn detect_layout_returns_empty_for_unrecognised_workspace() {
1833 let tmp = TempDir::new().unwrap();
1834 assert_eq!(detect_layout(tmp.path()), Layout::Empty);
1835 }
1836 #[test]
1837 fn detect_layout_returns_new_when_workspace_toml_present() {
1838 let tmp = TempDir::new().unwrap();
1839 write_workspace_toml(
1840 tmp.path(),
1841 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1842 );
1843 assert_eq!(detect_layout(tmp.path()), Layout::New);
1844 }
1845}