1use std::collections::BTreeSet;
45use std::path::{Path, PathBuf};
46
47use memstead_schema::{
48 PUBLISHED_MEM_FORMAT, PublishConversionError, PublishedMemConfig, SchemaRef,
49};
50use regex::Regex;
51use serde::{Deserialize, Serialize};
52
53pub const FILESYSTEM_WORKSPACE_FORMAT: u32 = 1;
61
62#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
66pub struct DepRef {
67 pub scope: String,
68 pub name: String,
69}
70
71impl DepRef {
72 pub fn as_display(&self) -> String {
74 format!("{}/{}", self.scope, self.name)
75 }
76}
77
78impl std::fmt::Display for DepRef {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 f.write_str(&self.as_display())
81 }
82}
83
84impl std::str::FromStr for DepRef {
85 type Err = String;
86
87 fn from_str(s: &str) -> Result<Self, Self::Err> {
88 let trimmed = s.trim();
89 if trimmed.is_empty() {
90 return Err("dep ref must not be empty (expected \"scope/name\")".into());
91 }
92 let (scope, name) = trimmed
93 .split_once('/')
94 .ok_or_else(|| format!("dep ref '{trimmed}' must be in 'scope/name' form"))?;
95 check_slug(scope, "scope")?;
96 check_slug(name, "name")?;
97 Ok(DepRef {
98 scope: scope.to_string(),
99 name: name.to_string(),
100 })
101 }
102}
103
104impl Serialize for DepRef {
105 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
106 serializer.serialize_str(&self.as_display())
107 }
108}
109
110impl<'de> Deserialize<'de> for DepRef {
111 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
112 let s = String::deserialize(deserializer)?;
113 s.parse::<DepRef>().map_err(serde::de::Error::custom)
114 }
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
126pub struct WorkspaceConfig {
127 pub format: u32,
130 #[serde(default, skip_serializing)]
136 pub name: String,
137 pub schema: SchemaRef,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub version: Option<semver::Version>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub description: Option<String>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub title: Option<String>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub subject: Option<memstead_schema::MemSubject>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub authors: Option<Vec<String>>,
157 #[serde(default, skip_serializing_if = "Vec::is_empty")]
160 pub deps: Vec<DepRef>,
161 #[serde(flatten)]
165 pub extra: serde_json::Map<String, serde_json::Value>,
166}
167
168impl WorkspaceConfig {
169 pub fn new(name: impl Into<String>, schema: SchemaRef) -> Self {
176 Self {
177 format: FILESYSTEM_WORKSPACE_FORMAT,
178 name: name.into(),
179 schema,
180 version: Some(semver::Version::new(0, 1, 0)),
181 description: None,
182 title: None,
183 subject: None,
184 authors: None,
185 deps: Vec::new(),
186 extra: serde_json::Map::new(),
187 }
188 }
189
190 pub fn add_dep(&mut self, dep: DepRef) -> bool {
195 if self.deps.iter().any(|d| d == &dep) {
196 return false;
197 }
198 self.deps.push(dep);
199 true
200 }
201
202 pub fn to_published(&self) -> Result<PublishedMemConfig, PublishConversionError> {
207 let version = self
208 .version
209 .clone()
210 .ok_or(PublishConversionError::MissingVersion)?;
211 Ok(PublishedMemConfig {
212 format: PUBLISHED_MEM_FORMAT,
213 name: self.name.clone(),
214 version,
215 description: self.description.clone(),
216 title: self.title.clone(),
217 subject: self.subject.clone(),
218 authors: self.authors.clone(),
219 schema: self.schema.clone(),
220 })
221 }
222}
223
224#[derive(Debug, thiserror::Error)]
226pub enum WorkspaceConfigError {
227 #[error("workspace config not found at {0}")]
228 NotFound(PathBuf),
229 #[error("workspace config io error at {path}: {source}")]
230 Io {
231 path: PathBuf,
232 #[source]
233 source: std::io::Error,
234 },
235 #[error("workspace config malformed: {0}")]
236 Malformed(String),
237 #[error(
238 "workspace config format {got} is not supported (expected {expected}) — \
239 re-run `memstead init` against a fresh folder"
240 )]
241 UnsupportedFormat { got: u32, expected: u32 },
242 #[error("workspace config invalid name: {0}")]
243 InvalidName(String),
244 #[error("workspace config has duplicate dep: {0}")]
245 DuplicateDep(String),
246}
247
248fn name_regex() -> &'static Regex {
249 static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
250 RE.get_or_init(|| Regex::new(r"^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$").unwrap())
251}
252
253fn check_slug(value: &str, label: &str) -> Result<(), String> {
254 if !name_regex().is_match(value) {
255 return Err(format!(
256 "{label} {value:?} must match ^[a-z0-9][a-z0-9-]{{0,62}}[a-z0-9]$"
257 ));
258 }
259 Ok(())
260}
261
262pub fn validate_mem_name(name: &str) -> Result<(), String> {
267 check_slug(name, "name")
268}
269
270pub fn config_path(workspace_root: &Path) -> PathBuf {
272 workspace_root
273 .join(crate::mem::MEM_META_DIR)
274 .join("config.json")
275}
276
277pub fn parse_workspace_config(bytes: &[u8]) -> Result<WorkspaceConfig, WorkspaceConfigError> {
280 let value: serde_json::Value = serde_json::from_slice(bytes)
281 .map_err(|e| WorkspaceConfigError::Malformed(e.to_string()))?;
282
283 if !value.is_object() {
284 return Err(WorkspaceConfigError::Malformed(
285 "expected a JSON object".to_string(),
286 ));
287 }
288
289 let format = value
292 .get("format")
293 .and_then(|v| v.as_u64())
294 .ok_or_else(|| WorkspaceConfigError::Malformed("missing or non-integer 'format'".into()))?;
295 if format != FILESYSTEM_WORKSPACE_FORMAT as u64 {
296 return Err(WorkspaceConfigError::UnsupportedFormat {
297 got: format as u32,
298 expected: FILESYSTEM_WORKSPACE_FORMAT,
299 });
300 }
301
302 let config: WorkspaceConfig = serde_json::from_value(value)
303 .map_err(|e| WorkspaceConfigError::Malformed(e.to_string()))?;
304
305 if !config.name.is_empty() {
309 check_slug(&config.name, "name").map_err(WorkspaceConfigError::InvalidName)?;
310 }
311
312 let mut seen: BTreeSet<String> = BTreeSet::new();
315 for dep in &config.deps {
316 let key = dep.as_display();
317 if !seen.insert(key.clone()) {
318 return Err(WorkspaceConfigError::DuplicateDep(key));
319 }
320 }
321
322 Ok(config)
323}
324
325pub fn read_workspace_config(
327 workspace_root: &Path,
328) -> Result<WorkspaceConfig, WorkspaceConfigError> {
329 let path = config_path(workspace_root);
330 let bytes = match std::fs::read(&path) {
331 Ok(b) => b,
332 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
333 return Err(WorkspaceConfigError::NotFound(path));
334 }
335 Err(e) => {
336 return Err(WorkspaceConfigError::Io { path, source: e });
337 }
338 };
339 let mut config = parse_workspace_config(&bytes)?;
340 if config.name.is_empty() {
344 config.name = workspace_root
345 .file_name()
346 .map(|n| n.to_string_lossy().to_string())
347 .unwrap_or_else(|| "mem".to_string());
348 }
349 Ok(config)
350}
351
352pub fn write_workspace_config(
357 workspace_root: &Path,
358 config: &WorkspaceConfig,
359) -> Result<(), WorkspaceConfigError> {
360 let target = config_path(workspace_root);
361 if let Some(parent) = target.parent() {
362 std::fs::create_dir_all(parent).map_err(|e| WorkspaceConfigError::Io {
363 path: parent.to_path_buf(),
364 source: e,
365 })?;
366 }
367
368 let mut bytes = serde_json::to_vec_pretty(config)
371 .map_err(|e| WorkspaceConfigError::Malformed(format!("serialise: {e}")))?;
372 bytes.push(b'\n');
373
374 let tmp = make_tmp_path(&target);
375 std::fs::write(&tmp, &bytes).map_err(|e| WorkspaceConfigError::Io {
376 path: tmp.clone(),
377 source: e,
378 })?;
379 if let Err(e) = std::fs::rename(&tmp, &target) {
380 let _ = std::fs::remove_file(&tmp);
381 return Err(WorkspaceConfigError::Io {
382 path: target,
383 source: e,
384 });
385 }
386 Ok(())
387}
388
389pub fn init_filesystem_mem(root: &Path, name: &str, schema: &SchemaRef) -> std::io::Result<()> {
403 init_filesystem_mem_at(root, root, name, schema)
404}
405
406pub fn init_filesystem_mem_at(
428 workspace_root: &Path,
429 mem_dir: &Path,
430 name: &str,
431 schema: &SchemaRef,
432) -> std::io::Result<()> {
433 use crate::workspace::{
434 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
435 };
436 use crate::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
437
438 let root = workspace_root;
439 std::fs::create_dir_all(mem_dir)?;
440 let config = WorkspaceConfig::new(name, schema.clone());
441 write_workspace_config(mem_dir, &config).map_err(std::io::Error::other)?;
442
443 let memstead_dir = root.join(crate::WORKSPACE_STORE_DIR);
444 std::fs::create_dir_all(memstead_dir.join("cache"))?;
445 std::fs::create_dir_all(memstead_dir.join("memstead-io"))?;
446 std::fs::write(
451 memstead_dir.join("workspace.toml"),
452 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
453 )?;
454
455 let workspace = Workspace {
456 mounts: vec![Mount {
457 mem: name.to_string(),
458 schema: Some(schema.clone()),
459 storage: MountStorage::Folder {
460 path: mem_dir.to_path_buf(),
461 },
462 capability: MountCapability::Write,
463 lifecycle: MountLifecycle::Eager,
464 cross_linkable: true,
465 migration_target: None,
466 }],
467 settings: WorkspaceSettings::default(),
468 };
469 FileWorkspaceStore::new()
470 .save_state(root, &workspace)
471 .map_err(std::io::Error::other)?;
472 Ok(())
473}
474
475fn make_tmp_path(target: &Path) -> PathBuf {
476 let name = target
477 .file_name()
478 .map(|n| n.to_string_lossy().to_string())
479 .unwrap_or_else(|| "_".to_string());
480 let nanos = std::time::SystemTime::now()
481 .duration_since(std::time::UNIX_EPOCH)
482 .map(|d| d.as_nanos())
483 .unwrap_or(0);
484 target.with_file_name(format!(".{name}.tmp.{nanos:x}"))
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use tempfile::TempDir;
491
492 fn versioned(name: &str, version: &str) -> SchemaRef {
493 SchemaRef::new(name, semver::Version::parse(version).unwrap())
494 }
495
496 #[test]
497 fn init_filesystem_mem_produces_a_rootable_workspace() {
498 let tmp = TempDir::new().unwrap();
499 let root = tmp.path().join("notes");
500 init_filesystem_mem(&root, "notes", &versioned("default", "1.0.0")).unwrap();
501
502 assert!(config_path(&root).is_file());
504 assert!(root.join(".memstead").join("workspace.toml").is_file());
505 assert!(
506 root.join(".memstead")
507 .join("state")
508 .join("mounts.json")
509 .is_file()
510 );
511
512 let engine = crate::Engine::from_workspace_root(&root).unwrap();
514 assert!(
515 engine
516 .mem_router()
517 .writable_mems()
518 .iter()
519 .any(|v| v == "notes"),
520 "init'd mem must be writable in the rooted engine"
521 );
522 }
523
524 #[test]
530 fn init_filesystem_mem_at_puts_the_mem_in_its_own_folder() {
531 let tmp = TempDir::new().unwrap();
532 let root = tmp.path().join("repo");
533 std::fs::create_dir_all(root.join("src")).unwrap();
534 std::fs::write(root.join("README.md"), b"# not an entity\n").unwrap();
535 let mem_dir = root.join("notes");
536
537 init_filesystem_mem_at(&root, &mem_dir, "notes", &versioned("default", "1.0.0")).unwrap();
538
539 assert!(root.join(".memstead").join("workspace.toml").is_file());
541 assert!(config_path(&mem_dir).is_file());
542 assert!(
543 !root.join(".memstead").join("config.json").is_file(),
544 "the root is not the mem, so it carries no mem config"
545 );
546
547 let engine = crate::Engine::from_workspace_root(&root).unwrap();
548 assert!(
549 engine
550 .mem_router()
551 .writable_mems()
552 .iter()
553 .any(|v| v == "notes"),
554 );
555
556 let mounts: serde_json::Value = serde_json::from_slice(
559 &std::fs::read(root.join(".memstead").join("state").join("mounts.json")).unwrap(),
560 )
561 .unwrap();
562 let on_disk = serde_json::to_string(&mounts).unwrap();
563 assert!(
564 on_disk.contains("\"notes\""),
565 "mount path stays relative to the workspace root: {on_disk}"
566 );
567 assert!(
568 !on_disk.contains(&tmp.path().display().to_string()),
569 "no absolute prefix is baked into the roster: {on_disk}"
570 );
571 }
572
573 fn ok_config_value() -> serde_json::Value {
574 serde_json::json!({
575 "format": FILESYSTEM_WORKSPACE_FORMAT,
576 "name": "demo-mem",
577 "schema": "default@1.0.0",
578 })
579 }
580
581 fn parse(value: serde_json::Value) -> Result<WorkspaceConfig, WorkspaceConfigError> {
582 parse_workspace_config(value.to_string().as_bytes())
583 }
584
585 #[test]
586 fn parses_minimal_config() {
587 let cfg = parse(ok_config_value()).unwrap();
588 assert_eq!(cfg.format, FILESYSTEM_WORKSPACE_FORMAT);
589 assert_eq!(cfg.name, "demo-mem");
590 assert_eq!(cfg.schema.as_display(), "default@1.0.0");
591 assert!(cfg.deps.is_empty());
592 assert!(cfg.version.is_none());
593 }
594
595 #[test]
596 fn parses_full_config() {
597 let v = serde_json::json!({
598 "format": FILESYSTEM_WORKSPACE_FORMAT,
599 "name": "demo-mem",
600 "schema": "default@1.0.0",
601 "version": "0.1.0",
602 "description": "demo",
603 "authors": ["alice"],
604 "deps": ["anthropic/core", "anthropic/agents"],
605 });
606 let cfg = parse(v).unwrap();
607 assert_eq!(cfg.deps.len(), 2);
608 assert_eq!(cfg.deps[0].as_display(), "anthropic/core");
609 assert_eq!(cfg.deps[1].as_display(), "anthropic/agents");
610 assert_eq!(cfg.version.unwrap().to_string(), "0.1.0");
611 assert_eq!(cfg.authors.unwrap(), vec!["alice".to_string()]);
612 }
613
614 #[test]
615 fn rejects_bare_name_schema_pin() {
616 let mut v = ok_config_value();
617 v["schema"] = serde_json::json!("default");
618 let err = parse(v).unwrap_err();
619 assert!(
620 matches!(err, WorkspaceConfigError::Malformed(_)),
621 "expected Malformed for bare-name pin, got {err:?}"
622 );
623 }
624
625 #[test]
626 fn rejects_unsupported_format() {
627 let mut v = ok_config_value();
628 v["format"] = serde_json::json!(99);
629 let err = parse(v).unwrap_err();
630 match err {
631 WorkspaceConfigError::UnsupportedFormat { got: 99, expected } => {
632 assert_eq!(expected, FILESYSTEM_WORKSPACE_FORMAT);
633 }
634 other => panic!("expected UnsupportedFormat, got {other:?}"),
635 }
636 }
637
638 #[test]
639 fn rejects_archive_format_in_workspace_position() {
640 let mut v = ok_config_value();
645 v["format"] = serde_json::json!(PUBLISHED_MEM_FORMAT);
646 let err = parse(v).unwrap_err();
647 assert!(matches!(
648 err,
649 WorkspaceConfigError::UnsupportedFormat { .. }
650 ));
651 }
652
653 #[test]
654 fn preserves_unknown_top_level_fields() {
655 let mut v = ok_config_value();
660 v["syncState"] = serde_json::json!({"public-docs": "abc123"});
661 let cfg = parse(v).unwrap();
662 assert_eq!(
663 cfg.extra.get("syncState"),
664 Some(&serde_json::json!({"public-docs": "abc123"}))
665 );
666 let back = serde_json::to_value(&cfg).unwrap();
667 assert_eq!(back["syncState"]["public-docs"], "abc123");
668 }
669
670 #[test]
671 fn rejects_invalid_name() {
672 let mut v = ok_config_value();
673 v["name"] = serde_json::json!("Invalid Name");
674 let err = parse(v).unwrap_err();
675 assert!(matches!(err, WorkspaceConfigError::InvalidName(_)));
676 }
677
678 #[test]
679 fn rejects_invalid_schema_pin() {
680 let mut v = ok_config_value();
681 v["schema"] = serde_json::json!("default@^1.0.0");
682 let err = parse(v).unwrap_err();
683 assert!(matches!(err, WorkspaceConfigError::Malformed(_)));
684 }
685
686 #[test]
687 fn rejects_dep_without_scope() {
688 let mut v = ok_config_value();
689 v["deps"] = serde_json::json!(["just-a-name"]);
690 let err = parse(v).unwrap_err();
691 assert!(matches!(err, WorkspaceConfigError::Malformed(_)));
692 }
693
694 #[test]
695 fn rejects_duplicate_deps_on_disk() {
696 let mut v = ok_config_value();
697 v["deps"] = serde_json::json!(["anthropic/core", "anthropic/core"]);
698 let err = parse(v).unwrap_err();
699 assert!(matches!(err, WorkspaceConfigError::DuplicateDep(_)));
700 }
701
702 #[test]
703 fn add_dep_is_idempotent() {
704 let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
705 let dep = DepRef {
706 scope: "anthropic".into(),
707 name: "core".into(),
708 };
709 assert!(cfg.add_dep(dep.clone()));
710 assert!(!cfg.add_dep(dep.clone()));
711 assert_eq!(cfg.deps.len(), 1);
712 }
713
714 #[test]
715 fn round_trip_through_disk_preserves_dep_order() {
716 let tmp = TempDir::new().unwrap();
717 let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
718 cfg.add_dep("anthropic/core".parse().unwrap());
719 cfg.add_dep("anthropic/agents".parse().unwrap());
720 cfg.add_dep("openai/sdk".parse().unwrap());
721
722 write_workspace_config(tmp.path(), &cfg).unwrap();
723 let read = read_workspace_config(tmp.path()).unwrap();
724 assert_eq!(
725 read.deps.iter().map(|d| d.as_display()).collect::<Vec<_>>(),
726 vec![
727 "anthropic/core".to_string(),
728 "anthropic/agents".to_string(),
729 "openai/sdk".to_string(),
730 ]
731 );
732 }
733
734 #[test]
735 fn read_missing_config_returns_not_found() {
736 let tmp = TempDir::new().unwrap();
737 let err = read_workspace_config(tmp.path()).unwrap_err();
738 assert!(matches!(err, WorkspaceConfigError::NotFound(_)));
739 }
740
741 #[test]
742 fn engine_written_config_omits_name_and_read_derives_basename() {
743 let tmp = TempDir::new().unwrap();
744 let root = tmp.path().join("my-mem");
745 std::fs::create_dir_all(&root).unwrap();
746 let cfg = WorkspaceConfig::new("my-mem", versioned("default", "1.0.0"));
747 write_workspace_config(&root, &cfg).unwrap();
748
749 let raw: serde_json::Value =
752 serde_json::from_slice(&std::fs::read(config_path(&root)).unwrap()).unwrap();
753 assert!(
754 raw.get("name").is_none(),
755 "config.json must not carry a path-derived `name`"
756 );
757
758 let read = read_workspace_config(&root).unwrap();
760 assert_eq!(read.name, "my-mem");
761 }
762
763 #[test]
764 fn read_tolerates_a_legacy_name_field() {
765 let tmp = TempDir::new().unwrap();
766 let v = serde_json::json!({
767 "format": FILESYSTEM_WORKSPACE_FORMAT,
768 "name": "legacy-name",
769 "schema": "default@1.0.0",
770 });
771 std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
772 std::fs::write(config_path(tmp.path()), v.to_string()).unwrap();
773 let read = read_workspace_config(tmp.path()).unwrap();
774 assert_eq!(read.name, "legacy-name");
777 }
778
779 #[test]
780 fn write_creates_memstead_parent_directory() {
781 let tmp = TempDir::new().unwrap();
782 assert!(!tmp.path().join(".memstead").exists());
783 let cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
784 write_workspace_config(tmp.path(), &cfg).unwrap();
785 assert!(tmp.path().join(".memstead").is_dir());
786 assert!(tmp.path().join(".memstead").join("config.json").is_file());
787 }
788
789 #[test]
790 fn to_published_drops_deps() {
791 let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
792 cfg.version = Some(semver::Version::parse("0.1.0").unwrap());
793 cfg.add_dep("anthropic/core".parse().unwrap());
794
795 let published = cfg.to_published().unwrap();
796 assert_eq!(published.format, PUBLISHED_MEM_FORMAT);
797 assert_eq!(published.name, "demo");
798 assert_eq!(published.version.to_string(), "0.1.0");
799 assert_eq!(published.schema.name, "default");
800 let serialised = serde_json::to_value(&published).unwrap();
803 assert!(serialised.get("deps").is_none());
804 }
805
806 #[test]
807 fn to_published_requires_version() {
808 let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
813 cfg.version = None;
814 let err = cfg.to_published().unwrap_err();
815 assert!(matches!(err, PublishConversionError::MissingVersion));
816 }
817
818 #[test]
819 fn dep_ref_roundtrip_via_serde() {
820 let dep = DepRef {
821 scope: "scope".into(),
822 name: "name".into(),
823 };
824 let s = serde_json::to_string(&dep).unwrap();
825 assert_eq!(s, "\"scope/name\"");
826 let back: DepRef = serde_json::from_str(&s).unwrap();
827 assert_eq!(back, dep);
828 }
829
830 #[test]
831 fn dep_ref_rejects_uppercase() {
832 let err: Result<DepRef, _> = "Scope/name".parse();
833 assert!(err.is_err());
834 }
835
836 #[test]
837 fn dep_ref_rejects_empty_segments() {
838 let err: Result<DepRef, _> = "/name".parse();
839 assert!(err.is_err());
840 let err: Result<DepRef, _> = "scope/".parse();
841 assert!(err.is_err());
842 }
843}