1use std::path::{Path, PathBuf};
45
46use memstead_schema::{
47 PUBLISHED_MEM_FORMAT, PublishConversionError, PublishedMemConfig, SchemaRef,
48};
49use regex::Regex;
50use serde::{Deserialize, Serialize};
51
52pub const FILESYSTEM_WORKSPACE_FORMAT: u32 = 1;
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70pub struct WorkspaceConfig {
71 pub format: u32,
74 #[serde(default, skip_serializing)]
80 pub name: String,
81 pub schema: SchemaRef,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub version: Option<semver::Version>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub description: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub title: Option<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub subject: Option<memstead_schema::MemSubject>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub authors: Option<Vec<String>>,
101 #[serde(flatten)]
105 pub extra: serde_json::Map<String, serde_json::Value>,
106}
107
108impl WorkspaceConfig {
109 pub fn new(name: impl Into<String>, schema: SchemaRef) -> Self {
116 Self {
117 format: FILESYSTEM_WORKSPACE_FORMAT,
118 name: name.into(),
119 schema,
120 version: Some(semver::Version::new(0, 1, 0)),
121 description: None,
122 title: None,
123 subject: None,
124 authors: None,
125 extra: serde_json::Map::new(),
126 }
127 }
128
129 pub fn to_published(&self) -> Result<PublishedMemConfig, PublishConversionError> {
134 let version = self
135 .version
136 .clone()
137 .ok_or(PublishConversionError::MissingVersion)?;
138 Ok(PublishedMemConfig {
139 format: PUBLISHED_MEM_FORMAT,
140 name: self.name.clone(),
141 version,
142 description: self.description.clone(),
143 title: self.title.clone(),
144 subject: self.subject.clone(),
145 authors: self.authors.clone(),
146 schema: self.schema.clone(),
147 })
148 }
149}
150
151#[derive(Debug, thiserror::Error)]
153pub enum WorkspaceConfigError {
154 #[error("workspace config not found at {0}")]
155 NotFound(PathBuf),
156 #[error("workspace config io error at {path}: {source}")]
157 Io {
158 path: PathBuf,
159 #[source]
160 source: std::io::Error,
161 },
162 #[error("workspace config malformed: {0}")]
163 Malformed(String),
164 #[error(
165 "workspace config format {got} is not supported (expected {expected}) — \
166 re-run `memstead init` against a fresh folder"
167 )]
168 UnsupportedFormat { got: u32, expected: u32 },
169 #[error("workspace config invalid name: {0}")]
170 InvalidName(String),
171}
172
173fn name_regex() -> &'static Regex {
174 static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
175 RE.get_or_init(|| Regex::new(r"^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$").unwrap())
176}
177
178fn check_slug(value: &str, label: &str) -> Result<(), String> {
179 if !name_regex().is_match(value) {
180 return Err(format!(
181 "{label} {value:?} must match ^[a-z0-9][a-z0-9-]{{0,62}}[a-z0-9]$"
182 ));
183 }
184 Ok(())
185}
186
187pub fn validate_mem_name(name: &str) -> Result<(), String> {
192 check_slug(name, "name")
193}
194
195pub fn config_path(workspace_root: &Path) -> PathBuf {
197 workspace_root
198 .join(crate::mem::MEM_META_DIR)
199 .join("config.json")
200}
201
202pub fn parse_workspace_config(bytes: &[u8]) -> Result<WorkspaceConfig, WorkspaceConfigError> {
205 let value: serde_json::Value = serde_json::from_slice(bytes)
206 .map_err(|e| WorkspaceConfigError::Malformed(e.to_string()))?;
207
208 if !value.is_object() {
209 return Err(WorkspaceConfigError::Malformed(
210 "expected a JSON object".to_string(),
211 ));
212 }
213
214 let format = value
217 .get("format")
218 .and_then(|v| v.as_u64())
219 .ok_or_else(|| WorkspaceConfigError::Malformed("missing or non-integer 'format'".into()))?;
220 if format != FILESYSTEM_WORKSPACE_FORMAT as u64 {
221 return Err(WorkspaceConfigError::UnsupportedFormat {
222 got: format as u32,
223 expected: FILESYSTEM_WORKSPACE_FORMAT,
224 });
225 }
226
227 let config: WorkspaceConfig = serde_json::from_value(value)
228 .map_err(|e| WorkspaceConfigError::Malformed(e.to_string()))?;
229
230 if !config.name.is_empty() {
234 check_slug(&config.name, "name").map_err(WorkspaceConfigError::InvalidName)?;
235 }
236
237 Ok(config)
238}
239
240pub fn read_workspace_config(
242 workspace_root: &Path,
243) -> Result<WorkspaceConfig, WorkspaceConfigError> {
244 let path = config_path(workspace_root);
245 let bytes = match std::fs::read(&path) {
246 Ok(b) => b,
247 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
248 return Err(WorkspaceConfigError::NotFound(path));
249 }
250 Err(e) => {
251 return Err(WorkspaceConfigError::Io { path, source: e });
252 }
253 };
254 let mut config = parse_workspace_config(&bytes)?;
255 if config.name.is_empty() {
259 config.name = workspace_root
260 .file_name()
261 .map(|n| n.to_string_lossy().to_string())
262 .unwrap_or_else(|| "mem".to_string());
263 }
264 Ok(config)
265}
266
267pub fn write_workspace_config(
272 workspace_root: &Path,
273 config: &WorkspaceConfig,
274) -> Result<(), WorkspaceConfigError> {
275 let target = config_path(workspace_root);
276 if let Some(parent) = target.parent() {
277 std::fs::create_dir_all(parent).map_err(|e| WorkspaceConfigError::Io {
278 path: parent.to_path_buf(),
279 source: e,
280 })?;
281 }
282
283 let mut bytes = serde_json::to_vec_pretty(config)
286 .map_err(|e| WorkspaceConfigError::Malformed(format!("serialise: {e}")))?;
287 bytes.push(b'\n');
288
289 let tmp = make_tmp_path(&target);
290 std::fs::write(&tmp, &bytes).map_err(|e| WorkspaceConfigError::Io {
291 path: tmp.clone(),
292 source: e,
293 })?;
294 if let Err(e) = std::fs::rename(&tmp, &target) {
295 let _ = std::fs::remove_file(&tmp);
296 return Err(WorkspaceConfigError::Io {
297 path: target,
298 source: e,
299 });
300 }
301 Ok(())
302}
303
304pub fn init_filesystem_mem(root: &Path, name: &str, schema: &SchemaRef) -> std::io::Result<()> {
318 init_filesystem_mem_at(root, root, name, schema)
319}
320
321pub fn init_filesystem_mem_at(
343 workspace_root: &Path,
344 mem_dir: &Path,
345 name: &str,
346 schema: &SchemaRef,
347) -> std::io::Result<()> {
348 use crate::workspace::{
349 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
350 };
351 use crate::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
352
353 let root = workspace_root;
354 std::fs::create_dir_all(mem_dir)?;
355 let config = WorkspaceConfig::new(name, schema.clone());
356 write_workspace_config(mem_dir, &config).map_err(std::io::Error::other)?;
357
358 let memstead_dir = root.join(crate::WORKSPACE_STORE_DIR);
359 std::fs::create_dir_all(memstead_dir.join("cache"))?;
360 std::fs::create_dir_all(memstead_dir.join("memstead-io"))?;
364 std::fs::write(
369 memstead_dir.join("workspace.toml"),
370 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
371 )?;
372
373 let workspace = Workspace {
374 mounts: vec![Mount {
375 mem: name.to_string(),
376 schema: Some(schema.clone()),
377 storage: MountStorage::Folder {
378 path: mem_dir.to_path_buf(),
379 },
380 capability: MountCapability::Write,
381 lifecycle: MountLifecycle::Eager,
382 cross_linkable: true,
383 migration_target: None,
384 }],
385 settings: WorkspaceSettings::default(),
386 };
387 FileWorkspaceStore::new()
388 .save_state(root, &workspace)
389 .map_err(std::io::Error::other)?;
390 Ok(())
391}
392
393fn make_tmp_path(target: &Path) -> PathBuf {
394 let name = target
395 .file_name()
396 .map(|n| n.to_string_lossy().to_string())
397 .unwrap_or_else(|| "_".to_string());
398 let nanos = std::time::SystemTime::now()
399 .duration_since(std::time::UNIX_EPOCH)
400 .map(|d| d.as_nanos())
401 .unwrap_or(0);
402 target.with_file_name(format!(".{name}.tmp.{nanos:x}"))
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use tempfile::TempDir;
409
410 fn versioned(name: &str, version: &str) -> SchemaRef {
411 SchemaRef::new(name, semver::Version::parse(version).unwrap())
412 }
413
414 #[test]
415 fn init_filesystem_mem_produces_a_rootable_workspace() {
416 let tmp = TempDir::new().unwrap();
417 let root = tmp.path().join("notes");
418 init_filesystem_mem(&root, "notes", &versioned("default", "1.0.0")).unwrap();
419
420 assert!(config_path(&root).is_file());
422 assert!(root.join(".memstead").join("workspace.toml").is_file());
423 assert!(
424 root.join(".memstead")
425 .join("state")
426 .join("mounts.json")
427 .is_file()
428 );
429
430 let engine = crate::Engine::from_workspace_root(&root).unwrap();
432 assert!(
433 engine
434 .mem_router()
435 .writable_mems()
436 .iter()
437 .any(|v| v == "notes"),
438 "init'd mem must be writable in the rooted engine"
439 );
440 }
441
442 #[test]
448 fn init_filesystem_mem_at_puts_the_mem_in_its_own_folder() {
449 let tmp = TempDir::new().unwrap();
450 let root = tmp.path().join("repo");
451 std::fs::create_dir_all(root.join("src")).unwrap();
452 std::fs::write(root.join("README.md"), b"# not an entity\n").unwrap();
453 let mem_dir = root.join("notes");
454
455 init_filesystem_mem_at(&root, &mem_dir, "notes", &versioned("default", "1.0.0")).unwrap();
456
457 assert!(root.join(".memstead").join("workspace.toml").is_file());
459 assert!(config_path(&mem_dir).is_file());
460 assert!(
461 !root.join(".memstead").join("config.json").is_file(),
462 "the root is not the mem, so it carries no mem config"
463 );
464
465 let engine = crate::Engine::from_workspace_root(&root).unwrap();
466 assert!(
467 engine
468 .mem_router()
469 .writable_mems()
470 .iter()
471 .any(|v| v == "notes"),
472 );
473
474 let mounts: serde_json::Value = serde_json::from_slice(
477 &std::fs::read(root.join(".memstead").join("state").join("mounts.json")).unwrap(),
478 )
479 .unwrap();
480 let on_disk = serde_json::to_string(&mounts).unwrap();
481 assert!(
482 on_disk.contains("\"notes\""),
483 "mount path stays relative to the workspace root: {on_disk}"
484 );
485 assert!(
486 !on_disk.contains(&tmp.path().display().to_string()),
487 "no absolute prefix is baked into the roster: {on_disk}"
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.version.is_none());
510 }
511
512 #[test]
513 fn parses_full_config() {
514 let v = serde_json::json!({
515 "format": FILESYSTEM_WORKSPACE_FORMAT,
516 "name": "demo-mem",
517 "schema": "default@1.0.0",
518 "version": "0.1.0",
519 "description": "demo",
520 "authors": ["alice"],
521 });
522 let cfg = parse(v).unwrap();
523 assert_eq!(cfg.version.unwrap().to_string(), "0.1.0");
524 assert_eq!(cfg.authors.unwrap(), vec!["alice".to_string()]);
525 }
526
527 #[test]
528 fn rejects_bare_name_schema_pin() {
529 let mut v = ok_config_value();
530 v["schema"] = serde_json::json!("default");
531 let err = parse(v).unwrap_err();
532 assert!(
533 matches!(err, WorkspaceConfigError::Malformed(_)),
534 "expected Malformed for bare-name pin, got {err:?}"
535 );
536 }
537
538 #[test]
539 fn rejects_unsupported_format() {
540 let mut v = ok_config_value();
541 v["format"] = serde_json::json!(99);
542 let err = parse(v).unwrap_err();
543 match err {
544 WorkspaceConfigError::UnsupportedFormat { got: 99, expected } => {
545 assert_eq!(expected, FILESYSTEM_WORKSPACE_FORMAT);
546 }
547 other => panic!("expected UnsupportedFormat, got {other:?}"),
548 }
549 }
550
551 #[test]
552 fn rejects_archive_format_in_workspace_position() {
553 let mut v = ok_config_value();
558 v["format"] = serde_json::json!(PUBLISHED_MEM_FORMAT);
559 let err = parse(v).unwrap_err();
560 assert!(matches!(
561 err,
562 WorkspaceConfigError::UnsupportedFormat { .. }
563 ));
564 }
565
566 #[test]
567 fn preserves_unknown_top_level_fields() {
568 let mut v = ok_config_value();
573 v["syncState"] = serde_json::json!({"public-docs": "abc123"});
574 let cfg = parse(v).unwrap();
575 assert_eq!(
576 cfg.extra.get("syncState"),
577 Some(&serde_json::json!({"public-docs": "abc123"}))
578 );
579 let back = serde_json::to_value(&cfg).unwrap();
580 assert_eq!(back["syncState"]["public-docs"], "abc123");
581 }
582
583 #[test]
584 fn rejects_invalid_name() {
585 let mut v = ok_config_value();
586 v["name"] = serde_json::json!("Invalid Name");
587 let err = parse(v).unwrap_err();
588 assert!(matches!(err, WorkspaceConfigError::InvalidName(_)));
589 }
590
591 #[test]
592 fn rejects_invalid_schema_pin() {
593 let mut v = ok_config_value();
594 v["schema"] = serde_json::json!("default@^1.0.0");
595 let err = parse(v).unwrap_err();
596 assert!(matches!(err, WorkspaceConfigError::Malformed(_)));
597 }
598
599 #[test]
600 fn read_missing_config_returns_not_found() {
601 let tmp = TempDir::new().unwrap();
602 let err = read_workspace_config(tmp.path()).unwrap_err();
603 assert!(matches!(err, WorkspaceConfigError::NotFound(_)));
604 }
605
606 #[test]
607 fn engine_written_config_omits_name_and_read_derives_basename() {
608 let tmp = TempDir::new().unwrap();
609 let root = tmp.path().join("my-mem");
610 std::fs::create_dir_all(&root).unwrap();
611 let cfg = WorkspaceConfig::new("my-mem", versioned("default", "1.0.0"));
612 write_workspace_config(&root, &cfg).unwrap();
613
614 let raw: serde_json::Value =
617 serde_json::from_slice(&std::fs::read(config_path(&root)).unwrap()).unwrap();
618 assert!(
619 raw.get("name").is_none(),
620 "config.json must not carry a path-derived `name`"
621 );
622
623 let read = read_workspace_config(&root).unwrap();
625 assert_eq!(read.name, "my-mem");
626 }
627
628 #[test]
629 fn read_tolerates_a_legacy_name_field() {
630 let tmp = TempDir::new().unwrap();
631 let v = serde_json::json!({
632 "format": FILESYSTEM_WORKSPACE_FORMAT,
633 "name": "legacy-name",
634 "schema": "default@1.0.0",
635 });
636 std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
637 std::fs::write(config_path(tmp.path()), v.to_string()).unwrap();
638 let read = read_workspace_config(tmp.path()).unwrap();
639 assert_eq!(read.name, "legacy-name");
642 }
643
644 #[test]
645 fn write_creates_memstead_parent_directory() {
646 let tmp = TempDir::new().unwrap();
647 assert!(!tmp.path().join(".memstead").exists());
648 let cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
649 write_workspace_config(tmp.path(), &cfg).unwrap();
650 assert!(tmp.path().join(".memstead").is_dir());
651 assert!(tmp.path().join(".memstead").join("config.json").is_file());
652 }
653
654 #[test]
660 fn fresh_config_emits_no_deps_key() {
661 let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
662 cfg.version = Some(semver::Version::parse("0.1.0").unwrap());
663 let serialised = serde_json::to_value(&cfg).unwrap();
664 assert!(
665 serialised.get("deps").is_none(),
666 "retired `deps` must not be written: {serialised}"
667 );
668
669 let published = cfg.to_published().unwrap();
670 assert_eq!(published.format, PUBLISHED_MEM_FORMAT);
671 assert_eq!(published.name, "demo");
672 assert_eq!(published.version.to_string(), "0.1.0");
673 assert_eq!(published.schema.name, "default");
674 let serialised = serde_json::to_value(&published).unwrap();
675 assert!(serialised.get("deps").is_none());
676 }
677
678 #[test]
679 fn to_published_requires_version() {
680 let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
685 cfg.version = None;
686 let err = cfg.to_published().unwrap_err();
687 assert!(matches!(err, PublishConversionError::MissingVersion));
688 }
689}