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 migrate;
17pub mod schema;
18pub mod source;
19pub mod types;
20pub mod workspace_config;
21
22use std::collections::HashMap;
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25
26pub use archive_provenance::{
27 ARCHIVE_PROVENANCE_FORMAT, ArchiveProvenance, EntityProvenance, History,
28};
29pub use config::{
30 ARCHIVE_ANCHORS_PATH, ARCHIVE_CONFIG_PATH, ARCHIVE_EXTENSION, ARCHIVE_META_DIR,
31 ARCHIVE_PROVENANCE_PATH, ARCHIVE_SCHEMA_PREFIX, CommunityOverride, ConfigCheckResult,
32 ConfigError, MEM_META_DIR, MemConfig, MemSubject, MutationStamp, PUBLISHED_MEM_FORMAT,
33 PUBLISHED_MEM_FORMATS_ACCEPTED, PublishConfig, PublishConversionError, PublishedMemConfig,
34 ReadMemSource, ReadMemSpec, RoleConfig, SchemaRef, VcsConfig, check_config, load_and_validate,
35 load_config, parse_mem_config, published_config_from, published_format_accepted,
36};
37pub use loader::{
38 HeadingKeyViolation, MetadataPolarityFormat, SchemaLoadError, check_reserved_metadata_keys,
39 check_section_formats, check_section_heading_roundtrip, load_authoring_package_from_memory,
40 load_schema_from_dir, load_schema_from_memory, load_schema_from_memory_with_format,
41 load_sealed_package,
42};
43pub use manifest::{
44 Cardinality, CommunityConfig, CrossMemRelationshipEntry, DefaultWritingGuidance, LabellingDef,
45 ManualAuthoring, PerEdgeDescription, RelationshipDef, RelationshipMode, RelationshipVocabulary,
46 SchemaManifest, SupportWalk,
47};
48pub use schema::Schema;
49pub use source::{
50 INSTALL_PROVENANCE_FILE, SchemaSourceError, SchemaSourceFile, collect_schema_source,
51};
52pub use types::{
53 ConstraintDef, ConstraintSeverity, DueAxis, FieldType, Filterable, MetadataFieldDef, MustReach,
54 PropagationDirection, ReachDirection, RequiredCardinality, RequiredOutgoing, SectionDef,
55 Serialization, SignalDef, SignalKind, SignalLevel, SignalThreshold, TableFormat,
56 TypeDefinition, derive_section_key,
57};
58
59pub mod builtin_names {
62 pub const SPEC: &str = "spec";
63 pub const MEMO: &str = "memo";
64 pub const ASSERTION: &str = "assertion";
65 pub const CONCEPT: &str = "concept";
66 pub const INQUIRY: &str = "inquiry";
67 pub const MODEL: &str = "model";
68 pub const NARRATIVE: &str = "narrative";
69 pub const PERSPECTIVE: &str = "perspective";
70 pub const PRINCIPLE: &str = "principle";
71 pub const PROCESS: &str = "process";
72
73 pub const ALL: [&str; 10] = [
74 SPEC,
75 MEMO,
76 ASSERTION,
77 CONCEPT,
78 INQUIRY,
79 MODEL,
80 NARRATIVE,
81 PERSPECTIVE,
82 PRINCIPLE,
83 PROCESS,
84 ];
85}
86
87#[derive(Debug)]
89pub struct SchemaRegistry {
90 schemas: HashMap<(String, semver::Version), Arc<Schema>>,
91}
92
93#[derive(Debug, thiserror::Error)]
94pub enum SchemaRegistryError {
95 #[error("schema '{name}' version '{version}' is already registered — cannot reinsert")]
96 AlreadyRegistered {
97 name: String,
98 version: semver::Version,
99 },
100}
101
102#[derive(Debug, thiserror::Error)]
107#[error(
108 "schema name '{name}' is ambiguous: {} versions registered ({}). \
109 Use a versioned pin (e.g. \"{name}@{}\") to disambiguate.",
110 .versions.len(),
111 .versions.join(", "),
112 .versions.first().map(String::as_str).unwrap_or("")
113)]
114pub struct SchemaNameAmbiguous {
115 pub name: String,
116 pub versions: Vec<String>,
117}
118
119#[derive(Debug, thiserror::Error)]
121pub enum WorkspaceSchemaLoadError {
122 #[error("failed to load schema at {}: {source}", .path.display())]
126 Invalid {
127 path: PathBuf,
128 #[source]
129 source: SchemaLoadError,
130 },
131
132 #[error("i/o error scanning {}: {source}", .path.display())]
134 Io {
135 path: PathBuf,
136 #[source]
137 source: std::io::Error,
138 },
139}
140
141impl SchemaRegistry {
142 pub fn empty() -> Self {
143 Self {
144 schemas: HashMap::new(),
145 }
146 }
147
148 pub fn builtin() -> Self {
150 let mut reg = Self::empty();
151 for schema in builtins::load_builtin_schemas()
152 .expect("built-in schemas must load cleanly — bug in shipped YAML")
153 {
154 reg.schemas.insert(
155 (schema.manifest.name.clone(), schema.version.clone()),
156 schema,
157 );
158 }
159 reg
160 }
161
162 pub fn load_for_workspace(
192 _workspace_root: Option<&Path>,
193 workspace_schemas_dir: Option<&Path>,
194 ) -> Result<Self, WorkspaceSchemaLoadError> {
195 let mut reg = Self::empty();
196
197 for schema in builtins::load_builtin_schemas()
199 .expect("built-in schemas must load cleanly — bug in shipped YAML")
200 {
201 let key = (schema.manifest.name.clone(), schema.version.clone());
202 reg.schemas.insert(key, schema);
203 }
204
205 if let Some(ws_dir) = workspace_schemas_dir {
208 for path in list_schema_subdirs(ws_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 reg.schemas.insert(key, Arc::new(schema));
217 }
218 }
219
220 Ok(reg)
221 }
222
223 pub fn resolve_by_name(&self, name: &str) -> Result<Option<Arc<Schema>>, SchemaNameAmbiguous> {
236 let candidates: Vec<&Arc<Schema>> = self
237 .schemas
238 .iter()
239 .filter(|((n, _), _)| n == name)
240 .map(|(_, s)| s)
241 .collect();
242 match candidates.len() {
243 0 => Ok(None),
244 1 => Ok(Some(candidates[0].clone())),
245 _ => {
246 let mut versions: Vec<String> =
247 candidates.iter().map(|s| s.version.to_string()).collect();
248 versions.sort();
249 Err(SchemaNameAmbiguous {
250 name: name.to_string(),
251 versions,
252 })
253 }
254 }
255 }
256
257 pub fn merge_from(&mut self, other: &SchemaRegistry) {
262 for (key, schema) in &other.schemas {
263 self.schemas
264 .entry(key.clone())
265 .or_insert_with(|| schema.clone());
266 }
267 }
268
269 pub fn insert_overwriting(&mut self, schema: Arc<Schema>) {
287 let key = (schema.manifest.name.clone(), schema.version.clone());
288 self.schemas.insert(key, schema);
289 }
290
291 pub fn get(&self, name: &str, version: &semver::Version) -> Option<Arc<Schema>> {
292 self.schemas
293 .get(&(name.to_string(), version.clone()))
294 .cloned()
295 }
296
297 pub fn iter(&self) -> impl Iterator<Item = Arc<Schema>> + '_ {
298 self.schemas.values().cloned()
299 }
300
301 pub fn available_versions(&self, name: &str) -> Vec<semver::Version> {
302 let mut versions: Vec<semver::Version> = self
303 .schemas
304 .keys()
305 .filter(|(n, _)| n == name)
306 .map(|(_, v)| v.clone())
307 .collect();
308 versions.sort();
309 versions
310 }
311
312 pub fn suggest_name(&self, name: &str) -> Option<String> {
318 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
319 let mut best: Option<(usize, String)> = None;
320 for (n, _) in self.schemas.keys() {
321 if !seen.insert(n.as_str()) {
322 continue;
323 }
324 let d = strsim::levenshtein(name, n);
325 match &best {
326 Some((bd, _)) if *bd <= d => {}
327 _ => best = Some((d, n.clone())),
328 }
329 }
330 best.and_then(|(d, n)| if d > 0 { Some(n) } else { None })
331 }
332
333 pub fn identities(&self) -> Vec<(String, semver::Version)> {
336 let mut ids: Vec<(String, semver::Version)> = self.schemas.keys().cloned().collect();
337 ids.sort();
338 ids
339 }
340
341 pub fn is_empty(&self) -> bool {
342 self.schemas.is_empty()
343 }
344
345 pub fn len(&self) -> usize {
346 self.schemas.len()
347 }
348
349 pub fn insert(&mut self, schema: Arc<Schema>) -> Result<(), SchemaRegistryError> {
350 let key = (schema.manifest.name.clone(), schema.version.clone());
351 if self.schemas.contains_key(&key) {
352 return Err(SchemaRegistryError::AlreadyRegistered {
353 name: key.0,
354 version: key.1,
355 });
356 }
357 self.schemas.insert(key, schema);
358 Ok(())
359 }
360}
361
362impl Default for SchemaRegistry {
363 fn default() -> Self {
364 Self::builtin()
365 }
366}
367
368pub fn type_by_name(name: &str) -> Option<Arc<TypeDefinition>> {
376 Schema::builtin_default().get_type(name)
377}
378
379fn list_schema_subdirs(dir: &Path) -> Result<Vec<PathBuf>, WorkspaceSchemaLoadError> {
387 if !dir.is_dir() {
388 return Ok(Vec::new());
389 }
390 let entries = std::fs::read_dir(dir).map_err(|e| WorkspaceSchemaLoadError::Io {
391 path: dir.to_path_buf(),
392 source: e,
393 })?;
394 let mut out = Vec::new();
395 for entry in entries {
396 let entry = entry.map_err(|e| WorkspaceSchemaLoadError::Io {
397 path: dir.to_path_buf(),
398 source: e,
399 })?;
400 let path = entry.path();
401 if !path.is_dir() {
402 continue;
403 }
404 let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
405 continue;
406 };
407 if name.starts_with('.') {
408 continue;
409 }
410 out.push(path);
411 }
412 out.sort();
414 Ok(out)
415}
416
417pub fn all_types() -> Vec<Arc<TypeDefinition>> {
419 let schema = Schema::builtin_default();
420 schema
423 .manifest
424 .types
425 .iter()
426 .filter_map(|name| schema.get_type(name))
427 .collect()
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[test]
435 fn builtin_registry_contains_default() {
436 let reg = SchemaRegistry::builtin();
437 assert!(!reg.is_empty());
438 let versions = reg.available_versions("default");
439 assert_eq!(versions.len(), 4);
444 }
445
446 #[test]
447 fn builtin_default_has_ten_types() {
448 assert_eq!(all_types().len(), 10);
449 for name in builtin_names::ALL {
450 assert!(type_by_name(name).is_some(), "missing type: {name}");
451 }
452 }
453
454 #[test]
455 fn registry_rejects_duplicate_insert() {
456 let schema = Schema::builtin_default();
457 let mut reg = SchemaRegistry::empty();
458 reg.insert(schema.clone()).unwrap();
459 let err = reg.insert(schema).unwrap_err();
460 assert!(matches!(err, SchemaRegistryError::AlreadyRegistered { .. }));
461 }
462
463 #[test]
470 fn software_lifecycle_date_fields_are_optional() {
471 let reg = SchemaRegistry::builtin();
472 let software = reg
473 .get("software", &semver::Version::new(0, 2, 0))
474 .expect("software builtin present");
475
476 let requirement = software.get_type("requirement").expect("requirement type");
477 assert!(
478 !requirement
479 .metadata_field("verified_on")
480 .unwrap()
481 .is_required(),
482 "verified_on must be optional — an unverified requirement has no verification date"
483 );
484 assert!(
486 requirement.metadata_field("source").unwrap().is_required(),
487 "source stays required"
488 );
489
490 let contract = software.get_type("contract").expect("contract type");
491 for field in ["deprecated_on", "removal_on"] {
492 assert!(
493 !contract.metadata_field(field).unwrap().is_required(),
494 "{field} must be optional — a current contract has no deprecation/removal date"
495 );
496 }
497 for field in ["protocol", "version"] {
499 assert!(
500 contract.metadata_field(field).unwrap().is_required(),
501 "{field} stays required"
502 );
503 }
504 }
505
506 mod workspace_layer {
507 use super::*;
508 use tempfile::TempDir;
509
510 fn write_schema(dir: &Path, name: &str, version: &str) {
512 std::fs::create_dir_all(dir.join("types")).unwrap();
513 let manifest = format!(
514 r#"name: {name}
515version: {version}
516description: test
517when_to_use: test
518types:
519 - spec
520relationships:
521 mode: strict
522 definitions:
523 - name: _default
524 description: default
525 default_weight: 1.0
526 - name: PART_OF
527 description: hier
528 default_weight: 3.0
529community:
530 resolution: 1.0
531 seed: 42
532"#
533 );
534 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
535 std::fs::write(
536 dir.join("types/spec.yaml"),
537 r#"name: spec
538description: test
539when_to_use: test
540sections:
541 - key: body
542 heading: Body
543 required: true
544 search_weight: 10.0
545 catch_all: true
546metadata_fields: []
547title_weight: 1.0
548text_fields: [body]
549hierarchy_relationship: PART_OF
550no_self_loop_relationships: []
551updatable_fields: [title, body]
552health_required_fields: [body]
553staleness_threshold_days: 30
554write_rules: []
555"#,
556 )
557 .unwrap();
558 }
559
560 #[test]
561 fn workspace_schema_resolves_by_name() {
562 let tmp = TempDir::new().unwrap();
563 let workspace_schemas = tmp.path().join("schemas");
564
565 write_schema(
569 &workspace_schemas.join("test-isolated"),
570 "test-isolated",
571 "1.0.0",
572 );
573
574 let reg =
575 SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
576 .expect("workspace load succeeds");
577
578 let resolved = reg
579 .resolve_by_name("test-isolated")
580 .expect("unique name resolves");
581 assert!(resolved.is_some(), "workspace schema must be registered");
582 }
583
584 #[test]
585 fn per_mem_schema_override_no_longer_resolves() {
586 let tmp = TempDir::new().unwrap();
591 let mem_override = tmp.path().join("mem/.memstead/schemas/default");
592 write_schema(&mem_override, "default", "1.0.0");
593
594 let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap();
595 let schema = reg
596 .get("default", &semver::Version::new(1, 0, 0))
597 .expect("builtin default still registered");
598 assert_eq!(
601 schema.types.len(),
602 10,
603 "per-mem override must no longer shadow the builtin"
604 );
605 }
606
607 #[test]
608 fn workspace_layer_falls_through_to_builtins() {
609 let tmp = TempDir::new().unwrap();
610 let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None)
612 .expect("builtin-only load succeeds");
613 assert!(
614 reg.get("default", &semver::Version::new(1, 1, 0)).is_some(),
615 "builtin default must be registered when no other layers contribute"
616 );
617 }
618
619 #[test]
620 fn workspace_overrides_builtin_at_same_key() {
621 let tmp = TempDir::new().unwrap();
625 let workspace_schemas = tmp.path().join("schemas");
626
627 write_schema(&workspace_schemas.join("default"), "default", "1.0.0");
628
629 let reg =
630 SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
631 .expect("workspace override loads");
632
633 let schema = reg
634 .get("default", &semver::Version::new(1, 0, 0))
635 .expect("default@1.0.0 still registered");
636 assert_eq!(
638 schema.types.len(),
639 1,
640 "workspace-level schema must replace the builtin shape"
641 );
642 }
643
644 #[test]
650 fn legacy_cache_directory_is_not_a_schema_source() {
651 let tmp = TempDir::new().unwrap();
652 let cache_dir = tmp.path().join(".memstead.cache/schemas/recipe-1.0.0");
653 write_schema(&cache_dir, "recipe", "1.0.0");
654
655 let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap();
656 assert!(
657 reg.get("recipe", &semver::Version::new(1, 0, 0)).is_none(),
658 "the retired cache directory must contribute nothing"
659 );
660 }
661
662 #[test]
663 fn two_level_chain_resolves_correctly() {
664 let tmp = TempDir::new().unwrap();
667 let workspace_schemas = tmp.path().join("schemas");
668
669 write_schema(&workspace_schemas.join("default"), "default", "1.0.0");
670 write_schema(&workspace_schemas.join("overridden"), "overridden", "1.0.0");
671
672 let reg =
673 SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
674 .unwrap();
675
676 assert_eq!(
678 reg.get("default", &semver::Version::new(1, 0, 0))
679 .expect("default registered")
680 .types
681 .len(),
682 1,
683 "workspace-level default must override the 10-type builtin"
684 );
685 assert!(
686 reg.get("overridden", &semver::Version::new(1, 0, 0))
687 .is_some(),
688 "a workspace-level-only schema registers"
689 );
690 }
691
692 #[test]
693 fn unknown_name_resolves_to_none() {
694 let tmp = TempDir::new().unwrap();
695 let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap();
696 assert!(reg.resolve_by_name("does-not-exist").unwrap().is_none());
697 }
698
699 #[test]
700 fn ambiguous_name_surfaces_versions() {
701 let tmp = TempDir::new().unwrap();
702 let workspace_schemas = tmp.path().join("schemas");
703
704 write_schema(
709 &workspace_schemas.join("test-ambig-1"),
710 "test-ambig",
711 "1.0.0",
712 );
713 write_schema(
714 &workspace_schemas.join("test-ambig-2"),
715 "test-ambig",
716 "2.0.0",
717 );
718
719 let reg =
720 SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
721 .unwrap();
722 let err = reg
723 .resolve_by_name("test-ambig")
724 .expect_err("two versions under same name must be ambiguous");
725 assert_eq!(err.versions.len(), 2);
726 assert!(err.versions.iter().any(|v| v == "1.0.0"));
727 assert!(err.versions.iter().any(|v| v == "2.0.0"));
728 let msg = format!("{err}");
729 assert!(msg.contains("ambiguous"));
730 assert!(msg.contains("1.0.0"));
731 }
732 }
733}