1pub mod archive_provenance;
9pub mod base_metadata;
10pub mod builtins;
11pub mod config;
12pub mod content_expr;
13pub mod loader;
14pub mod manifest;
15pub mod meta_schema;
16pub mod schema;
17pub mod source;
18pub mod types;
19pub mod workspace_config;
20
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23use std::sync::Arc;
24
25pub use archive_provenance::{
26 ARCHIVE_PROVENANCE_FORMAT, ArchiveProvenance, EntityProvenance, History,
27};
28pub use config::{
29 ARCHIVE_ANCHORS_PATH, ARCHIVE_CONFIG_PATH, ARCHIVE_EXTENSION, ARCHIVE_META_DIR,
30 ARCHIVE_PROVENANCE_PATH, ARCHIVE_SCHEMA_PREFIX, CommunityOverride, ConfigCheckResult,
31 ConfigError, MEM_META_DIR, MemConfig, MemSubject, MutationStamp, PUBLISHED_MEM_FORMAT,
32 PublishConfig, PublishConversionError, PublishedMemConfig, ReadMemSource, ReadMemSpec,
33 RoleConfig, SchemaRef, VcsConfig, check_config, load_and_validate, load_config,
34 parse_mem_config, published_config_from, published_format_accepted,
35};
36pub use loader::{
37 HeadingKeyViolation, MetadataPolarityFormat, SchemaLoadError, check_reserved_metadata_keys,
38 check_section_formats, check_section_heading_roundtrip, load_schema_from_dir,
39 load_schema_from_memory, load_schema_from_memory_with_format,
40};
41pub use manifest::{
42 Cardinality, CommunityConfig, CrossMemRelationshipEntry, DefaultWritingGuidance,
43 ManualAuthoring, PerEdgeDescription, RelationshipDef, RelationshipMode, RelationshipVocabulary,
44 SchemaManifest,
45};
46pub use schema::Schema;
47pub use source::{
48 INSTALL_PROVENANCE_FILE, SchemaSourceError, SchemaSourceFile, collect_schema_source,
49};
50pub use types::{
51 ConstraintDef, ConstraintSeverity, DueAxis, FieldType, Filterable, MetadataFieldDef,
52 PropagationDirection, RequiredCardinality, RequiredOutgoing, SectionDef, Serialization,
53 TableFormat, TypeDefinition, derive_section_key,
54};
55
56pub mod builtin_names {
59 pub const SPEC: &str = "spec";
60 pub const MEMO: &str = "memo";
61 pub const ASSERTION: &str = "assertion";
62 pub const CONCEPT: &str = "concept";
63 pub const INQUIRY: &str = "inquiry";
64 pub const MODEL: &str = "model";
65 pub const NARRATIVE: &str = "narrative";
66 pub const PERSPECTIVE: &str = "perspective";
67 pub const PRINCIPLE: &str = "principle";
68 pub const PROCESS: &str = "process";
69
70 pub const ALL: [&str; 10] = [
71 SPEC,
72 MEMO,
73 ASSERTION,
74 CONCEPT,
75 INQUIRY,
76 MODEL,
77 NARRATIVE,
78 PERSPECTIVE,
79 PRINCIPLE,
80 PROCESS,
81 ];
82}
83
84#[derive(Debug)]
86pub struct SchemaRegistry {
87 schemas: HashMap<(String, semver::Version), Arc<Schema>>,
88}
89
90#[derive(Debug, thiserror::Error)]
91pub enum SchemaRegistryError {
92 #[error("schema '{name}' version '{version}' is already registered — cannot reinsert")]
93 AlreadyRegistered {
94 name: String,
95 version: semver::Version,
96 },
97}
98
99#[derive(Debug, thiserror::Error)]
104#[error(
105 "schema name '{name}' is ambiguous: {} versions registered ({}). \
106 Use a versioned pin (e.g. \"{name}@{}\") to disambiguate.",
107 .versions.len(),
108 .versions.join(", "),
109 .versions.first().map(String::as_str).unwrap_or("")
110)]
111pub struct SchemaNameAmbiguous {
112 pub name: String,
113 pub versions: Vec<String>,
114}
115
116#[derive(Debug, thiserror::Error)]
118pub enum WorkspaceSchemaLoadError {
119 #[error("failed to load schema at {}: {source}", .path.display())]
123 Invalid {
124 path: PathBuf,
125 #[source]
126 source: SchemaLoadError,
127 },
128
129 #[error("i/o error scanning {}: {source}", .path.display())]
132 Io {
133 path: PathBuf,
134 #[source]
135 source: std::io::Error,
136 },
137
138 #[error(
142 "schema cache collision: '{name}-{version}' has more than one source directory ({} and {})",
143 .first.display(),
144 .second.display()
145 )]
146 CacheCollision {
147 name: String,
148 version: semver::Version,
149 first: PathBuf,
150 second: PathBuf,
151 },
152}
153
154impl SchemaRegistry {
155 pub fn empty() -> Self {
156 Self {
157 schemas: HashMap::new(),
158 }
159 }
160
161 pub fn builtin() -> Self {
163 let mut reg = Self::empty();
164 for schema in builtins::load_builtin_schemas()
165 .expect("built-in schemas must load cleanly — bug in shipped YAML")
166 {
167 reg.schemas.insert(
168 (schema.manifest.name.clone(), schema.version.clone()),
169 schema,
170 );
171 }
172 reg
173 }
174
175 pub fn load_for_workspace(
203 workspace_root: Option<&Path>,
204 workspace_schemas_dir: Option<&Path>,
205 ) -> Result<Self, WorkspaceSchemaLoadError> {
206 let mut reg = Self::empty();
207
208 if let Some(ws_root) = workspace_root {
210 let cache_dir = ws_root.join(".memstead.cache").join("schemas");
211 let mut seen: HashMap<(String, semver::Version), PathBuf> = HashMap::new();
216 for path in list_schema_subdirs(&cache_dir)? {
217 let schema = loader::load_schema_from_dir(&path).map_err(|source| {
218 WorkspaceSchemaLoadError::Invalid {
219 path: path.clone(),
220 source,
221 }
222 })?;
223 let key = (schema.manifest.name.clone(), schema.version.clone());
224 if let Some(prev) = seen.get(&key) {
225 return Err(WorkspaceSchemaLoadError::CacheCollision {
226 name: key.0,
227 version: key.1,
228 first: prev.clone(),
229 second: path,
230 });
231 }
232 seen.insert(key.clone(), path);
233 reg.schemas.insert(key, Arc::new(schema));
234 }
235 }
236
237 for schema in builtins::load_builtin_schemas()
239 .expect("built-in schemas must load cleanly — bug in shipped YAML")
240 {
241 let key = (schema.manifest.name.clone(), schema.version.clone());
242 reg.schemas.insert(key, schema);
243 }
244
245 if let Some(ws_dir) = workspace_schemas_dir {
248 for path in list_schema_subdirs(ws_dir)? {
249 let schema = loader::load_schema_from_dir(&path).map_err(|source| {
250 WorkspaceSchemaLoadError::Invalid {
251 path: path.clone(),
252 source,
253 }
254 })?;
255 let key = (schema.manifest.name.clone(), schema.version.clone());
256 reg.schemas.insert(key, Arc::new(schema));
257 }
258 }
259
260 Ok(reg)
261 }
262
263 pub fn resolve_by_name(&self, name: &str) -> Result<Option<Arc<Schema>>, SchemaNameAmbiguous> {
276 let candidates: Vec<&Arc<Schema>> = self
277 .schemas
278 .iter()
279 .filter(|((n, _), _)| n == name)
280 .map(|(_, s)| s)
281 .collect();
282 match candidates.len() {
283 0 => Ok(None),
284 1 => Ok(Some(candidates[0].clone())),
285 _ => {
286 let mut versions: Vec<String> =
287 candidates.iter().map(|s| s.version.to_string()).collect();
288 versions.sort();
289 Err(SchemaNameAmbiguous {
290 name: name.to_string(),
291 versions,
292 })
293 }
294 }
295 }
296
297 pub fn merge_from(&mut self, other: &SchemaRegistry) {
302 for (key, schema) in &other.schemas {
303 self.schemas
304 .entry(key.clone())
305 .or_insert_with(|| schema.clone());
306 }
307 }
308
309 pub fn insert_overwriting(&mut self, schema: Arc<Schema>) {
327 let key = (schema.manifest.name.clone(), schema.version.clone());
328 self.schemas.insert(key, schema);
329 }
330
331 pub fn get(&self, name: &str, version: &semver::Version) -> Option<Arc<Schema>> {
332 self.schemas
333 .get(&(name.to_string(), version.clone()))
334 .cloned()
335 }
336
337 pub fn iter(&self) -> impl Iterator<Item = Arc<Schema>> + '_ {
338 self.schemas.values().cloned()
339 }
340
341 pub fn available_versions(&self, name: &str) -> Vec<semver::Version> {
342 let mut versions: Vec<semver::Version> = self
343 .schemas
344 .keys()
345 .filter(|(n, _)| n == name)
346 .map(|(_, v)| v.clone())
347 .collect();
348 versions.sort();
349 versions
350 }
351
352 pub fn suggest_name(&self, name: &str) -> Option<String> {
358 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
359 let mut best: Option<(usize, String)> = None;
360 for (n, _) in self.schemas.keys() {
361 if !seen.insert(n.as_str()) {
362 continue;
363 }
364 let d = strsim::levenshtein(name, n);
365 match &best {
366 Some((bd, _)) if *bd <= d => {}
367 _ => best = Some((d, n.clone())),
368 }
369 }
370 best.and_then(|(d, n)| if d > 0 { Some(n) } else { None })
371 }
372
373 pub fn identities(&self) -> Vec<(String, semver::Version)> {
376 let mut ids: Vec<(String, semver::Version)> = self.schemas.keys().cloned().collect();
377 ids.sort();
378 ids
379 }
380
381 pub fn is_empty(&self) -> bool {
382 self.schemas.is_empty()
383 }
384
385 pub fn len(&self) -> usize {
386 self.schemas.len()
387 }
388
389 pub fn insert(&mut self, schema: Arc<Schema>) -> Result<(), SchemaRegistryError> {
390 let key = (schema.manifest.name.clone(), schema.version.clone());
391 if self.schemas.contains_key(&key) {
392 return Err(SchemaRegistryError::AlreadyRegistered {
393 name: key.0,
394 version: key.1,
395 });
396 }
397 self.schemas.insert(key, schema);
398 Ok(())
399 }
400}
401
402impl Default for SchemaRegistry {
403 fn default() -> Self {
404 Self::builtin()
405 }
406}
407
408pub fn type_by_name(name: &str) -> Option<Arc<TypeDefinition>> {
416 Schema::builtin_default().get_type(name)
417}
418
419fn list_schema_subdirs(dir: &Path) -> Result<Vec<PathBuf>, WorkspaceSchemaLoadError> {
427 if !dir.is_dir() {
428 return Ok(Vec::new());
429 }
430 let entries = std::fs::read_dir(dir).map_err(|e| WorkspaceSchemaLoadError::Io {
431 path: dir.to_path_buf(),
432 source: e,
433 })?;
434 let mut out = Vec::new();
435 for entry in entries {
436 let entry = entry.map_err(|e| WorkspaceSchemaLoadError::Io {
437 path: dir.to_path_buf(),
438 source: e,
439 })?;
440 let path = entry.path();
441 if !path.is_dir() {
442 continue;
443 }
444 let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
445 continue;
446 };
447 if name.starts_with('.') {
448 continue;
449 }
450 out.push(path);
451 }
452 out.sort();
454 Ok(out)
455}
456
457pub fn all_types() -> Vec<Arc<TypeDefinition>> {
459 let schema = Schema::builtin_default();
460 schema
463 .manifest
464 .types
465 .iter()
466 .filter_map(|name| schema.get_type(name))
467 .collect()
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 #[test]
475 fn builtin_registry_contains_default() {
476 let reg = SchemaRegistry::builtin();
477 assert!(!reg.is_empty());
478 let versions = reg.available_versions("default");
479 assert_eq!(versions.len(), 4);
484 }
485
486 #[test]
487 fn builtin_default_has_ten_types() {
488 assert_eq!(all_types().len(), 10);
489 for name in builtin_names::ALL {
490 assert!(type_by_name(name).is_some(), "missing type: {name}");
491 }
492 }
493
494 #[test]
495 fn registry_rejects_duplicate_insert() {
496 let schema = Schema::builtin_default();
497 let mut reg = SchemaRegistry::empty();
498 reg.insert(schema.clone()).unwrap();
499 let err = reg.insert(schema).unwrap_err();
500 assert!(matches!(err, SchemaRegistryError::AlreadyRegistered { .. }));
501 }
502
503 #[test]
510 fn software_lifecycle_date_fields_are_optional() {
511 let reg = SchemaRegistry::builtin();
512 let software = reg
513 .get("software", &semver::Version::new(0, 2, 0))
514 .expect("software builtin present");
515
516 let requirement = software.get_type("requirement").expect("requirement type");
517 assert!(
518 !requirement
519 .metadata_field("verified_on")
520 .unwrap()
521 .is_required(),
522 "verified_on must be optional — an unverified requirement has no verification date"
523 );
524 assert!(
526 requirement.metadata_field("source").unwrap().is_required(),
527 "source stays required"
528 );
529
530 let contract = software.get_type("contract").expect("contract type");
531 for field in ["deprecated_on", "removal_on"] {
532 assert!(
533 !contract.metadata_field(field).unwrap().is_required(),
534 "{field} must be optional — a current contract has no deprecation/removal date"
535 );
536 }
537 for field in ["protocol", "version"] {
539 assert!(
540 contract.metadata_field(field).unwrap().is_required(),
541 "{field} stays required"
542 );
543 }
544 }
545
546 mod workspace_layer {
547 use super::*;
548 use tempfile::TempDir;
549
550 fn write_schema(dir: &Path, name: &str, version: &str) {
552 std::fs::create_dir_all(dir.join("types")).unwrap();
553 let manifest = format!(
554 r#"name: {name}
555version: {version}
556description: test
557when_to_use: test
558types:
559 - spec
560relationships:
561 mode: strict
562 definitions:
563 - name: _default
564 description: default
565 default_weight: 1.0
566 - name: PART_OF
567 description: hier
568 default_weight: 3.0
569community:
570 resolution: 1.0
571 seed: 42
572"#
573 );
574 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
575 std::fs::write(
576 dir.join("types/spec.yaml"),
577 r#"name: spec
578description: test
579when_to_use: test
580sections:
581 - key: body
582 heading: Body
583 required: true
584 search_weight: 10.0
585 catch_all: true
586metadata_fields: []
587title_weight: 1.0
588text_fields: [body]
589hierarchy_relationship: PART_OF
590no_self_loop_relationships: []
591updatable_fields: [title, body]
592health_required_fields: [body]
593staleness_threshold_days: 30
594write_rules: []
595"#,
596 )
597 .unwrap();
598 }
599
600 #[test]
601 fn workspace_schema_resolves_by_name() {
602 let tmp = TempDir::new().unwrap();
603 let workspace_schemas = tmp.path().join("schemas");
604
605 write_schema(
609 &workspace_schemas.join("test-isolated"),
610 "test-isolated",
611 "1.0.0",
612 );
613
614 let reg =
615 SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
616 .expect("workspace load succeeds");
617
618 let resolved = reg
619 .resolve_by_name("test-isolated")
620 .expect("unique name resolves");
621 assert!(resolved.is_some(), "workspace schema must be registered");
622 }
623
624 #[test]
625 fn per_mem_schema_override_no_longer_resolves() {
626 let tmp = TempDir::new().unwrap();
631 let mem_override = tmp.path().join("mem/.memstead/schemas/default");
632 write_schema(&mem_override, "default", "1.0.0");
633
634 let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap();
635 let schema = reg
636 .get("default", &semver::Version::new(1, 0, 0))
637 .expect("builtin default still registered");
638 assert_eq!(
641 schema.types.len(),
642 10,
643 "per-mem override must no longer shadow the builtin"
644 );
645 }
646
647 #[test]
648 fn workspace_layer_falls_through_to_builtins() {
649 let tmp = TempDir::new().unwrap();
650 let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None)
652 .expect("builtin-only load succeeds");
653 assert!(
654 reg.get("default", &semver::Version::new(1, 1, 0)).is_some(),
655 "builtin default must be registered when no other layers contribute"
656 );
657 }
658
659 #[test]
660 fn workspace_overrides_builtin_at_same_key() {
661 let tmp = TempDir::new().unwrap();
665 let workspace_schemas = tmp.path().join("schemas");
666
667 write_schema(&workspace_schemas.join("default"), "default", "1.0.0");
668
669 let reg =
670 SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
671 .expect("workspace override loads");
672
673 let schema = reg
674 .get("default", &semver::Version::new(1, 0, 0))
675 .expect("default@1.0.0 still registered");
676 assert_eq!(
678 schema.types.len(),
679 1,
680 "workspace-level schema must replace the builtin shape"
681 );
682 }
683
684 #[test]
685 fn workspace_cache_path_is_workspace_wide() {
686 let tmp = TempDir::new().unwrap();
689 let cache_dir = tmp.path().join(".memstead.cache/schemas/recipe-1.0.0");
690 write_schema(&cache_dir, "recipe", "1.0.0");
691
692 let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap();
693 assert!(
694 reg.get("recipe", &semver::Version::new(1, 0, 0)).is_some(),
695 "workspace-wide cache schema must register"
696 );
697 }
698
699 #[test]
700 fn schema_cache_collision_yields_error() {
701 let tmp = TempDir::new().unwrap();
705 let first = tmp.path().join(".memstead.cache/schemas/dup-a");
706 let second = tmp.path().join(".memstead.cache/schemas/dup-b");
707 write_schema(&first, "shared", "1.0.0");
708 write_schema(&second, "shared", "1.0.0");
709
710 let err = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap_err();
711 assert!(
712 matches!(err, WorkspaceSchemaLoadError::CacheCollision { .. }),
713 "expected CacheCollision, got {err:?}"
714 );
715 }
716
717 #[test]
718 fn three_level_chain_resolves_correctly() {
719 let tmp = TempDir::new().unwrap();
722 let workspace_schemas = tmp.path().join("schemas");
723
724 let cache_only = tmp.path().join(".memstead.cache/schemas/cache-only-1.0.0");
726 write_schema(&cache_only, "cache-only", "1.0.0");
727
728 write_schema(&workspace_schemas.join("default"), "default", "1.0.0");
730
731 let cache_overridden = tmp.path().join(".memstead.cache/schemas/overridden-1.0.0");
733 write_schema(&cache_overridden, "overridden", "1.0.0");
734 write_schema(&workspace_schemas.join("overridden"), "overridden", "1.0.0");
735
736 let reg =
737 SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
738 .unwrap();
739
740 assert!(
741 reg.get("cache-only", &semver::Version::new(1, 0, 0))
742 .is_some(),
743 "cache-only schema must register via cache layer"
744 );
745 assert_eq!(
747 reg.get("default", &semver::Version::new(1, 0, 0))
748 .expect("default registered")
749 .types
750 .len(),
751 1,
752 "workspace-level default must override the 10-type builtin"
753 );
754 assert!(
755 reg.get("overridden", &semver::Version::new(1, 0, 0))
756 .is_some(),
757 "workspace-level schema overrides the cache copy at the same key"
758 );
759 }
760
761 #[test]
762 fn unknown_name_resolves_to_none() {
763 let tmp = TempDir::new().unwrap();
764 let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap();
765 assert!(reg.resolve_by_name("does-not-exist").unwrap().is_none());
766 }
767
768 #[test]
769 fn ambiguous_name_surfaces_versions() {
770 let tmp = TempDir::new().unwrap();
771 let workspace_schemas = tmp.path().join("schemas");
772
773 write_schema(
778 &workspace_schemas.join("test-ambig-1"),
779 "test-ambig",
780 "1.0.0",
781 );
782 write_schema(
783 &workspace_schemas.join("test-ambig-2"),
784 "test-ambig",
785 "2.0.0",
786 );
787
788 let reg =
789 SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
790 .unwrap();
791 let err = reg
792 .resolve_by_name("test-ambig")
793 .expect_err("two versions under same name must be ambiguous");
794 assert_eq!(err.versions.len(), 2);
795 assert!(err.versions.iter().any(|v| v == "1.0.0"));
796 assert!(err.versions.iter().any(|v| v == "2.0.0"));
797 let msg = format!("{err}");
798 assert!(msg.contains("ambiguous"));
799 assert!(msg.contains("1.0.0"));
800 }
801 }
802}