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(
842 "mem {mem}: git-branch storage needs the memstead-git-branch crate \
843 (`cargo add memstead-git-branch`). Simplest: open the workspace with \
844 `memstead_git_branch::workspace_store::engine_from_workspace_root(root)` \
845 instead of the memstead-base constructor. Alternative, if you build the \
846 Engine yourself: `engine.set_backend_factory(\
847 memstead_git_branch::storage::instantiate_full_backend)` before mounting"
848 )]
849 GitBranchRequiresMemRepoFeature { mem: String },
850}
851
852impl InstantiateError {
853 pub fn code(&self) -> &'static str {
859 match self {
860 InstantiateError::GitBranchRequiresMemRepoFeature { .. } => {
861 "UNSUPPORTED_WORKSPACE_SHAPE"
862 }
863 }
864 }
865}
866
867pub fn instantiate_lean_backend(mount: &Mount) -> Result<Box<dyn MemBackend>, InstantiateError> {
877 match &mount.storage {
878 MountStorage::Folder { path } => Ok(Box::new(FilesystemMemWriter::new(path.clone()))),
879 MountStorage::Archive { path } => Ok(Box::new(ArchiveBackend::new(path.clone()))),
880 MountStorage::InMemory => Ok(Box::new(InMemoryBackend::new())),
881 MountStorage::GitBranch { .. } => Err(InstantiateError::GitBranchRequiresMemRepoFeature {
882 mem: mount.mem.clone(),
883 }),
884 }
885}
886
887#[derive(Debug, Clone, Copy, PartialEq, Eq)]
895pub enum Layout {
896 Empty,
899 New,
902}
903
904pub fn detect_layout(workspace_root: &Path) -> Layout {
908 if is_workspace_root(workspace_root) {
909 Layout::New
910 } else {
911 Layout::Empty
912 }
913}
914
915pub fn standalone_workspace(workspace_root: &Path) -> Option<Workspace> {
935 let config = memstead_schema::config::load_and_validate(workspace_root).ok()?;
936 let schema = config.schema.clone()?;
937 let name = config.name.clone().unwrap_or_else(|| {
938 workspace_root
939 .file_name()
940 .map(|n| n.to_string_lossy().to_string())
941 .unwrap_or_else(|| "mem".to_string())
942 });
943 let mount = Mount {
944 mem: name,
945 schema: Some(schema),
946 storage: MountStorage::Folder {
947 path: workspace_root.to_path_buf(),
948 },
949 capability: MountCapability::Write,
950 lifecycle: MountLifecycle::Eager,
951 cross_linkable: false,
952 migration_target: None,
953 };
954 Some(Workspace {
955 mounts: vec![mount],
956 settings: WorkspaceSettings::default(),
957 })
958}
959
960#[cfg(test)]
961mod tests {
962 use super::*;
963 use memstead_schema::SchemaRef;
964 use std::io::Write as _;
965 use tempfile::TempDir;
966
967 fn pin(s: &str) -> SchemaRef {
968 s.parse().unwrap()
969 }
970
971 fn folder_mount(mem: &str, path: PathBuf) -> Mount {
972 Mount {
973 mem: mem.to_string(),
974 schema: Some(pin("default@1.0.0")),
975 storage: MountStorage::Folder { path },
976 capability: MountCapability::Write,
977 lifecycle: MountLifecycle::Eager,
978 cross_linkable: true,
979 migration_target: None,
980 }
981 }
982
983 fn write_workspace_toml(workspace_root: &Path, body: &str) {
984 let path = FileWorkspaceStore::workspace_toml_path(workspace_root);
985 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
986 std::fs::write(path, body).unwrap();
987 }
988
989 #[test]
990 fn load_returns_not_initialised_when_memstead_dir_absent() {
991 let tmp = TempDir::new().unwrap();
992 let store = FileWorkspaceStore::new();
993 let err = store.load(tmp.path()).unwrap_err();
994 assert!(matches!(err, StoreError::NotInitialised { .. }));
995 }
996
997 #[test]
1003 fn parse_workspace_settings_reflects_cross_mem_links_edit() {
1004 let tmp = TempDir::new().unwrap();
1005 write_workspace_toml(
1006 tmp.path(),
1007 r#"
1008format = "memstead-git-branch-2"
1009
1010[persistence_adapter]
1011name = "file-two-layer"
1012
1013[cross_mem_links]
1014team-a = ["team-b"]
1015"#,
1016 );
1017 let settings = super::parse_workspace_settings(tmp.path()).unwrap();
1018 assert!(
1019 settings.cross_mem_links.contains_key("team-a"),
1020 "initial parse must surface the team-a grant; got {:?}",
1021 settings.cross_mem_links
1022 );
1023
1024 write_workspace_toml(
1026 tmp.path(),
1027 r#"
1028format = "memstead-git-branch-2"
1029
1030[persistence_adapter]
1031name = "file-two-layer"
1032
1033[cross_mem_links]
1034"#,
1035 );
1036 let refreshed = super::parse_workspace_settings(tmp.path()).unwrap();
1037 assert!(
1038 refreshed.cross_mem_links.is_empty(),
1039 "refreshed parse must drop the team-a grant; got {:?}",
1040 refreshed.cross_mem_links
1041 );
1042 }
1043
1044 #[test]
1049 fn parse_workspace_settings_reflects_allowlist_edit() {
1050 let tmp = TempDir::new().unwrap();
1051 write_workspace_toml(
1052 tmp.path(),
1053 r#"
1054format = "memstead-git-branch-2"
1055
1056[persistence_adapter]
1057name = "file-two-layer"
1058"#,
1059 );
1060 let initial = super::parse_workspace_settings(tmp.path()).unwrap();
1061 assert!(initial.mem_create_rules.is_empty());
1062
1063 write_workspace_toml(
1065 tmp.path(),
1066 r#"
1067format = "memstead-git-branch-2"
1068
1069[persistence_adapter]
1070name = "file-two-layer"
1071
1072[[mem_management.create]]
1073pattern = "test-*"
1074schemas = ["default@1.0.0"]
1075"#,
1076 );
1077 let refreshed = super::parse_workspace_settings(tmp.path()).unwrap();
1078 assert_eq!(refreshed.mem_create_rules.len(), 1);
1079 assert_eq!(refreshed.mem_create_rules[0].pattern, "test-*");
1080 }
1081
1082 #[test]
1083 fn load_returns_not_initialised_when_workspace_toml_missing() {
1084 let tmp = TempDir::new().unwrap();
1085 std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
1086 let store = FileWorkspaceStore::new();
1087 let err = store.load(tmp.path()).unwrap_err();
1088 assert!(matches!(err, StoreError::NotInitialised { .. }));
1089 }
1090
1091 #[test]
1092 fn load_with_no_mounts_yields_empty_mount_list() {
1093 let tmp = TempDir::new().unwrap();
1094 write_workspace_toml(
1095 tmp.path(),
1096 r#"
1097format = "memstead-git-branch-2"
1098
1099[persistence_adapter]
1100name = "file-two-layer"
1101"#,
1102 );
1103 let store = FileWorkspaceStore::new();
1104 let workspace = store.load(tmp.path()).unwrap();
1105 assert!(workspace.mounts.is_empty());
1106 }
1107
1108 #[test]
1109 fn load_with_no_mem_management_yields_empty_settings() {
1110 let tmp = TempDir::new().unwrap();
1115 write_workspace_toml(
1116 tmp.path(),
1117 r#"
1118format = "memstead-git-branch-2"
1119
1120[persistence_adapter]
1121name = "file-two-layer"
1122"#,
1123 );
1124 let store = FileWorkspaceStore::new();
1125 let workspace = store.load(tmp.path()).unwrap();
1126 assert!(workspace.settings.mem_create_rules.is_empty());
1127 assert!(workspace.settings.mem_delete_rules.is_empty());
1128 assert!(workspace.settings.cross_mem_links.is_empty());
1129 }
1130
1131 #[test]
1132 fn load_picks_up_cross_mem_links_wildcard_and_list() {
1133 use memstead_schema::workspace_config::CrossLinkValue;
1138 let tmp = TempDir::new().unwrap();
1139 write_workspace_toml(
1140 tmp.path(),
1141 r#"
1142format = "memstead-git-branch-2"
1143
1144[persistence_adapter]
1145name = "file-two-layer"
1146
1147[cross_mem_links]
1148specs = "*"
1149engine = ["specs", "macos"]
1150locked = []
1151"#,
1152 );
1153 let store = FileWorkspaceStore::new();
1154 let workspace = store.load(tmp.path()).unwrap();
1155 let cvl = &workspace.settings.cross_mem_links;
1156 assert_eq!(cvl.len(), 3);
1157 assert_eq!(cvl.get("specs"), Some(&CrossLinkValue::Wildcard));
1158 assert_eq!(
1159 cvl.get("engine"),
1160 Some(&CrossLinkValue::List(vec![
1161 "specs".to_string(),
1162 "macos".to_string()
1163 ]))
1164 );
1165 assert_eq!(cvl.get("locked"), Some(&CrossLinkValue::List(vec![])));
1166 }
1167
1168 #[test]
1169 fn load_rejects_cross_mem_links_mixed_wildcard_and_names() {
1170 let tmp = TempDir::new().unwrap();
1175 write_workspace_toml(
1176 tmp.path(),
1177 r#"
1178format = "memstead-git-branch-2"
1179
1180[persistence_adapter]
1181name = "file-two-layer"
1182
1183[cross_mem_links]
1184specs = ["*", "engine"]
1185"#,
1186 );
1187 let store = FileWorkspaceStore::new();
1188 let err = store.load(tmp.path()).unwrap_err();
1189 match err {
1190 StoreError::Parse { message, .. } => {
1191 assert!(message.contains("[cross_mem_links].specs"));
1192 assert!(message.contains("wildcard"));
1193 }
1194 other => panic!("expected StoreError::Parse, got {other:?}"),
1195 }
1196 }
1197
1198 #[test]
1199 fn load_picks_up_default_cross_links_on_create_rule() {
1200 use memstead_schema::workspace_config::CrossLinkValue;
1204 let tmp = TempDir::new().unwrap();
1205 write_workspace_toml(
1206 tmp.path(),
1207 r#"
1208format = "memstead-git-branch-2"
1209
1210[persistence_adapter]
1211name = "file-two-layer"
1212
1213[[mem_management.create]]
1214pattern = "exec-*"
1215schemas = ["default"]
1216default_cross_links = "*"
1217"#,
1218 );
1219 let store = FileWorkspaceStore::new();
1220 let workspace = store.load(tmp.path()).unwrap();
1221 let rule = &workspace.settings.mem_create_rules[0];
1222 assert_eq!(rule.pattern, "exec-*");
1223 assert_eq!(rule.default_cross_links, Some(CrossLinkValue::Wildcard));
1224 }
1225
1226 #[test]
1227 fn load_picks_up_mem_management_create_and_delete_rules() {
1228 let tmp = TempDir::new().unwrap();
1232 write_workspace_toml(
1233 tmp.path(),
1234 r#"
1235format = "memstead-git-branch-2"
1236
1237[persistence_adapter]
1238name = "file-two-layer"
1239
1240[[mem_management.create]]
1241pattern = "exec-*"
1242schemas = ["default@1.0.0", "*"]
1243
1244[[mem_management.create]]
1245pattern = "scratch-*"
1246schemas = ["default"]
1247
1248[[mem_management.delete]]
1249pattern = "exec-*"
1250"#,
1251 );
1252 let store = FileWorkspaceStore::new();
1253 let workspace = store.load(tmp.path()).unwrap();
1254 assert_eq!(workspace.settings.mem_create_rules.len(), 2);
1255 assert_eq!(workspace.settings.mem_create_rules[0].pattern, "exec-*");
1256 assert_eq!(
1257 workspace.settings.mem_create_rules[0].schemas,
1258 vec!["default@1.0.0".to_string(), "*".to_string()]
1259 );
1260 assert_eq!(workspace.settings.mem_create_rules[1].pattern, "scratch-*");
1261 assert_eq!(workspace.settings.mem_delete_rules.len(), 1);
1262 assert_eq!(workspace.settings.mem_delete_rules[0].pattern, "exec-*");
1263 }
1264
1265 #[test]
1270 fn save_state_round_trips_migration_target() {
1271 let tmp = TempDir::new().unwrap();
1272 write_workspace_toml(
1273 tmp.path(),
1274 "\nformat = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1275 );
1276 let store = FileWorkspaceStore::new();
1277 let mut migrating = folder_mount("specs", PathBuf::from("/work/mem"));
1278 migrating.migration_target = Some(pin("mig-b@0.1.0"));
1279 let settled = folder_mount("other", PathBuf::from("/work/other"));
1280 let original = Workspace {
1281 mounts: vec![migrating, settled],
1282 settings: WorkspaceSettings::default(),
1283 };
1284 store.save_state(tmp.path(), &original).unwrap();
1285 let raw =
1286 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1287 assert!(
1288 raw.contains("mig-b@0.1.0"),
1289 "migration_target must persist: {raw}"
1290 );
1291 assert_eq!(
1292 raw.matches("migration_target").count(),
1293 1,
1294 "settled mounts must omit the key entirely: {raw}"
1295 );
1296 let loaded = store.load(tmp.path()).unwrap();
1297 assert_eq!(loaded.mounts[0].migration_target, Some(pin("mig-b@0.1.0")));
1298 assert_eq!(loaded.mounts[1].migration_target, None);
1299 }
1300
1301 #[test]
1302 fn save_state_then_load_round_trips_mount_list() {
1303 let tmp = TempDir::new().unwrap();
1304 write_workspace_toml(
1305 tmp.path(),
1306 r#"
1307format = "memstead-git-branch-2"
1308
1309[persistence_adapter]
1310name = "file-two-layer"
1311"#,
1312 );
1313 let store = FileWorkspaceStore::new();
1314 let original = Workspace {
1315 mounts: vec![
1316 folder_mount("specs", PathBuf::from("/work/mem")),
1317 Mount {
1318 mem: "engine".to_string(),
1319 schema: Some(pin("default@1.0.0")),
1320 storage: MountStorage::GitBranch {
1321 gitdir: PathBuf::from("/work/mem-repo/.git"),
1322 branch: "engine".to_string(),
1323 },
1324 capability: MountCapability::Write,
1325 lifecycle: MountLifecycle::Eager,
1326 cross_linkable: true,
1327 migration_target: None,
1328 },
1329 Mount {
1330 mem: "external".to_string(),
1331 schema: Some(pin("default@1.0.0")),
1332 storage: MountStorage::Archive {
1333 path: PathBuf::from("/deps/external.mem"),
1334 },
1335 capability: MountCapability::ReadOnly,
1336 lifecycle: MountLifecycle::Lazy,
1337 cross_linkable: false,
1338 migration_target: None,
1339 },
1340 ],
1341 settings: WorkspaceSettings::default(),
1342 };
1343 store.save_state(tmp.path(), &original).unwrap();
1344
1345 assert!(FileWorkspaceStore::mounts_json_path(tmp.path()).is_file());
1347
1348 let reloaded = store.load(tmp.path()).unwrap();
1349 assert_eq!(reloaded.mounts.len(), original.mounts.len());
1350 for (a, b) in reloaded.mounts.iter().zip(original.mounts.iter()) {
1351 assert_eq!(a.mem, b.mem);
1352 assert_eq!(a.schema, b.schema);
1353 assert_eq!(a.capability, b.capability);
1354 assert_eq!(a.lifecycle, b.lifecycle);
1355 assert_eq!(a.cross_linkable, b.cross_linkable);
1356 assert_eq!(a.storage, b.storage);
1357 }
1358 }
1359
1360 #[test]
1361 fn save_state_round_trips_unset_schema_assertion() {
1362 let tmp = TempDir::new().unwrap();
1367 write_workspace_toml(
1368 tmp.path(),
1369 r#"
1370format = "memstead-git-branch-2"
1371
1372[persistence_adapter]
1373name = "file-two-layer"
1374"#,
1375 );
1376 let store = FileWorkspaceStore::new();
1377 let original = Workspace {
1378 mounts: vec![Mount {
1379 mem: "foreign".to_string(),
1380 schema: None,
1381 storage: MountStorage::Folder {
1382 path: tmp.path().join("foreign"),
1383 },
1384 capability: MountCapability::ReadOnly,
1385 lifecycle: MountLifecycle::Eager,
1386 cross_linkable: false,
1387 migration_target: None,
1388 }],
1389 settings: WorkspaceSettings::default(),
1390 };
1391 store.save_state(tmp.path(), &original).unwrap();
1392
1393 let raw =
1395 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1396 assert!(
1397 !raw.contains("\"schema\""),
1398 "unset schema assertion must omit the key on the wire; got:\n{raw}"
1399 );
1400
1401 let reloaded = store.load(tmp.path()).unwrap();
1403 assert_eq!(reloaded.mounts.len(), 1);
1404 assert_eq!(reloaded.mounts[0].schema, None);
1405 }
1406
1407 #[test]
1408 fn save_state_does_not_touch_workspace_toml() {
1409 let tmp = TempDir::new().unwrap();
1410 let original_body = r#"
1411format = "memstead-git-branch-2"
1412
1413[persistence_adapter]
1414name = "file-two-layer"
1415"#;
1416 write_workspace_toml(tmp.path(), original_body);
1417 let store = FileWorkspaceStore::new();
1418 let workspace = Workspace::default();
1419 store.save_state(tmp.path(), &workspace).unwrap();
1420 let toml_after =
1422 std::fs::read_to_string(FileWorkspaceStore::workspace_toml_path(tmp.path())).unwrap();
1423 assert_eq!(toml_after, original_body);
1424 }
1425
1426 #[test]
1427 fn save_state_writes_paths_relative_to_workspace_root() {
1428 let tmp = TempDir::new().unwrap();
1429 write_workspace_toml(
1430 tmp.path(),
1431 r#"
1432format = "memstead-git-branch-2"
1433
1434[persistence_adapter]
1435name = "file-two-layer"
1436"#,
1437 );
1438 let store = FileWorkspaceStore::new();
1439 let workspace = Workspace {
1440 mounts: vec![
1441 Mount {
1442 mem: "engine".to_string(),
1443 schema: Some(pin("default@1.0.0")),
1444 storage: MountStorage::GitBranch {
1445 gitdir: tmp.path().join("mem-repo").join(".git"),
1446 branch: "engine".to_string(),
1447 },
1448 capability: MountCapability::Write,
1449 lifecycle: MountLifecycle::Eager,
1450 cross_linkable: true,
1451 migration_target: None,
1452 },
1453 Mount {
1454 mem: "external".to_string(),
1455 schema: Some(pin("default@1.0.0")),
1456 storage: MountStorage::Archive {
1457 path: PathBuf::from("/global/cache/external.mem"),
1458 },
1459 capability: MountCapability::ReadOnly,
1460 lifecycle: MountLifecycle::Lazy,
1461 cross_linkable: false,
1462 migration_target: None,
1463 },
1464 ],
1465 settings: WorkspaceSettings::default(),
1466 };
1467 store.save_state(tmp.path(), &workspace).unwrap();
1468
1469 let on_disk =
1470 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1471 assert!(on_disk.contains("\"memstead-mounts-3\""));
1473 assert!(
1475 on_disk.contains("\"mem-repo/.git\""),
1476 "expected relative gitdir, got: {on_disk}"
1477 );
1478 assert!(
1479 !on_disk.contains(tmp.path().to_str().unwrap()),
1480 "in-workspace path should not include the absolute tmp prefix: {on_disk}"
1481 );
1482 assert!(on_disk.contains("\"/global/cache/external.mem\""));
1484
1485 let reloaded = store.load(tmp.path()).unwrap();
1487 match &reloaded.mounts[0].storage {
1488 MountStorage::GitBranch { gitdir, .. } => {
1489 assert_eq!(gitdir, &tmp.path().join("mem-repo").join(".git"));
1490 }
1491 other => panic!("expected GitBranch storage, got {other:?}"),
1492 }
1493 match &reloaded.mounts[1].storage {
1494 MountStorage::Archive { path } => {
1495 assert_eq!(path, &PathBuf::from("/global/cache/external.mem"));
1496 }
1497 other => panic!("expected Archive storage, got {other:?}"),
1498 }
1499 }
1500
1501 #[test]
1502 fn load_absolute_inside_root_path_then_save_rewrites_relative() {
1503 let tmp = TempDir::new().unwrap();
1504 write_workspace_toml(
1505 tmp.path(),
1506 r#"
1507format = "memstead-git-branch-2"
1508
1509[persistence_adapter]
1510name = "file-two-layer"
1511"#,
1512 );
1513 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1518 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1519 let abs_gitdir = tmp.path().join("mem-repo").join(".git");
1520 let mounts_body = format!(
1521 r#"{{
1522 "format": "memstead-mounts-3",
1523 "mounts": [
1524 {{
1525 "mem": "engine",
1526 "schema": "default@1.0.0",
1527 "storage": {{
1528 "type": "git-branch",
1529 "gitdir": "{}",
1530 "branch": "engine"
1531 }},
1532 "capability": "write",
1533 "lifecycle": "eager",
1534 "cross_linkable": true
1535 }}
1536 ]
1537}}"#,
1538 abs_gitdir.to_str().unwrap()
1539 );
1540 std::fs::write(&mounts_path, &mounts_body).unwrap();
1541
1542 let store = FileWorkspaceStore::new();
1543 let workspace = store.load(tmp.path()).unwrap();
1546 match &workspace.mounts[0].storage {
1547 MountStorage::GitBranch { gitdir, .. } => assert_eq!(gitdir, &abs_gitdir),
1548 other => panic!("expected GitBranch storage, got {other:?}"),
1549 }
1550
1551 store.save_state(tmp.path(), &workspace).unwrap();
1554 let on_disk = std::fs::read_to_string(&mounts_path).unwrap();
1555 assert!(on_disk.contains("\"memstead-mounts-3\""));
1556 assert!(on_disk.contains("\"mem-repo/.git\""));
1557 assert!(!on_disk.contains(tmp.path().to_str().unwrap()));
1558 }
1559
1560 #[test]
1569 fn save_state_preserves_refs_heads_branch_form() {
1570 let tmp = TempDir::new().unwrap();
1571 write_workspace_toml(
1572 tmp.path(),
1573 r#"
1574format = "memstead-git-branch-2"
1575
1576[persistence_adapter]
1577name = "file-two-layer"
1578"#,
1579 );
1580 let store = FileWorkspaceStore::new();
1581 let original = Workspace {
1582 mounts: vec![Mount {
1583 mem: "engine".to_string(),
1584 schema: Some(pin("default@1.0.0")),
1585 storage: MountStorage::GitBranch {
1586 gitdir: tmp.path().join("mem-repo").join(".git"),
1587 branch: "refs/heads/demo/engine".to_string(),
1588 },
1589 capability: MountCapability::Write,
1590 lifecycle: MountLifecycle::Eager,
1591 cross_linkable: true,
1592 migration_target: None,
1593 }],
1594 settings: WorkspaceSettings::default(),
1595 };
1596 store.save_state(tmp.path(), &original).unwrap();
1597
1598 let on_disk =
1599 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1600 assert!(
1601 on_disk.contains("\"branch\": \"refs/heads/demo/engine\""),
1602 "expected fully-qualified ref on disk, got: {on_disk}"
1603 );
1604
1605 let reloaded = store.load(tmp.path()).unwrap();
1606 match &reloaded.mounts[0].storage {
1607 MountStorage::GitBranch { branch, .. } => {
1608 assert_eq!(branch, "refs/heads/demo/engine");
1609 }
1610 other => panic!("expected GitBranch storage, got {other:?}"),
1611 }
1612 }
1613
1614 #[test]
1624 fn load_preserves_short_form_branch_without_rewrite() {
1625 let tmp = TempDir::new().unwrap();
1626 write_workspace_toml(
1627 tmp.path(),
1628 r#"
1629format = "memstead-git-branch-2"
1630
1631[persistence_adapter]
1632name = "file-two-layer"
1633"#,
1634 );
1635 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1636 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1637 std::fs::write(
1638 &mounts_path,
1639 r#"{
1640 "format": "memstead-mounts-3",
1641 "mounts": [
1642 {
1643 "mem": "engine",
1644 "schema": "default@1.0.0",
1645 "storage": {
1646 "type": "git-branch",
1647 "gitdir": "mem-repo/.git",
1648 "branch": "demo/engine"
1649 },
1650 "capability": "write",
1651 "lifecycle": "eager",
1652 "cross_linkable": true
1653 }
1654 ]
1655}"#,
1656 )
1657 .unwrap();
1658
1659 let store = FileWorkspaceStore::new();
1660 let workspace = store.load(tmp.path()).unwrap();
1661 match &workspace.mounts[0].storage {
1662 MountStorage::GitBranch { branch, .. } => {
1663 assert_eq!(
1664 branch, "demo/engine",
1665 "reader must not silently rewrite short-form branch"
1666 );
1667 }
1668 other => panic!("expected GitBranch storage, got {other:?}"),
1669 }
1670 }
1671
1672 #[test]
1673 fn load_rejects_format_version_mismatch_on_toml() {
1674 let tmp = TempDir::new().unwrap();
1675 write_workspace_toml(
1676 tmp.path(),
1677 r#"
1678format = "memstead-git-branch-99"
1679
1680[persistence_adapter]
1681name = "file-two-layer"
1682"#,
1683 );
1684 let store = FileWorkspaceStore::new();
1685 let err = store.load(tmp.path()).unwrap_err();
1686 match err {
1687 StoreError::FormatMismatch {
1688 expected, found, ..
1689 } => {
1690 assert_eq!(expected, "memstead-git-branch-2");
1691 assert_eq!(found, "memstead-git-branch-99");
1692 }
1693 other => panic!("expected FormatMismatch, got {other:?}"),
1694 }
1695 }
1696
1697 #[test]
1698 fn load_rejects_format_version_mismatch_on_mounts_json() {
1699 let tmp = TempDir::new().unwrap();
1700 write_workspace_toml(
1701 tmp.path(),
1702 r#"
1703format = "memstead-git-branch-2"
1704
1705[persistence_adapter]
1706name = "file-two-layer"
1707"#,
1708 );
1709 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1710 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1711 std::fs::write(
1712 &mounts_path,
1713 r#"{ "format": "memstead-mounts-99", "mounts": [] }"#,
1714 )
1715 .unwrap();
1716 let store = FileWorkspaceStore::new();
1717 let err = store.load(tmp.path()).unwrap_err();
1718 assert!(matches!(err, StoreError::FormatMismatch { .. }));
1719 }
1720
1721 #[test]
1724 fn load_refuses_pre_rename_toml_as_legacy_layout() {
1725 let tmp = TempDir::new().unwrap();
1726 write_workspace_toml(
1727 tmp.path(),
1728 r#"
1729format = "memstead-git-branch-1"
1730
1731[persistence_adapter]
1732name = "file-two-layer"
1733"#,
1734 );
1735 let store = FileWorkspaceStore::new();
1736 let err = store.load(tmp.path()).unwrap_err();
1737 match err {
1738 StoreError::LegacyLayout { found, .. } => {
1739 assert_eq!(found, "memstead-git-branch-1");
1740 }
1741 other => panic!("expected LegacyLayout, got {other:?}"),
1742 }
1743 }
1744
1745 #[test]
1752 fn load_refuses_pre_rename_mounts_json_as_legacy_layout() {
1753 for legacy in ["memstead-mounts-1", "memstead-mounts-2"] {
1754 let tmp = TempDir::new().unwrap();
1755 write_workspace_toml(
1756 tmp.path(),
1757 r#"
1758format = "memstead-git-branch-2"
1759
1760[persistence_adapter]
1761name = "file-two-layer"
1762"#,
1763 );
1764 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1765 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1766 std::fs::write(
1767 &mounts_path,
1768 format!(
1769 r#"{{ "format": "{legacy}", "mounts": [{{ "unit": "notes", "storage": {{ "type": "folder", "path": "notes" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#
1770 ),
1771 )
1772 .unwrap();
1773 let store = FileWorkspaceStore::new();
1774 let err = store.load(tmp.path()).unwrap_err();
1775 match err {
1776 StoreError::LegacyLayout { found, .. } => assert_eq!(found, legacy),
1777 other => panic!("expected LegacyLayout for {legacy}, got {other:?}"),
1778 }
1779 }
1780 }
1781
1782 #[test]
1783 fn load_rejects_invalid_toml() {
1784 let tmp = TempDir::new().unwrap();
1785 write_workspace_toml(tmp.path(), "this is not = valid = toml");
1786 let store = FileWorkspaceStore::new();
1787 let err = store.load(tmp.path()).unwrap_err();
1788 assert!(matches!(err, StoreError::Parse { .. }));
1789 }
1790
1791 #[test]
1792 fn load_rejects_unknown_top_level_key() {
1793 let tmp = TempDir::new().unwrap();
1797 write_workspace_toml(
1798 tmp.path(),
1799 "format = \"memstead-git-branch-2\"\nnonexistent_key = true\n",
1800 );
1801 let store = FileWorkspaceStore::new();
1802 let err = store.load(tmp.path()).unwrap_err();
1803 match err {
1804 StoreError::Parse { message, .. } => {
1805 assert!(
1806 message.contains("nonexistent_key"),
1807 "refusal must name the unknown key: {message}"
1808 );
1809 }
1810 other => panic!("expected Parse error, got {other:?}"),
1811 }
1812 }
1813
1814 #[test]
1815 fn instantiate_lean_backend_handles_folder_archive_and_in_memory() {
1816 let tmp = TempDir::new().unwrap();
1817 let folder = folder_mount("local", tmp.path().to_path_buf());
1818 let archive_path = tmp.path().join("ext.mem");
1819 let f = std::fs::File::create(&archive_path).unwrap();
1821 let mut w = zip::ZipWriter::new(f);
1822 w.start_file("a.md", zip::write::SimpleFileOptions::default())
1823 .unwrap();
1824 w.write_all(b"# a").unwrap();
1825 w.finish().unwrap();
1826 let archive = Mount {
1827 mem: "external".to_string(),
1828 schema: Some(pin("default@1.0.0")),
1829 storage: MountStorage::Archive { path: archive_path },
1830 capability: MountCapability::ReadOnly,
1831 lifecycle: MountLifecycle::Lazy,
1832 cross_linkable: false,
1833 migration_target: None,
1834 };
1835 let in_memory = Mount {
1836 mem: "session".to_string(),
1837 schema: Some(pin("default@1.0.0")),
1838 storage: MountStorage::InMemory,
1839 capability: MountCapability::Write,
1840 lifecycle: MountLifecycle::Eager,
1841 cross_linkable: true,
1842 migration_target: None,
1843 };
1844
1845 let _: Box<dyn MemBackend> = instantiate_lean_backend(&folder).unwrap();
1846 let _: Box<dyn MemBackend> = instantiate_lean_backend(&archive).unwrap();
1847 let _: Box<dyn MemBackend> = instantiate_lean_backend(&in_memory).unwrap();
1850 }
1851
1852 #[test]
1857 fn save_state_round_trips_in_memory_variant_unambiguously() {
1858 let tmp = TempDir::new().unwrap();
1859 write_workspace_toml(
1860 tmp.path(),
1861 r#"
1862format = "memstead-git-branch-2"
1863
1864[persistence_adapter]
1865name = "file-two-layer"
1866"#,
1867 );
1868 let store = FileWorkspaceStore::new();
1869 let original = Workspace {
1870 mounts: vec![
1871 folder_mount("local", PathBuf::from("/work/mem")),
1872 Mount {
1873 mem: "session".to_string(),
1874 schema: Some(pin("default@1.0.0")),
1875 storage: MountStorage::InMemory,
1876 capability: MountCapability::Write,
1877 lifecycle: MountLifecycle::Eager,
1878 cross_linkable: true,
1879 migration_target: None,
1880 },
1881 ],
1882 settings: WorkspaceSettings::default(),
1883 };
1884 store.save_state(tmp.path(), &original).unwrap();
1885
1886 let raw =
1888 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1889 assert!(raw.contains("\"type\": \"in-memory\""), "got: {raw}");
1890
1891 let reloaded = store.load(tmp.path()).unwrap();
1892 assert_eq!(reloaded.mounts.len(), 2);
1893 let session = reloaded
1896 .mounts
1897 .iter()
1898 .find(|m| m.mem == "session")
1899 .expect("session mount survives reload");
1900 assert_eq!(session.storage, MountStorage::InMemory);
1901 let local = reloaded.mounts.iter().find(|m| m.mem == "local").unwrap();
1904 assert!(matches!(local.storage, MountStorage::Folder { .. }));
1905 }
1906
1907 #[test]
1908 fn instantiate_lean_backend_rejects_git_branch_with_typed_error() {
1909 let mount = Mount {
1910 mem: "engine".to_string(),
1911 schema: Some(pin("default@1.0.0")),
1912 storage: MountStorage::GitBranch {
1913 gitdir: PathBuf::from("/some/path/.git"),
1914 branch: "engine".to_string(),
1915 },
1916 capability: MountCapability::Write,
1917 lifecycle: MountLifecycle::Eager,
1918 cross_linkable: true,
1919 migration_target: None,
1920 };
1921 match instantiate_lean_backend(&mount) {
1925 Err(InstantiateError::GitBranchRequiresMemRepoFeature { mem }) => {
1926 assert_eq!(mem, "engine");
1927 }
1928 Ok(_) => panic!("expected GitBranchRequiresMemRepoFeature, got Ok"),
1929 }
1930 }
1931
1932 #[test]
1933 fn detect_layout_returns_empty_for_unrecognised_workspace() {
1934 let tmp = TempDir::new().unwrap();
1935 assert_eq!(detect_layout(tmp.path()), Layout::Empty);
1936 }
1937 #[test]
1938 fn detect_layout_returns_new_when_workspace_toml_present() {
1939 let tmp = TempDir::new().unwrap();
1940 write_workspace_toml(
1941 tmp.path(),
1942 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1943 );
1944 assert_eq!(detect_layout(tmp.path()), Layout::New);
1945 }
1946}