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),
Migrate(MigrateArgs),
}
#[derive(ClapArgs, Debug)]
pub struct MigrateArgs {
pub path: PathBuf,
#[arg(long)]
pub write: bool,
}
#[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<()> {
if matches!(
args.command,
Some(
SchemaCommand::New(_)
| SchemaCommand::Validate(_)
| SchemaCommand::Install(_)
| SchemaCommand::Migrate(_)
)
) && let Some((_, root)) = ctx.workspace_shape()
{
let _ = memstead_schema::meta_schema::publish_meta_schemas(&root);
}
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),
(Some(SchemaCommand::Migrate(a)), _) => migrate(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 (origin, name, ver, readme_bytes) =
match memstead_schema::builtins::builtin_package(&schema_ref.name, &version) {
Some(pkg) => {
let bytes = pkg
.files
.iter()
.find(|(path, _)| path == memstead_schema::builtins::PACKAGE_README_FILE)
.map(|(_, bytes)| bytes.to_vec());
(
"builtin",
pkg.name.to_string(),
pkg.version.to_string(),
bytes,
)
}
None => match workspace_installed_readme(ctx, &schema_ref) {
Some(bytes) => (
"workspace",
schema_ref.name.clone(),
version.clone(),
Some(bytes),
),
None => {
return Err(CliError::new(
ExitKind::Validation,
"SCHEMA_NOT_FOUND",
format!(
"no built-in or workspace-installed schema {}",
schema_ref.as_display()
),
)
.into());
}
},
};
let readme = readme_bytes
.as_deref()
.and_then(|bytes| std::str::from_utf8(bytes).ok())
.map(|text| memstead_schema::builtins::render_package_readme(&name, &ver, text));
let pin = format!("{name}@{ver}");
if ctx.json {
print_json(&json!({
"schema": pin,
"name": name,
"version": ver,
"origin": origin,
"readme": readme,
}))?;
return Ok(());
}
let label = if origin == "builtin" {
"built-in"
} else {
"workspace-installed"
};
match readme {
Some(text) => print_markdown(&format!(
"<!-- {pin}: {label} package README, rendered for this generation -->\n{text}"
)),
None => print_markdown(&format!(
"`{pin}` is a {label} package that ships no README.\n"
)),
}
Ok(())
}
fn workspace_installed_readme(ctx: &CliContext, schema_ref: &SchemaRef) -> Option<Vec<u8>> {
let (_, root) = ctx.workspace_shape()?;
let fs_readme = root
.join(".memstead")
.join("schemas")
.join(format!("{}@{}", schema_ref.name, schema_ref.version))
.join("README.md");
if let Ok(bytes) = std::fs::read(&fs_readme) {
return Some(bytes);
}
#[cfg(feature = "mem-repo")]
{
let gitdir = root.join("mem-repo").join(".git");
if gitdir.is_dir()
&& let Ok(Some(bytes)) =
memstead_git_branch::storage_memstead::read_schema_file_from_memstead_ref(
&gitdir,
&schema_ref.name,
&schema_ref.version.to_string(),
"README.md",
)
{
return Some(bytes);
}
}
None
}
fn resolve_builtin_read_ref(reference: &str) -> anyhow::Result<SchemaRef> {
if reference.contains('@') {
return reference.parse::<SchemaRef>().map_err(|e: String| {
CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!("invalid schema pin {reference:?}: {e}"),
)
.into()
});
}
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 migrate(ctx: &CliContext, args: MigrateArgs) -> anyhow::Result<()> {
use memstead_schema::migrate::{MigrateError, migrate_package, next_steps, write_migration};
let map_err = |e: MigrateError| -> anyhow::Error {
let reason = match &e {
MigrateError::NotAPackage { .. } => "not_a_package",
MigrateError::SealedPackage { .. } => "sealed_package",
MigrateError::Io { .. } | MigrateError::NotUtf8 { .. } => "io",
MigrateError::UnmigratableValue { .. } => "unmigratable_value",
MigrateError::PackageDoesNotLoad { .. } => "package_does_not_load",
MigrateError::RewriteLeavesViolations { .. } => "rewrite_leaves_violations",
MigrateError::Unfaithful { .. } => "unfaithful_rewrite",
};
CliError::new(
ExitKind::Validation,
"SCHEMA_MIGRATE_FAILED",
format!("schema migrate at {}: {e}", args.path.display()),
)
.with_details(json!({ "path": args.path, "reason": reason, "error": e.to_string() }))
.into()
};
let report = migrate_package(&args.path).map_err(map_err)?;
let wrote = args.write && !report.is_noop();
if wrote {
write_migration(&report).map_err(map_err)?;
}
let steps = next_steps(&args.path, &report.schema);
if ctx.json {
let rewrites: Vec<serde_json::Value> = report
.files
.iter()
.flat_map(|f| {
f.rewrites.iter().map(move |r| {
json!({
"file": f.rel_path,
"line": r.line,
"key": r.key,
"path": r.path,
"action": r.action.to_string(),
})
})
})
.collect();
print_json(&json!({
"ok": true,
"schema": report.schema,
"path": report.package,
"dry_run": !args.write,
"wrote": wrote,
"rewrites": rewrites,
"files_changed": report.changed_files().map(|f| &f.rel_path).collect::<Vec<_>>(),
"legacy_polarity": report.legacy_polarity,
"required_added": report.required_added.iter().map(|b| json!({
"type": b.type_name, "field": b.field,
})).collect::<Vec<_>>(),
"next_steps": if report.is_noop() { Vec::new() } else { steps },
}))?;
return Ok(());
}
let mut out = String::new();
if report.is_noop() {
out.push_str(&format!(
"# Schema migrate
Nothing to migrate: `{}` at `{}` carries no retired key. No file written.
",
report.schema,
report.package.display(),
));
print_markdown(&out);
return Ok(());
}
let mode = if wrote {
"written"
} else {
"dry run, nothing written"
};
out.push_str(&format!(
"# Schema migrate — {mode}
`{}` at `{}` — {} rewrite(s) in {} file(s)
",
report.schema,
report.package.display(),
report.rewrite_count(),
report.changed_files().count(),
));
for file in report.changed_files() {
out.push_str(&format!(
"
## {}
",
file.rel_path
));
for r in &file.rewrites {
out.push_str(&format!(
"- {r}
"
));
}
}
if report.legacy_polarity {
out.push_str(
"
## Polarity
This package carries the retired `optional:` key, so it was written when an absent key meant required — the meaning its sealed copies still have. The rewrite keeps that meaning: every metadata field declaring neither key gets `required: true`. Delete the line where you did not mean it.
",
);
}
out.push_str(
"
## Next steps
",
);
if !wrote {
out.push_str(&format!(
"1. Review the rewrites above, then apply them: `memstead schema migrate {} --write`
",
args.path.display()
));
}
let offset = if wrote { 1 } else { 2 };
for (i, step) in steps.iter().enumerate() {
out.push_str(&format!(
"{}. `{step}`
",
i + offset
));
}
out.push_str(
"
`version` was not bumped: whether a spelling migration deserves a new version is your call. Sealed copies inside mems are untouched; they keep loading as they are.
",
);
print_markdown(&out);
Ok(())
}
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,
ctx.workspace_shape().map(|(_, r)| r).as_deref(),
)?;
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,
workspace_root: Option<&Path>,
) -> 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());
let stamped = workspace_root
.and_then(|root| root.canonicalize().ok())
.and_then(|root| {
authoring_path
.strip_prefix(&root)
.ok()
.map(|rel| rel.display().to_string())
})
.unwrap_or_else(|| authoring_path.display().to_string());
files.push(memstead_schema::SchemaSourceFile {
archive_path: memstead_schema::INSTALL_PROVENANCE_FILE.to_string(),
bytes: serde_json::to_vec_pretty(&json!({
"authoring_path": stamped,
}))
.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(),
identity: None,
}
}
#[test]
fn validate_builtin_default_copy_refuses_retired_exemplar_spelling() {
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);
let err = validate(&ctx(), ValidateArgs { path: dst.clone() })
.expect_err("legacy exemplar spelling refuses as authoring input");
assert!(
err.to_string().contains("rel_type"),
"refusal carries the rename pointer: {err}"
);
for entry in std::fs::read_dir(dst.join("types")).unwrap() {
let path = entry.unwrap().path();
let text = std::fs::read_to_string(&path).unwrap();
let converged = text
.replace("\n - to: ", "\n - target: ")
.replace("\n type: ", "\n rel_type: ");
std::fs::write(&path, converged).unwrap();
}
validate(&ctx(), ValidateArgs { path: dst })
.expect("converged 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 migrate_dry_run_then_write_makes_legacy_copy_validate() {
let src = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../memstead-schema/builtins/schemas/default-1.3");
let dir = tempfile::tempdir().unwrap();
let dst = dir.path().join("authoring");
copy_dir_without_marker(&src, &dst);
let snapshot = |d: &Path| -> Vec<(String, Vec<u8>)> {
let mut files = Vec::new();
for entry in std::fs::read_dir(d.join("types")).unwrap() {
let entry = entry.unwrap();
files.push((
entry.file_name().to_string_lossy().into_owned(),
std::fs::read(entry.path()).unwrap(),
));
}
files.sort();
files
};
let before = snapshot(&dst);
validate(&ctx(), ValidateArgs { path: dst.clone() })
.expect_err("legacy copy refuses before migration");
migrate(
&ctx(),
MigrateArgs {
path: dst.clone(),
write: false,
},
)
.expect("dry run computes");
assert_eq!(snapshot(&dst), before, "dry run writes nothing");
migrate(
&ctx(),
MigrateArgs {
path: dst.clone(),
write: true,
},
)
.expect("write applies");
assert_ne!(snapshot(&dst), before, "--write rewrites the files");
validate(&ctx(), ValidateArgs { path: dst.clone() }).expect("migrated copy validates");
let after = snapshot(&dst);
migrate(
&ctx(),
MigrateArgs {
path: dst.clone(),
write: true,
},
)
.expect("noop run");
assert_eq!(snapshot(&dst), after);
}
#[test]
fn migrate_refuses_sealed_package() {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../memstead-schema/builtins/schemas/default-1.3");
let err = migrate(&ctx(), MigrateArgs { path, write: true })
.expect_err("sealed package must refuse");
let cli = err.downcast_ref::<CliError>().expect("typed CLI error");
assert_eq!(cli.code, "SCHEMA_MIGRATE_FAILED");
assert_eq!(cli.details.as_ref().unwrap()["reason"], "sealed_package");
}
#[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", None).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");
}
}