use std::path::{Path, PathBuf};
use clap::{Args as ClapArgs, Subcommand};
use serde_json::json;
use memstead_schema::SchemaRef;
use crate::CliError;
use crate::output::{ExitKind, print_json, print_markdown};
use crate::setup::{CliContext, WorkspaceShape};
#[derive(ClapArgs, Debug)]
#[command(args_conflicts_with_subcommands = true)]
pub struct Args {
#[command(subcommand)]
pub command: Option<SchemaCommand>,
#[arg(value_name = "REF")]
pub reference: Option<String>,
}
#[derive(Subcommand, Debug)]
pub enum SchemaCommand {
New(NewArgs),
Validate(ValidateArgs),
Install(InstallArgs),
}
#[derive(ClapArgs, Debug)]
pub struct NewArgs {
pub name: String,
}
#[derive(ClapArgs, Debug)]
pub struct ValidateArgs {
pub path: PathBuf,
}
#[derive(ClapArgs, Debug)]
pub struct InstallArgs {
pub source: String,
}
pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
match (args.command, args.reference) {
(Some(SchemaCommand::New(a)), _) => scaffold_new(ctx, a),
(Some(SchemaCommand::Validate(a)), _) => validate(ctx, a),
(Some(SchemaCommand::Install(a)), _) => install(ctx, a),
(None, Some(reference)) => show_builtin(ctx, &reference),
(None, None) => Err(CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
"memstead schema needs a built-in reference to render (`memstead schema \
planning@0.4.0`) or a subcommand (`new`, `validate`, `install`); \
`memstead schema --help` lists them"
.to_string(),
)
.into()),
}
}
fn show_builtin(ctx: &CliContext, reference: &str) -> anyhow::Result<()> {
let schema_ref = resolve_builtin_read_ref(reference)?;
let version = schema_ref.version.to_string();
let pkg = memstead_schema::builtins::builtin_package(&schema_ref.name, &version).ok_or_else(
|| {
CliError::new(
ExitKind::Validation,
"SCHEMA_NOT_FOUND",
format!("no built-in schema {}", schema_ref.as_display()),
)
},
)?;
let readme = pkg
.files
.iter()
.find(|(path, _)| path == memstead_schema::builtins::PACKAGE_README_FILE)
.and_then(|(_, bytes)| std::str::from_utf8(bytes).ok())
.map(|text| {
memstead_schema::builtins::render_package_readme(&pkg.name, &pkg.version, text)
});
let pin = format!("{}@{}", pkg.name, pkg.version);
if ctx.json {
print_json(&json!({
"schema": pin,
"name": pkg.name,
"version": pkg.version,
"origin": "builtin",
"readme": readme,
}))?;
return Ok(());
}
match readme {
Some(text) => print_markdown(&format!(
"<!-- {pin}: built-in package README, rendered for this generation -->\n{text}"
)),
None => print_markdown(&format!(
"`{pin}` is a built-in package that ships no README.\n"
)),
}
Ok(())
}
fn resolve_builtin_read_ref(reference: &str) -> anyhow::Result<SchemaRef> {
if reference.contains('@') {
return resolve_builtin_ref(reference);
}
let reg = memstead_schema::SchemaRegistry::builtin();
let mut versions = reg.available_versions(reference);
versions.sort();
match versions.pop() {
Some(v) => Ok(SchemaRef::new(reference.to_string(), v)),
None => Err(CliError::new(
ExitKind::Validation,
"SCHEMA_NOT_FOUND",
format!(
"no built-in schema named {reference:?}; built-ins: {}",
builtin_names_joined(®)
),
)
.into()),
}
}
fn builtin_names_joined(reg: &memstead_schema::SchemaRegistry) -> String {
let mut names: Vec<String> = reg.identities().into_iter().map(|(n, _)| n).collect();
names.sort();
names.dedup();
names.join(", ")
}
const SCAFFOLD_VERSION: &str = "0.1.0";
fn scaffold_new(ctx: &CliContext, args: NewArgs) -> anyhow::Result<()> {
if let Err(reason) = memstead_schema::loader::validate_schema_name(&args.name) {
let suggestion = suggest_schema_name(&args.name);
return Err(CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!(
"invalid schema name {name:?}: {reason} (lowercase letter first, \
then lowercase letters, digits, hyphens). \
Try: memstead schema new {suggestion}",
name = args.name,
),
)
.with_details(json!({
"name": args.name,
"reason": reason,
"suggestion": suggestion,
}))
.into());
}
let pkg_dir = PathBuf::from(&args.name);
if pkg_dir.join("schema.yaml").is_file() {
return Err(CliError::new(
ExitKind::Validation,
"SCHEMA_PACKAGE_EXISTS",
format!(
"{} already contains a schema package — `memstead schema new` \
never overwrites. Check it with: memstead schema validate {}",
pkg_dir.display(),
args.name,
),
)
.with_details(json!({ "path": pkg_dir }))
.into());
}
if pkg_dir.is_dir()
&& let Some(entry) = std::fs::read_dir(&pkg_dir)
.map_err(|e| {
CliError::new(
ExitKind::Generic,
"IO_ERROR",
format!("read {}: {e}", pkg_dir.display()),
)
})?
.next()
.transpose()
.map_err(|e| {
CliError::new(
ExitKind::Generic,
"IO_ERROR",
format!("read {}: {e}", pkg_dir.display()),
)
})?
{
let found = entry.file_name().to_string_lossy().to_string();
return Err(CliError::new(
ExitKind::Validation,
"TARGET_NOT_EMPTY",
format!(
"{} exists and is not empty (found `{found}`) — clear it or \
pick a different name: memstead schema new {}-schema",
pkg_dir.display(),
args.name,
),
)
.with_details(json!({ "path": pkg_dir, "found": [found] }))
.into());
}
let manifest = scaffold_manifest(&args.name);
let example_type = scaffold_example_type();
std::fs::create_dir_all(pkg_dir.join("types")).map_err(|e| {
CliError::new(
ExitKind::Generic,
"IO_ERROR",
format!("create {}: {e}", pkg_dir.join("types").display()),
)
})?;
for (rel, content) in [
("schema.yaml", &manifest),
("types/note.yaml", &example_type),
] {
let dest = pkg_dir.join(rel);
std::fs::write(&dest, content).map_err(|e| {
CliError::new(
ExitKind::Generic,
"IO_ERROR",
format!("write {}: {e}", dest.display()),
)
})?;
}
if let Err(e) = memstead_schema::loader::load_schema_from_dir(&pkg_dir)
.and_then(|s| memstead_schema::check_reserved_metadata_keys(&s).map(|()| s))
.and_then(|s| memstead_schema::check_section_formats(&s).map(|()| s))
{
return Err(CliError::new(
ExitKind::Generic,
crate::INTERNAL_CODE,
format!(
"scaffold bug: generated package at {} fails validation: {e} — \
please report this",
pkg_dir.display(),
),
)
.into());
}
let next_steps = scaffold_next_steps(ctx, &args.name);
if ctx.json {
print_json(&json!({
"ok": true,
"schema": format!("{}@{SCAFFOLD_VERSION}", args.name),
"path": pkg_dir,
"files": ["schema.yaml", "types/note.yaml"],
"next_steps": next_steps
.iter()
.map(|s| json!({ "command": s.command, "note": s.note }))
.collect::<Vec<_>>(),
}))?;
} else {
let steps: Vec<String> = next_steps
.iter()
.enumerate()
.map(|(i, s)| match &s.note {
Some(note) => format!("{}. `{}` — {note}", i + 1, s.command),
None => format!("{}. `{}`", i + 1, s.command),
})
.collect();
print_markdown(&format!(
"# Schema package scaffolded\n\n`{name}@{SCAFFOLD_VERSION}` at `{dir}` \
(schema.yaml + types/note.yaml, one commented example type).\n\n\
Edit the package, then:\n\n{steps}\n",
name = args.name,
dir = pkg_dir.display(),
steps = steps.join("\n"),
));
}
Ok(())
}
fn scaffold_next_steps(ctx: &CliContext, name: &str) -> Vec<Step> {
use memstead_base::workspace::MountCapability;
use memstead_base::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
let workspace = ctx.workspace_shape().and_then(|(shape, root)| match shape {
WorkspaceShape::Filesystem => FileWorkspaceStore::new().load(&root).ok().and_then(|ws| {
let mut writable = ws
.mounts
.iter()
.filter(|m| m.capability == MountCapability::Write);
match (writable.next(), writable.next()) {
(Some(only), None) => Some((only.mem.clone(), root.clone())),
_ => None,
}
}),
WorkspaceShape::MemRepo => None,
});
let mem = workspace
.as_ref()
.map(|(mem, _)| mem.clone())
.unwrap_or_else(|| "<mem>".to_string());
let quickstart_seed = workspace
.as_ref()
.filter(|(_, root)| root.join("welcome-to-memstead.md").is_file())
.map(|(mem, _)| format!("{mem}--welcome-to-memstead"));
#[cfg(feature = "mem-repo")]
{
let mut steps = vec![
Step::bare(format!("memstead schema validate {name}")),
Step::bare(format!("memstead schema install {name}")),
];
if let Some(seed_id) = quickstart_seed {
steps.push(Step {
command: format!("memstead delete {seed_id}"),
note: Some(
"the quickstart seed — the pin below switches atomically only when \
every entity conforms to the new schema"
.to_string(),
),
});
}
steps.push(Step::bare(format!(
"memstead mem set-schema {mem} {name}@{SCAFFOLD_VERSION}"
)));
steps
}
#[cfg(not(feature = "mem-repo"))]
{
let _ = (mem, quickstart_seed); let (fresh_dir, install_source) = match ctx.workspace_shape() {
Some((_, root)) => {
let parent = root.parent().unwrap_or(&root).to_path_buf();
let pkg = std::env::current_dir().unwrap_or_default().join(name);
(
format!("\"{}\"", parent.join(format!("{name}-mem")).display()),
format!("\"{}\"", pkg.display()),
)
}
None => (format!("{name}-mem"), format!("../{name}")),
};
vec![
Step::bare(format!("memstead schema validate {name}")),
Step {
command: format!(
"mkdir {fresh_dir} && cd {fresh_dir} && memstead init --name {name}-mem \
--schema {name}@{SCAFFOLD_VERSION}"
),
note: Some(
"this binary cannot re-pin an existing mem, so the schema gets a \
fresh one"
.to_string(),
),
},
Step {
command: format!("memstead schema install {install_source}"),
note: Some(
"run inside the new folder — the workspace boots once its pinned \
schema is installed"
.to_string(),
),
},
]
}
}
struct Step {
command: String,
note: Option<String>,
}
impl Step {
fn bare(command: String) -> Self {
Step {
command,
note: None,
}
}
}
fn suggest_schema_name(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for c in raw.to_lowercase().chars() {
if c.is_ascii_lowercase() || c.is_ascii_digit() {
out.push(c);
} else if !out.ends_with('-') && !out.is_empty() {
out.push('-');
}
}
let trimmed: String = out
.trim_matches('-')
.chars()
.skip_while(|c| !c.is_ascii_lowercase())
.collect();
let trimmed = trimmed.trim_matches('-');
if trimmed.is_empty() {
"my-schema".to_string()
} else {
trimmed.to_string()
}
}
fn scaffold_manifest(name: &str) -> String {
format!(
r#"# Schema package scaffolded by `memstead schema new`.
# A schema package is one folder: this manifest plus one YAML file per
# entity type under types/. Re-check any time with:
# memstead schema validate {name}
name: {name}
version: {SCAFFOLD_VERSION}
# Shown in schema catalogues (memstead_overview, the registry).
description: |
Describe the subject this schema models and the types it declares.
# Read by agents (and humans) choosing a schema for a new mem.
when_to_use: |
Say when this schema fits — and when an author should reach for a
different one.
# Optional: served to agents working in a mem pinned to this schema.
system_message: |
You are working in a graph using the {name} schema. Prefer precise
types, link generously, and keep sections in their declared shape.
# One entry per file under types/ — `note` matches types/note.yaml.
# Add a type by adding both the file and its entry here.
types:
- note
relationships:
# strict: only the definitions below are legal edge types.
# open: any UPPER_SNAKE_CASE name is accepted; definitions add weights.
mode: strict
# Optional relationships-level declarations (engine 0.10.0+):
# acyclic_sets — acyclicity over the UNION of a rel-type set, for
# cycles no single rel-type contains:
# acyclic_sets:
# - [GROUNDS, CONCLUDES]
# labelling — name the attack rel-types and the engine serves
# the grounded labelling (accepted/defeated/
# undecided) with evidence; optional support walk
# adds chain-shape statistics.
definitions:
- name: PART_OF
description: Hierarchical containment — the source is structurally part of the target.
default_weight: 3.0
acyclic: true
- name: RELATES_TO
description: General association between two entities when no sharper type fits.
default_weight: 1.0
# Every key below is OPTIONAL, but its default is not always the
# permissive one — uncomment what you need.
#
# Per-edge `--description` text. DEFAULT IS `forbidden`: leave this
# out and every `memstead relate ... --description` on this type is
# REFUSED with DESCRIPTION_NOT_PERMITTED.
# per_edge_description: optional # forbidden | optional | required
#
# Restrict which types this edge may join. Omit for "any type".
# source_types: [note]
# target_types: [note]
#
# cardinality_per_source: 1 # at most one such edge per source
# manual_authoring: false # true = engine-emitted only
- name: REFERENCES
description: Soft reference. Auto-emitted from body wiki-links — never author by hand.
default_weight: 0.5
# Required entry — the fallback weight for any relationship not
# listed above.
- name: _default
description: Fallback weight for any relationship not otherwise specified.
default_weight: 1.0
# Body wiki-links `[[target]]` auto-emit as REFERENCES relations.
# Remove this key to make unbacked wiki-links a validation error instead.
alias_target_rel_type: REFERENCES
# Community detection (graph clustering) tuning. REQUIRED — the block
# must be present; the values below are the defaults, keep them unless
# you know why you are changing them.
community:
resolution: 1.0
seed: 42
# The complete key reference for schema packages — every key the loader
# accepts, with its type and default — is the meta-schema shipped in
# your workspace at `.memstead/meta-schemas/schema-manifest.schema.json`.
# This scaffold teaches by example; that file is exhaustive.
"#
)
}
fn scaffold_example_type() -> String {
r#"# One entity type = one file. `name` must match the filename stem
# and appear in the manifest's `types:` list.
#
# Keys marked REQUIRED must be present in every type file — deleting
# one fails `memstead schema validate`. Everything else is optional.
# REQUIRED.
name: note
# REQUIRED.
description: |
A general-purpose note — replace this with your first real type.
# REQUIRED.
when_to_use: |
Use while sketching the schema; rename or split into sharper types
as the domain vocabulary firms up.
# REQUIRED. Sections are the entity's markdown body. `required: true`
# sections must be present on every create.
sections:
- key: summary
heading: Summary
required: true
search_weight: 40.0
write_rules:
- "One or two sentences. Must stand alone in a search result."
- key: details
heading: Details
required: false
search_weight: 10.0
# catch_all: content under unmatched headings lands here.
catch_all: true
write_rules:
- "Everything beyond the summary. Bullets over prose."
# REQUIRED (the key; it may be an empty list). Typed, filterable
# frontmatter fields — beyond the built-in
# type / created_date / last_modified / tags.
# One rule for fields and sections alike: absence of `required` means
# optional. `required: true` refuses a create that leaves the field
# unset — unless a default fills it (required + default = always
# present, never refused).
metadata_fields:
- key: status
# required + default_value: every entity carries a status, and the
# default means a create never has to supply one.
required: true
description: Lifecycle state of the note.
field_type: string
default_value: active
enum_values: [active, archived]
filterable: equality
- key: source
# No `required` key: optional — an entity without a source is
# admitted. Use health_required_fields or a constraint if missing
# values should surface as findings instead.
description: Where the note's content came from.
field_type: string
# REQUIRED. Search ranking: how much a title match weighs.
title_weight: 100.0
# REQUIRED. Sections included in full-text search.
text_fields: [summary, details]
# REQUIRED. Which declared relationship expresses hierarchy for this type.
hierarchy_relationship: PART_OF
# One effect only: relate refuses a self-loop (from == to) on the rel-types
# listed here. Nothing propagates; for impact propagation declare a
# `status_propagation` constraint instead.
no_self_loop_relationships: [PART_OF]
# Fields `memstead update` may touch on this type.
updatable_fields: [title, summary, details, status, tags]
# Sections the health report treats as required.
health_required_fields: [summary]
# Days without modification before health flags the entity stale.
staleness_threshold_days: 180
# Further optional type-level declarations (engine 0.10.0+), shapes in
# the authoring guide and the generated type-definition.schema.json:
# required_outgoing — edge obligations (cardinality, warn/block
# severity, optional when_field/when_value pair
# arming a block on a metadata enum value)
# must_reach — reachability obligations over a relation set
# (direction out/in, terminal_types, max_depth);
# health-sweep only, always warn
# constraints — the five-form vocabulary (requires_when,
# unique, enum_from_neighbour, status_propagation
# with rel_type or rel_types)
# signals — edge_load counts with notice/warn thresholds,
# served with contributors on every read
# Prose guidance served to agents writing entities of this type.
write_rules:
- "Notes are placeholders — split recurring shapes into dedicated types."
"#
.to_string()
}
fn validate(ctx: &CliContext, args: ValidateArgs) -> anyhow::Result<()> {
if args.path.join("schema-format.json").is_file() {
return Err(CliError::new(
ExitKind::Validation,
"SCHEMA_VALIDATION_FAILED",
format!(
"{} is a sealed schema package (it carries `schema-format.json`, the seal \
marker), not authoring input — `schema validate` checks the directories you \
author, before sealing. Validate the package's source directory instead, or \
install this package directly with `memstead schema install`.",
args.path.display(),
),
)
.with_details(json!({
"path": args.path,
"reason": "sealed_package",
}))
.into());
}
match memstead_schema::loader::load_schema_from_dir(&args.path)
.and_then(|s| memstead_schema::check_section_heading_roundtrip(&s).map(|()| s))
.and_then(|s| memstead_schema::check_reserved_metadata_keys(&s).map(|()| s))
.and_then(|s| memstead_schema::check_section_formats(&s).map(|()| s))
{
Ok(schema) => {
let schema = std::sync::Arc::new(schema);
if let Err(defect) = memstead_base::Engine::validate_schema_exemplars(&schema) {
return Err(CliError::new(
ExitKind::Validation,
"SCHEMA_VALIDATION_FAILED",
format!("schema at {} is invalid: {defect}", args.path.display()),
)
.with_details(json!({ "path": args.path, "error": defect }))
.into());
}
let (name, version) = schema.id();
let type_count = schema.types.len();
if ctx.json {
print_json(&json!({
"ok": true,
"schema": format!("{name}@{version}"),
"types": type_count,
"path": args.path,
}))?;
} else {
print_markdown(&format!(
"# Schema valid\n\n`{name}@{version}` — {type_count} type(s) at `{}`\n",
args.path.display(),
));
}
Ok(())
}
Err(e) => Err(CliError::new(
ExitKind::Validation,
"SCHEMA_VALIDATION_FAILED",
format!("schema at {} is invalid: {e}", args.path.display()),
)
.with_details(json!({
"path": args.path,
"error": e.to_string(),
}))
.into()),
}
}
fn install(ctx: &CliContext, args: InstallArgs) -> anyhow::Result<()> {
let (shape, root) = ctx.workspace_shape().ok_or_else(|| {
CliError::new(
ExitKind::Generic,
"NO_WORKSPACE",
"not inside a Memstead workspace (no `.memstead/workspace.toml` in any \
ancestor) — cd into your workspace first, or create one: memstead quickstart"
.to_string(),
)
})?;
let (schema_ref, files) = resolve_source(&args.source)?;
match shape {
WorkspaceShape::Filesystem => {
let pkg_dir = root
.join(".memstead")
.join("schemas")
.join(format!("{}@{}", schema_ref.name, schema_ref.version));
write_package(&pkg_dir, &files)?;
if ctx.json {
print_json(&json!({
"ok": true,
"schema": format!("{}@{}", schema_ref.name, schema_ref.version),
"backend": "folder",
"path": pkg_dir,
"files": files.iter().map(|f| &f.archive_path).collect::<Vec<_>>(),
}))?;
} else {
print_markdown(&format!(
"# Schema installed\n\n`{}@{}` → `{}` ({} file(s))\n",
schema_ref.name,
schema_ref.version,
pkg_dir.display(),
files.len(),
));
}
Ok(())
}
WorkspaceShape::MemRepo => install_to_git_branch(ctx, &schema_ref, &files),
}
}
#[cfg(feature = "mem-repo")]
fn install_to_git_branch(
ctx: &CliContext,
schema_ref: &SchemaRef,
files: &[memstead_schema::SchemaSourceFile],
) -> anyhow::Result<()> {
let Some((_shape, root)) = ctx.workspace_shape() else {
return Err(crate::setup::workspace_not_initialised_error(
"No workspace found. Run from a directory containing `.memstead/workspace.toml`.",
)
.into());
};
let pairs: Vec<(String, Vec<u8>)> = files
.iter()
.map(|f| (f.archive_path.clone(), f.bytes.clone()))
.collect();
let commit = memstead_git_branch::repair::install_schema_below_boot(
&root,
&schema_ref.name,
&schema_ref.version.to_string(),
&pairs,
)
.map_err(|e| crate::setup::boot_error_to_cli(&root, e))?;
if ctx.json {
print_json(&json!({
"ok": true,
"schema": format!("{}@{}", schema_ref.name, schema_ref.version),
"backend": "git-branch",
"ref": format!("__MEMSTEAD:schemas/{}@{}", schema_ref.name, schema_ref.version),
"commit": commit,
}))?;
} else {
print_markdown(&format!(
"# Schema installed\n\n`{}@{}` → `__MEMSTEAD:schemas/{}@{}` (commit `{}`)\n",
schema_ref.name, schema_ref.version, schema_ref.name, schema_ref.version, commit,
));
}
Ok(())
}
#[cfg(not(feature = "mem-repo"))]
fn install_to_git_branch(
_ctx: &CliContext,
_schema_ref: &SchemaRef,
_files: &[memstead_schema::SchemaSourceFile],
) -> anyhow::Result<()> {
Err(CliError::new(
ExitKind::Generic,
"MEM_REPO_NOT_SUPPORTED",
"this binary was built without git-branch support — use the `memstead` binary to \
install a schema into a mem-repo workspace."
.to_string(),
)
.into())
}
fn resolve_source(
source: &str,
) -> anyhow::Result<(SchemaRef, Vec<memstead_schema::SchemaSourceFile>)> {
let as_path = Path::new(source);
if as_path.is_dir() {
let schema = memstead_schema::load_schema_from_dir(as_path)
.and_then(|s| memstead_schema::check_section_heading_roundtrip(&s).map(|()| s))
.and_then(|s| memstead_schema::check_reserved_metadata_keys(&s).map(|()| s))
.and_then(|s| memstead_schema::check_section_formats(&s).map(|()| s))
.map_err(|e| {
CliError::new(
ExitKind::Validation,
"SCHEMA_VALIDATION_FAILED",
format!("package at {source} is invalid: {e}"),
)
.with_details(json!({ "path": source, "error": e.to_string() }))
})?;
let schema = std::sync::Arc::new(schema);
if let Err(defect) = memstead_base::Engine::validate_schema_exemplars(&schema) {
return Err(CliError::new(
ExitKind::Validation,
"SCHEMA_VALIDATION_FAILED",
format!("package at {source} is invalid: {defect}"),
)
.with_details(json!({ "path": source, "error": defect }))
.into());
}
let (name, version) = schema.id();
let mut files = collect_dir_package(as_path)?;
let authoring_path = as_path
.canonicalize()
.unwrap_or_else(|_| as_path.to_path_buf());
files.push(memstead_schema::SchemaSourceFile {
archive_path: memstead_schema::INSTALL_PROVENANCE_FILE.to_string(),
bytes: serde_json::to_vec_pretty(&json!({
"authoring_path": authoring_path.display().to_string(),
}))
.expect("provenance stamp serialises"),
});
let files = marked_package(files);
Ok((SchemaRef::new(name, version), files))
} else {
let schema_ref = resolve_builtin_ref(source)?;
let mut files =
memstead_schema::collect_schema_source(None, None, &schema_ref).map_err(|e| {
CliError::new(
ExitKind::Validation,
"SCHEMA_NOT_FOUND",
format!(
"could not collect source for {}: {e}",
schema_ref.as_display()
),
)
})?;
if let Some(tpl) = memstead_schema::builtins::builtin_mem_template(&schema_ref.name) {
files.push(memstead_schema::SchemaSourceFile {
archive_path: "mem-template.json".to_string(),
bytes: serde_json::to_vec_pretty(&tpl).unwrap_or_default(),
});
}
Ok((schema_ref, files))
}
}
fn resolve_builtin_ref(source: &str) -> anyhow::Result<SchemaRef> {
let reg = memstead_schema::SchemaRegistry::builtin();
if source.contains('@') {
let r: SchemaRef = source.parse().map_err(|e: String| {
CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!("invalid schema pin {source:?}: {e}"),
)
})?;
if reg.get(&r.name, &r.version).is_none() {
return Err(CliError::new(
ExitKind::Validation,
"SCHEMA_NOT_FOUND",
format!(
"no built-in schema {source} — pass a path to install a non-built-in package"
),
)
.into());
}
Ok(r)
} else {
match reg.resolve_by_name(source) {
Ok(Some(s)) => {
let (n, v) = s.id();
Ok(SchemaRef::new(n, v))
}
Ok(None) => Err(CliError::new(
ExitKind::Validation,
"SCHEMA_NOT_FOUND",
format!(
"no built-in schema named {source:?} — pass a path to install a non-built-in \
package, or a `name@version` pin"
),
)
.into()),
Err(e) => Err(CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!("built-in name {source:?} is ambiguous: {e}"),
)
.into()),
}
}
}
fn collect_dir_package(dir: &Path) -> anyhow::Result<Vec<memstead_schema::SchemaSourceFile>> {
use memstead_schema::SchemaSourceFile;
let mut out = vec![SchemaSourceFile {
archive_path: "schema.yaml".to_string(),
bytes: std::fs::read(dir.join("schema.yaml"))?,
}];
let types = dir.join("types");
if types.is_dir() {
let mut paths: Vec<PathBuf> = std::fs::read_dir(&types)?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("yaml"))
.collect();
paths.sort();
for p in paths {
if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
out.push(SchemaSourceFile {
archive_path: format!("types/{name}"),
bytes: std::fs::read(&p)?,
});
}
}
}
for opt in ["mem-template.json", "README.md"] {
let p = dir.join(opt);
if p.is_file() {
out.push(SchemaSourceFile {
archive_path: opt.to_string(),
bytes: std::fs::read(&p)?,
});
}
}
Ok(out)
}
fn marked_package(
mut files: Vec<memstead_schema::SchemaSourceFile>,
) -> Vec<memstead_schema::SchemaSourceFile> {
let marker = memstead_schema::loader::SCHEMA_FORMAT_MARKER_FILE;
if !files.iter().any(|f| f.archive_path == marker) {
files.push(memstead_schema::SchemaSourceFile {
archive_path: marker.to_string(),
bytes: memstead_schema::loader::SCHEMA_FORMAT_MARKER_CONTENT
.as_bytes()
.to_vec(),
});
}
files
}
fn write_package(
pkg_dir: &Path,
files: &[memstead_schema::SchemaSourceFile],
) -> anyhow::Result<()> {
for f in files {
let dest = pkg_dir.join(&f.archive_path);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
CliError::new(
ExitKind::Generic,
"IO_ERROR",
format!("could not create {}: {e}", parent.display()),
)
})?;
}
let bytes = retarget_yaml_directive(&f.archive_path, &f.bytes);
std::fs::write(&dest, &bytes).map_err(|e| {
CliError::new(
ExitKind::Generic,
"IO_ERROR",
format!("could not write {}: {e}", dest.display()),
)
})?;
}
Ok(())
}
fn directive_for(archive_path: &str) -> Option<&'static str> {
if archive_path == "schema.yaml" {
Some("# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json")
} else if archive_path.starts_with("types/") && archive_path.ends_with(".yaml") {
Some("# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json")
} else {
None
}
}
fn retarget_yaml_directive(archive_path: &str, bytes: &[u8]) -> Vec<u8> {
let Some(directive) = directive_for(archive_path) else {
return bytes.to_vec();
};
let Ok(text) = std::str::from_utf8(bytes) else {
return bytes.to_vec();
};
let body = if text.starts_with("# yaml-language-server:") {
text.split_once('\n').map(|(_, rest)| rest).unwrap_or("")
} else {
text
};
format!("{directive}\n{body}").into_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn ctx() -> CliContext {
CliContext {
json: false,
quiet: true,
role: Default::default(),
}
}
#[test]
fn validate_accepts_builtin_default_schema() {
let src = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../memstead-schema/builtins/schemas/default-1.3");
assert!(src.join("schema.yaml").is_file(), "fixture moved: {src:?}");
let dir = tempfile::tempdir().unwrap();
let dst = dir.path().join("authoring");
copy_dir_without_marker(&src, &dst);
validate(&ctx(), ValidateArgs { path: dst })
.expect("default builtin content must validate");
}
fn copy_dir_without_marker(src: &Path, dst: &Path) {
std::fs::create_dir_all(dst).unwrap();
for entry in std::fs::read_dir(src).unwrap() {
let entry = entry.unwrap();
let name = entry.file_name();
if name == "schema-format.json" {
continue;
}
let target = dst.join(&name);
if entry.file_type().unwrap().is_dir() {
copy_dir_without_marker(&entry.path(), &target);
} else {
std::fs::copy(entry.path(), &target).unwrap();
}
}
}
#[test]
fn validate_names_sealed_package() {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../memstead-schema/builtins/schemas/default-1.3");
let err = validate(&ctx(), ValidateArgs { path }).expect_err("sealed package must refuse");
let cli = err
.downcast_ref::<CliError>()
.expect("error is a typed CliError");
assert_eq!(cli.code, "SCHEMA_VALIDATION_FAILED");
assert!(
cli.message.contains("sealed schema package"),
"message names the sealed package: {}",
cli.message,
);
assert_eq!(
cli.details.as_ref().unwrap()["reason"],
json!("sealed_package"),
);
}
#[test]
fn validate_rejects_malformed_schema_with_typed_code() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("schema.yaml"), "name: [unterminated\n").unwrap();
let err = validate(
&ctx(),
ValidateArgs {
path: dir.path().to_path_buf(),
},
)
.expect_err("malformed schema must refuse");
let cli = err
.downcast_ref::<CliError>()
.expect("error is a typed CliError");
assert_eq!(cli.code, "SCHEMA_VALIDATION_FAILED");
assert_eq!(cli.kind, ExitKind::Validation);
assert_eq!(
cli.details.as_ref().unwrap()["path"],
json!(dir.path()),
"details echoes the offending path",
);
}
#[test]
fn resolve_builtin_read_ref_defaults_bare_names_to_newest() {
let newest = resolve_builtin_read_ref("planning").expect("bare planning reads");
let mut all = memstead_schema::SchemaRegistry::builtin().available_versions("planning");
all.sort();
assert_eq!(Some(&newest.version), all.last());
let pinned = resolve_builtin_read_ref("planning@0.1.0").expect("pin reads");
assert_eq!(pinned.version.to_string(), "0.1.0");
let err = resolve_builtin_read_ref("not-a-builtin").expect_err("unknown refuses");
let cli = err.downcast_ref::<CliError>().unwrap();
assert_eq!(cli.code, "SCHEMA_NOT_FOUND");
assert!(
cli.message.contains("planning"),
"names the roster: {}",
cli.message
);
}
#[test]
fn resolve_builtin_ref_handles_name_pin_and_unknown() {
let bare = resolve_builtin_ref("software@0.2.0").expect("software pin resolves");
assert_eq!(bare.name, "software");
let pinned = resolve_builtin_ref("planning@0.1.0").expect("explicit pin resolves");
assert_eq!(pinned.name, "planning");
assert_eq!(pinned.version.to_string(), "0.1.0");
resolve_builtin_ref("planning@0.2.0").expect("bumped pin resolves");
resolve_builtin_ref("planning").expect_err("bare planning is ambiguous");
let err = resolve_builtin_ref("not-a-builtin").expect_err("unknown name refuses");
assert_eq!(
err.downcast_ref::<CliError>().unwrap().code,
"SCHEMA_NOT_FOUND",
);
}
#[test]
fn resolve_source_for_builtin_includes_schema_and_template() {
let (schema_ref, files) =
resolve_source("planning@0.1.0").expect("planning source collects");
assert_eq!(schema_ref.name, "planning");
let paths: Vec<&str> = files.iter().map(|f| f.archive_path.as_str()).collect();
assert!(paths.contains(&"schema.yaml"), "got {paths:?}");
assert!(
paths.contains(&"mem-template.json"),
"built-in install must carry the mem-template.json, got {paths:?}",
);
}
#[test]
fn collect_and_write_package_round_trips() {
let src = tempfile::tempdir().unwrap();
std::fs::create_dir_all(src.path().join("types")).unwrap();
std::fs::write(src.path().join("schema.yaml"), b"name: x\n").unwrap();
std::fs::write(src.path().join("types/doc.yaml"), b"name: doc\n").unwrap();
std::fs::write(src.path().join("mem-template.json"), b"{}\n").unwrap();
let files = collect_dir_package(src.path()).unwrap();
let dest = tempfile::tempdir().unwrap();
let pkg = dest.path().join("x@0.1.0");
write_package(&pkg, &files).unwrap();
let schema = std::fs::read_to_string(pkg.join("schema.yaml")).unwrap();
assert_eq!(
schema,
"# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json\nname: x\n",
);
let doc = std::fs::read_to_string(pkg.join("types/doc.yaml")).unwrap();
assert_eq!(
doc,
"# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json\nname: doc\n",
);
assert_eq!(
std::fs::read(pkg.join("mem-template.json")).unwrap(),
b"{}\n"
);
write_package(&pkg, &files).unwrap();
assert_eq!(
std::fs::read_to_string(pkg.join("schema.yaml")).unwrap(),
schema
);
}
#[test]
fn retarget_yaml_directive_replaces_or_prepends() {
let existing = b"# yaml-language-server: $schema=../../../generated/schema-manifest.schema.json\nname: y\n";
let out = String::from_utf8(retarget_yaml_directive("schema.yaml", existing)).unwrap();
assert_eq!(
out,
"# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json\nname: y\n",
);
let bare = retarget_yaml_directive("types/t.yaml", b"name: t\n");
assert_eq!(
String::from_utf8(bare).unwrap(),
"# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json\nname: t\n",
);
assert_eq!(retarget_yaml_directive("README.md", b"# hi\n"), b"# hi\n");
}
}