pub mod archive_provenance;
pub mod base_metadata;
pub mod builtins;
pub mod config;
pub mod content_expr;
pub mod loader;
pub mod manifest;
pub mod meta_schema;
pub mod schema;
pub mod source;
pub mod types;
pub mod workspace_config;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub use archive_provenance::{
ARCHIVE_PROVENANCE_FORMAT, ArchiveProvenance, EntityProvenance, History,
};
pub use config::{
ARCHIVE_ANCHORS_PATH, ARCHIVE_CONFIG_PATH, ARCHIVE_EXTENSION, ARCHIVE_META_DIR,
ARCHIVE_PROVENANCE_PATH, ARCHIVE_SCHEMA_PREFIX, CommunityOverride, ConfigCheckResult,
ConfigError, MEM_META_DIR, MemConfig, MemSubject, MutationStamp, PUBLISHED_MEM_FORMAT,
PUBLISHED_MEM_FORMATS_ACCEPTED, PublishConfig, PublishConversionError, PublishedMemConfig,
ReadMemSource, ReadMemSpec, RoleConfig, SchemaRef, VcsConfig, check_config, load_and_validate,
load_config, parse_mem_config, published_config_from, published_format_accepted,
};
pub use loader::{
HeadingKeyViolation, MetadataPolarityFormat, SchemaLoadError, check_reserved_metadata_keys,
check_section_formats, check_section_heading_roundtrip, load_schema_from_dir,
load_schema_from_memory, load_schema_from_memory_with_format, load_sealed_package,
};
pub use manifest::{
Cardinality, CommunityConfig, CrossMemRelationshipEntry, DefaultWritingGuidance,
ManualAuthoring, PerEdgeDescription, RelationshipDef, RelationshipMode, RelationshipVocabulary,
SchemaManifest,
};
pub use schema::Schema;
pub use source::{
INSTALL_PROVENANCE_FILE, SchemaSourceError, SchemaSourceFile, collect_schema_source,
};
pub use types::{
ConstraintDef, ConstraintSeverity, DueAxis, FieldType, Filterable, MetadataFieldDef,
PropagationDirection, RequiredCardinality, RequiredOutgoing, SectionDef, Serialization,
TableFormat, TypeDefinition, derive_section_key,
};
pub mod builtin_names {
pub const SPEC: &str = "spec";
pub const MEMO: &str = "memo";
pub const ASSERTION: &str = "assertion";
pub const CONCEPT: &str = "concept";
pub const INQUIRY: &str = "inquiry";
pub const MODEL: &str = "model";
pub const NARRATIVE: &str = "narrative";
pub const PERSPECTIVE: &str = "perspective";
pub const PRINCIPLE: &str = "principle";
pub const PROCESS: &str = "process";
pub const ALL: [&str; 10] = [
SPEC,
MEMO,
ASSERTION,
CONCEPT,
INQUIRY,
MODEL,
NARRATIVE,
PERSPECTIVE,
PRINCIPLE,
PROCESS,
];
}
#[derive(Debug)]
pub struct SchemaRegistry {
schemas: HashMap<(String, semver::Version), Arc<Schema>>,
}
#[derive(Debug, thiserror::Error)]
pub enum SchemaRegistryError {
#[error("schema '{name}' version '{version}' is already registered — cannot reinsert")]
AlreadyRegistered {
name: String,
version: semver::Version,
},
}
#[derive(Debug, thiserror::Error)]
#[error(
"schema name '{name}' is ambiguous: {} versions registered ({}). \
Use a versioned pin (e.g. \"{name}@{}\") to disambiguate.",
.versions.len(),
.versions.join(", "),
.versions.first().map(String::as_str).unwrap_or("")
)]
pub struct SchemaNameAmbiguous {
pub name: String,
pub versions: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum WorkspaceSchemaLoadError {
#[error("failed to load schema at {}: {source}", .path.display())]
Invalid {
path: PathBuf,
#[source]
source: SchemaLoadError,
},
#[error("i/o error scanning {}: {source}", .path.display())]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
impl SchemaRegistry {
pub fn empty() -> Self {
Self {
schemas: HashMap::new(),
}
}
pub fn builtin() -> Self {
let mut reg = Self::empty();
for schema in builtins::load_builtin_schemas()
.expect("built-in schemas must load cleanly — bug in shipped YAML")
{
reg.schemas.insert(
(schema.manifest.name.clone(), schema.version.clone()),
schema,
);
}
reg
}
pub fn load_for_workspace(
_workspace_root: Option<&Path>,
workspace_schemas_dir: Option<&Path>,
) -> Result<Self, WorkspaceSchemaLoadError> {
let mut reg = Self::empty();
for schema in builtins::load_builtin_schemas()
.expect("built-in schemas must load cleanly — bug in shipped YAML")
{
let key = (schema.manifest.name.clone(), schema.version.clone());
reg.schemas.insert(key, schema);
}
if let Some(ws_dir) = workspace_schemas_dir {
for path in list_schema_subdirs(ws_dir)? {
let schema = loader::load_schema_from_dir(&path).map_err(|source| {
WorkspaceSchemaLoadError::Invalid {
path: path.clone(),
source,
}
})?;
let key = (schema.manifest.name.clone(), schema.version.clone());
reg.schemas.insert(key, Arc::new(schema));
}
}
Ok(reg)
}
pub fn resolve_by_name(&self, name: &str) -> Result<Option<Arc<Schema>>, SchemaNameAmbiguous> {
let candidates: Vec<&Arc<Schema>> = self
.schemas
.iter()
.filter(|((n, _), _)| n == name)
.map(|(_, s)| s)
.collect();
match candidates.len() {
0 => Ok(None),
1 => Ok(Some(candidates[0].clone())),
_ => {
let mut versions: Vec<String> =
candidates.iter().map(|s| s.version.to_string()).collect();
versions.sort();
Err(SchemaNameAmbiguous {
name: name.to_string(),
versions,
})
}
}
}
pub fn merge_from(&mut self, other: &SchemaRegistry) {
for (key, schema) in &other.schemas {
self.schemas
.entry(key.clone())
.or_insert_with(|| schema.clone());
}
}
pub fn insert_overwriting(&mut self, schema: Arc<Schema>) {
let key = (schema.manifest.name.clone(), schema.version.clone());
self.schemas.insert(key, schema);
}
pub fn get(&self, name: &str, version: &semver::Version) -> Option<Arc<Schema>> {
self.schemas
.get(&(name.to_string(), version.clone()))
.cloned()
}
pub fn iter(&self) -> impl Iterator<Item = Arc<Schema>> + '_ {
self.schemas.values().cloned()
}
pub fn available_versions(&self, name: &str) -> Vec<semver::Version> {
let mut versions: Vec<semver::Version> = self
.schemas
.keys()
.filter(|(n, _)| n == name)
.map(|(_, v)| v.clone())
.collect();
versions.sort();
versions
}
pub fn suggest_name(&self, name: &str) -> Option<String> {
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut best: Option<(usize, String)> = None;
for (n, _) in self.schemas.keys() {
if !seen.insert(n.as_str()) {
continue;
}
let d = strsim::levenshtein(name, n);
match &best {
Some((bd, _)) if *bd <= d => {}
_ => best = Some((d, n.clone())),
}
}
best.and_then(|(d, n)| if d > 0 { Some(n) } else { None })
}
pub fn identities(&self) -> Vec<(String, semver::Version)> {
let mut ids: Vec<(String, semver::Version)> = self.schemas.keys().cloned().collect();
ids.sort();
ids
}
pub fn is_empty(&self) -> bool {
self.schemas.is_empty()
}
pub fn len(&self) -> usize {
self.schemas.len()
}
pub fn insert(&mut self, schema: Arc<Schema>) -> Result<(), SchemaRegistryError> {
let key = (schema.manifest.name.clone(), schema.version.clone());
if self.schemas.contains_key(&key) {
return Err(SchemaRegistryError::AlreadyRegistered {
name: key.0,
version: key.1,
});
}
self.schemas.insert(key, schema);
Ok(())
}
}
impl Default for SchemaRegistry {
fn default() -> Self {
Self::builtin()
}
}
pub fn type_by_name(name: &str) -> Option<Arc<TypeDefinition>> {
Schema::builtin_default().get_type(name)
}
fn list_schema_subdirs(dir: &Path) -> Result<Vec<PathBuf>, WorkspaceSchemaLoadError> {
if !dir.is_dir() {
return Ok(Vec::new());
}
let entries = std::fs::read_dir(dir).map_err(|e| WorkspaceSchemaLoadError::Io {
path: dir.to_path_buf(),
source: e,
})?;
let mut out = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| WorkspaceSchemaLoadError::Io {
path: dir.to_path_buf(),
source: e,
})?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if name.starts_with('.') {
continue;
}
out.push(path);
}
out.sort();
Ok(out)
}
pub fn all_types() -> Vec<Arc<TypeDefinition>> {
let schema = Schema::builtin_default();
schema
.manifest
.types
.iter()
.filter_map(|name| schema.get_type(name))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_registry_contains_default() {
let reg = SchemaRegistry::builtin();
assert!(!reg.is_empty());
let versions = reg.available_versions("default");
assert_eq!(versions.len(), 4);
}
#[test]
fn builtin_default_has_ten_types() {
assert_eq!(all_types().len(), 10);
for name in builtin_names::ALL {
assert!(type_by_name(name).is_some(), "missing type: {name}");
}
}
#[test]
fn registry_rejects_duplicate_insert() {
let schema = Schema::builtin_default();
let mut reg = SchemaRegistry::empty();
reg.insert(schema.clone()).unwrap();
let err = reg.insert(schema).unwrap_err();
assert!(matches!(err, SchemaRegistryError::AlreadyRegistered { .. }));
}
#[test]
fn software_lifecycle_date_fields_are_optional() {
let reg = SchemaRegistry::builtin();
let software = reg
.get("software", &semver::Version::new(0, 2, 0))
.expect("software builtin present");
let requirement = software.get_type("requirement").expect("requirement type");
assert!(
!requirement
.metadata_field("verified_on")
.unwrap()
.is_required(),
"verified_on must be optional — an unverified requirement has no verification date"
);
assert!(
requirement.metadata_field("source").unwrap().is_required(),
"source stays required"
);
let contract = software.get_type("contract").expect("contract type");
for field in ["deprecated_on", "removal_on"] {
assert!(
!contract.metadata_field(field).unwrap().is_required(),
"{field} must be optional — a current contract has no deprecation/removal date"
);
}
for field in ["protocol", "version"] {
assert!(
contract.metadata_field(field).unwrap().is_required(),
"{field} stays required"
);
}
}
mod workspace_layer {
use super::*;
use tempfile::TempDir;
fn write_schema(dir: &Path, name: &str, version: &str) {
std::fs::create_dir_all(dir.join("types")).unwrap();
let manifest = format!(
r#"name: {name}
version: {version}
description: test
when_to_use: test
types:
- spec
relationships:
mode: strict
definitions:
- name: _default
description: default
default_weight: 1.0
- name: PART_OF
description: hier
default_weight: 3.0
community:
resolution: 1.0
seed: 42
"#
);
std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
std::fs::write(
dir.join("types/spec.yaml"),
r#"name: spec
description: test
when_to_use: test
sections:
- key: body
heading: Body
required: true
search_weight: 10.0
catch_all: true
metadata_fields: []
title_weight: 1.0
text_fields: [body]
hierarchy_relationship: PART_OF
no_self_loop_relationships: []
updatable_fields: [title, body]
health_required_fields: [body]
staleness_threshold_days: 30
write_rules: []
"#,
)
.unwrap();
}
#[test]
fn workspace_schema_resolves_by_name() {
let tmp = TempDir::new().unwrap();
let workspace_schemas = tmp.path().join("schemas");
write_schema(
&workspace_schemas.join("test-isolated"),
"test-isolated",
"1.0.0",
);
let reg =
SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
.expect("workspace load succeeds");
let resolved = reg
.resolve_by_name("test-isolated")
.expect("unique name resolves");
assert!(resolved.is_some(), "workspace schema must be registered");
}
#[test]
fn per_mem_schema_override_no_longer_resolves() {
let tmp = TempDir::new().unwrap();
let mem_override = tmp.path().join("mem/.memstead/schemas/default");
write_schema(&mem_override, "default", "1.0.0");
let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap();
let schema = reg
.get("default", &semver::Version::new(1, 0, 0))
.expect("builtin default still registered");
assert_eq!(
schema.types.len(),
10,
"per-mem override must no longer shadow the builtin"
);
}
#[test]
fn workspace_layer_falls_through_to_builtins() {
let tmp = TempDir::new().unwrap();
let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None)
.expect("builtin-only load succeeds");
assert!(
reg.get("default", &semver::Version::new(1, 1, 0)).is_some(),
"builtin default must be registered when no other layers contribute"
);
}
#[test]
fn workspace_overrides_builtin_at_same_key() {
let tmp = TempDir::new().unwrap();
let workspace_schemas = tmp.path().join("schemas");
write_schema(&workspace_schemas.join("default"), "default", "1.0.0");
let reg =
SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
.expect("workspace override loads");
let schema = reg
.get("default", &semver::Version::new(1, 0, 0))
.expect("default@1.0.0 still registered");
assert_eq!(
schema.types.len(),
1,
"workspace-level schema must replace the builtin shape"
);
}
#[test]
fn legacy_cache_directory_is_not_a_schema_source() {
let tmp = TempDir::new().unwrap();
let cache_dir = tmp.path().join(".memstead.cache/schemas/recipe-1.0.0");
write_schema(&cache_dir, "recipe", "1.0.0");
let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap();
assert!(
reg.get("recipe", &semver::Version::new(1, 0, 0)).is_none(),
"the retired cache directory must contribute nothing"
);
}
#[test]
fn two_level_chain_resolves_correctly() {
let tmp = TempDir::new().unwrap();
let workspace_schemas = tmp.path().join("schemas");
write_schema(&workspace_schemas.join("default"), "default", "1.0.0");
write_schema(&workspace_schemas.join("overridden"), "overridden", "1.0.0");
let reg =
SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
.unwrap();
assert_eq!(
reg.get("default", &semver::Version::new(1, 0, 0))
.expect("default registered")
.types
.len(),
1,
"workspace-level default must override the 10-type builtin"
);
assert!(
reg.get("overridden", &semver::Version::new(1, 0, 0))
.is_some(),
"a workspace-level-only schema registers"
);
}
#[test]
fn unknown_name_resolves_to_none() {
let tmp = TempDir::new().unwrap();
let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap();
assert!(reg.resolve_by_name("does-not-exist").unwrap().is_none());
}
#[test]
fn ambiguous_name_surfaces_versions() {
let tmp = TempDir::new().unwrap();
let workspace_schemas = tmp.path().join("schemas");
write_schema(
&workspace_schemas.join("test-ambig-1"),
"test-ambig",
"1.0.0",
);
write_schema(
&workspace_schemas.join("test-ambig-2"),
"test-ambig",
"2.0.0",
);
let reg =
SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
.unwrap();
let err = reg
.resolve_by_name("test-ambig")
.expect_err("two versions under same name must be ambiguous");
assert_eq!(err.versions.len(), 2);
assert!(err.versions.iter().any(|v| v == "1.0.0"));
assert!(err.versions.iter().any(|v| v == "2.0.0"));
let msg = format!("{err}");
assert!(msg.contains("ambiguous"));
assert!(msg.contains("1.0.0"));
}
}
}