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