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)]
120#[serde(deny_unknown_fields)]
121pub struct WorkspaceConfig {
122 pub format: u32,
125 #[serde(default, skip_serializing)]
131 pub name: String,
132 pub schema: SchemaRef,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub version: Option<semver::Version>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub description: Option<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub authors: Option<Vec<String>>,
145 #[serde(default, skip_serializing_if = "Vec::is_empty")]
148 pub deps: Vec<DepRef>,
149}
150
151impl WorkspaceConfig {
152 pub fn new(name: impl Into<String>, schema: SchemaRef) -> Self {
159 Self {
160 format: FILESYSTEM_WORKSPACE_FORMAT,
161 name: name.into(),
162 schema,
163 version: Some(semver::Version::new(0, 1, 0)),
164 description: None,
165 authors: None,
166 deps: Vec::new(),
167 }
168 }
169
170 pub fn add_dep(&mut self, dep: DepRef) -> bool {
175 if self.deps.iter().any(|d| d == &dep) {
176 return false;
177 }
178 self.deps.push(dep);
179 true
180 }
181
182 pub fn to_published(&self) -> Result<PublishedMemConfig, PublishConversionError> {
187 let version = self
188 .version
189 .clone()
190 .ok_or(PublishConversionError::MissingVersion)?;
191 Ok(PublishedMemConfig {
192 format: PUBLISHED_MEM_FORMAT,
193 name: self.name.clone(),
194 version,
195 description: self.description.clone(),
196 authors: self.authors.clone(),
197 schema: self.schema.clone(),
198 })
199 }
200}
201
202#[derive(Debug, thiserror::Error)]
204pub enum WorkspaceConfigError {
205 #[error("workspace config not found at {0}")]
206 NotFound(PathBuf),
207 #[error("workspace config io error at {path}: {source}")]
208 Io {
209 path: PathBuf,
210 #[source]
211 source: std::io::Error,
212 },
213 #[error("workspace config malformed: {0}")]
214 Malformed(String),
215 #[error(
216 "workspace config format {got} is not supported (expected {expected}) — \
217 re-run `memstead init` against a fresh folder"
218 )]
219 UnsupportedFormat { got: u32, expected: u32 },
220 #[error("workspace config invalid name: {0}")]
221 InvalidName(String),
222 #[error("workspace config has duplicate dep: {0}")]
223 DuplicateDep(String),
224}
225
226fn name_regex() -> &'static Regex {
227 static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
228 RE.get_or_init(|| Regex::new(r"^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$").unwrap())
229}
230
231fn check_slug(value: &str, label: &str) -> Result<(), String> {
232 if !name_regex().is_match(value) {
233 return Err(format!(
234 "{label} {value:?} must match ^[a-z0-9][a-z0-9-]{{0,62}}[a-z0-9]$"
235 ));
236 }
237 Ok(())
238}
239
240pub fn validate_mem_name(name: &str) -> Result<(), String> {
245 check_slug(name, "name")
246}
247
248pub fn config_path(workspace_root: &Path) -> PathBuf {
250 workspace_root
251 .join(crate::mem::MEM_META_DIR)
252 .join("config.json")
253}
254
255pub fn parse_workspace_config(bytes: &[u8]) -> Result<WorkspaceConfig, WorkspaceConfigError> {
258 let value: serde_json::Value = serde_json::from_slice(bytes)
259 .map_err(|e| WorkspaceConfigError::Malformed(e.to_string()))?;
260
261 if !value.is_object() {
262 return Err(WorkspaceConfigError::Malformed(
263 "expected a JSON object".to_string(),
264 ));
265 }
266
267 let format = value
270 .get("format")
271 .and_then(|v| v.as_u64())
272 .ok_or_else(|| WorkspaceConfigError::Malformed("missing or non-integer 'format'".into()))?;
273 if format != FILESYSTEM_WORKSPACE_FORMAT as u64 {
274 return Err(WorkspaceConfigError::UnsupportedFormat {
275 got: format as u32,
276 expected: FILESYSTEM_WORKSPACE_FORMAT,
277 });
278 }
279
280 let config: WorkspaceConfig = serde_json::from_value(value)
281 .map_err(|e| WorkspaceConfigError::Malformed(e.to_string()))?;
282
283 if !config.name.is_empty() {
287 check_slug(&config.name, "name").map_err(WorkspaceConfigError::InvalidName)?;
288 }
289
290 let mut seen: BTreeSet<String> = BTreeSet::new();
293 for dep in &config.deps {
294 let key = dep.as_display();
295 if !seen.insert(key.clone()) {
296 return Err(WorkspaceConfigError::DuplicateDep(key));
297 }
298 }
299
300 Ok(config)
301}
302
303pub fn read_workspace_config(
305 workspace_root: &Path,
306) -> Result<WorkspaceConfig, WorkspaceConfigError> {
307 let path = config_path(workspace_root);
308 let bytes = match std::fs::read(&path) {
309 Ok(b) => b,
310 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
311 return Err(WorkspaceConfigError::NotFound(path));
312 }
313 Err(e) => {
314 return Err(WorkspaceConfigError::Io { path, source: e });
315 }
316 };
317 let mut config = parse_workspace_config(&bytes)?;
318 if config.name.is_empty() {
322 config.name = workspace_root
323 .file_name()
324 .map(|n| n.to_string_lossy().to_string())
325 .unwrap_or_else(|| "mem".to_string());
326 }
327 Ok(config)
328}
329
330pub fn write_workspace_config(
335 workspace_root: &Path,
336 config: &WorkspaceConfig,
337) -> Result<(), WorkspaceConfigError> {
338 let target = config_path(workspace_root);
339 if let Some(parent) = target.parent() {
340 std::fs::create_dir_all(parent).map_err(|e| WorkspaceConfigError::Io {
341 path: parent.to_path_buf(),
342 source: e,
343 })?;
344 }
345
346 let mut bytes = serde_json::to_vec_pretty(config)
349 .map_err(|e| WorkspaceConfigError::Malformed(format!("serialise: {e}")))?;
350 bytes.push(b'\n');
351
352 let tmp = make_tmp_path(&target);
353 std::fs::write(&tmp, &bytes).map_err(|e| WorkspaceConfigError::Io {
354 path: tmp.clone(),
355 source: e,
356 })?;
357 if let Err(e) = std::fs::rename(&tmp, &target) {
358 let _ = std::fs::remove_file(&tmp);
359 return Err(WorkspaceConfigError::Io {
360 path: target,
361 source: e,
362 });
363 }
364 Ok(())
365}
366
367pub fn init_filesystem_mem(root: &Path, name: &str, schema: &SchemaRef) -> std::io::Result<()> {
381 use crate::workspace::{
382 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
383 };
384 use crate::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
385
386 let config = WorkspaceConfig::new(name, schema.clone());
387 write_workspace_config(root, &config).map_err(std::io::Error::other)?;
388
389 let memstead_dir = root.join(crate::WORKSPACE_STORE_DIR);
390 std::fs::create_dir_all(memstead_dir.join("cache"))?;
391 std::fs::create_dir_all(memstead_dir.join("memstead-io"))?;
392 std::fs::write(
396 memstead_dir.join("workspace.toml"),
397 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
398 )?;
399
400 let workspace = Workspace {
401 mounts: vec![Mount {
402 mem: name.to_string(),
403 schema: Some(schema.clone()),
404 storage: MountStorage::Folder {
405 path: root.to_path_buf(),
406 },
407 capability: MountCapability::Write,
408 lifecycle: MountLifecycle::Eager,
409 cross_linkable: true,
410 migration_target: None,
411 }],
412 settings: WorkspaceSettings::default(),
413 };
414 FileWorkspaceStore::new()
415 .save_state(root, &workspace)
416 .map_err(std::io::Error::other)?;
417 Ok(())
418}
419
420fn make_tmp_path(target: &Path) -> PathBuf {
421 let name = target
422 .file_name()
423 .map(|n| n.to_string_lossy().to_string())
424 .unwrap_or_else(|| "_".to_string());
425 let nanos = std::time::SystemTime::now()
426 .duration_since(std::time::UNIX_EPOCH)
427 .map(|d| d.as_nanos())
428 .unwrap_or(0);
429 target.with_file_name(format!(".{name}.tmp.{nanos:x}"))
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use tempfile::TempDir;
436
437 fn versioned(name: &str, version: &str) -> SchemaRef {
438 SchemaRef::new(name, semver::Version::parse(version).unwrap())
439 }
440
441 #[test]
442 fn init_filesystem_mem_produces_a_rootable_workspace() {
443 let tmp = TempDir::new().unwrap();
444 let root = tmp.path().join("notes");
445 init_filesystem_mem(&root, "notes", &versioned("default", "1.0.0")).unwrap();
446
447 assert!(config_path(&root).is_file());
449 assert!(root.join(".memstead").join("workspace.toml").is_file());
450 assert!(
451 root.join(".memstead")
452 .join("state")
453 .join("mounts.json")
454 .is_file()
455 );
456
457 let engine = crate::Engine::from_workspace_root(&root).unwrap();
459 assert!(
460 engine
461 .mem_router()
462 .writable_mems()
463 .iter()
464 .any(|v| v == "notes"),
465 "init'd mem must be writable in the rooted engine"
466 );
467 }
468
469 fn ok_config_value() -> serde_json::Value {
470 serde_json::json!({
471 "format": FILESYSTEM_WORKSPACE_FORMAT,
472 "name": "demo-mem",
473 "schema": "default@1.0.0",
474 })
475 }
476
477 fn parse(value: serde_json::Value) -> Result<WorkspaceConfig, WorkspaceConfigError> {
478 parse_workspace_config(value.to_string().as_bytes())
479 }
480
481 #[test]
482 fn parses_minimal_config() {
483 let cfg = parse(ok_config_value()).unwrap();
484 assert_eq!(cfg.format, FILESYSTEM_WORKSPACE_FORMAT);
485 assert_eq!(cfg.name, "demo-mem");
486 assert_eq!(cfg.schema.as_display(), "default@1.0.0");
487 assert!(cfg.deps.is_empty());
488 assert!(cfg.version.is_none());
489 }
490
491 #[test]
492 fn parses_full_config() {
493 let v = serde_json::json!({
494 "format": FILESYSTEM_WORKSPACE_FORMAT,
495 "name": "demo-mem",
496 "schema": "default@1.0.0",
497 "version": "0.1.0",
498 "description": "demo",
499 "authors": ["alice"],
500 "deps": ["anthropic/core", "anthropic/agents"],
501 });
502 let cfg = parse(v).unwrap();
503 assert_eq!(cfg.deps.len(), 2);
504 assert_eq!(cfg.deps[0].as_display(), "anthropic/core");
505 assert_eq!(cfg.deps[1].as_display(), "anthropic/agents");
506 assert_eq!(cfg.version.unwrap().to_string(), "0.1.0");
507 assert_eq!(cfg.authors.unwrap(), vec!["alice".to_string()]);
508 }
509
510 #[test]
511 fn rejects_bare_name_schema_pin() {
512 let mut v = ok_config_value();
513 v["schema"] = serde_json::json!("default");
514 let err = parse(v).unwrap_err();
515 assert!(
516 matches!(err, WorkspaceConfigError::Malformed(_)),
517 "expected Malformed for bare-name pin, got {err:?}"
518 );
519 }
520
521 #[test]
522 fn rejects_unsupported_format() {
523 let mut v = ok_config_value();
524 v["format"] = serde_json::json!(99);
525 let err = parse(v).unwrap_err();
526 match err {
527 WorkspaceConfigError::UnsupportedFormat { got: 99, expected } => {
528 assert_eq!(expected, FILESYSTEM_WORKSPACE_FORMAT);
529 }
530 other => panic!("expected UnsupportedFormat, got {other:?}"),
531 }
532 }
533
534 #[test]
535 fn rejects_archive_format_in_workspace_position() {
536 let mut v = ok_config_value();
541 v["format"] = serde_json::json!(PUBLISHED_MEM_FORMAT);
542 let err = parse(v).unwrap_err();
543 assert!(matches!(
544 err,
545 WorkspaceConfigError::UnsupportedFormat { .. }
546 ));
547 }
548
549 #[test]
550 fn rejects_unknown_top_level_field() {
551 let mut v = ok_config_value();
552 v["surprise"] = serde_json::json!(true);
553 let err = parse(v).unwrap_err();
554 assert!(matches!(err, WorkspaceConfigError::Malformed(_)));
555 }
556
557 #[test]
558 fn rejects_invalid_name() {
559 let mut v = ok_config_value();
560 v["name"] = serde_json::json!("Invalid Name");
561 let err = parse(v).unwrap_err();
562 assert!(matches!(err, WorkspaceConfigError::InvalidName(_)));
563 }
564
565 #[test]
566 fn rejects_invalid_schema_pin() {
567 let mut v = ok_config_value();
568 v["schema"] = serde_json::json!("default@^1.0.0");
569 let err = parse(v).unwrap_err();
570 assert!(matches!(err, WorkspaceConfigError::Malformed(_)));
571 }
572
573 #[test]
574 fn rejects_dep_without_scope() {
575 let mut v = ok_config_value();
576 v["deps"] = serde_json::json!(["just-a-name"]);
577 let err = parse(v).unwrap_err();
578 assert!(matches!(err, WorkspaceConfigError::Malformed(_)));
579 }
580
581 #[test]
582 fn rejects_duplicate_deps_on_disk() {
583 let mut v = ok_config_value();
584 v["deps"] = serde_json::json!(["anthropic/core", "anthropic/core"]);
585 let err = parse(v).unwrap_err();
586 assert!(matches!(err, WorkspaceConfigError::DuplicateDep(_)));
587 }
588
589 #[test]
590 fn add_dep_is_idempotent() {
591 let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
592 let dep = DepRef {
593 scope: "anthropic".into(),
594 name: "core".into(),
595 };
596 assert!(cfg.add_dep(dep.clone()));
597 assert!(!cfg.add_dep(dep.clone()));
598 assert_eq!(cfg.deps.len(), 1);
599 }
600
601 #[test]
602 fn round_trip_through_disk_preserves_dep_order() {
603 let tmp = TempDir::new().unwrap();
604 let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
605 cfg.add_dep("anthropic/core".parse().unwrap());
606 cfg.add_dep("anthropic/agents".parse().unwrap());
607 cfg.add_dep("openai/sdk".parse().unwrap());
608
609 write_workspace_config(tmp.path(), &cfg).unwrap();
610 let read = read_workspace_config(tmp.path()).unwrap();
611 assert_eq!(
612 read.deps.iter().map(|d| d.as_display()).collect::<Vec<_>>(),
613 vec![
614 "anthropic/core".to_string(),
615 "anthropic/agents".to_string(),
616 "openai/sdk".to_string(),
617 ]
618 );
619 }
620
621 #[test]
622 fn read_missing_config_returns_not_found() {
623 let tmp = TempDir::new().unwrap();
624 let err = read_workspace_config(tmp.path()).unwrap_err();
625 assert!(matches!(err, WorkspaceConfigError::NotFound(_)));
626 }
627
628 #[test]
629 fn engine_written_config_omits_name_and_read_derives_basename() {
630 let tmp = TempDir::new().unwrap();
631 let root = tmp.path().join("my-mem");
632 std::fs::create_dir_all(&root).unwrap();
633 let cfg = WorkspaceConfig::new("my-mem", versioned("default", "1.0.0"));
634 write_workspace_config(&root, &cfg).unwrap();
635
636 let raw: serde_json::Value =
639 serde_json::from_slice(&std::fs::read(config_path(&root)).unwrap()).unwrap();
640 assert!(
641 raw.get("name").is_none(),
642 "config.json must not carry a path-derived `name`"
643 );
644
645 let read = read_workspace_config(&root).unwrap();
647 assert_eq!(read.name, "my-mem");
648 }
649
650 #[test]
651 fn read_tolerates_a_legacy_name_field() {
652 let tmp = TempDir::new().unwrap();
653 let v = serde_json::json!({
654 "format": FILESYSTEM_WORKSPACE_FORMAT,
655 "name": "legacy-name",
656 "schema": "default@1.0.0",
657 });
658 std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
659 std::fs::write(config_path(tmp.path()), v.to_string()).unwrap();
660 let read = read_workspace_config(tmp.path()).unwrap();
661 assert_eq!(read.name, "legacy-name");
664 }
665
666 #[test]
667 fn write_creates_memstead_parent_directory() {
668 let tmp = TempDir::new().unwrap();
669 assert!(!tmp.path().join(".memstead").exists());
670 let cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
671 write_workspace_config(tmp.path(), &cfg).unwrap();
672 assert!(tmp.path().join(".memstead").is_dir());
673 assert!(tmp.path().join(".memstead").join("config.json").is_file());
674 }
675
676 #[test]
677 fn to_published_drops_deps() {
678 let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
679 cfg.version = Some(semver::Version::parse("0.1.0").unwrap());
680 cfg.add_dep("anthropic/core".parse().unwrap());
681
682 let published = cfg.to_published().unwrap();
683 assert_eq!(published.format, PUBLISHED_MEM_FORMAT);
684 assert_eq!(published.name, "demo");
685 assert_eq!(published.version.to_string(), "0.1.0");
686 assert_eq!(published.schema.name, "default");
687 let serialised = serde_json::to_value(&published).unwrap();
690 assert!(serialised.get("deps").is_none());
691 }
692
693 #[test]
694 fn to_published_requires_version() {
695 let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
700 cfg.version = None;
701 let err = cfg.to_published().unwrap_err();
702 assert!(matches!(err, PublishConversionError::MissingVersion));
703 }
704
705 #[test]
706 fn dep_ref_roundtrip_via_serde() {
707 let dep = DepRef {
708 scope: "scope".into(),
709 name: "name".into(),
710 };
711 let s = serde_json::to_string(&dep).unwrap();
712 assert_eq!(s, "\"scope/name\"");
713 let back: DepRef = serde_json::from_str(&s).unwrap();
714 assert_eq!(back, dep);
715 }
716
717 #[test]
718 fn dep_ref_rejects_uppercase() {
719 let err: Result<DepRef, _> = "Scope/name".parse();
720 assert!(err.is_err());
721 }
722
723 #[test]
724 fn dep_ref_rejects_empty_segments() {
725 let err: Result<DepRef, _> = "/name".parse();
726 assert!(err.is_err());
727 let err: Result<DepRef, _> = "scope/".parse();
728 assert!(err.is_err());
729 }
730}