1pub mod archive_provenance;
9pub mod base_metadata;
10pub mod builtins;
11pub mod config;
12pub mod loader;
13pub mod manifest;
14pub mod meta_schema;
15pub mod schema;
16pub mod source;
17pub mod types;
18pub mod workspace_config;
19
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23
24pub use archive_provenance::{
25 ARCHIVE_PROVENANCE_FORMAT, ArchiveProvenance, EntityProvenance, History,
26};
27pub use config::{
28 ARCHIVE_ANCHORS_PATH, ARCHIVE_CONFIG_PATH, ARCHIVE_EXTENSION, ARCHIVE_META_DIR,
29 ARCHIVE_PROVENANCE_PATH, ARCHIVE_SCHEMA_PREFIX, CommunityOverride, ConfigCheckResult,
30 ConfigError, MEM_META_DIR, MemConfig, PUBLISHED_MEM_FORMAT, PublishConfig,
31 PublishConversionError, PublishedMemConfig, ReadMemSource, ReadMemSpec, RoleConfig, SchemaRef,
32 VcsConfig, check_config, load_and_validate, load_config, parse_mem_config,
33 published_config_from,
34};
35pub use loader::{SchemaLoadError, load_schema_from_dir, load_schema_from_memory};
36pub use manifest::{
37 Cardinality, CommunityConfig, CrossMemRelationshipEntry, DefaultWritingGuidance,
38 ManualAuthoring, PerEdgeDescription, RelationshipDef, RelationshipMode, RelationshipVocabulary,
39 SchemaManifest,
40};
41pub use schema::Schema;
42pub use source::{SchemaSourceError, SchemaSourceFile, collect_schema_source};
43pub use types::{
44 FieldType, Filterable, MetadataFieldDef, RequiredCardinality, RequiredOutgoing, SectionDef,
45 Serialization, TypeDefinition, TypeExample,
46};
47
48pub mod builtin_names {
51 pub const SPEC: &str = "spec";
52 pub const MEMO: &str = "memo";
53 pub const ASSERTION: &str = "assertion";
54 pub const CONCEPT: &str = "concept";
55 pub const INQUIRY: &str = "inquiry";
56 pub const MODEL: &str = "model";
57 pub const NARRATIVE: &str = "narrative";
58 pub const PERSPECTIVE: &str = "perspective";
59 pub const PRINCIPLE: &str = "principle";
60 pub const PROCESS: &str = "process";
61
62 pub const ALL: [&str; 10] = [
63 SPEC,
64 MEMO,
65 ASSERTION,
66 CONCEPT,
67 INQUIRY,
68 MODEL,
69 NARRATIVE,
70 PERSPECTIVE,
71 PRINCIPLE,
72 PROCESS,
73 ];
74}
75
76#[derive(Debug)]
78pub struct SchemaRegistry {
79 schemas: HashMap<(String, semver::Version), Arc<Schema>>,
80}
81
82#[derive(Debug, thiserror::Error)]
83pub enum SchemaRegistryError {
84 #[error("schema '{name}' version '{version}' is already registered — cannot reinsert")]
85 AlreadyRegistered {
86 name: String,
87 version: semver::Version,
88 },
89}
90
91#[derive(Debug, thiserror::Error)]
96#[error(
97 "schema name '{name}' is ambiguous: {} versions registered ({}). \
98 Use a versioned pin (e.g. \"{name}@{}\") to disambiguate.",
99 .versions.len(),
100 .versions.join(", "),
101 .versions.first().map(String::as_str).unwrap_or("")
102)]
103pub struct SchemaNameAmbiguous {
104 pub name: String,
105 pub versions: Vec<String>,
106}
107
108#[derive(Debug, thiserror::Error)]
110pub enum WorkspaceSchemaLoadError {
111 #[error("failed to load schema at {}: {source}", .path.display())]
115 Invalid {
116 path: PathBuf,
117 #[source]
118 source: SchemaLoadError,
119 },
120
121 #[error("i/o error scanning {}: {source}", .path.display())]
124 Io {
125 path: PathBuf,
126 #[source]
127 source: std::io::Error,
128 },
129
130 #[error(
134 "schema cache collision: '{name}-{version}' has more than one source directory ({} and {})",
135 .first.display(),
136 .second.display()
137 )]
138 CacheCollision {
139 name: String,
140 version: semver::Version,
141 first: PathBuf,
142 second: PathBuf,
143 },
144}
145
146impl SchemaRegistry {
147 pub fn empty() -> Self {
148 Self {
149 schemas: HashMap::new(),
150 }
151 }
152
153 pub fn builtin() -> Self {
155 let mut reg = Self::empty();
156 for schema in builtins::load_builtin_schemas()
157 .expect("built-in schemas must load cleanly — bug in shipped YAML")
158 {
159 reg.schemas.insert(
160 (schema.manifest.name.clone(), schema.version.clone()),
161 schema,
162 );
163 }
164 reg
165 }
166
167 pub fn load_for_workspace(
195 workspace_root: Option<&Path>,
196 workspace_schemas_dir: Option<&Path>,
197 ) -> Result<Self, WorkspaceSchemaLoadError> {
198 let mut reg = Self::empty();
199
200 if let Some(ws_root) = workspace_root {
202 let cache_dir = ws_root.join(".memstead.cache").join("schemas");
203 let mut seen: HashMap<(String, semver::Version), PathBuf> = HashMap::new();
208 for path in list_schema_subdirs(&cache_dir)? {
209 let schema = loader::load_schema_from_dir(&path).map_err(|source| {
210 WorkspaceSchemaLoadError::Invalid {
211 path: path.clone(),
212 source,
213 }
214 })?;
215 let key = (schema.manifest.name.clone(), schema.version.clone());
216 if let Some(prev) = seen.get(&key) {
217 return Err(WorkspaceSchemaLoadError::CacheCollision {
218 name: key.0,
219 version: key.1,
220 first: prev.clone(),
221 second: path,
222 });
223 }
224 seen.insert(key.clone(), path);
225 reg.schemas.insert(key, Arc::new(schema));
226 }
227 }
228
229 for schema in builtins::load_builtin_schemas()
231 .expect("built-in schemas must load cleanly — bug in shipped YAML")
232 {
233 let key = (schema.manifest.name.clone(), schema.version.clone());
234 reg.schemas.insert(key, schema);
235 }
236
237 if let Some(ws_dir) = workspace_schemas_dir {
240 for path in list_schema_subdirs(ws_dir)? {
241 let schema = loader::load_schema_from_dir(&path).map_err(|source| {
242 WorkspaceSchemaLoadError::Invalid {
243 path: path.clone(),
244 source,
245 }
246 })?;
247 let key = (schema.manifest.name.clone(), schema.version.clone());
248 reg.schemas.insert(key, Arc::new(schema));
249 }
250 }
251
252 Ok(reg)
253 }
254
255 pub fn resolve_by_name(&self, name: &str) -> Result<Option<Arc<Schema>>, SchemaNameAmbiguous> {
268 let candidates: Vec<&Arc<Schema>> = self
269 .schemas
270 .iter()
271 .filter(|((n, _), _)| n == name)
272 .map(|(_, s)| s)
273 .collect();
274 match candidates.len() {
275 0 => Ok(None),
276 1 => Ok(Some(candidates[0].clone())),
277 _ => {
278 let mut versions: Vec<String> =
279 candidates.iter().map(|s| s.version.to_string()).collect();
280 versions.sort();
281 Err(SchemaNameAmbiguous {
282 name: name.to_string(),
283 versions,
284 })
285 }
286 }
287 }
288
289 pub fn merge_from(&mut self, other: &SchemaRegistry) {
294 for (key, schema) in &other.schemas {
295 self.schemas
296 .entry(key.clone())
297 .or_insert_with(|| schema.clone());
298 }
299 }
300
301 pub fn insert_overwriting(&mut self, schema: Arc<Schema>) {
319 let key = (schema.manifest.name.clone(), schema.version.clone());
320 self.schemas.insert(key, schema);
321 }
322
323 pub fn get(&self, name: &str, version: &semver::Version) -> Option<Arc<Schema>> {
324 self.schemas
325 .get(&(name.to_string(), version.clone()))
326 .cloned()
327 }
328
329 pub fn iter(&self) -> impl Iterator<Item = Arc<Schema>> + '_ {
330 self.schemas.values().cloned()
331 }
332
333 pub fn available_versions(&self, name: &str) -> Vec<semver::Version> {
334 let mut versions: Vec<semver::Version> = self
335 .schemas
336 .keys()
337 .filter(|(n, _)| n == name)
338 .map(|(_, v)| v.clone())
339 .collect();
340 versions.sort();
341 versions
342 }
343
344 pub fn suggest_name(&self, name: &str) -> Option<String> {
350 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
351 let mut best: Option<(usize, String)> = None;
352 for (n, _) in self.schemas.keys() {
353 if !seen.insert(n.as_str()) {
354 continue;
355 }
356 let d = strsim::levenshtein(name, n);
357 match &best {
358 Some((bd, _)) if *bd <= d => {}
359 _ => best = Some((d, n.clone())),
360 }
361 }
362 best.and_then(|(d, n)| if d > 0 { Some(n) } else { None })
363 }
364
365 pub fn identities(&self) -> Vec<(String, semver::Version)> {
368 let mut ids: Vec<(String, semver::Version)> = self.schemas.keys().cloned().collect();
369 ids.sort();
370 ids
371 }
372
373 pub fn is_empty(&self) -> bool {
374 self.schemas.is_empty()
375 }
376
377 pub fn len(&self) -> usize {
378 self.schemas.len()
379 }
380
381 pub fn insert(&mut self, schema: Arc<Schema>) -> Result<(), SchemaRegistryError> {
382 let key = (schema.manifest.name.clone(), schema.version.clone());
383 if self.schemas.contains_key(&key) {
384 return Err(SchemaRegistryError::AlreadyRegistered {
385 name: key.0,
386 version: key.1,
387 });
388 }
389 self.schemas.insert(key, schema);
390 Ok(())
391 }
392}
393
394impl Default for SchemaRegistry {
395 fn default() -> Self {
396 Self::builtin()
397 }
398}
399
400pub fn type_by_name(name: &str) -> Option<Arc<TypeDefinition>> {
408 Schema::builtin_default().get_type(name)
409}
410
411fn list_schema_subdirs(dir: &Path) -> Result<Vec<PathBuf>, WorkspaceSchemaLoadError> {
419 if !dir.is_dir() {
420 return Ok(Vec::new());
421 }
422 let entries = std::fs::read_dir(dir).map_err(|e| WorkspaceSchemaLoadError::Io {
423 path: dir.to_path_buf(),
424 source: e,
425 })?;
426 let mut out = Vec::new();
427 for entry in entries {
428 let entry = entry.map_err(|e| WorkspaceSchemaLoadError::Io {
429 path: dir.to_path_buf(),
430 source: e,
431 })?;
432 let path = entry.path();
433 if !path.is_dir() {
434 continue;
435 }
436 let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
437 continue;
438 };
439 if name.starts_with('.') {
440 continue;
441 }
442 out.push(path);
443 }
444 out.sort();
446 Ok(out)
447}
448
449pub fn all_types() -> Vec<Arc<TypeDefinition>> {
451 let schema = Schema::builtin_default();
452 schema
455 .manifest
456 .types
457 .iter()
458 .filter_map(|name| schema.get_type(name))
459 .collect()
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465
466 #[test]
467 fn builtin_registry_contains_default() {
468 let reg = SchemaRegistry::builtin();
469 assert!(!reg.is_empty());
470 let versions = reg.available_versions("default");
471 assert_eq!(versions.len(), 1);
472 }
473
474 #[test]
475 fn builtin_default_has_ten_types() {
476 assert_eq!(all_types().len(), 10);
477 for name in builtin_names::ALL {
478 assert!(type_by_name(name).is_some(), "missing type: {name}");
479 }
480 }
481
482 #[test]
483 fn registry_rejects_duplicate_insert() {
484 let schema = Schema::builtin_default();
485 let mut reg = SchemaRegistry::empty();
486 reg.insert(schema.clone()).unwrap();
487 let err = reg.insert(schema).unwrap_err();
488 assert!(matches!(err, SchemaRegistryError::AlreadyRegistered { .. }));
489 }
490
491 #[test]
498 fn software_lifecycle_date_fields_are_optional() {
499 let reg = SchemaRegistry::builtin();
500 let software = reg
501 .resolve_by_name("software")
502 .unwrap()
503 .expect("software builtin present");
504
505 let requirement = software.get_type("requirement").expect("requirement type");
506 assert!(
507 requirement.metadata_field("verified_on").unwrap().optional,
508 "verified_on must be optional — an unverified requirement has no verification date"
509 );
510 assert!(
512 !requirement.metadata_field("source").unwrap().optional,
513 "source stays required"
514 );
515
516 let contract = software.get_type("contract").expect("contract type");
517 for field in ["deprecated_on", "removal_on"] {
518 assert!(
519 contract.metadata_field(field).unwrap().optional,
520 "{field} must be optional — a current contract has no deprecation/removal date"
521 );
522 }
523 for field in ["protocol", "version"] {
525 assert!(
526 !contract.metadata_field(field).unwrap().optional,
527 "{field} stays required"
528 );
529 }
530 }
531
532 mod workspace_layer {
533 use super::*;
534 use tempfile::TempDir;
535
536 fn write_schema(dir: &Path, name: &str, version: &str) {
538 std::fs::create_dir_all(dir.join("types")).unwrap();
539 let manifest = format!(
540 r#"name: {name}
541version: {version}
542description: test
543when_to_use: test
544types:
545 - spec
546relationships:
547 mode: strict
548 definitions:
549 - name: _default
550 description: default
551 default_weight: 1.0
552 - name: PART_OF
553 description: hier
554 default_weight: 3.0
555community:
556 resolution: 1.0
557 seed: 42
558"#
559 );
560 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
561 std::fs::write(
562 dir.join("types/spec.yaml"),
563 r#"name: spec
564description: test
565when_to_use: test
566sections:
567 - key: body
568 heading: Body
569 required: true
570 search_weight: 10.0
571 catch_all: true
572metadata_fields: []
573title_weight: 1.0
574text_fields: [body]
575hierarchy_relationship: PART_OF
576propagating_relationships: []
577updatable_fields: [title, body]
578health_required_fields: [body]
579staleness_threshold_days: 30
580write_rules: []
581"#,
582 )
583 .unwrap();
584 }
585
586 #[test]
587 fn workspace_schema_resolves_by_name() {
588 let tmp = TempDir::new().unwrap();
589 let workspace_schemas = tmp.path().join("schemas");
590
591 write_schema(
595 &workspace_schemas.join("test-isolated"),
596 "test-isolated",
597 "1.0.0",
598 );
599
600 let reg =
601 SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
602 .expect("workspace load succeeds");
603
604 let resolved = reg
605 .resolve_by_name("test-isolated")
606 .expect("unique name resolves");
607 assert!(resolved.is_some(), "workspace schema must be registered");
608 }
609
610 #[test]
611 fn per_mem_schema_override_no_longer_resolves() {
612 let tmp = TempDir::new().unwrap();
617 let mem_override = tmp.path().join("mem/.memstead/schemas/default");
618 write_schema(&mem_override, "default", "1.0.0");
619
620 let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap();
621 let schema = reg
622 .get("default", &semver::Version::new(1, 0, 0))
623 .expect("builtin default still registered");
624 assert_eq!(
627 schema.types.len(),
628 10,
629 "per-mem override must no longer shadow the builtin"
630 );
631 }
632
633 #[test]
634 fn workspace_layer_falls_through_to_builtins() {
635 let tmp = TempDir::new().unwrap();
636 let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None)
638 .expect("builtin-only load succeeds");
639 let resolved = reg.resolve_by_name("default").expect("unique");
640 assert!(
641 resolved.is_some(),
642 "builtin default must be registered when no other layers contribute"
643 );
644 }
645
646 #[test]
647 fn workspace_overrides_builtin_at_same_key() {
648 let tmp = TempDir::new().unwrap();
652 let workspace_schemas = tmp.path().join("schemas");
653
654 write_schema(&workspace_schemas.join("default"), "default", "1.0.0");
655
656 let reg =
657 SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
658 .expect("workspace override loads");
659
660 let schema = reg
661 .get("default", &semver::Version::new(1, 0, 0))
662 .expect("default@1.0.0 still registered");
663 assert_eq!(
665 schema.types.len(),
666 1,
667 "workspace-level schema must replace the builtin shape"
668 );
669 }
670
671 #[test]
672 fn workspace_cache_path_is_workspace_wide() {
673 let tmp = TempDir::new().unwrap();
676 let cache_dir = tmp.path().join(".memstead.cache/schemas/recipe-1.0.0");
677 write_schema(&cache_dir, "recipe", "1.0.0");
678
679 let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap();
680 assert!(
681 reg.get("recipe", &semver::Version::new(1, 0, 0)).is_some(),
682 "workspace-wide cache schema must register"
683 );
684 }
685
686 #[test]
687 fn schema_cache_collision_yields_error() {
688 let tmp = TempDir::new().unwrap();
692 let first = tmp.path().join(".memstead.cache/schemas/dup-a");
693 let second = tmp.path().join(".memstead.cache/schemas/dup-b");
694 write_schema(&first, "shared", "1.0.0");
695 write_schema(&second, "shared", "1.0.0");
696
697 let err = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap_err();
698 assert!(
699 matches!(err, WorkspaceSchemaLoadError::CacheCollision { .. }),
700 "expected CacheCollision, got {err:?}"
701 );
702 }
703
704 #[test]
705 fn three_level_chain_resolves_correctly() {
706 let tmp = TempDir::new().unwrap();
709 let workspace_schemas = tmp.path().join("schemas");
710
711 let cache_only = tmp.path().join(".memstead.cache/schemas/cache-only-1.0.0");
713 write_schema(&cache_only, "cache-only", "1.0.0");
714
715 write_schema(&workspace_schemas.join("default"), "default", "1.0.0");
717
718 let cache_overridden = tmp.path().join(".memstead.cache/schemas/overridden-1.0.0");
720 write_schema(&cache_overridden, "overridden", "1.0.0");
721 write_schema(&workspace_schemas.join("overridden"), "overridden", "1.0.0");
722
723 let reg =
724 SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
725 .unwrap();
726
727 assert!(
728 reg.get("cache-only", &semver::Version::new(1, 0, 0))
729 .is_some(),
730 "cache-only schema must register via cache layer"
731 );
732 assert_eq!(
734 reg.get("default", &semver::Version::new(1, 0, 0))
735 .expect("default registered")
736 .types
737 .len(),
738 1,
739 "workspace-level default must override the 10-type builtin"
740 );
741 assert!(
742 reg.get("overridden", &semver::Version::new(1, 0, 0))
743 .is_some(),
744 "workspace-level schema overrides the cache copy at the same key"
745 );
746 }
747
748 #[test]
749 fn unknown_name_resolves_to_none() {
750 let tmp = TempDir::new().unwrap();
751 let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap();
752 assert!(reg.resolve_by_name("does-not-exist").unwrap().is_none());
753 }
754
755 #[test]
756 fn ambiguous_name_surfaces_versions() {
757 let tmp = TempDir::new().unwrap();
758 let workspace_schemas = tmp.path().join("schemas");
759
760 write_schema(
765 &workspace_schemas.join("test-ambig-1"),
766 "test-ambig",
767 "1.0.0",
768 );
769 write_schema(
770 &workspace_schemas.join("test-ambig-2"),
771 "test-ambig",
772 "2.0.0",
773 );
774
775 let reg =
776 SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
777 .unwrap();
778 let err = reg
779 .resolve_by_name("test-ambig")
780 .expect_err("two versions under same name must be ambiguous");
781 assert_eq!(err.versions.len(), 2);
782 assert!(err.versions.iter().any(|v| v == "1.0.0"));
783 assert!(err.versions.iter().any(|v| v == "2.0.0"));
784 let msg = format!("{err}");
785 assert!(msg.contains("ambiguous"));
786 assert!(msg.contains("1.0.0"));
787 }
788 }
789}