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";
71
72#[derive(Debug, thiserror::Error)]
77pub enum StoreError {
78 #[error("workspace store not found at {path} — run `memstead mem-repo init` first")]
82 NotInitialised { path: PathBuf },
83 #[error(
85 "workspace store io error at {path}: {source} — no memstead command repairs this; \
86 check filesystem permissions and disk state"
87 )]
88 Io {
89 path: PathBuf,
90 #[source]
91 source: std::io::Error,
92 },
93 #[error(
97 "workspace store parse error at {path}: {message} — no memstead command repairs this; \
98 fix the named file by hand or restore it from version control"
99 )]
100 Parse { path: PathBuf, message: String },
101 #[error(
104 "workspace store format mismatch at {path}: expected {expected}, found {found} — \
105 no memstead command repairs this; use an engine version whose format matches the file"
106 )]
107 FormatMismatch {
108 path: PathBuf,
109 expected: String,
110 found: String,
111 },
112 #[error(
117 "pre-rename workspace layout at {path} (found format {found}): migrate the workspace \
118 state in place — rewrite mounts.json to memstead-mounts-3 (record field `mem`, storage \
119 paths under mem-repo/), workspace.toml to memstead-git-branch-2 (tables `mem_management`, \
120 `cross_mem_links`), rename the gitdir container to mem-repo/, and move the metadata \
121 branch tree to mems/ — then retry"
122 )]
123 LegacyLayout { path: PathBuf, found: String },
124 #[error(
132 "legacy (pre-v2) projection config at {path}: this workspace predates the single-record \
133 binding format v2 — run `memstead projection migrate` to convert it in place once"
134 )]
135 LegacyProjectionStore { path: PathBuf },
136 #[error(
140 "unsupported binding format version {version} at {path}: this engine understands v2 (version 2)"
141 )]
142 UnknownBindingVersion { path: PathBuf, version: i64 },
143 #[error("workspace store error: {0}")]
146 Other(String),
147}
148
149impl StoreError {
150 pub fn code(&self) -> &'static str {
157 match self {
158 StoreError::NotInitialised { .. } => "WORKSPACE_NOT_INITIALISED",
162 StoreError::Io { .. } => "WORKSPACE_STORE_IO",
163 StoreError::Parse { .. } => "WORKSPACE_STORE_PARSE",
164 StoreError::FormatMismatch { .. } => "WORKSPACE_STORE_FORMAT_MISMATCH",
165 StoreError::LegacyLayout { .. } => "LEGACY_WORKSPACE_LAYOUT",
166 StoreError::LegacyProjectionStore { .. } => "PROJECTION_STORE_LEGACY",
167 StoreError::UnknownBindingVersion { .. } => "UNKNOWN_BINDING_VERSION",
168 StoreError::Other(_) => "WORKSPACE_STORE_ERROR",
169 }
170 }
171}
172
173pub trait WorkspaceStoreAdapter: Send + Sync {
179 fn load(&self, workspace_root: &Path) -> Result<Workspace, StoreError>;
185
186 fn save_state(&self, workspace_root: &Path, workspace: &Workspace) -> Result<(), StoreError>;
197
198 fn read_state_bytes(&self, workspace_root: &Path) -> Result<Option<Vec<u8>>, StoreError>;
202
203 fn parse_state_bytes(
206 &self,
207 workspace_root: &Path,
208 bytes: &[u8],
209 ) -> Result<Vec<Mount>, StoreError>;
210
211 fn save_state_cas(
219 &self,
220 workspace_root: &Path,
221 workspace: &Workspace,
222 expected: Option<&[u8]>,
223 ) -> Result<bool, StoreError>;
224}
225
226#[derive(Debug, Default, Clone, Copy)]
232pub struct FileWorkspaceStore;
233
234impl FileWorkspaceStore {
235 pub fn new() -> Self {
238 Self
239 }
240
241 pub fn workspace_toml_path(workspace_root: &Path) -> PathBuf {
243 workspace_root
244 .join(WORKSPACE_STORE_DIR)
245 .join("workspace.toml")
246 }
247
248 pub fn mounts_json_path(workspace_root: &Path) -> PathBuf {
250 workspace_root
251 .join(WORKSPACE_STORE_DIR)
252 .join("state")
253 .join("mounts.json")
254 }
255}
256
257const WORKSPACE_TOML_FORMAT: &str = "memstead-git-branch-2";
258const WORKSPACE_TOML_FORMAT_LEGACY: &str = "memstead-git-branch-1";
262const MOUNTS_JSON_FORMAT_V3: &str = "memstead-mounts-3";
272const MOUNTS_JSON_FORMAT_LEGACY: [&str; 2] = ["memstead-mounts-1", "memstead-mounts-2"];
277
278#[derive(Deserialize)]
282struct MountsFormatProbe {
283 format: String,
284}
285
286fn check_workspace_toml_format(format: &str, toml_path: &Path) -> Result<(), StoreError> {
290 if format == WORKSPACE_TOML_FORMAT {
291 return Ok(());
292 }
293 if format == WORKSPACE_TOML_FORMAT_LEGACY {
294 return Err(StoreError::LegacyLayout {
295 path: toml_path.to_path_buf(),
296 found: format.to_string(),
297 });
298 }
299 Err(StoreError::FormatMismatch {
300 path: toml_path.to_path_buf(),
301 expected: WORKSPACE_TOML_FORMAT.to_string(),
302 found: format.to_string(),
303 })
304}
305
306fn absolutize_mount_path(value: PathBuf, workspace_root: &Path) -> PathBuf {
312 if value.is_absolute() {
313 value
314 } else {
315 workspace_root.join(value)
316 }
317}
318
319fn relativize_mount_path(value: &Path, workspace_root: &Path) -> PathBuf {
325 match value.strip_prefix(workspace_root) {
326 Ok(rel) => rel.to_path_buf(),
327 Err(_) => value.to_path_buf(),
328 }
329}
330
331pub fn is_workspace_root(dir: &Path) -> bool {
336 FileWorkspaceStore::workspace_toml_path(dir).is_file()
337}
338
339pub fn is_mem_repo_shaped(workspace_root: &Path) -> bool {
348 workspace_root.join("mem-repo").join(".git").is_dir()
349}
350
351pub fn workspace_shape_label(workspace_root: &Path) -> &'static str {
357 if is_mem_repo_shaped(workspace_root) {
358 "mem-repo"
359 } else {
360 "filesystem-mem"
361 }
362}
363
364impl WorkspaceStoreAdapter for FileWorkspaceStore {
365 fn load(&self, workspace_root: &Path) -> Result<Workspace, StoreError> {
366 let memstead_dir = workspace_root.join(WORKSPACE_STORE_DIR);
367 if !memstead_dir.is_dir() {
368 return Err(StoreError::NotInitialised {
369 path: workspace_root.to_path_buf(),
370 });
371 }
372
373 let toml_path = Self::workspace_toml_path(workspace_root);
375 let toml_text = std::fs::read_to_string(&toml_path).map_err(|e| {
376 if e.kind() == std::io::ErrorKind::NotFound {
377 StoreError::NotInitialised {
378 path: workspace_root.to_path_buf(),
379 }
380 } else {
381 StoreError::Io {
382 path: toml_path.clone(),
383 source: e,
384 }
385 }
386 })?;
387 let toml_doc: WorkspaceTomlDoc =
388 toml::from_str(&toml_text).map_err(|e| StoreError::Parse {
389 path: toml_path.clone(),
390 message: e.to_string(),
391 })?;
392 check_workspace_toml_format(&toml_doc.format, &toml_path)?;
393
394 let mounts_path = Self::mounts_json_path(workspace_root);
398 let mounts: Vec<Mount> = match std::fs::read_to_string(&mounts_path) {
399 Ok(text) => parse_mounts_text(&text, &mounts_path, workspace_root)?,
400 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
401 Err(e) => {
402 return Err(StoreError::Io {
403 path: mounts_path,
404 source: e,
405 });
406 }
407 };
408
409 warn_if_legacy_schemas_dir(toml_doc.schemas_dir.as_deref());
410 let settings = build_settings(
411 toml_doc.mem_management,
412 toml_doc.cross_mem_links,
413 toml_doc.mcp,
414 toml_doc.mutations,
415 toml_doc.plugin,
416 )?;
417 Ok(Workspace { mounts, settings })
418 }
419
420 fn save_state(&self, workspace_root: &Path, workspace: &Workspace) -> Result<(), StoreError> {
421 let mounts_path = Self::mounts_json_path(workspace_root);
422 ensure_state_dir(&mounts_path)?;
423 let text = render_mounts_text(workspace, workspace_root, &mounts_path)?;
424 std::fs::write(&mounts_path, text).map_err(|e| StoreError::Io {
425 path: mounts_path,
426 source: e,
427 })?;
428 Ok(())
429 }
430
431 fn read_state_bytes(&self, workspace_root: &Path) -> Result<Option<Vec<u8>>, StoreError> {
432 let mounts_path = Self::mounts_json_path(workspace_root);
433 match std::fs::read(&mounts_path) {
434 Ok(b) => Ok(Some(b)),
435 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
436 Err(e) => Err(StoreError::Io {
437 path: mounts_path,
438 source: e,
439 }),
440 }
441 }
442
443 fn parse_state_bytes(
444 &self,
445 workspace_root: &Path,
446 bytes: &[u8],
447 ) -> Result<Vec<Mount>, StoreError> {
448 let mounts_path = Self::mounts_json_path(workspace_root);
449 let text = std::str::from_utf8(bytes).map_err(|e| StoreError::Parse {
450 path: mounts_path.clone(),
451 message: e.to_string(),
452 })?;
453 parse_mounts_text(text, &mounts_path, workspace_root)
454 }
455
456 fn save_state_cas(
457 &self,
458 workspace_root: &Path,
459 workspace: &Workspace,
460 expected: Option<&[u8]>,
461 ) -> Result<bool, StoreError> {
462 let mounts_path = Self::mounts_json_path(workspace_root);
463 ensure_state_dir(&mounts_path)?;
464 let text = render_mounts_text(workspace, workspace_root, &mounts_path)?;
465
466 let lock_path = mounts_path.with_extension("json.lock");
472 let mut held = None;
473 for attempt in 0..50 {
474 match std::fs::OpenOptions::new()
475 .write(true)
476 .create_new(true)
477 .open(&lock_path)
478 {
479 Ok(f) => {
480 held = Some(f);
481 break;
482 }
483 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
484 if attempt == 49 {
485 let _ = std::fs::remove_file(&lock_path);
486 }
487 std::thread::sleep(std::time::Duration::from_millis(10));
488 }
489 Err(e) => {
490 return Err(StoreError::Io {
491 path: lock_path,
492 source: e,
493 });
494 }
495 }
496 }
497 let _lock = match held {
498 Some(f) => f,
499 None => std::fs::OpenOptions::new()
500 .write(true)
501 .create(true)
502 .truncate(true)
503 .open(&lock_path)
504 .map_err(|e| StoreError::Io {
505 path: lock_path.clone(),
506 source: e,
507 })?,
508 };
509 let release = || {
510 let _ = std::fs::remove_file(&lock_path);
511 };
512
513 let current = match std::fs::read(&mounts_path) {
514 Ok(b) => Some(b),
515 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
516 Err(e) => {
517 release();
518 return Err(StoreError::Io {
519 path: mounts_path,
520 source: e,
521 });
522 }
523 };
524 if current.as_deref() != expected {
525 release();
526 return Ok(false);
527 }
528 let result = std::fs::write(&mounts_path, text).map_err(|e| StoreError::Io {
529 path: mounts_path,
530 source: e,
531 });
532 release();
533 result.map(|_| true)
534 }
535}
536
537fn ensure_state_dir(mounts_path: &Path) -> Result<(), StoreError> {
539 if let Some(parent) = mounts_path.parent() {
540 std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
541 path: parent.to_path_buf(),
542 source: e,
543 })?;
544 }
545 Ok(())
546}
547
548fn render_mounts_text(
550 workspace: &Workspace,
551 workspace_root: &Path,
552 mounts_path: &Path,
553) -> Result<String, StoreError> {
554 let doc = MountsJsonDoc {
555 format: MOUNTS_JSON_FORMAT_V3.to_string(),
556 mounts: workspace
557 .mounts
558 .iter()
559 .map(|m| MountWire::from_mount(m, workspace_root))
560 .collect(),
561 };
562 serde_json::to_string_pretty(&doc).map_err(|e| StoreError::Parse {
563 path: mounts_path.to_path_buf(),
564 message: e.to_string(),
565 })
566}
567
568fn parse_mounts_text(
574 text: &str,
575 mounts_path: &Path,
576 workspace_root: &Path,
577) -> Result<Vec<Mount>, StoreError> {
578 let probe: MountsFormatProbe = serde_json::from_str(text).map_err(|e| StoreError::Parse {
579 path: mounts_path.to_path_buf(),
580 message: e.to_string(),
581 })?;
582 if MOUNTS_JSON_FORMAT_LEGACY.contains(&probe.format.as_str()) {
583 return Err(StoreError::LegacyLayout {
584 path: mounts_path.to_path_buf(),
585 found: probe.format,
586 });
587 }
588 if probe.format != MOUNTS_JSON_FORMAT_V3 {
589 return Err(StoreError::FormatMismatch {
590 path: mounts_path.to_path_buf(),
591 expected: MOUNTS_JSON_FORMAT_V3.to_string(),
592 found: probe.format,
593 });
594 }
595 let doc: MountsJsonDoc = serde_json::from_str(text).map_err(|e| StoreError::Parse {
596 path: mounts_path.to_path_buf(),
597 message: e.to_string(),
598 })?;
599 Ok(doc
600 .mounts
601 .into_iter()
602 .map(|w| w.into_mount(workspace_root))
603 .collect())
604}
605
606#[derive(Debug, Serialize, Deserialize)]
611#[serde(deny_unknown_fields)]
612struct WorkspaceTomlDoc {
613 format: String,
617 #[serde(default)]
621 persistence_adapter: PersistenceAdapterDecl,
622 #[serde(default)]
627 mem_management: MemManagementWire,
628 #[serde(default)]
635 cross_mem_links: toml::Table,
636 #[serde(default)]
643 schemas_dir: Option<std::path::PathBuf>,
644 #[serde(default)]
648 mcp: McpSection,
649 #[serde(default)]
652 mutations: MutationsSection,
653 #[serde(default)]
657 plugin: std::collections::HashMap<String, toml::Table>,
658}
659
660#[derive(Debug, Default, Serialize, Deserialize)]
664struct MemManagementWire {
665 #[serde(default)]
666 create: Vec<CreateRuleWire>,
667 #[serde(default)]
668 delete: Vec<DeleteRuleWire>,
669}
670
671#[derive(Debug, Serialize, Deserialize)]
681struct CreateRuleWire {
682 pattern: String,
683 #[serde(default)]
684 schemas: Vec<String>,
685 #[serde(default)]
686 default_cross_links: Option<toml::Value>,
687}
688
689#[derive(Debug, Serialize, Deserialize)]
692struct DeleteRuleWire {
693 pattern: String,
694}
695
696pub fn parse_workspace_settings(
714 workspace_root: &Path,
715) -> Result<crate::workspace::WorkspaceSettings, StoreError> {
716 let memstead_dir = workspace_root.join(WORKSPACE_STORE_DIR);
717 if !memstead_dir.is_dir() {
718 return Err(StoreError::NotInitialised {
719 path: workspace_root.to_path_buf(),
720 });
721 }
722 let toml_path = FileWorkspaceStore::workspace_toml_path(workspace_root);
723 let toml_text = std::fs::read_to_string(&toml_path).map_err(|e| {
724 if e.kind() == std::io::ErrorKind::NotFound {
725 StoreError::NotInitialised {
726 path: workspace_root.to_path_buf(),
727 }
728 } else {
729 StoreError::Io {
730 path: toml_path.clone(),
731 source: e,
732 }
733 }
734 })?;
735 let toml_doc: WorkspaceTomlDoc = toml::from_str(&toml_text).map_err(|e| StoreError::Parse {
736 path: toml_path.clone(),
737 message: e.to_string(),
738 })?;
739 check_workspace_toml_format(&toml_doc.format, &toml_path)?;
740 warn_if_legacy_schemas_dir(toml_doc.schemas_dir.as_deref());
741 build_settings(
742 toml_doc.mem_management,
743 toml_doc.cross_mem_links,
744 toml_doc.mcp,
745 toml_doc.mutations,
746 toml_doc.plugin,
747 )
748}
749
750fn build_settings(
756 vm: MemManagementWire,
757 cross_mem_links_raw: toml::Table,
758 mcp: McpSection,
759 mutations: MutationsSection,
760 plugin: std::collections::HashMap<String, toml::Table>,
761) -> Result<WorkspaceSettings, StoreError> {
762 let mut create_rules = Vec::with_capacity(vm.create.len());
763 for r in vm.create {
764 let default_cross_links = match r.default_cross_links {
765 None => None,
766 Some(value) => {
767 let location = format!(
768 "[[mem_management.create]] pattern={}.default_cross_links",
769 r.pattern
770 );
771 Some(parse_cross_link_value(&location, &value)?)
772 }
773 };
774 create_rules.push(crate::workspace::CreateRuleSetting {
775 pattern: r.pattern,
776 schemas: r.schemas,
777 default_cross_links,
778 });
779 }
780
781 let mut cross_mem_links = std::collections::BTreeMap::new();
782 for (mem, value) in &cross_mem_links_raw {
783 let location = format!("[cross_mem_links].{mem}");
784 let parsed = parse_cross_link_value(&location, value)?;
785 cross_mem_links.insert(mem.clone(), parsed);
786 }
787
788 Ok(WorkspaceSettings {
789 mem_create_rules: create_rules,
790 mem_delete_rules: vm
791 .delete
792 .into_iter()
793 .map(|r| crate::workspace::DeleteRuleSetting { pattern: r.pattern })
794 .collect(),
795 cross_mem_links,
796 mcp,
797 mutations,
798 plugin,
799 })
800}
801
802fn warn_if_legacy_schemas_dir(schemas_dir: Option<&std::path::Path>) {
809 if let Some(dir) = schemas_dir {
810 tracing::warn!(
811 "`schemas_dir` (= {:?}) in workspace.toml is retired and ignored — \
812 authored schemas are read from the fixed `<workspace>/.memstead/schemas/`. \
813 Remove the key to silence this warning.",
814 dir
815 );
816 }
817}
818
819fn parse_cross_link_value(
823 location: &str,
824 value: &toml::Value,
825) -> Result<memstead_schema::workspace_config::CrossLinkValue, StoreError> {
826 memstead_schema::workspace_config::CrossLinkValue::parse_toml(location, value).map_err(|e| {
827 StoreError::Parse {
828 path: std::path::PathBuf::from("workspace.toml"),
829 message: e.to_string(),
830 }
831 })
832}
833
834#[derive(Debug, Serialize, Deserialize)]
837struct PersistenceAdapterDecl {
838 name: String,
839}
840
841impl Default for PersistenceAdapterDecl {
842 fn default() -> Self {
843 Self {
844 name: "file-two-layer".to_string(),
845 }
846 }
847}
848
849#[derive(Debug, Serialize, Deserialize)]
853struct MountsJsonDoc {
854 format: String,
855 mounts: Vec<MountWire>,
856}
857
858#[derive(Debug, Serialize, Deserialize)]
863struct MountWire {
864 mem: String,
865 #[serde(default, skip_serializing_if = "Option::is_none")]
871 schema: Option<String>,
872 #[serde(default, skip_serializing_if = "Option::is_none")]
877 migration_target: Option<String>,
878 storage: MountStorageWire,
879 capability: CapabilityWire,
880 lifecycle: LifecycleWire,
881 cross_linkable: bool,
882}
883
884#[derive(Debug, Serialize, Deserialize)]
885#[serde(tag = "type", rename_all = "kebab-case")]
886enum MountStorageWire {
887 Folder {
888 path: PathBuf,
889 },
890 GitBranch {
891 gitdir: PathBuf,
892 branch: String,
893 },
894 Archive {
895 path: PathBuf,
896 },
897 InMemory,
905}
906
907#[derive(Debug, Serialize, Deserialize)]
908#[serde(rename_all = "kebab-case")]
909enum CapabilityWire {
910 ReadOnly,
911 Write,
912}
913
914#[derive(Debug, Serialize, Deserialize)]
915#[serde(rename_all = "kebab-case")]
916enum LifecycleWire {
917 Eager,
918 Lazy,
919}
920
921impl MountWire {
922 fn from_mount(m: &Mount, workspace_root: &Path) -> Self {
923 Self {
924 mem: m.mem.clone(),
925 schema: m.schema.as_ref().map(|s| s.to_string()),
926 migration_target: m.migration_target.as_ref().map(|t| t.to_string()),
927 storage: match &m.storage {
928 MountStorage::Folder { path } => MountStorageWire::Folder {
929 path: relativize_mount_path(path, workspace_root),
930 },
931 MountStorage::GitBranch { gitdir, branch } => MountStorageWire::GitBranch {
932 gitdir: relativize_mount_path(gitdir, workspace_root),
933 branch: branch.clone(),
934 },
935 MountStorage::Archive { path } => MountStorageWire::Archive {
936 path: relativize_mount_path(path, workspace_root),
937 },
938 MountStorage::InMemory => MountStorageWire::InMemory,
939 },
940 capability: match m.capability {
941 MountCapability::ReadOnly => CapabilityWire::ReadOnly,
942 MountCapability::Write => CapabilityWire::Write,
943 },
944 lifecycle: match m.lifecycle {
945 MountLifecycle::Eager => LifecycleWire::Eager,
946 MountLifecycle::Lazy => LifecycleWire::Lazy,
947 },
948 cross_linkable: m.cross_linkable,
949 }
950 }
951
952 fn into_mount(self, workspace_root: &Path) -> Mount {
953 Mount {
954 mem: self.mem,
955 schema: self.schema.map(|s| {
956 s.parse()
957 .expect("schema pin on disk must be `<name>@<version>`")
958 }),
959 migration_target: self.migration_target.map(|t| {
960 t.parse()
961 .expect("migration_target on disk must be `<name>@<version>`")
962 }),
963 storage: match self.storage {
964 MountStorageWire::Folder { path } => MountStorage::Folder {
965 path: absolutize_mount_path(path, workspace_root),
966 },
967 MountStorageWire::GitBranch { gitdir, branch } => MountStorage::GitBranch {
968 gitdir: absolutize_mount_path(gitdir, workspace_root),
969 branch,
970 },
971 MountStorageWire::Archive { path } => MountStorage::Archive {
972 path: absolutize_mount_path(path, workspace_root),
973 },
974 MountStorageWire::InMemory => MountStorage::InMemory,
975 },
976 capability: match self.capability {
977 CapabilityWire::ReadOnly => MountCapability::ReadOnly,
978 CapabilityWire::Write => MountCapability::Write,
979 },
980 lifecycle: match self.lifecycle {
981 LifecycleWire::Eager => MountLifecycle::Eager,
982 LifecycleWire::Lazy => MountLifecycle::Lazy,
983 },
984 cross_linkable: self.cross_linkable,
985 }
986 }
987}
988
989#[derive(Debug, thiserror::Error)]
991pub enum InstantiateError {
992 #[error(
1004 "mem {mem}: git-branch storage needs the memstead-git-branch crate \
1005 (`cargo add memstead-git-branch`). Simplest: open the workspace with \
1006 `memstead_git_branch::workspace_store::engine_from_workspace_root(root)` \
1007 instead of the memstead-base constructor. Alternative, if you build the \
1008 Engine yourself: `engine.set_backend_factory(\
1009 memstead_git_branch::storage::instantiate_full_backend)` before mounting"
1010 )]
1011 GitBranchRequiresMemRepoFeature { mem: String },
1012}
1013
1014impl InstantiateError {
1015 pub fn code(&self) -> &'static str {
1021 match self {
1022 InstantiateError::GitBranchRequiresMemRepoFeature { .. } => {
1023 "UNSUPPORTED_WORKSPACE_SHAPE"
1024 }
1025 }
1026 }
1027}
1028
1029pub fn instantiate_lean_backend(mount: &Mount) -> Result<Box<dyn MemBackend>, InstantiateError> {
1039 match &mount.storage {
1040 MountStorage::Folder { path } => Ok(Box::new(FilesystemMemWriter::new(path.clone()))),
1041 MountStorage::Archive { path } => Ok(Box::new(ArchiveBackend::new(path.clone()))),
1042 MountStorage::InMemory => Ok(Box::new(InMemoryBackend::new())),
1043 MountStorage::GitBranch { .. } => Err(InstantiateError::GitBranchRequiresMemRepoFeature {
1044 mem: mount.mem.clone(),
1045 }),
1046 }
1047}
1048
1049#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1057pub enum Layout {
1058 Empty,
1061 New,
1064}
1065
1066pub fn detect_layout(workspace_root: &Path) -> Layout {
1070 if is_workspace_root(workspace_root) {
1071 Layout::New
1072 } else {
1073 Layout::Empty
1074 }
1075}
1076
1077pub fn standalone_workspace(workspace_root: &Path) -> Option<Workspace> {
1097 let config = memstead_schema::config::load_and_validate(workspace_root).ok()?;
1098 let schema = config.schema.clone()?;
1099 let name = config.name.clone().unwrap_or_else(|| {
1100 workspace_root
1101 .file_name()
1102 .map(|n| n.to_string_lossy().to_string())
1103 .unwrap_or_else(|| "mem".to_string())
1104 });
1105 let mount = Mount {
1106 mem: name,
1107 schema: Some(schema),
1108 storage: MountStorage::Folder {
1109 path: workspace_root.to_path_buf(),
1110 },
1111 capability: MountCapability::Write,
1112 lifecycle: MountLifecycle::Eager,
1113 cross_linkable: false,
1114 migration_target: None,
1115 };
1116 Some(Workspace {
1117 mounts: vec![mount],
1118 settings: WorkspaceSettings::default(),
1119 })
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124 use super::*;
1125 use memstead_schema::SchemaRef;
1126 use std::io::Write as _;
1127 use tempfile::TempDir;
1128
1129 fn pin(s: &str) -> SchemaRef {
1130 s.parse().unwrap()
1131 }
1132
1133 fn folder_mount(mem: &str, path: PathBuf) -> Mount {
1134 Mount {
1135 mem: mem.to_string(),
1136 schema: Some(pin("default@1.0.0")),
1137 storage: MountStorage::Folder { path },
1138 capability: MountCapability::Write,
1139 lifecycle: MountLifecycle::Eager,
1140 cross_linkable: true,
1141 migration_target: None,
1142 }
1143 }
1144
1145 fn write_workspace_toml(workspace_root: &Path, body: &str) {
1146 let path = FileWorkspaceStore::workspace_toml_path(workspace_root);
1147 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1148 std::fs::write(path, body).unwrap();
1149 }
1150
1151 #[test]
1152 fn load_returns_not_initialised_when_memstead_dir_absent() {
1153 let tmp = TempDir::new().unwrap();
1154 let store = FileWorkspaceStore::new();
1155 let err = store.load(tmp.path()).unwrap_err();
1156 assert!(matches!(err, StoreError::NotInitialised { .. }));
1157 }
1158
1159 #[test]
1165 fn parse_workspace_settings_reflects_cross_mem_links_edit() {
1166 let tmp = TempDir::new().unwrap();
1167 write_workspace_toml(
1168 tmp.path(),
1169 r#"
1170format = "memstead-git-branch-2"
1171
1172[persistence_adapter]
1173name = "file-two-layer"
1174
1175[cross_mem_links]
1176team-a = ["team-b"]
1177"#,
1178 );
1179 let settings = super::parse_workspace_settings(tmp.path()).unwrap();
1180 assert!(
1181 settings.cross_mem_links.contains_key("team-a"),
1182 "initial parse must surface the team-a grant; got {:?}",
1183 settings.cross_mem_links
1184 );
1185
1186 write_workspace_toml(
1188 tmp.path(),
1189 r#"
1190format = "memstead-git-branch-2"
1191
1192[persistence_adapter]
1193name = "file-two-layer"
1194
1195[cross_mem_links]
1196"#,
1197 );
1198 let refreshed = super::parse_workspace_settings(tmp.path()).unwrap();
1199 assert!(
1200 refreshed.cross_mem_links.is_empty(),
1201 "refreshed parse must drop the team-a grant; got {:?}",
1202 refreshed.cross_mem_links
1203 );
1204 }
1205
1206 #[test]
1211 fn parse_workspace_settings_reflects_allowlist_edit() {
1212 let tmp = TempDir::new().unwrap();
1213 write_workspace_toml(
1214 tmp.path(),
1215 r#"
1216format = "memstead-git-branch-2"
1217
1218[persistence_adapter]
1219name = "file-two-layer"
1220"#,
1221 );
1222 let initial = super::parse_workspace_settings(tmp.path()).unwrap();
1223 assert!(initial.mem_create_rules.is_empty());
1224
1225 write_workspace_toml(
1227 tmp.path(),
1228 r#"
1229format = "memstead-git-branch-2"
1230
1231[persistence_adapter]
1232name = "file-two-layer"
1233
1234[[mem_management.create]]
1235pattern = "test-*"
1236schemas = ["default@1.0.0"]
1237"#,
1238 );
1239 let refreshed = super::parse_workspace_settings(tmp.path()).unwrap();
1240 assert_eq!(refreshed.mem_create_rules.len(), 1);
1241 assert_eq!(refreshed.mem_create_rules[0].pattern, "test-*");
1242 }
1243
1244 #[test]
1245 fn load_returns_not_initialised_when_workspace_toml_missing() {
1246 let tmp = TempDir::new().unwrap();
1247 std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
1248 let store = FileWorkspaceStore::new();
1249 let err = store.load(tmp.path()).unwrap_err();
1250 assert!(matches!(err, StoreError::NotInitialised { .. }));
1251 }
1252
1253 #[test]
1254 fn load_with_no_mounts_yields_empty_mount_list() {
1255 let tmp = TempDir::new().unwrap();
1256 write_workspace_toml(
1257 tmp.path(),
1258 r#"
1259format = "memstead-git-branch-2"
1260
1261[persistence_adapter]
1262name = "file-two-layer"
1263"#,
1264 );
1265 let store = FileWorkspaceStore::new();
1266 let workspace = store.load(tmp.path()).unwrap();
1267 assert!(workspace.mounts.is_empty());
1268 }
1269
1270 #[test]
1271 fn load_with_no_mem_management_yields_empty_settings() {
1272 let tmp = TempDir::new().unwrap();
1277 write_workspace_toml(
1278 tmp.path(),
1279 r#"
1280format = "memstead-git-branch-2"
1281
1282[persistence_adapter]
1283name = "file-two-layer"
1284"#,
1285 );
1286 let store = FileWorkspaceStore::new();
1287 let workspace = store.load(tmp.path()).unwrap();
1288 assert!(workspace.settings.mem_create_rules.is_empty());
1289 assert!(workspace.settings.mem_delete_rules.is_empty());
1290 assert!(workspace.settings.cross_mem_links.is_empty());
1291 }
1292
1293 #[test]
1294 fn load_picks_up_cross_mem_links_wildcard_and_list() {
1295 use memstead_schema::workspace_config::CrossLinkValue;
1300 let tmp = TempDir::new().unwrap();
1301 write_workspace_toml(
1302 tmp.path(),
1303 r#"
1304format = "memstead-git-branch-2"
1305
1306[persistence_adapter]
1307name = "file-two-layer"
1308
1309[cross_mem_links]
1310specs = "*"
1311engine = ["specs", "macos"]
1312locked = []
1313"#,
1314 );
1315 let store = FileWorkspaceStore::new();
1316 let workspace = store.load(tmp.path()).unwrap();
1317 let cvl = &workspace.settings.cross_mem_links;
1318 assert_eq!(cvl.len(), 3);
1319 assert_eq!(cvl.get("specs"), Some(&CrossLinkValue::Wildcard));
1320 assert_eq!(
1321 cvl.get("engine"),
1322 Some(&CrossLinkValue::List(vec![
1323 "specs".to_string(),
1324 "macos".to_string()
1325 ]))
1326 );
1327 assert_eq!(cvl.get("locked"), Some(&CrossLinkValue::List(vec![])));
1328 }
1329
1330 #[test]
1331 fn load_rejects_cross_mem_links_mixed_wildcard_and_names() {
1332 let tmp = TempDir::new().unwrap();
1337 write_workspace_toml(
1338 tmp.path(),
1339 r#"
1340format = "memstead-git-branch-2"
1341
1342[persistence_adapter]
1343name = "file-two-layer"
1344
1345[cross_mem_links]
1346specs = ["*", "engine"]
1347"#,
1348 );
1349 let store = FileWorkspaceStore::new();
1350 let err = store.load(tmp.path()).unwrap_err();
1351 match err {
1352 StoreError::Parse { message, .. } => {
1353 assert!(message.contains("[cross_mem_links].specs"));
1354 assert!(message.contains("wildcard"));
1355 }
1356 other => panic!("expected StoreError::Parse, got {other:?}"),
1357 }
1358 }
1359
1360 #[test]
1361 fn load_picks_up_default_cross_links_on_create_rule() {
1362 use memstead_schema::workspace_config::CrossLinkValue;
1366 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[[mem_management.create]]
1376pattern = "exec-*"
1377schemas = ["default"]
1378default_cross_links = "*"
1379"#,
1380 );
1381 let store = FileWorkspaceStore::new();
1382 let workspace = store.load(tmp.path()).unwrap();
1383 let rule = &workspace.settings.mem_create_rules[0];
1384 assert_eq!(rule.pattern, "exec-*");
1385 assert_eq!(rule.default_cross_links, Some(CrossLinkValue::Wildcard));
1386 }
1387
1388 #[test]
1389 fn load_picks_up_mem_management_create_and_delete_rules() {
1390 let tmp = TempDir::new().unwrap();
1394 write_workspace_toml(
1395 tmp.path(),
1396 r#"
1397format = "memstead-git-branch-2"
1398
1399[persistence_adapter]
1400name = "file-two-layer"
1401
1402[[mem_management.create]]
1403pattern = "exec-*"
1404schemas = ["default@1.0.0", "*"]
1405
1406[[mem_management.create]]
1407pattern = "scratch-*"
1408schemas = ["default"]
1409
1410[[mem_management.delete]]
1411pattern = "exec-*"
1412"#,
1413 );
1414 let store = FileWorkspaceStore::new();
1415 let workspace = store.load(tmp.path()).unwrap();
1416 assert_eq!(workspace.settings.mem_create_rules.len(), 2);
1417 assert_eq!(workspace.settings.mem_create_rules[0].pattern, "exec-*");
1418 assert_eq!(
1419 workspace.settings.mem_create_rules[0].schemas,
1420 vec!["default@1.0.0".to_string(), "*".to_string()]
1421 );
1422 assert_eq!(workspace.settings.mem_create_rules[1].pattern, "scratch-*");
1423 assert_eq!(workspace.settings.mem_delete_rules.len(), 1);
1424 assert_eq!(workspace.settings.mem_delete_rules[0].pattern, "exec-*");
1425 }
1426
1427 #[test]
1432 fn save_state_round_trips_migration_target() {
1433 let tmp = TempDir::new().unwrap();
1434 write_workspace_toml(
1435 tmp.path(),
1436 "\nformat = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1437 );
1438 let store = FileWorkspaceStore::new();
1439 let mut migrating = folder_mount("specs", PathBuf::from("/work/mem"));
1440 migrating.migration_target = Some(pin("mig-b@0.1.0"));
1441 let settled = folder_mount("other", PathBuf::from("/work/other"));
1442 let original = Workspace {
1443 mounts: vec![migrating, settled],
1444 settings: WorkspaceSettings::default(),
1445 };
1446 store.save_state(tmp.path(), &original).unwrap();
1447 let raw =
1448 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1449 assert!(
1450 raw.contains("mig-b@0.1.0"),
1451 "migration_target must persist: {raw}"
1452 );
1453 assert_eq!(
1454 raw.matches("migration_target").count(),
1455 1,
1456 "settled mounts must omit the key entirely: {raw}"
1457 );
1458 let loaded = store.load(tmp.path()).unwrap();
1459 assert_eq!(loaded.mounts[0].migration_target, Some(pin("mig-b@0.1.0")));
1460 assert_eq!(loaded.mounts[1].migration_target, None);
1461 }
1462
1463 #[test]
1464 fn save_state_then_load_round_trips_mount_list() {
1465 let tmp = TempDir::new().unwrap();
1466 write_workspace_toml(
1467 tmp.path(),
1468 r#"
1469format = "memstead-git-branch-2"
1470
1471[persistence_adapter]
1472name = "file-two-layer"
1473"#,
1474 );
1475 let store = FileWorkspaceStore::new();
1476 let original = Workspace {
1477 mounts: vec![
1478 folder_mount("specs", PathBuf::from("/work/mem")),
1479 Mount {
1480 mem: "engine".to_string(),
1481 schema: Some(pin("default@1.0.0")),
1482 storage: MountStorage::GitBranch {
1483 gitdir: PathBuf::from("/work/mem-repo/.git"),
1484 branch: "engine".to_string(),
1485 },
1486 capability: MountCapability::Write,
1487 lifecycle: MountLifecycle::Eager,
1488 cross_linkable: true,
1489 migration_target: None,
1490 },
1491 Mount {
1492 mem: "external".to_string(),
1493 schema: Some(pin("default@1.0.0")),
1494 storage: MountStorage::Archive {
1495 path: PathBuf::from("/deps/external.mem"),
1496 },
1497 capability: MountCapability::ReadOnly,
1498 lifecycle: MountLifecycle::Lazy,
1499 cross_linkable: false,
1500 migration_target: None,
1501 },
1502 ],
1503 settings: WorkspaceSettings::default(),
1504 };
1505 store.save_state(tmp.path(), &original).unwrap();
1506
1507 assert!(FileWorkspaceStore::mounts_json_path(tmp.path()).is_file());
1509
1510 let reloaded = store.load(tmp.path()).unwrap();
1511 assert_eq!(reloaded.mounts.len(), original.mounts.len());
1512 for (a, b) in reloaded.mounts.iter().zip(original.mounts.iter()) {
1513 assert_eq!(a.mem, b.mem);
1514 assert_eq!(a.schema, b.schema);
1515 assert_eq!(a.capability, b.capability);
1516 assert_eq!(a.lifecycle, b.lifecycle);
1517 assert_eq!(a.cross_linkable, b.cross_linkable);
1518 assert_eq!(a.storage, b.storage);
1519 }
1520 }
1521
1522 #[test]
1523 fn save_state_round_trips_unset_schema_assertion() {
1524 let tmp = TempDir::new().unwrap();
1529 write_workspace_toml(
1530 tmp.path(),
1531 r#"
1532format = "memstead-git-branch-2"
1533
1534[persistence_adapter]
1535name = "file-two-layer"
1536"#,
1537 );
1538 let store = FileWorkspaceStore::new();
1539 let original = Workspace {
1540 mounts: vec![Mount {
1541 mem: "foreign".to_string(),
1542 schema: None,
1543 storage: MountStorage::Folder {
1544 path: tmp.path().join("foreign"),
1545 },
1546 capability: MountCapability::ReadOnly,
1547 lifecycle: MountLifecycle::Eager,
1548 cross_linkable: false,
1549 migration_target: None,
1550 }],
1551 settings: WorkspaceSettings::default(),
1552 };
1553 store.save_state(tmp.path(), &original).unwrap();
1554
1555 let raw =
1557 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1558 assert!(
1559 !raw.contains("\"schema\""),
1560 "unset schema assertion must omit the key on the wire; got:\n{raw}"
1561 );
1562
1563 let reloaded = store.load(tmp.path()).unwrap();
1565 assert_eq!(reloaded.mounts.len(), 1);
1566 assert_eq!(reloaded.mounts[0].schema, None);
1567 }
1568
1569 #[test]
1570 fn save_state_does_not_touch_workspace_toml() {
1571 let tmp = TempDir::new().unwrap();
1572 let original_body = r#"
1573format = "memstead-git-branch-2"
1574
1575[persistence_adapter]
1576name = "file-two-layer"
1577"#;
1578 write_workspace_toml(tmp.path(), original_body);
1579 let store = FileWorkspaceStore::new();
1580 let workspace = Workspace::default();
1581 store.save_state(tmp.path(), &workspace).unwrap();
1582 let toml_after =
1584 std::fs::read_to_string(FileWorkspaceStore::workspace_toml_path(tmp.path())).unwrap();
1585 assert_eq!(toml_after, original_body);
1586 }
1587
1588 #[test]
1589 fn save_state_writes_paths_relative_to_workspace_root() {
1590 let tmp = TempDir::new().unwrap();
1591 write_workspace_toml(
1592 tmp.path(),
1593 r#"
1594format = "memstead-git-branch-2"
1595
1596[persistence_adapter]
1597name = "file-two-layer"
1598"#,
1599 );
1600 let store = FileWorkspaceStore::new();
1601 let workspace = Workspace {
1602 mounts: vec![
1603 Mount {
1604 mem: "engine".to_string(),
1605 schema: Some(pin("default@1.0.0")),
1606 storage: MountStorage::GitBranch {
1607 gitdir: tmp.path().join("mem-repo").join(".git"),
1608 branch: "engine".to_string(),
1609 },
1610 capability: MountCapability::Write,
1611 lifecycle: MountLifecycle::Eager,
1612 cross_linkable: true,
1613 migration_target: None,
1614 },
1615 Mount {
1616 mem: "external".to_string(),
1617 schema: Some(pin("default@1.0.0")),
1618 storage: MountStorage::Archive {
1619 path: PathBuf::from("/global/cache/external.mem"),
1620 },
1621 capability: MountCapability::ReadOnly,
1622 lifecycle: MountLifecycle::Lazy,
1623 cross_linkable: false,
1624 migration_target: None,
1625 },
1626 ],
1627 settings: WorkspaceSettings::default(),
1628 };
1629 store.save_state(tmp.path(), &workspace).unwrap();
1630
1631 let on_disk =
1632 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1633 assert!(on_disk.contains("\"memstead-mounts-3\""));
1635 assert!(
1637 on_disk.contains("\"mem-repo/.git\""),
1638 "expected relative gitdir, got: {on_disk}"
1639 );
1640 assert!(
1641 !on_disk.contains(tmp.path().to_str().unwrap()),
1642 "in-workspace path should not include the absolute tmp prefix: {on_disk}"
1643 );
1644 assert!(on_disk.contains("\"/global/cache/external.mem\""));
1646
1647 let reloaded = store.load(tmp.path()).unwrap();
1649 match &reloaded.mounts[0].storage {
1650 MountStorage::GitBranch { gitdir, .. } => {
1651 assert_eq!(gitdir, &tmp.path().join("mem-repo").join(".git"));
1652 }
1653 other => panic!("expected GitBranch storage, got {other:?}"),
1654 }
1655 match &reloaded.mounts[1].storage {
1656 MountStorage::Archive { path } => {
1657 assert_eq!(path, &PathBuf::from("/global/cache/external.mem"));
1658 }
1659 other => panic!("expected Archive storage, got {other:?}"),
1660 }
1661 }
1662
1663 #[test]
1664 fn load_absolute_inside_root_path_then_save_rewrites_relative() {
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());
1680 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1681 let abs_gitdir = tmp.path().join("mem-repo").join(".git");
1682 let mounts_body = format!(
1683 r#"{{
1684 "format": "memstead-mounts-3",
1685 "mounts": [
1686 {{
1687 "mem": "engine",
1688 "schema": "default@1.0.0",
1689 "storage": {{
1690 "type": "git-branch",
1691 "gitdir": "{}",
1692 "branch": "engine"
1693 }},
1694 "capability": "write",
1695 "lifecycle": "eager",
1696 "cross_linkable": true
1697 }}
1698 ]
1699}}"#,
1700 abs_gitdir.to_str().unwrap()
1701 );
1702 std::fs::write(&mounts_path, &mounts_body).unwrap();
1703
1704 let store = FileWorkspaceStore::new();
1705 let workspace = store.load(tmp.path()).unwrap();
1708 match &workspace.mounts[0].storage {
1709 MountStorage::GitBranch { gitdir, .. } => assert_eq!(gitdir, &abs_gitdir),
1710 other => panic!("expected GitBranch storage, got {other:?}"),
1711 }
1712
1713 store.save_state(tmp.path(), &workspace).unwrap();
1716 let on_disk = std::fs::read_to_string(&mounts_path).unwrap();
1717 assert!(on_disk.contains("\"memstead-mounts-3\""));
1718 assert!(on_disk.contains("\"mem-repo/.git\""));
1719 assert!(!on_disk.contains(tmp.path().to_str().unwrap()));
1720 }
1721
1722 #[test]
1731 fn save_state_preserves_refs_heads_branch_form() {
1732 let tmp = TempDir::new().unwrap();
1733 write_workspace_toml(
1734 tmp.path(),
1735 r#"
1736format = "memstead-git-branch-2"
1737
1738[persistence_adapter]
1739name = "file-two-layer"
1740"#,
1741 );
1742 let store = FileWorkspaceStore::new();
1743 let original = Workspace {
1744 mounts: vec![Mount {
1745 mem: "engine".to_string(),
1746 schema: Some(pin("default@1.0.0")),
1747 storage: MountStorage::GitBranch {
1748 gitdir: tmp.path().join("mem-repo").join(".git"),
1749 branch: "refs/heads/demo/engine".to_string(),
1750 },
1751 capability: MountCapability::Write,
1752 lifecycle: MountLifecycle::Eager,
1753 cross_linkable: true,
1754 migration_target: None,
1755 }],
1756 settings: WorkspaceSettings::default(),
1757 };
1758 store.save_state(tmp.path(), &original).unwrap();
1759
1760 let on_disk =
1761 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1762 assert!(
1763 on_disk.contains("\"branch\": \"refs/heads/demo/engine\""),
1764 "expected fully-qualified ref on disk, got: {on_disk}"
1765 );
1766
1767 let reloaded = store.load(tmp.path()).unwrap();
1768 match &reloaded.mounts[0].storage {
1769 MountStorage::GitBranch { branch, .. } => {
1770 assert_eq!(branch, "refs/heads/demo/engine");
1771 }
1772 other => panic!("expected GitBranch storage, got {other:?}"),
1773 }
1774 }
1775
1776 #[test]
1786 fn load_preserves_short_form_branch_without_rewrite() {
1787 let tmp = TempDir::new().unwrap();
1788 write_workspace_toml(
1789 tmp.path(),
1790 r#"
1791format = "memstead-git-branch-2"
1792
1793[persistence_adapter]
1794name = "file-two-layer"
1795"#,
1796 );
1797 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1798 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1799 std::fs::write(
1800 &mounts_path,
1801 r#"{
1802 "format": "memstead-mounts-3",
1803 "mounts": [
1804 {
1805 "mem": "engine",
1806 "schema": "default@1.0.0",
1807 "storage": {
1808 "type": "git-branch",
1809 "gitdir": "mem-repo/.git",
1810 "branch": "demo/engine"
1811 },
1812 "capability": "write",
1813 "lifecycle": "eager",
1814 "cross_linkable": true
1815 }
1816 ]
1817}"#,
1818 )
1819 .unwrap();
1820
1821 let store = FileWorkspaceStore::new();
1822 let workspace = store.load(tmp.path()).unwrap();
1823 match &workspace.mounts[0].storage {
1824 MountStorage::GitBranch { branch, .. } => {
1825 assert_eq!(
1826 branch, "demo/engine",
1827 "reader must not silently rewrite short-form branch"
1828 );
1829 }
1830 other => panic!("expected GitBranch storage, got {other:?}"),
1831 }
1832 }
1833
1834 #[test]
1835 fn load_rejects_format_version_mismatch_on_toml() {
1836 let tmp = TempDir::new().unwrap();
1837 write_workspace_toml(
1838 tmp.path(),
1839 r#"
1840format = "memstead-git-branch-99"
1841
1842[persistence_adapter]
1843name = "file-two-layer"
1844"#,
1845 );
1846 let store = FileWorkspaceStore::new();
1847 let err = store.load(tmp.path()).unwrap_err();
1848 match err {
1849 StoreError::FormatMismatch {
1850 expected, found, ..
1851 } => {
1852 assert_eq!(expected, "memstead-git-branch-2");
1853 assert_eq!(found, "memstead-git-branch-99");
1854 }
1855 other => panic!("expected FormatMismatch, got {other:?}"),
1856 }
1857 }
1858
1859 #[test]
1860 fn load_rejects_format_version_mismatch_on_mounts_json() {
1861 let tmp = TempDir::new().unwrap();
1862 write_workspace_toml(
1863 tmp.path(),
1864 r#"
1865format = "memstead-git-branch-2"
1866
1867[persistence_adapter]
1868name = "file-two-layer"
1869"#,
1870 );
1871 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1872 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1873 std::fs::write(
1874 &mounts_path,
1875 r#"{ "format": "memstead-mounts-99", "mounts": [] }"#,
1876 )
1877 .unwrap();
1878 let store = FileWorkspaceStore::new();
1879 let err = store.load(tmp.path()).unwrap_err();
1880 assert!(matches!(err, StoreError::FormatMismatch { .. }));
1881 }
1882
1883 #[test]
1886 fn load_refuses_pre_rename_toml_as_legacy_layout() {
1887 let tmp = TempDir::new().unwrap();
1888 write_workspace_toml(
1889 tmp.path(),
1890 r#"
1891format = "memstead-git-branch-1"
1892
1893[persistence_adapter]
1894name = "file-two-layer"
1895"#,
1896 );
1897 let store = FileWorkspaceStore::new();
1898 let err = store.load(tmp.path()).unwrap_err();
1899 match err {
1900 StoreError::LegacyLayout { found, .. } => {
1901 assert_eq!(found, "memstead-git-branch-1");
1902 }
1903 other => panic!("expected LegacyLayout, got {other:?}"),
1904 }
1905 }
1906
1907 #[test]
1914 fn load_refuses_pre_rename_mounts_json_as_legacy_layout() {
1915 for legacy in ["memstead-mounts-1", "memstead-mounts-2"] {
1916 let tmp = TempDir::new().unwrap();
1917 write_workspace_toml(
1918 tmp.path(),
1919 r#"
1920format = "memstead-git-branch-2"
1921
1922[persistence_adapter]
1923name = "file-two-layer"
1924"#,
1925 );
1926 let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1927 std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1928 std::fs::write(
1929 &mounts_path,
1930 format!(
1931 r#"{{ "format": "{legacy}", "mounts": [{{ "unit": "notes", "storage": {{ "type": "folder", "path": "notes" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#
1932 ),
1933 )
1934 .unwrap();
1935 let store = FileWorkspaceStore::new();
1936 let err = store.load(tmp.path()).unwrap_err();
1937 match err {
1938 StoreError::LegacyLayout { found, .. } => assert_eq!(found, legacy),
1939 other => panic!("expected LegacyLayout for {legacy}, got {other:?}"),
1940 }
1941 }
1942 }
1943
1944 #[test]
1945 fn load_rejects_invalid_toml() {
1946 let tmp = TempDir::new().unwrap();
1947 write_workspace_toml(tmp.path(), "this is not = valid = toml");
1948 let store = FileWorkspaceStore::new();
1949 let err = store.load(tmp.path()).unwrap_err();
1950 assert!(matches!(err, StoreError::Parse { .. }));
1951 }
1952
1953 #[test]
1954 fn load_rejects_unknown_top_level_key() {
1955 let tmp = TempDir::new().unwrap();
1959 write_workspace_toml(
1960 tmp.path(),
1961 "format = \"memstead-git-branch-2\"\nnonexistent_key = true\n",
1962 );
1963 let store = FileWorkspaceStore::new();
1964 let err = store.load(tmp.path()).unwrap_err();
1965 match err {
1966 StoreError::Parse { message, .. } => {
1967 assert!(
1968 message.contains("nonexistent_key"),
1969 "refusal must name the unknown key: {message}"
1970 );
1971 }
1972 other => panic!("expected Parse error, got {other:?}"),
1973 }
1974 }
1975
1976 #[test]
1977 fn instantiate_lean_backend_handles_folder_archive_and_in_memory() {
1978 let tmp = TempDir::new().unwrap();
1979 let folder = folder_mount("local", tmp.path().to_path_buf());
1980 let archive_path = tmp.path().join("ext.mem");
1981 let f = std::fs::File::create(&archive_path).unwrap();
1983 let mut w = zip::ZipWriter::new(f);
1984 w.start_file("a.md", zip::write::SimpleFileOptions::default())
1985 .unwrap();
1986 w.write_all(b"# a").unwrap();
1987 w.finish().unwrap();
1988 let archive = Mount {
1989 mem: "external".to_string(),
1990 schema: Some(pin("default@1.0.0")),
1991 storage: MountStorage::Archive { path: archive_path },
1992 capability: MountCapability::ReadOnly,
1993 lifecycle: MountLifecycle::Lazy,
1994 cross_linkable: false,
1995 migration_target: None,
1996 };
1997 let in_memory = Mount {
1998 mem: "session".to_string(),
1999 schema: Some(pin("default@1.0.0")),
2000 storage: MountStorage::InMemory,
2001 capability: MountCapability::Write,
2002 lifecycle: MountLifecycle::Eager,
2003 cross_linkable: true,
2004 migration_target: None,
2005 };
2006
2007 let _: Box<dyn MemBackend> = instantiate_lean_backend(&folder).unwrap();
2008 let _: Box<dyn MemBackend> = instantiate_lean_backend(&archive).unwrap();
2009 let _: Box<dyn MemBackend> = instantiate_lean_backend(&in_memory).unwrap();
2012 }
2013
2014 #[test]
2019 fn save_state_round_trips_in_memory_variant_unambiguously() {
2020 let tmp = TempDir::new().unwrap();
2021 write_workspace_toml(
2022 tmp.path(),
2023 r#"
2024format = "memstead-git-branch-2"
2025
2026[persistence_adapter]
2027name = "file-two-layer"
2028"#,
2029 );
2030 let store = FileWorkspaceStore::new();
2031 let original = Workspace {
2032 mounts: vec![
2033 folder_mount("local", PathBuf::from("/work/mem")),
2034 Mount {
2035 mem: "session".to_string(),
2036 schema: Some(pin("default@1.0.0")),
2037 storage: MountStorage::InMemory,
2038 capability: MountCapability::Write,
2039 lifecycle: MountLifecycle::Eager,
2040 cross_linkable: true,
2041 migration_target: None,
2042 },
2043 ],
2044 settings: WorkspaceSettings::default(),
2045 };
2046 store.save_state(tmp.path(), &original).unwrap();
2047
2048 let raw =
2050 std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
2051 assert!(raw.contains("\"type\": \"in-memory\""), "got: {raw}");
2052
2053 let reloaded = store.load(tmp.path()).unwrap();
2054 assert_eq!(reloaded.mounts.len(), 2);
2055 let session = reloaded
2058 .mounts
2059 .iter()
2060 .find(|m| m.mem == "session")
2061 .expect("session mount survives reload");
2062 assert_eq!(session.storage, MountStorage::InMemory);
2063 let local = reloaded.mounts.iter().find(|m| m.mem == "local").unwrap();
2066 assert!(matches!(local.storage, MountStorage::Folder { .. }));
2067 }
2068
2069 #[test]
2070 fn instantiate_lean_backend_rejects_git_branch_with_typed_error() {
2071 let mount = Mount {
2072 mem: "engine".to_string(),
2073 schema: Some(pin("default@1.0.0")),
2074 storage: MountStorage::GitBranch {
2075 gitdir: PathBuf::from("/some/path/.git"),
2076 branch: "engine".to_string(),
2077 },
2078 capability: MountCapability::Write,
2079 lifecycle: MountLifecycle::Eager,
2080 cross_linkable: true,
2081 migration_target: None,
2082 };
2083 match instantiate_lean_backend(&mount) {
2087 Err(InstantiateError::GitBranchRequiresMemRepoFeature { mem }) => {
2088 assert_eq!(mem, "engine");
2089 }
2090 Ok(_) => panic!("expected GitBranchRequiresMemRepoFeature, got Ok"),
2091 }
2092 }
2093
2094 #[test]
2095 fn detect_layout_returns_empty_for_unrecognised_workspace() {
2096 let tmp = TempDir::new().unwrap();
2097 assert_eq!(detect_layout(tmp.path()), Layout::Empty);
2098 }
2099 #[test]
2100 fn detect_layout_returns_new_when_workspace_toml_present() {
2101 let tmp = TempDir::new().unwrap();
2102 write_workspace_toml(
2103 tmp.path(),
2104 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2105 );
2106 assert_eq!(detect_layout(tmp.path()), Layout::New);
2107 }
2108}