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