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)]
pub struct Args {
#[command(subcommand)]
pub command: SchemaCommand,
}
#[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 {
SchemaCommand::New(a) => scaffold_new(ctx, a),
SchemaCommand::Validate(a) => validate(ctx, a),
SchemaCommand::Install(a) => install(ctx, a),
}
}
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
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.
name: note
description: |
A general-purpose note — replace this with your first real type.
when_to_use: |
Use while sketching the schema; rename or split into sharper types
as the domain vocabulary firms up.
# 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."
# 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
# Search ranking: how much a title match weighs.
title_weight: 100.0
# Sections included in full-text search.
text_fields: [summary, details]
# 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
# 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<()> {
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 files = marked_package(files);
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"),
});
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 path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../memstead-schema/builtins/schemas/default-1.3");
assert!(
path.join("schema.yaml").is_file(),
"fixture moved: {path:?}"
);
validate(&ctx(), ValidateArgs { path }).expect("default builtin must validate");
}
#[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_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");
}
}