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 use crate::workspace::{
404 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
405 };
406 use crate::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
407
408 let config = WorkspaceConfig::new(name, schema.clone());
409 write_workspace_config(root, &config).map_err(std::io::Error::other)?;
410
411 let memstead_dir = root.join(crate::WORKSPACE_STORE_DIR);
412 std::fs::create_dir_all(memstead_dir.join("cache"))?;
413 std::fs::create_dir_all(memstead_dir.join("memstead-io"))?;
414 std::fs::write(
418 memstead_dir.join("workspace.toml"),
419 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
420 )?;
421
422 let workspace = Workspace {
423 mounts: vec![Mount {
424 mem: name.to_string(),
425 schema: Some(schema.clone()),
426 storage: MountStorage::Folder {
427 path: root.to_path_buf(),
428 },
429 capability: MountCapability::Write,
430 lifecycle: MountLifecycle::Eager,
431 cross_linkable: true,
432 migration_target: None,
433 }],
434 settings: WorkspaceSettings::default(),
435 };
436 FileWorkspaceStore::new()
437 .save_state(root, &workspace)
438 .map_err(std::io::Error::other)?;
439 Ok(())
440}
441
442fn make_tmp_path(target: &Path) -> PathBuf {
443 let name = target
444 .file_name()
445 .map(|n| n.to_string_lossy().to_string())
446 .unwrap_or_else(|| "_".to_string());
447 let nanos = std::time::SystemTime::now()
448 .duration_since(std::time::UNIX_EPOCH)
449 .map(|d| d.as_nanos())
450 .unwrap_or(0);
451 target.with_file_name(format!(".{name}.tmp.{nanos:x}"))
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use tempfile::TempDir;
458
459 fn versioned(name: &str, version: &str) -> SchemaRef {
460 SchemaRef::new(name, semver::Version::parse(version).unwrap())
461 }
462
463 #[test]
464 fn init_filesystem_mem_produces_a_rootable_workspace() {
465 let tmp = TempDir::new().unwrap();
466 let root = tmp.path().join("notes");
467 init_filesystem_mem(&root, "notes", &versioned("default", "1.0.0")).unwrap();
468
469 assert!(config_path(&root).is_file());
471 assert!(root.join(".memstead").join("workspace.toml").is_file());
472 assert!(
473 root.join(".memstead")
474 .join("state")
475 .join("mounts.json")
476 .is_file()
477 );
478
479 let engine = crate::Engine::from_workspace_root(&root).unwrap();
481 assert!(
482 engine
483 .mem_router()
484 .writable_mems()
485 .iter()
486 .any(|v| v == "notes"),
487 "init'd mem must be writable in the rooted engine"
488 );
489 }
490
491 fn ok_config_value() -> serde_json::Value {
492 serde_json::json!({
493 "format": FILESYSTEM_WORKSPACE_FORMAT,
494 "name": "demo-mem",
495 "schema": "default@1.0.0",
496 })
497 }
498
499 fn parse(value: serde_json::Value) -> Result<WorkspaceConfig, WorkspaceConfigError> {
500 parse_workspace_config(value.to_string().as_bytes())
501 }
502
503 #[test]
504 fn parses_minimal_config() {
505 let cfg = parse(ok_config_value()).unwrap();
506 assert_eq!(cfg.format, FILESYSTEM_WORKSPACE_FORMAT);
507 assert_eq!(cfg.name, "demo-mem");
508 assert_eq!(cfg.schema.as_display(), "default@1.0.0");
509 assert!(cfg.deps.is_empty());
510 assert!(cfg.version.is_none());
511 }
512
513 #[test]
514 fn parses_full_config() {
515 let v = serde_json::json!({
516 "format": FILESYSTEM_WORKSPACE_FORMAT,
517 "name": "demo-mem",
518 "schema": "default@1.0.0",
519 "version": "0.1.0",
520 "description": "demo",
521 "authors": ["alice"],
522 "deps": ["anthropic/core", "anthropic/agents"],
523 });
524 let cfg = parse(v).unwrap();
525 assert_eq!(cfg.deps.len(), 2);
526 assert_eq!(cfg.deps[0].as_display(), "anthropic/core");
527 assert_eq!(cfg.deps[1].as_display(), "anthropic/agents");
528 assert_eq!(cfg.version.unwrap().to_string(), "0.1.0");
529 assert_eq!(cfg.authors.unwrap(), vec!["alice".to_string()]);
530 }
531
532 #[test]
533 fn rejects_bare_name_schema_pin() {
534 let mut v = ok_config_value();
535 v["schema"] = serde_json::json!("default");
536 let err = parse(v).unwrap_err();
537 assert!(
538 matches!(err, WorkspaceConfigError::Malformed(_)),
539 "expected Malformed for bare-name pin, got {err:?}"
540 );
541 }
542
543 #[test]
544 fn rejects_unsupported_format() {
545 let mut v = ok_config_value();
546 v["format"] = serde_json::json!(99);
547 let err = parse(v).unwrap_err();
548 match err {
549 WorkspaceConfigError::UnsupportedFormat { got: 99, expected } => {
550 assert_eq!(expected, FILESYSTEM_WORKSPACE_FORMAT);
551 }
552 other => panic!("expected UnsupportedFormat, got {other:?}"),
553 }
554 }
555
556 #[test]
557 fn rejects_archive_format_in_workspace_position() {
558 let mut v = ok_config_value();
563 v["format"] = serde_json::json!(PUBLISHED_MEM_FORMAT);
564 let err = parse(v).unwrap_err();
565 assert!(matches!(
566 err,
567 WorkspaceConfigError::UnsupportedFormat { .. }
568 ));
569 }
570
571 #[test]
572 fn preserves_unknown_top_level_fields() {
573 let mut v = ok_config_value();
578 v["syncState"] = serde_json::json!({"public-docs": "abc123"});
579 let cfg = parse(v).unwrap();
580 assert_eq!(
581 cfg.extra.get("syncState"),
582 Some(&serde_json::json!({"public-docs": "abc123"}))
583 );
584 let back = serde_json::to_value(&cfg).unwrap();
585 assert_eq!(back["syncState"]["public-docs"], "abc123");
586 }
587
588 #[test]
589 fn rejects_invalid_name() {
590 let mut v = ok_config_value();
591 v["name"] = serde_json::json!("Invalid Name");
592 let err = parse(v).unwrap_err();
593 assert!(matches!(err, WorkspaceConfigError::InvalidName(_)));
594 }
595
596 #[test]
597 fn rejects_invalid_schema_pin() {
598 let mut v = ok_config_value();
599 v["schema"] = serde_json::json!("default@^1.0.0");
600 let err = parse(v).unwrap_err();
601 assert!(matches!(err, WorkspaceConfigError::Malformed(_)));
602 }
603
604 #[test]
605 fn rejects_dep_without_scope() {
606 let mut v = ok_config_value();
607 v["deps"] = serde_json::json!(["just-a-name"]);
608 let err = parse(v).unwrap_err();
609 assert!(matches!(err, WorkspaceConfigError::Malformed(_)));
610 }
611
612 #[test]
613 fn rejects_duplicate_deps_on_disk() {
614 let mut v = ok_config_value();
615 v["deps"] = serde_json::json!(["anthropic/core", "anthropic/core"]);
616 let err = parse(v).unwrap_err();
617 assert!(matches!(err, WorkspaceConfigError::DuplicateDep(_)));
618 }
619
620 #[test]
621 fn add_dep_is_idempotent() {
622 let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
623 let dep = DepRef {
624 scope: "anthropic".into(),
625 name: "core".into(),
626 };
627 assert!(cfg.add_dep(dep.clone()));
628 assert!(!cfg.add_dep(dep.clone()));
629 assert_eq!(cfg.deps.len(), 1);
630 }
631
632 #[test]
633 fn round_trip_through_disk_preserves_dep_order() {
634 let tmp = TempDir::new().unwrap();
635 let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
636 cfg.add_dep("anthropic/core".parse().unwrap());
637 cfg.add_dep("anthropic/agents".parse().unwrap());
638 cfg.add_dep("openai/sdk".parse().unwrap());
639
640 write_workspace_config(tmp.path(), &cfg).unwrap();
641 let read = read_workspace_config(tmp.path()).unwrap();
642 assert_eq!(
643 read.deps.iter().map(|d| d.as_display()).collect::<Vec<_>>(),
644 vec![
645 "anthropic/core".to_string(),
646 "anthropic/agents".to_string(),
647 "openai/sdk".to_string(),
648 ]
649 );
650 }
651
652 #[test]
653 fn read_missing_config_returns_not_found() {
654 let tmp = TempDir::new().unwrap();
655 let err = read_workspace_config(tmp.path()).unwrap_err();
656 assert!(matches!(err, WorkspaceConfigError::NotFound(_)));
657 }
658
659 #[test]
660 fn engine_written_config_omits_name_and_read_derives_basename() {
661 let tmp = TempDir::new().unwrap();
662 let root = tmp.path().join("my-mem");
663 std::fs::create_dir_all(&root).unwrap();
664 let cfg = WorkspaceConfig::new("my-mem", versioned("default", "1.0.0"));
665 write_workspace_config(&root, &cfg).unwrap();
666
667 let raw: serde_json::Value =
670 serde_json::from_slice(&std::fs::read(config_path(&root)).unwrap()).unwrap();
671 assert!(
672 raw.get("name").is_none(),
673 "config.json must not carry a path-derived `name`"
674 );
675
676 let read = read_workspace_config(&root).unwrap();
678 assert_eq!(read.name, "my-mem");
679 }
680
681 #[test]
682 fn read_tolerates_a_legacy_name_field() {
683 let tmp = TempDir::new().unwrap();
684 let v = serde_json::json!({
685 "format": FILESYSTEM_WORKSPACE_FORMAT,
686 "name": "legacy-name",
687 "schema": "default@1.0.0",
688 });
689 std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
690 std::fs::write(config_path(tmp.path()), v.to_string()).unwrap();
691 let read = read_workspace_config(tmp.path()).unwrap();
692 assert_eq!(read.name, "legacy-name");
695 }
696
697 #[test]
698 fn write_creates_memstead_parent_directory() {
699 let tmp = TempDir::new().unwrap();
700 assert!(!tmp.path().join(".memstead").exists());
701 let cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
702 write_workspace_config(tmp.path(), &cfg).unwrap();
703 assert!(tmp.path().join(".memstead").is_dir());
704 assert!(tmp.path().join(".memstead").join("config.json").is_file());
705 }
706
707 #[test]
708 fn to_published_drops_deps() {
709 let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
710 cfg.version = Some(semver::Version::parse("0.1.0").unwrap());
711 cfg.add_dep("anthropic/core".parse().unwrap());
712
713 let published = cfg.to_published().unwrap();
714 assert_eq!(published.format, PUBLISHED_MEM_FORMAT);
715 assert_eq!(published.name, "demo");
716 assert_eq!(published.version.to_string(), "0.1.0");
717 assert_eq!(published.schema.name, "default");
718 let serialised = serde_json::to_value(&published).unwrap();
721 assert!(serialised.get("deps").is_none());
722 }
723
724 #[test]
725 fn to_published_requires_version() {
726 let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
731 cfg.version = None;
732 let err = cfg.to_published().unwrap_err();
733 assert!(matches!(err, PublishConversionError::MissingVersion));
734 }
735
736 #[test]
737 fn dep_ref_roundtrip_via_serde() {
738 let dep = DepRef {
739 scope: "scope".into(),
740 name: "name".into(),
741 };
742 let s = serde_json::to_string(&dep).unwrap();
743 assert_eq!(s, "\"scope/name\"");
744 let back: DepRef = serde_json::from_str(&s).unwrap();
745 assert_eq!(back, dep);
746 }
747
748 #[test]
749 fn dep_ref_rejects_uppercase() {
750 let err: Result<DepRef, _> = "Scope/name".parse();
751 assert!(err.is_err());
752 }
753
754 #[test]
755 fn dep_ref_rejects_empty_segments() {
756 let err: Result<DepRef, _> = "/name".parse();
757 assert!(err.is_err());
758 let err: Result<DepRef, _> = "scope/".parse();
759 assert!(err.is_err());
760 }
761}