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,
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())]
131 Io {
132 path: PathBuf,
133 #[source]
134 source: std::io::Error,
135 },
136}
137
138impl SchemaRegistry {
139 pub fn empty() -> Self {
140 Self {
141 schemas: HashMap::new(),
142 }
143 }
144
145 pub fn builtin() -> Self {
147 let mut reg = Self::empty();
148 for schema in builtins::load_builtin_schemas()
149 .expect("built-in schemas must load cleanly — bug in shipped YAML")
150 {
151 reg.schemas.insert(
152 (schema.manifest.name.clone(), schema.version.clone()),
153 schema,
154 );
155 }
156 reg
157 }
158
159 pub fn load_for_workspace(
189 _workspace_root: Option<&Path>,
190 workspace_schemas_dir: Option<&Path>,
191 ) -> Result<Self, WorkspaceSchemaLoadError> {
192 let mut reg = Self::empty();
193
194 for schema in builtins::load_builtin_schemas()
196 .expect("built-in schemas must load cleanly — bug in shipped YAML")
197 {
198 let key = (schema.manifest.name.clone(), schema.version.clone());
199 reg.schemas.insert(key, schema);
200 }
201
202 if let Some(ws_dir) = workspace_schemas_dir {
205 for path in list_schema_subdirs(ws_dir)? {
206 let schema = loader::load_schema_from_dir(&path).map_err(|source| {
207 WorkspaceSchemaLoadError::Invalid {
208 path: path.clone(),
209 source,
210 }
211 })?;
212 let key = (schema.manifest.name.clone(), schema.version.clone());
213 reg.schemas.insert(key, Arc::new(schema));
214 }
215 }
216
217 Ok(reg)
218 }
219
220 pub fn resolve_by_name(&self, name: &str) -> Result<Option<Arc<Schema>>, SchemaNameAmbiguous> {
233 let candidates: Vec<&Arc<Schema>> = self
234 .schemas
235 .iter()
236 .filter(|((n, _), _)| n == name)
237 .map(|(_, s)| s)
238 .collect();
239 match candidates.len() {
240 0 => Ok(None),
241 1 => Ok(Some(candidates[0].clone())),
242 _ => {
243 let mut versions: Vec<String> =
244 candidates.iter().map(|s| s.version.to_string()).collect();
245 versions.sort();
246 Err(SchemaNameAmbiguous {
247 name: name.to_string(),
248 versions,
249 })
250 }
251 }
252 }
253
254 pub fn merge_from(&mut self, other: &SchemaRegistry) {
259 for (key, schema) in &other.schemas {
260 self.schemas
261 .entry(key.clone())
262 .or_insert_with(|| schema.clone());
263 }
264 }
265
266 pub fn insert_overwriting(&mut self, schema: Arc<Schema>) {
284 let key = (schema.manifest.name.clone(), schema.version.clone());
285 self.schemas.insert(key, schema);
286 }
287
288 pub fn get(&self, name: &str, version: &semver::Version) -> Option<Arc<Schema>> {
289 self.schemas
290 .get(&(name.to_string(), version.clone()))
291 .cloned()
292 }
293
294 pub fn iter(&self) -> impl Iterator<Item = Arc<Schema>> + '_ {
295 self.schemas.values().cloned()
296 }
297
298 pub fn available_versions(&self, name: &str) -> Vec<semver::Version> {
299 let mut versions: Vec<semver::Version> = self
300 .schemas
301 .keys()
302 .filter(|(n, _)| n == name)
303 .map(|(_, v)| v.clone())
304 .collect();
305 versions.sort();
306 versions
307 }
308
309 pub fn suggest_name(&self, name: &str) -> Option<String> {
315 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
316 let mut best: Option<(usize, String)> = None;
317 for (n, _) in self.schemas.keys() {
318 if !seen.insert(n.as_str()) {
319 continue;
320 }
321 let d = strsim::levenshtein(name, n);
322 match &best {
323 Some((bd, _)) if *bd <= d => {}
324 _ => best = Some((d, n.clone())),
325 }
326 }
327 best.and_then(|(d, n)| if d > 0 { Some(n) } else { None })
328 }
329
330 pub fn identities(&self) -> Vec<(String, semver::Version)> {
333 let mut ids: Vec<(String, semver::Version)> = self.schemas.keys().cloned().collect();
334 ids.sort();
335 ids
336 }
337
338 pub fn is_empty(&self) -> bool {
339 self.schemas.is_empty()
340 }
341
342 pub fn len(&self) -> usize {
343 self.schemas.len()
344 }
345
346 pub fn insert(&mut self, schema: Arc<Schema>) -> Result<(), SchemaRegistryError> {
347 let key = (schema.manifest.name.clone(), schema.version.clone());
348 if self.schemas.contains_key(&key) {
349 return Err(SchemaRegistryError::AlreadyRegistered {
350 name: key.0,
351 version: key.1,
352 });
353 }
354 self.schemas.insert(key, schema);
355 Ok(())
356 }
357}
358
359impl Default for SchemaRegistry {
360 fn default() -> Self {
361 Self::builtin()
362 }
363}
364
365pub fn type_by_name(name: &str) -> Option<Arc<TypeDefinition>> {
373 Schema::builtin_default().get_type(name)
374}
375
376fn list_schema_subdirs(dir: &Path) -> Result<Vec<PathBuf>, WorkspaceSchemaLoadError> {
384 if !dir.is_dir() {
385 return Ok(Vec::new());
386 }
387 let entries = std::fs::read_dir(dir).map_err(|e| WorkspaceSchemaLoadError::Io {
388 path: dir.to_path_buf(),
389 source: e,
390 })?;
391 let mut out = Vec::new();
392 for entry in entries {
393 let entry = entry.map_err(|e| WorkspaceSchemaLoadError::Io {
394 path: dir.to_path_buf(),
395 source: e,
396 })?;
397 let path = entry.path();
398 if !path.is_dir() {
399 continue;
400 }
401 let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
402 continue;
403 };
404 if name.starts_with('.') {
405 continue;
406 }
407 out.push(path);
408 }
409 out.sort();
411 Ok(out)
412}
413
414pub fn all_types() -> Vec<Arc<TypeDefinition>> {
416 let schema = Schema::builtin_default();
417 schema
420 .manifest
421 .types
422 .iter()
423 .filter_map(|name| schema.get_type(name))
424 .collect()
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn builtin_registry_contains_default() {
433 let reg = SchemaRegistry::builtin();
434 assert!(!reg.is_empty());
435 let versions = reg.available_versions("default");
436 assert_eq!(versions.len(), 4);
441 }
442
443 #[test]
444 fn builtin_default_has_ten_types() {
445 assert_eq!(all_types().len(), 10);
446 for name in builtin_names::ALL {
447 assert!(type_by_name(name).is_some(), "missing type: {name}");
448 }
449 }
450
451 #[test]
452 fn registry_rejects_duplicate_insert() {
453 let schema = Schema::builtin_default();
454 let mut reg = SchemaRegistry::empty();
455 reg.insert(schema.clone()).unwrap();
456 let err = reg.insert(schema).unwrap_err();
457 assert!(matches!(err, SchemaRegistryError::AlreadyRegistered { .. }));
458 }
459
460 #[test]
467 fn software_lifecycle_date_fields_are_optional() {
468 let reg = SchemaRegistry::builtin();
469 let software = reg
470 .get("software", &semver::Version::new(0, 2, 0))
471 .expect("software builtin present");
472
473 let requirement = software.get_type("requirement").expect("requirement type");
474 assert!(
475 !requirement
476 .metadata_field("verified_on")
477 .unwrap()
478 .is_required(),
479 "verified_on must be optional — an unverified requirement has no verification date"
480 );
481 assert!(
483 requirement.metadata_field("source").unwrap().is_required(),
484 "source stays required"
485 );
486
487 let contract = software.get_type("contract").expect("contract type");
488 for field in ["deprecated_on", "removal_on"] {
489 assert!(
490 !contract.metadata_field(field).unwrap().is_required(),
491 "{field} must be optional — a current contract has no deprecation/removal date"
492 );
493 }
494 for field in ["protocol", "version"] {
496 assert!(
497 contract.metadata_field(field).unwrap().is_required(),
498 "{field} stays required"
499 );
500 }
501 }
502
503 mod workspace_layer {
504 use super::*;
505 use tempfile::TempDir;
506
507 fn write_schema(dir: &Path, name: &str, version: &str) {
509 std::fs::create_dir_all(dir.join("types")).unwrap();
510 let manifest = format!(
511 r#"name: {name}
512version: {version}
513description: test
514when_to_use: test
515types:
516 - spec
517relationships:
518 mode: strict
519 definitions:
520 - name: _default
521 description: default
522 default_weight: 1.0
523 - name: PART_OF
524 description: hier
525 default_weight: 3.0
526community:
527 resolution: 1.0
528 seed: 42
529"#
530 );
531 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
532 std::fs::write(
533 dir.join("types/spec.yaml"),
534 r#"name: spec
535description: test
536when_to_use: test
537sections:
538 - key: body
539 heading: Body
540 required: true
541 search_weight: 10.0
542 catch_all: true
543metadata_fields: []
544title_weight: 1.0
545text_fields: [body]
546hierarchy_relationship: PART_OF
547no_self_loop_relationships: []
548updatable_fields: [title, body]
549health_required_fields: [body]
550staleness_threshold_days: 30
551write_rules: []
552"#,
553 )
554 .unwrap();
555 }
556
557 #[test]
558 fn workspace_schema_resolves_by_name() {
559 let tmp = TempDir::new().unwrap();
560 let workspace_schemas = tmp.path().join("schemas");
561
562 write_schema(
566 &workspace_schemas.join("test-isolated"),
567 "test-isolated",
568 "1.0.0",
569 );
570
571 let reg =
572 SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
573 .expect("workspace load succeeds");
574
575 let resolved = reg
576 .resolve_by_name("test-isolated")
577 .expect("unique name resolves");
578 assert!(resolved.is_some(), "workspace schema must be registered");
579 }
580
581 #[test]
582 fn per_mem_schema_override_no_longer_resolves() {
583 let tmp = TempDir::new().unwrap();
588 let mem_override = tmp.path().join("mem/.memstead/schemas/default");
589 write_schema(&mem_override, "default", "1.0.0");
590
591 let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap();
592 let schema = reg
593 .get("default", &semver::Version::new(1, 0, 0))
594 .expect("builtin default still registered");
595 assert_eq!(
598 schema.types.len(),
599 10,
600 "per-mem override must no longer shadow the builtin"
601 );
602 }
603
604 #[test]
605 fn workspace_layer_falls_through_to_builtins() {
606 let tmp = TempDir::new().unwrap();
607 let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None)
609 .expect("builtin-only load succeeds");
610 assert!(
611 reg.get("default", &semver::Version::new(1, 1, 0)).is_some(),
612 "builtin default must be registered when no other layers contribute"
613 );
614 }
615
616 #[test]
617 fn workspace_overrides_builtin_at_same_key() {
618 let tmp = TempDir::new().unwrap();
622 let workspace_schemas = tmp.path().join("schemas");
623
624 write_schema(&workspace_schemas.join("default"), "default", "1.0.0");
625
626 let reg =
627 SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
628 .expect("workspace override loads");
629
630 let schema = reg
631 .get("default", &semver::Version::new(1, 0, 0))
632 .expect("default@1.0.0 still registered");
633 assert_eq!(
635 schema.types.len(),
636 1,
637 "workspace-level schema must replace the builtin shape"
638 );
639 }
640
641 #[test]
647 fn legacy_cache_directory_is_not_a_schema_source() {
648 let tmp = TempDir::new().unwrap();
649 let cache_dir = tmp.path().join(".memstead.cache/schemas/recipe-1.0.0");
650 write_schema(&cache_dir, "recipe", "1.0.0");
651
652 let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap();
653 assert!(
654 reg.get("recipe", &semver::Version::new(1, 0, 0)).is_none(),
655 "the retired cache directory must contribute nothing"
656 );
657 }
658
659 #[test]
660 fn two_level_chain_resolves_correctly() {
661 let tmp = TempDir::new().unwrap();
664 let workspace_schemas = tmp.path().join("schemas");
665
666 write_schema(&workspace_schemas.join("default"), "default", "1.0.0");
667 write_schema(&workspace_schemas.join("overridden"), "overridden", "1.0.0");
668
669 let reg =
670 SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
671 .unwrap();
672
673 assert_eq!(
675 reg.get("default", &semver::Version::new(1, 0, 0))
676 .expect("default registered")
677 .types
678 .len(),
679 1,
680 "workspace-level default must override the 10-type builtin"
681 );
682 assert!(
683 reg.get("overridden", &semver::Version::new(1, 0, 0))
684 .is_some(),
685 "a workspace-level-only schema registers"
686 );
687 }
688
689 #[test]
690 fn unknown_name_resolves_to_none() {
691 let tmp = TempDir::new().unwrap();
692 let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap();
693 assert!(reg.resolve_by_name("does-not-exist").unwrap().is_none());
694 }
695
696 #[test]
697 fn ambiguous_name_surfaces_versions() {
698 let tmp = TempDir::new().unwrap();
699 let workspace_schemas = tmp.path().join("schemas");
700
701 write_schema(
706 &workspace_schemas.join("test-ambig-1"),
707 "test-ambig",
708 "1.0.0",
709 );
710 write_schema(
711 &workspace_schemas.join("test-ambig-2"),
712 "test-ambig",
713 "2.0.0",
714 );
715
716 let reg =
717 SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
718 .unwrap();
719 let err = reg
720 .resolve_by_name("test-ambig")
721 .expect_err("two versions under same name must be ambiguous");
722 assert_eq!(err.versions.len(), 2);
723 assert!(err.versions.iter().any(|v| v == "1.0.0"));
724 assert!(err.versions.iter().any(|v| v == "2.0.0"));
725 let msg = format!("{err}");
726 assert!(msg.contains("ambiguous"));
727 assert!(msg.contains("1.0.0"));
728 }
729 }
730}