Skip to main content

memstead_cli/commands/
schema.rs

1//! `memstead schema validate <path>` — load a schema package from disk
2//! and report whether it conforms to the engine's schema rules.
3//!
4//! Validation runs the same loader the engine uses at boot
5//! (`memstead_schema::loader::load_schema_from_dir`), so a package that
6//! validates here is one the engine will accept. Parse failures carry
7//! the YAML layer's line/column in their message; structural failures
8//! (undeclared relationship vocabulary, type/file mismatch, missing
9//! `_default` weight, …) carry the engine's typed diagnostic.
10//!
11//! `memstead schema install <name|path>` copies a schema package into
12//! the current workspace's local schema storage so a mem can pin it.
13//! It resolves the source — a built-in name (`planning`, `planning@0.1.0`)
14//! or a path to a package directory — validates it, and writes the
15//! package (including any `mem-template.json`) under the folder
16//! backend's fixed `<workspace>/.memstead/schemas/<name>@<version>/`
17//! location. Installing a built-in forks it into local storage, which
18//! shadows the built-in per the resolution order — the customization
19//! entry point. Idempotent: re-running reproduces the same files.
20//! Git-branch (mem-repo) workspaces are a destination too: install
21//! routes through the engine's below-boot repair surface
22//! (`memstead_git_branch::repair::install_schema_below_boot`), which
23//! runs the same validation gate as the booted `Engine::install_schema`
24//! and seals the package onto the `__MEMSTEAD:schemas/` ref.
25//!
26//! `validate` is flavour-agnostic and touches no workspace. `install`
27//! never boots the workspace on either flavour — it is a named remedy
28//! for boot-blocking states (repair-below-boot rule), so it operates on
29//! configuration and schema storage only: the folder flavour writes
30//! `.memstead/schemas/` directly, the mem-repo flavour writes the
31//! engine-owned `__MEMSTEAD` ref through the engine's own repair
32//! surface.
33
34use std::path::{Path, PathBuf};
35
36use clap::{Args as ClapArgs, Subcommand};
37use serde_json::json;
38
39use memstead_schema::SchemaRef;
40
41use crate::CliError;
42use crate::output::{ExitKind, print_json, print_markdown};
43use crate::setup::{CliContext, WorkspaceShape};
44
45#[derive(ClapArgs, Debug)]
46#[command(args_conflicts_with_subcommands = true)]
47pub struct Args {
48    #[command(subcommand)]
49    pub command: Option<SchemaCommand>,
50
51    /// Render a built-in schema package's README as the package it
52    /// ships in. `<REF>` is a bare built-in name (`planning`, resolved
53    /// to its newest generation) or a pin (`planning@0.4.0`). Every
54    /// `<name>@<x.y.z>` reference to the package's own name is
55    /// rewritten to the resolved pin: sibling generations of a built-in
56    /// carry the README of their first generation verbatim (sealed
57    /// bytes, never edited), so the resolved manifest is the one source
58    /// of the identity the reader sees. Markdown by default; `--json`
59    /// carries `schema`, `name`, `version`, `origin` and the rendered
60    /// `readme` (`null` when the package ships none). Unknown names
61    /// refuse with `SCHEMA_NOT_FOUND`.
62    #[arg(value_name = "REF")]
63    pub reference: Option<String>,
64}
65
66#[derive(Subcommand, Debug)]
67pub enum SchemaCommand {
68    /// Scaffold a new schema package at `./<name>/` — a manifest plus
69    /// one commented example type — that `memstead schema validate`
70    /// passes unmodified. Prints the follow-up commands that take the
71    /// package from folder to pinned mem.
72    New(NewArgs),
73
74    /// Validate a schema package directory (`schema.yaml` plus an
75    /// optional `types/*.yaml`) against the engine's schema loader, as
76    /// the package you are AUTHORING — always read in the current
77    /// schema language, so retired keys (`optional:`,
78    /// `propagating_relationships`) refuse here by design. A package
79    /// already sealed under an older language can therefore be
80    /// refused by this command and still load: sealed content is read
81    /// under the generation it was sealed in, and that is the point of
82    /// sealing. Validate what you write, not what you installed.
83    /// Exits non-zero (`SCHEMA_VALIDATION_FAILED`) on any conformance
84    /// error, with the YAML line/column in the message where the parse
85    /// layer provides it.
86    Validate(ValidateArgs),
87
88    /// Install a schema package into the current folder workspace's
89    /// `.memstead/schemas/<name>@<version>/` so a mem can pin it.
90    /// `<source>` is a built-in name (`planning`, `planning@0.1.0`) or a
91    /// path to a package directory. Validates before copying; idempotent.
92    ///
93    /// Repair note — engines 0.6.0 through 0.8.1 mis-stamped LEGACY
94    /// packages as current-language at install, flipping their bare
95    /// metadata fields from required to optional. If you installed a
96    /// legacy package in that window, re-run the install with a fixed
97    /// engine; the detection command and details are in the docs-site
98    /// schema-authoring guide.
99    Install(InstallArgs),
100}
101
102#[derive(ClapArgs, Debug)]
103pub struct NewArgs {
104    /// Schema name. Grammar: starts with a lowercase letter, then
105    /// lowercase letters, digits, and hyphens. The package is written
106    /// to `./<name>/`.
107    pub name: String,
108}
109
110#[derive(ClapArgs, Debug)]
111pub struct ValidateArgs {
112    /// Path to the schema package directory (the folder containing
113    /// `schema.yaml`).
114    pub path: PathBuf,
115}
116
117#[derive(ClapArgs, Debug)]
118pub struct InstallArgs {
119    /// Built-in schema name (`planning`, `planning@0.1.0`) or a path to
120    /// a schema package directory.
121    pub source: String,
122}
123
124pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
125    // Publish the authoring meta-schemas into `.memstead/meta-schemas/` so an
126    // editor validates authored schema YAML against them (resolved by each
127    // package's `# yaml-language-server:` directive). This is the only place
128    // that writes them: they used to be published at engine boot, which made
129    // every read of a mem modify the directory it read. Only the three
130    // schema-authoring subcommands publish; rendering a built-in
131    // (`memstead schema <ref>`) is a read and stays one. Best-effort — a
132    // read-only workspace, or none at all, still runs the command.
133    if matches!(
134        args.command,
135        Some(SchemaCommand::New(_) | SchemaCommand::Validate(_) | SchemaCommand::Install(_))
136    ) && let Some((_, root)) = ctx.workspace_shape()
137    {
138        let _ = memstead_schema::meta_schema::publish_meta_schemas(&root);
139    }
140    match (args.command, args.reference) {
141        (Some(SchemaCommand::New(a)), _) => scaffold_new(ctx, a),
142        (Some(SchemaCommand::Validate(a)), _) => validate(ctx, a),
143        (Some(SchemaCommand::Install(a)), _) => install(ctx, a),
144        (None, Some(reference)) => show_builtin(ctx, &reference),
145        (None, None) => Err(CliError::new(
146            ExitKind::Validation,
147            "INVALID_INPUT",
148            "memstead schema needs a built-in reference to render (`memstead schema \
149             planning@0.4.0`) or a subcommand (`new`, `validate`, `install`); \
150             `memstead schema --help` lists them"
151                .to_string(),
152        )
153        .into()),
154    }
155}
156
157/// `memstead schema <REF>`: the built-in package's README rendered for
158/// the package it ships in (see [`Args::reference`]).
159fn show_builtin(ctx: &CliContext, reference: &str) -> anyhow::Result<()> {
160    let schema_ref = resolve_builtin_read_ref(reference)?;
161    let version = schema_ref.version.to_string();
162    let pkg = memstead_schema::builtins::builtin_package(&schema_ref.name, &version).ok_or_else(
163        || {
164            CliError::new(
165                ExitKind::Validation,
166                "SCHEMA_NOT_FOUND",
167                format!("no built-in schema {}", schema_ref.as_display()),
168            )
169        },
170    )?;
171    let readme = pkg
172        .files
173        .iter()
174        .find(|(path, _)| path == memstead_schema::builtins::PACKAGE_README_FILE)
175        .and_then(|(_, bytes)| std::str::from_utf8(bytes).ok())
176        .map(|text| {
177            memstead_schema::builtins::render_package_readme(&pkg.name, &pkg.version, text)
178        });
179    let pin = format!("{}@{}", pkg.name, pkg.version);
180    if ctx.json {
181        print_json(&json!({
182            "schema": pin,
183            "name": pkg.name,
184            "version": pkg.version,
185            "origin": "builtin",
186            "readme": readme,
187        }))?;
188        return Ok(());
189    }
190    match readme {
191        Some(text) => print_markdown(&format!(
192            "<!-- {pin}: built-in package README, rendered for this generation -->\n{text}"
193        )),
194        None => print_markdown(&format!(
195            "`{pin}` is a built-in package that ships no README.\n"
196        )),
197    }
198    Ok(())
199}
200
201/// Resolve a reference for a READ: a pin as given, a bare name to its
202/// newest built-in generation (a read is safe to default; `install`
203/// keeps refusing bare names so a pin is always explicit).
204fn resolve_builtin_read_ref(reference: &str) -> anyhow::Result<SchemaRef> {
205    if reference.contains('@') {
206        return resolve_builtin_ref(reference);
207    }
208    let reg = memstead_schema::SchemaRegistry::builtin();
209    let mut versions = reg.available_versions(reference);
210    versions.sort();
211    match versions.pop() {
212        Some(v) => Ok(SchemaRef::new(reference.to_string(), v)),
213        None => Err(CliError::new(
214            ExitKind::Validation,
215            "SCHEMA_NOT_FOUND",
216            format!(
217                "no built-in schema named {reference:?}; built-ins: {}",
218                builtin_names_joined(&reg)
219            ),
220        )
221        .into()),
222    }
223}
224
225fn builtin_names_joined(reg: &memstead_schema::SchemaRegistry) -> String {
226    let mut names: Vec<String> = reg.identities().into_iter().map(|(n, _)| n).collect();
227    names.sort();
228    names.dedup();
229    names.join(", ")
230}
231
232/// Version every scaffolded package starts at.
233const SCAFFOLD_VERSION: &str = "0.1.0";
234
235fn scaffold_new(ctx: &CliContext, args: NewArgs) -> anyhow::Result<()> {
236    if let Err(reason) = memstead_schema::loader::validate_schema_name(&args.name) {
237        let suggestion = suggest_schema_name(&args.name);
238        return Err(CliError::new(
239            ExitKind::Validation,
240            "INVALID_INPUT",
241            format!(
242                "invalid schema name {name:?}: {reason} (lowercase letter first, \
243                 then lowercase letters, digits, hyphens). \
244                 Try: memstead schema new {suggestion}",
245                name = args.name,
246            ),
247        )
248        .with_details(json!({
249            "name": args.name,
250            "reason": reason,
251            "suggestion": suggestion,
252        }))
253        .into());
254    }
255
256    let pkg_dir = PathBuf::from(&args.name);
257    if pkg_dir.join("schema.yaml").is_file() {
258        return Err(CliError::new(
259            ExitKind::Validation,
260            "SCHEMA_PACKAGE_EXISTS",
261            format!(
262                "{} already contains a schema package — `memstead schema new` \
263                 never overwrites. Check it with: memstead schema validate {}",
264                pkg_dir.display(),
265                args.name,
266            ),
267        )
268        .with_details(json!({ "path": pkg_dir }))
269        .into());
270    }
271    if pkg_dir.is_dir()
272        && let Some(entry) = std::fs::read_dir(&pkg_dir)
273            .map_err(|e| {
274                CliError::new(
275                    ExitKind::Generic,
276                    "IO_ERROR",
277                    format!("read {}: {e}", pkg_dir.display()),
278                )
279            })?
280            .next()
281            .transpose()
282            .map_err(|e| {
283                CliError::new(
284                    ExitKind::Generic,
285                    "IO_ERROR",
286                    format!("read {}: {e}", pkg_dir.display()),
287                )
288            })?
289    {
290        let found = entry.file_name().to_string_lossy().to_string();
291        return Err(CliError::new(
292            ExitKind::Validation,
293            "TARGET_NOT_EMPTY",
294            format!(
295                "{} exists and is not empty (found `{found}`) — clear it or \
296                     pick a different name: memstead schema new {}-schema",
297                pkg_dir.display(),
298                args.name,
299            ),
300        )
301        .with_details(json!({ "path": pkg_dir, "found": [found] }))
302        .into());
303    }
304
305    let manifest = scaffold_manifest(&args.name);
306    let example_type = scaffold_example_type();
307    std::fs::create_dir_all(pkg_dir.join("types")).map_err(|e| {
308        CliError::new(
309            ExitKind::Generic,
310            "IO_ERROR",
311            format!("create {}: {e}", pkg_dir.join("types").display()),
312        )
313    })?;
314    for (rel, content) in [
315        ("schema.yaml", &manifest),
316        ("types/note.yaml", &example_type),
317    ] {
318        let dest = pkg_dir.join(rel);
319        std::fs::write(&dest, content).map_err(|e| {
320            CliError::new(
321                ExitKind::Generic,
322                "IO_ERROR",
323                format!("write {}: {e}", dest.display()),
324            )
325        })?;
326    }
327
328    // Self-check with the engine loader — the scaffold's contract is
329    // "validates clean as generated"; fail loudly here rather than at
330    // the user's `schema validate` if a template edit ever breaks it.
331    if let Err(e) = memstead_schema::loader::load_schema_from_dir(&pkg_dir)
332        .and_then(|s| memstead_schema::check_reserved_metadata_keys(&s).map(|()| s))
333        .and_then(|s| memstead_schema::check_section_formats(&s).map(|()| s))
334    {
335        return Err(CliError::new(
336            ExitKind::Generic,
337            crate::INTERNAL_CODE,
338            format!(
339                "scaffold bug: generated package at {} fails validation: {e} — \
340                 please report this",
341                pkg_dir.display(),
342            ),
343        )
344        .into());
345    }
346
347    let next_steps = scaffold_next_steps(ctx, &args.name);
348    if ctx.json {
349        print_json(&json!({
350            "ok": true,
351            "schema": format!("{}@{SCAFFOLD_VERSION}", args.name),
352            "path": pkg_dir,
353            "files": ["schema.yaml", "types/note.yaml"],
354            "next_steps": next_steps
355                .iter()
356                .map(|s| json!({ "command": s.command, "note": s.note }))
357                .collect::<Vec<_>>(),
358        }))?;
359    } else {
360        let steps: Vec<String> = next_steps
361            .iter()
362            .enumerate()
363            .map(|(i, s)| match &s.note {
364                Some(note) => format!("{}. `{}` — {note}", i + 1, s.command),
365                None => format!("{}. `{}`", i + 1, s.command),
366            })
367            .collect();
368        print_markdown(&format!(
369            "# Schema package scaffolded\n\n`{name}@{SCAFFOLD_VERSION}` at `{dir}` \
370             (schema.yaml + types/note.yaml, one commented example type).\n\n\
371             Edit the package, then:\n\n{steps}\n",
372            name = args.name,
373            dir = pkg_dir.display(),
374            steps = steps.join("\n"),
375        ));
376    }
377    Ok(())
378}
379
380/// The follow-up command sequence printed by `schema new` — the rest of
381/// the custom-schema flow is copy-paste from here. When the command
382/// runs inside a single-writable-mem workspace, the pin step names the
383/// actual mem (from the mount roster — the authoritative name source);
384/// otherwise it keeps a `<mem>` placeholder. When the workspace still
385/// carries the `memstead quickstart` seed entity, a delete step for it
386/// precedes the pin: `mem set-schema` switches atomically only when
387/// every entity conforms to the target, and the default-schema seed
388/// never conforms to a fresh custom schema — without the delete the
389/// verbatim flow ends in a dual-pin migration instead of a pinned mem.
390fn scaffold_next_steps(ctx: &CliContext, name: &str) -> Vec<Step> {
391    use memstead_base::workspace::MountCapability;
392    use memstead_base::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
393    let workspace = ctx.workspace_shape().and_then(|(shape, root)| match shape {
394        WorkspaceShape::Filesystem => FileWorkspaceStore::new().load(&root).ok().and_then(|ws| {
395            let mut writable = ws
396                .mounts
397                .iter()
398                .filter(|m| m.capability == MountCapability::Write);
399            match (writable.next(), writable.next()) {
400                (Some(only), None) => Some((only.mem.clone(), root.clone())),
401                _ => None,
402            }
403        }),
404        WorkspaceShape::MemRepo => None,
405    });
406    let mem = workspace
407        .as_ref()
408        .map(|(mem, _)| mem.clone())
409        .unwrap_or_else(|| "<mem>".to_string());
410    // Filesystem mems live at the workspace root; the quickstart seed
411    // is the fixed-slug `welcome-to-memstead.md`.
412    let quickstart_seed = workspace
413        .as_ref()
414        .filter(|(_, root)| root.join("welcome-to-memstead.md").is_file())
415        .map(|(mem, _)| format!("{mem}--welcome-to-memstead"));
416    // Full flavour: install into the current workspace, then re-pin the
417    // mem in place.
418    #[cfg(feature = "mem-repo")]
419    {
420        let mut steps = vec![
421            Step::bare(format!("memstead schema validate {name}")),
422            Step::bare(format!("memstead schema install {name}")),
423        ];
424        if let Some(seed_id) = quickstart_seed {
425            steps.push(Step {
426                command: format!("memstead delete {seed_id}"),
427                note: Some(
428                    "the quickstart seed — the pin below switches atomically only when \
429                     every entity conforms to the new schema"
430                        .to_string(),
431                ),
432            });
433        }
434        steps.push(Step::bare(format!(
435            "memstead mem set-schema {mem} {name}@{SCAFFOLD_VERSION}"
436        )));
437        steps
438    }
439    // Lean flavour: no `mem set-schema`, so the custom schema gets a
440    // fresh mem. Order matters — `init` pins without resolving, and the
441    // engine only boots once the package is installed *inside the new
442    // workspace*, so the install step comes right after init and points
443    // back at the scaffolded package. When `schema new` ran inside an
444    // existing workspace, the fresh mem must land OUTSIDE it (workspaces
445    // don't nest, and `init`'s refusal there is a dead end on this
446    // binary) — the printed paths anchor at the workspace root's parent,
447    // absolute and quoted so the sequence stays verbatim-runnable.
448    #[cfg(not(feature = "mem-repo"))]
449    {
450        let _ = (mem, quickstart_seed); // full-only context
451        let (fresh_dir, install_source) = match ctx.workspace_shape() {
452            Some((_, root)) => {
453                let parent = root.parent().unwrap_or(&root).to_path_buf();
454                let pkg = std::env::current_dir().unwrap_or_default().join(name);
455                (
456                    format!("\"{}\"", parent.join(format!("{name}-mem")).display()),
457                    format!("\"{}\"", pkg.display()),
458                )
459            }
460            None => (format!("{name}-mem"), format!("../{name}")),
461        };
462        vec![
463            Step::bare(format!("memstead schema validate {name}")),
464            Step {
465                command: format!(
466                    "mkdir {fresh_dir} && cd {fresh_dir} && memstead init --name {name}-mem \
467                     --schema {name}@{SCAFFOLD_VERSION}"
468                ),
469                note: Some(
470                    "this binary cannot re-pin an existing mem, so the schema gets a \
471                     fresh one"
472                        .to_string(),
473                ),
474            },
475            Step {
476                command: format!("memstead schema install {install_source}"),
477                note: Some(
478                    "run inside the new folder — the workspace boots once its pinned \
479                     schema is installed"
480                        .to_string(),
481                ),
482            },
483        ]
484    }
485}
486
487/// One printed follow-up step: a verbatim-runnable command plus an
488/// optional explanation rendered outside the command so copy-paste
489/// stays clean.
490struct Step {
491    command: String,
492    note: Option<String>,
493}
494
495impl Step {
496    fn bare(command: String) -> Self {
497        Step {
498            command,
499            note: None,
500        }
501    }
502}
503
504/// Best-effort correction for an invalid schema name, offered in the
505/// refusal message: lowercase, non-grammar characters to hyphens,
506/// hyphen runs collapsed, leading non-letters and trailing hyphens
507/// trimmed.
508fn suggest_schema_name(raw: &str) -> String {
509    let mut out = String::with_capacity(raw.len());
510    for c in raw.to_lowercase().chars() {
511        if c.is_ascii_lowercase() || c.is_ascii_digit() {
512            out.push(c);
513        } else if !out.ends_with('-') && !out.is_empty() {
514            out.push('-');
515        }
516    }
517    let trimmed: String = out
518        .trim_matches('-')
519        .chars()
520        .skip_while(|c| !c.is_ascii_lowercase())
521        .collect();
522    let trimmed = trimmed.trim_matches('-');
523    if trimmed.is_empty() {
524        "my-schema".to_string()
525    } else {
526        trimmed.to_string()
527    }
528}
529
530/// The generated `schema.yaml` — a minimal, valid manifest whose
531/// comments teach each knob. Kept in one place with the example type
532/// so the scaffold reads as a coherent package.
533fn scaffold_manifest(name: &str) -> String {
534    format!(
535        r#"# Schema package scaffolded by `memstead schema new`.
536# A schema package is one folder: this manifest plus one YAML file per
537# entity type under types/. Re-check any time with:
538#   memstead schema validate {name}
539
540name: {name}
541version: {SCAFFOLD_VERSION}
542
543# Shown in schema catalogues (memstead_overview, the registry).
544description: |
545  Describe the subject this schema models and the types it declares.
546
547# Read by agents (and humans) choosing a schema for a new mem.
548when_to_use: |
549  Say when this schema fits — and when an author should reach for a
550  different one.
551
552# Optional: served to agents working in a mem pinned to this schema.
553system_message: |
554  You are working in a graph using the {name} schema. Prefer precise
555  types, link generously, and keep sections in their declared shape.
556
557# One entry per file under types/ — `note` matches types/note.yaml.
558# Add a type by adding both the file and its entry here.
559types:
560  - note
561
562relationships:
563  # strict: only the definitions below are legal edge types.
564  # open: any UPPER_SNAKE_CASE name is accepted; definitions add weights.
565  mode: strict
566  # Optional relationships-level declarations (engine 0.10.0+):
567  #   acyclic_sets — acyclicity over the UNION of a rel-type set, for
568  #                  cycles no single rel-type contains:
569  #                    acyclic_sets:
570  #                      - [GROUNDS, CONCLUDES]
571  #   labelling    — name the attack rel-types and the engine serves
572  #                  the grounded labelling (accepted/defeated/
573  #                  undecided) with evidence; optional support walk
574  #                  adds chain-shape statistics.
575  definitions:
576    - name: PART_OF
577      description: Hierarchical containment — the source is structurally part of the target.
578      default_weight: 3.0
579      acyclic: true
580    - name: RELATES_TO
581      description: General association between two entities when no sharper type fits.
582      default_weight: 1.0
583      # Every key below is OPTIONAL, but its default is not always the
584      # permissive one — uncomment what you need.
585      #
586      # Per-edge `--description` text. DEFAULT IS `forbidden`: leave this
587      # out and every `memstead relate ... --description` on this type is
588      # REFUSED with DESCRIPTION_NOT_PERMITTED.
589      # per_edge_description: optional   # forbidden | optional | required
590      #
591      # Restrict which types this edge may join. Omit for "any type".
592      # source_types: [note]
593      # target_types: [note]
594      #
595      # cardinality_per_source: 1   # at most one such edge per source
596      # manual_authoring: false     # true = engine-emitted only
597    - name: REFERENCES
598      description: Soft reference. Auto-emitted from body wiki-links — never author by hand.
599      default_weight: 0.5
600    # Required entry — the fallback weight for any relationship not
601    # listed above.
602    - name: _default
603      description: Fallback weight for any relationship not otherwise specified.
604      default_weight: 1.0
605
606# Body wiki-links `[[target]]` auto-emit as REFERENCES relations.
607# Remove this key to make unbacked wiki-links a validation error instead.
608alias_target_rel_type: REFERENCES
609
610# Community detection (graph clustering) tuning. REQUIRED — the block
611# must be present; the values below are the defaults, keep them unless
612# you know why you are changing them.
613community:
614  resolution: 1.0
615  seed: 42
616
617# The complete key reference for schema packages — every key the loader
618# accepts, with its type and default — is the meta-schema shipped in
619# your workspace at `.memstead/meta-schemas/schema-manifest.schema.json`.
620# This scaffold teaches by example; that file is exhaustive.
621"#
622    )
623}
624
625/// The generated example type. One well-commented type teaches the
626/// format; the built-in catalogue (`memstead schema install default`,
627/// then `.memstead/schemas/default@*/types/`) shows ten more.
628fn scaffold_example_type() -> String {
629    r#"# One entity type = one file. `name` must match the filename stem
630# and appear in the manifest's `types:` list.
631#
632# Keys marked REQUIRED must be present in every type file — deleting
633# one fails `memstead schema validate`. Everything else is optional.
634
635# REQUIRED.
636name: note
637# REQUIRED.
638description: |
639  A general-purpose note — replace this with your first real type.
640# REQUIRED.
641when_to_use: |
642  Use while sketching the schema; rename or split into sharper types
643  as the domain vocabulary firms up.
644
645# REQUIRED. Sections are the entity's markdown body. `required: true`
646# sections must be present on every create.
647sections:
648  - key: summary
649    heading: Summary
650    required: true
651    search_weight: 40.0
652    write_rules:
653      - "One or two sentences. Must stand alone in a search result."
654  - key: details
655    heading: Details
656    required: false
657    search_weight: 10.0
658    # catch_all: content under unmatched headings lands here.
659    catch_all: true
660    write_rules:
661      - "Everything beyond the summary. Bullets over prose."
662
663# REQUIRED (the key; it may be an empty list). Typed, filterable
664# frontmatter fields — beyond the built-in
665# type / created_date / last_modified / tags.
666# One rule for fields and sections alike: absence of `required` means
667# optional. `required: true` refuses a create that leaves the field
668# unset — unless a default fills it (required + default = always
669# present, never refused).
670metadata_fields:
671  - key: status
672    # required + default_value: every entity carries a status, and the
673    # default means a create never has to supply one.
674    required: true
675    description: Lifecycle state of the note.
676    field_type: string
677    default_value: active
678    enum_values: [active, archived]
679    filterable: equality
680  - key: source
681    # No `required` key: optional — an entity without a source is
682    # admitted. Use health_required_fields or a constraint if missing
683    # values should surface as findings instead.
684    description: Where the note's content came from.
685    field_type: string
686
687# REQUIRED. Search ranking: how much a title match weighs.
688title_weight: 100.0
689# REQUIRED. Sections included in full-text search.
690text_fields: [summary, details]
691# REQUIRED. Which declared relationship expresses hierarchy for this type.
692hierarchy_relationship: PART_OF
693# One effect only: relate refuses a self-loop (from == to) on the rel-types
694# listed here. Nothing propagates; for impact propagation declare a
695# `status_propagation` constraint instead.
696no_self_loop_relationships: [PART_OF]
697# Fields `memstead update` may touch on this type.
698updatable_fields: [title, summary, details, status, tags]
699# Sections the health report treats as required.
700health_required_fields: [summary]
701# Days without modification before health flags the entity stale.
702staleness_threshold_days: 180
703# Further optional type-level declarations (engine 0.10.0+), shapes in
704# the authoring guide and the generated type-definition.schema.json:
705#   required_outgoing  — edge obligations (cardinality, warn/block
706#                        severity, optional when_field/when_value pair
707#                        arming a block on a metadata enum value)
708#   must_reach         — reachability obligations over a relation set
709#                        (direction out/in, terminal_types, max_depth);
710#                        health-sweep only, always warn
711#   constraints        — the five-form vocabulary (requires_when,
712#                        unique, enum_from_neighbour, status_propagation
713#                        with rel_type or rel_types)
714#   signals            — edge_load counts with notice/warn thresholds,
715#                        served with contributors on every read
716# Prose guidance served to agents writing entities of this type.
717write_rules:
718  - "Notes are placeholders — split recurring shapes into dedicated types."
719"#
720    .to_string()
721}
722
723fn validate(ctx: &CliContext, args: ValidateArgs) -> anyhow::Result<()> {
724    // A directory carrying `schema-format.json` is a SEALED package —
725    // installer output, not authoring input. Reporting conformance
726    // errors against it sends the author fixing a file the seal wrote;
727    // name what it is instead. (The validate-vs-loader tolerance
728    // asymmetry itself is by design and documented; this is only the
729    // recognition hint.)
730    if args.path.join("schema-format.json").is_file() {
731        return Err(CliError::new(
732            ExitKind::Validation,
733            "SCHEMA_VALIDATION_FAILED",
734            format!(
735                "{} is a sealed schema package (it carries `schema-format.json`, the seal \
736                 marker), not authoring input — `schema validate` checks the directories you \
737                 author, before sealing. Validate the package's source directory instead, or \
738                 install this package directly with `memstead schema install`.",
739                args.path.display(),
740            ),
741        )
742        .with_details(json!({
743            "path": args.path,
744            "reason": "sealed_package",
745        }))
746        .into());
747    }
748    match memstead_schema::loader::load_schema_from_dir(&args.path)
749        .and_then(|s| memstead_schema::check_section_heading_roundtrip(&s).map(|()| s))
750        .and_then(|s| memstead_schema::check_reserved_metadata_keys(&s).map(|()| s))
751        .and_then(|s| memstead_schema::check_section_formats(&s).map(|()| s))
752    {
753        Ok(schema) => {
754            // Exemplar gate — same validator the install/seal path
755            // runs; the authoring pre-flight must not pass a package
756            // installation would refuse.
757            let schema = std::sync::Arc::new(schema);
758            if let Err(defect) = memstead_base::Engine::validate_schema_exemplars(&schema) {
759                return Err(CliError::new(
760                    ExitKind::Validation,
761                    "SCHEMA_VALIDATION_FAILED",
762                    format!("schema at {} is invalid: {defect}", args.path.display()),
763                )
764                .with_details(json!({ "path": args.path, "error": defect }))
765                .into());
766            }
767            let (name, version) = schema.id();
768            let type_count = schema.types.len();
769            if ctx.json {
770                print_json(&json!({
771                    "ok": true,
772                    "schema": format!("{name}@{version}"),
773                    "types": type_count,
774                    "path": args.path,
775                }))?;
776            } else {
777                print_markdown(&format!(
778                    "# Schema valid\n\n`{name}@{version}` — {type_count} type(s) at `{}`\n",
779                    args.path.display(),
780                ));
781            }
782            Ok(())
783        }
784        Err(e) => Err(CliError::new(
785            ExitKind::Validation,
786            "SCHEMA_VALIDATION_FAILED",
787            format!("schema at {} is invalid: {e}", args.path.display()),
788        )
789        .with_details(json!({
790            "path": args.path,
791            "error": e.to_string(),
792        }))
793        .into()),
794    }
795}
796
797fn install(ctx: &CliContext, args: InstallArgs) -> anyhow::Result<()> {
798    let (shape, root) = ctx.workspace_shape().ok_or_else(|| {
799        CliError::new(
800            ExitKind::Generic,
801            "NO_WORKSPACE",
802            "not inside a Memstead workspace (no `.memstead/workspace.toml` in any \
803             ancestor) — cd into your workspace first, or create one: memstead quickstart"
804                .to_string(),
805        )
806    })?;
807    let (schema_ref, files) = resolve_source(&args.source)?;
808
809    match shape {
810        WorkspaceShape::Filesystem => {
811            // Folder backend: write the package under `.memstead/schemas/`.
812            // AS-GIVEN: the resolver decided the generation — an authored
813            // directory source arrives stamped; a legacy builtin arrives
814            // unmarked, and stamping it here would flip every bare
815            // field's meaning in later seals (the plan-05 defect).
816            let pkg_dir = root
817                .join(".memstead")
818                .join("schemas")
819                .join(format!("{}@{}", schema_ref.name, schema_ref.version));
820            write_package(&pkg_dir, &files)?;
821            if ctx.json {
822                print_json(&json!({
823                    "ok": true,
824                    "schema": format!("{}@{}", schema_ref.name, schema_ref.version),
825                    "backend": "folder",
826                    "path": pkg_dir,
827                    "files": files.iter().map(|f| &f.archive_path).collect::<Vec<_>>(),
828                }))?;
829            } else {
830                print_markdown(&format!(
831                    "# Schema installed\n\n`{}@{}` → `{}` ({} file(s))\n",
832                    schema_ref.name,
833                    schema_ref.version,
834                    pkg_dir.display(),
835                    files.len(),
836                ));
837            }
838            Ok(())
839        }
840        WorkspaceShape::MemRepo => install_to_git_branch(ctx, &schema_ref, &files),
841    }
842}
843
844/// Install onto the git-branch backend — write the package onto the
845/// workspace's `__MEMSTEAD:schemas/` ref through the engine's
846/// below-boot repair surface (`memstead_git_branch::repair`), which
847/// runs the same `validate_schema_package` gate and the same ref
848/// writer as the booted `Engine::install_schema`. Deliberately never
849/// boots the workspace: `schema install` is a named remedy for
850/// boot-blocking states (an unresolvable pin whose package was never
851/// installed), so it must work on exactly the workspace whose boot it
852/// repairs — the plenum outage's failed escape route. Only present in
853/// the `mem-repo`-featured build; the lean binary refuses (it has no
854/// git-branch ref writer).
855#[cfg(feature = "mem-repo")]
856fn install_to_git_branch(
857    ctx: &CliContext,
858    schema_ref: &SchemaRef,
859    files: &[memstead_schema::SchemaSourceFile],
860) -> anyhow::Result<()> {
861    let Some((_shape, root)) = ctx.workspace_shape() else {
862        return Err(crate::setup::workspace_not_initialised_error(
863            "No workspace found. Run from a directory containing `.memstead/workspace.toml`.",
864        )
865        .into());
866    };
867    let pairs: Vec<(String, Vec<u8>)> = files
868        .iter()
869        .map(|f| (f.archive_path.clone(), f.bytes.clone()))
870        .collect();
871    let commit = memstead_git_branch::repair::install_schema_below_boot(
872        &root,
873        &schema_ref.name,
874        &schema_ref.version.to_string(),
875        &pairs,
876    )
877    .map_err(|e| crate::setup::boot_error_to_cli(&root, e))?;
878    if ctx.json {
879        print_json(&json!({
880            "ok": true,
881            "schema": format!("{}@{}", schema_ref.name, schema_ref.version),
882            "backend": "git-branch",
883            "ref": format!("__MEMSTEAD:schemas/{}@{}", schema_ref.name, schema_ref.version),
884            "commit": commit,
885        }))?;
886    } else {
887        print_markdown(&format!(
888            "# Schema installed\n\n`{}@{}` → `__MEMSTEAD:schemas/{}@{}` (commit `{}`)\n",
889            schema_ref.name, schema_ref.version, schema_ref.name, schema_ref.version, commit,
890        ));
891    }
892    Ok(())
893}
894
895#[cfg(not(feature = "mem-repo"))]
896fn install_to_git_branch(
897    _ctx: &CliContext,
898    _schema_ref: &SchemaRef,
899    _files: &[memstead_schema::SchemaSourceFile],
900) -> anyhow::Result<()> {
901    Err(CliError::new(
902        ExitKind::Generic,
903        "MEM_REPO_NOT_SUPPORTED",
904        "this binary was built without git-branch support — use the `memstead` binary to \
905         install a schema into a mem-repo workspace."
906            .to_string(),
907    )
908    .into())
909}
910
911/// Resolve `<source>` (a path to a package dir, or a built-in name /
912/// `name@version`) to its pin and the package files to write.
913fn resolve_source(
914    source: &str,
915) -> anyhow::Result<(SchemaRef, Vec<memstead_schema::SchemaSourceFile>)> {
916    let as_path = Path::new(source);
917    if as_path.is_dir() {
918        // Path source — validate with the engine loader before copying.
919        // Includes the heading round-trip and reserved-metadata-key
920        // gates: install is an authoring path, so a schema whose
921        // headings cannot derive back to their keys, or that declares
922        // a reserved `type`/`mem`/`id` metadata field, is refused
923        // here, never sealed.
924        let schema = memstead_schema::load_schema_from_dir(as_path)
925            .and_then(|s| memstead_schema::check_section_heading_roundtrip(&s).map(|()| s))
926            .and_then(|s| memstead_schema::check_reserved_metadata_keys(&s).map(|()| s))
927            .and_then(|s| memstead_schema::check_section_formats(&s).map(|()| s))
928            .map_err(|e| {
929                CliError::new(
930                    ExitKind::Validation,
931                    "SCHEMA_VALIDATION_FAILED",
932                    format!("package at {source} is invalid: {e}"),
933                )
934                .with_details(json!({ "path": source, "error": e.to_string() }))
935            })?;
936        // Exemplar gate — the same real-create-path validation the
937        // mem-repo install runs via `validate_schema_package`; a
938        // non-conformant exemplar never seals on either backend.
939        let schema = std::sync::Arc::new(schema);
940        if let Err(defect) = memstead_base::Engine::validate_schema_exemplars(&schema) {
941            return Err(CliError::new(
942                ExitKind::Validation,
943                "SCHEMA_VALIDATION_FAILED",
944                format!("package at {source} is invalid: {defect}"),
945            )
946            .with_details(json!({ "path": source, "error": defect }))
947            .into());
948        }
949        let (name, version) = schema.id();
950        let mut files = collect_dir_package(as_path)?;
951        // Stamp the seal with its authoring provenance: the canonical
952        // path this package was installed from. Detection basis for
953        // the authoring-drift health axis; only path-sourced installs
954        // get one (a built-in or archive source has no authoring dir).
955        // The stamp stays workspace-local — package collectors and the
956        // export paths exclude it by name.
957        let authoring_path = as_path
958            .canonicalize()
959            .unwrap_or_else(|_| as_path.to_path_buf());
960        files.push(memstead_schema::SchemaSourceFile {
961            archive_path: memstead_schema::INSTALL_PROVENANCE_FILE.to_string(),
962            bytes: serde_json::to_vec_pretty(&json!({
963                "authoring_path": authoring_path.display().to_string(),
964            }))
965            .expect("provenance stamp serialises"),
966        });
967        // A directory source is the AUTHORING tier and just validated
968        // under the current language (the retired `optional:` key would
969        // have refused above) — the one place a current-generation stamp
970        // is verified, so it is minted here and only here. Builtin and
971        // sealed sources below carry their marker (or its absence — the
972        // legacy claim) as-found.
973        let files = marked_package(files);
974        Ok((SchemaRef::new(name, version), files))
975    } else {
976        // Name source — resolve against the built-in catalogue.
977        let schema_ref = resolve_builtin_ref(source)?;
978        let mut files =
979            memstead_schema::collect_schema_source(None, None, &schema_ref).map_err(|e| {
980                CliError::new(
981                    ExitKind::Validation,
982                    "SCHEMA_NOT_FOUND",
983                    format!(
984                        "could not collect source for {}: {e}",
985                        schema_ref.as_display()
986                    ),
987                )
988            })?;
989        // Built-in packages may ship a `mem-template.json`; install it
990        // alongside the schema so the scaffolding travels with the fork.
991        if let Some(tpl) = memstead_schema::builtins::builtin_mem_template(&schema_ref.name) {
992            files.push(memstead_schema::SchemaSourceFile {
993                archive_path: "mem-template.json".to_string(),
994                bytes: serde_json::to_vec_pretty(&tpl).unwrap_or_default(),
995            });
996        }
997        Ok((schema_ref, files))
998    }
999}
1000
1001/// Resolve a built-in source string (`planning` or `planning@0.1.0`) to
1002/// a concrete pin against the embedded catalogue.
1003fn resolve_builtin_ref(source: &str) -> anyhow::Result<SchemaRef> {
1004    let reg = memstead_schema::SchemaRegistry::builtin();
1005    if source.contains('@') {
1006        let r: SchemaRef = source.parse().map_err(|e: String| {
1007            CliError::new(
1008                ExitKind::Validation,
1009                "INVALID_INPUT",
1010                format!("invalid schema pin {source:?}: {e}"),
1011            )
1012        })?;
1013        if reg.get(&r.name, &r.version).is_none() {
1014            return Err(CliError::new(
1015                ExitKind::Validation,
1016                "SCHEMA_NOT_FOUND",
1017                format!(
1018                    "no built-in schema {source} — pass a path to install a non-built-in package"
1019                ),
1020            )
1021            .into());
1022        }
1023        Ok(r)
1024    } else {
1025        match reg.resolve_by_name(source) {
1026            Ok(Some(s)) => {
1027                let (n, v) = s.id();
1028                Ok(SchemaRef::new(n, v))
1029            }
1030            Ok(None) => Err(CliError::new(
1031                ExitKind::Validation,
1032                "SCHEMA_NOT_FOUND",
1033                format!(
1034                    "no built-in schema named {source:?} — pass a path to install a non-built-in \
1035                     package, or a `name@version` pin"
1036                ),
1037            )
1038            .into()),
1039            Err(e) => Err(CliError::new(
1040                ExitKind::Validation,
1041                "INVALID_INPUT",
1042                format!("built-in name {source:?} is ambiguous: {e}"),
1043            )
1044            .into()),
1045        }
1046    }
1047}
1048
1049/// Collect the package files from an on-disk directory: `schema.yaml`,
1050/// `types/*.yaml`, and the optional `mem-template.json` / `README.md`.
1051fn collect_dir_package(dir: &Path) -> anyhow::Result<Vec<memstead_schema::SchemaSourceFile>> {
1052    use memstead_schema::SchemaSourceFile;
1053    let mut out = vec![SchemaSourceFile {
1054        archive_path: "schema.yaml".to_string(),
1055        bytes: std::fs::read(dir.join("schema.yaml"))?,
1056    }];
1057    let types = dir.join("types");
1058    if types.is_dir() {
1059        let mut paths: Vec<PathBuf> = std::fs::read_dir(&types)?
1060            .filter_map(|e| e.ok().map(|e| e.path()))
1061            .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("yaml"))
1062            .collect();
1063        paths.sort();
1064        for p in paths {
1065            if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
1066                out.push(SchemaSourceFile {
1067                    archive_path: format!("types/{name}"),
1068                    bytes: std::fs::read(&p)?,
1069                });
1070            }
1071        }
1072    }
1073    for opt in ["mem-template.json", "README.md"] {
1074        let p = dir.join(opt);
1075        if p.is_file() {
1076            out.push(SchemaSourceFile {
1077                archive_path: opt.to_string(),
1078                bytes: std::fs::read(&p)?,
1079            });
1080        }
1081    }
1082    Ok(out)
1083}
1084
1085/// Append the sealed format marker to a resolved package's file list
1086/// if absent. For the authoring (directory-source) resolver branch
1087/// ONLY — the one place current-language content is verified; sealed
1088/// and builtin sources travel as-found (see `with_format_marker`'s
1089/// contract in the schema crate).
1090fn marked_package(
1091    mut files: Vec<memstead_schema::SchemaSourceFile>,
1092) -> Vec<memstead_schema::SchemaSourceFile> {
1093    let marker = memstead_schema::loader::SCHEMA_FORMAT_MARKER_FILE;
1094    if !files.iter().any(|f| f.archive_path == marker) {
1095        files.push(memstead_schema::SchemaSourceFile {
1096            archive_path: marker.to_string(),
1097            bytes: memstead_schema::loader::SCHEMA_FORMAT_MARKER_CONTENT
1098                .as_bytes()
1099                .to_vec(),
1100        });
1101    }
1102    files
1103}
1104
1105/// Write the resolved package files under `pkg_dir`, creating parent
1106/// directories. The `# yaml-language-server:` directive on each YAML is
1107/// rewritten to the installed-location form so an editor resolves it
1108/// against the workspace's published `.memstead/meta-schemas/` rather
1109/// than the package source's repo-relative path. Idempotent —
1110/// re-running reproduces identical files.
1111fn write_package(
1112    pkg_dir: &Path,
1113    files: &[memstead_schema::SchemaSourceFile],
1114) -> anyhow::Result<()> {
1115    for f in files {
1116        let dest = pkg_dir.join(&f.archive_path);
1117        if let Some(parent) = dest.parent() {
1118            std::fs::create_dir_all(parent).map_err(|e| {
1119                CliError::new(
1120                    ExitKind::Generic,
1121                    "IO_ERROR",
1122                    format!("could not create {}: {e}", parent.display()),
1123                )
1124            })?;
1125        }
1126        let bytes = retarget_yaml_directive(&f.archive_path, &f.bytes);
1127        std::fs::write(&dest, &bytes).map_err(|e| {
1128            CliError::new(
1129                ExitKind::Generic,
1130                "IO_ERROR",
1131                format!("could not write {}: {e}", dest.display()),
1132            )
1133        })?;
1134    }
1135    Ok(())
1136}
1137
1138/// The installed-location `# yaml-language-server:` directive for a
1139/// package member, or `None` for non-YAML members (README,
1140/// mem-template.json). Paths are relative to the member's location
1141/// under `.memstead/schemas/<name>@<version>/` and resolve to the
1142/// workspace's `.memstead/meta-schemas/`, published by the schema
1143/// subcommands (see the note in `run`), never by engine boot.
1144fn directive_for(archive_path: &str) -> Option<&'static str> {
1145    if archive_path == "schema.yaml" {
1146        Some("# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json")
1147    } else if archive_path.starts_with("types/") && archive_path.ends_with(".yaml") {
1148        Some("# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json")
1149    } else {
1150        None
1151    }
1152}
1153
1154/// Replace a leading `# yaml-language-server:` directive (or prepend one)
1155/// so the installed YAML points at the workspace-published meta-schema.
1156/// Non-YAML members and non-UTF-8 bytes pass through verbatim.
1157fn retarget_yaml_directive(archive_path: &str, bytes: &[u8]) -> Vec<u8> {
1158    let Some(directive) = directive_for(archive_path) else {
1159        return bytes.to_vec();
1160    };
1161    let Ok(text) = std::str::from_utf8(bytes) else {
1162        return bytes.to_vec();
1163    };
1164    let body = if text.starts_with("# yaml-language-server:") {
1165        text.split_once('\n').map(|(_, rest)| rest).unwrap_or("")
1166    } else {
1167        text
1168    };
1169    format!("{directive}\n{body}").into_bytes()
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174    use super::*;
1175    use std::path::Path;
1176
1177    fn ctx() -> CliContext {
1178        CliContext {
1179            json: false,
1180            quiet: true,
1181            role: Default::default(),
1182            identity: None,
1183        }
1184    }
1185
1186    /// An unsealed copy of the current built-in is treated as fresh
1187    /// authoring input, and the shipped default-1.3 still speaks the
1188    /// retired exemplar-relation spelling (`to:` / `type:`) — its
1189    /// bytes are sealed, so it converges only at the family's next
1190    /// minted-for-meaning bump. Until then, validating the copy
1191    /// REFUSES with the rename pointer (install-time strict); the
1192    /// sealed original keeps loading through the catalogue's translate
1193    /// path, pinned by the loader suite. A converged copy of the same
1194    /// content validates cleanly — proving the refusal is the spelling
1195    /// alone.
1196    #[test]
1197    fn validate_builtin_default_copy_refuses_retired_exemplar_spelling() {
1198        let src = Path::new(env!("CARGO_MANIFEST_DIR"))
1199            .join("../memstead-schema/builtins/schemas/default-1.3");
1200        assert!(src.join("schema.yaml").is_file(), "fixture moved: {src:?}");
1201        let dir = tempfile::tempdir().unwrap();
1202        let dst = dir.path().join("authoring");
1203        copy_dir_without_marker(&src, &dst);
1204        let err = validate(&ctx(), ValidateArgs { path: dst.clone() })
1205            .expect_err("legacy exemplar spelling refuses as authoring input");
1206        assert!(
1207            err.to_string().contains("rel_type"),
1208            "refusal carries the rename pointer: {err}"
1209        );
1210
1211        // Mechanical rename to the mutation vocabulary — then the same
1212        // content validates cleanly.
1213        for entry in std::fs::read_dir(dst.join("types")).unwrap() {
1214            let path = entry.unwrap().path();
1215            let text = std::fs::read_to_string(&path).unwrap();
1216            let converged = text
1217                .replace("\n    - to: ", "\n    - target: ")
1218                .replace("\n      type: ", "\n      rel_type: ");
1219            std::fs::write(&path, converged).unwrap();
1220        }
1221        validate(&ctx(), ValidateArgs { path: dst })
1222            .expect("converged default builtin content must validate");
1223    }
1224
1225    fn copy_dir_without_marker(src: &Path, dst: &Path) {
1226        std::fs::create_dir_all(dst).unwrap();
1227        for entry in std::fs::read_dir(src).unwrap() {
1228            let entry = entry.unwrap();
1229            let name = entry.file_name();
1230            if name == "schema-format.json" {
1231                continue;
1232            }
1233            let target = dst.join(&name);
1234            if entry.file_type().unwrap().is_dir() {
1235                copy_dir_without_marker(&entry.path(), &target);
1236            } else {
1237                std::fs::copy(entry.path(), &target).unwrap();
1238            }
1239        }
1240    }
1241
1242    /// A directory carrying `schema-format.json` — a sealed package —
1243    /// is named as such instead of being conformance-checked as
1244    /// authoring input. The shipped builtin is exactly that shape.
1245    #[test]
1246    fn validate_names_sealed_package() {
1247        let path = Path::new(env!("CARGO_MANIFEST_DIR"))
1248            .join("../memstead-schema/builtins/schemas/default-1.3");
1249        let err = validate(&ctx(), ValidateArgs { path }).expect_err("sealed package must refuse");
1250        let cli = err
1251            .downcast_ref::<CliError>()
1252            .expect("error is a typed CliError");
1253        assert_eq!(cli.code, "SCHEMA_VALIDATION_FAILED");
1254        assert!(
1255            cli.message.contains("sealed schema package"),
1256            "message names the sealed package: {}",
1257            cli.message,
1258        );
1259        assert_eq!(
1260            cli.details.as_ref().unwrap()["reason"],
1261            json!("sealed_package"),
1262        );
1263    }
1264
1265    /// A malformed `schema.yaml` refuses with the typed
1266    /// `SCHEMA_VALIDATION_FAILED` code carrying the path in `details`.
1267    #[test]
1268    fn validate_rejects_malformed_schema_with_typed_code() {
1269        let dir = tempfile::tempdir().unwrap();
1270        std::fs::write(dir.path().join("schema.yaml"), "name: [unterminated\n").unwrap();
1271        let err = validate(
1272            &ctx(),
1273            ValidateArgs {
1274                path: dir.path().to_path_buf(),
1275            },
1276        )
1277        .expect_err("malformed schema must refuse");
1278        let cli = err
1279            .downcast_ref::<CliError>()
1280            .expect("error is a typed CliError");
1281        assert_eq!(cli.code, "SCHEMA_VALIDATION_FAILED");
1282        assert_eq!(cli.kind, ExitKind::Validation);
1283        assert_eq!(
1284            cli.details.as_ref().unwrap()["path"],
1285            json!(dir.path()),
1286            "details echoes the offending path",
1287        );
1288    }
1289
1290    /// A read resolves a bare name to the newest generation and a pin
1291    /// as given; an unknown name refuses typed with the roster.
1292    #[test]
1293    fn resolve_builtin_read_ref_defaults_bare_names_to_newest() {
1294        let newest = resolve_builtin_read_ref("planning").expect("bare planning reads");
1295        let mut all = memstead_schema::SchemaRegistry::builtin().available_versions("planning");
1296        all.sort();
1297        assert_eq!(Some(&newest.version), all.last());
1298        let pinned = resolve_builtin_read_ref("planning@0.1.0").expect("pin reads");
1299        assert_eq!(pinned.version.to_string(), "0.1.0");
1300        let err = resolve_builtin_read_ref("not-a-builtin").expect_err("unknown refuses");
1301        let cli = err.downcast_ref::<CliError>().unwrap();
1302        assert_eq!(cli.code, "SCHEMA_NOT_FOUND");
1303        assert!(
1304            cli.message.contains("planning"),
1305            "names the roster: {}",
1306            cli.message
1307        );
1308    }
1309
1310    /// A bare built-in name resolves to its concrete pin; an explicit
1311    /// `name@version` is accepted; an unknown name refuses typed.
1312    #[test]
1313    fn resolve_builtin_ref_handles_name_pin_and_unknown() {
1314        // Every built-in ships multiple versions since the plan-06
1315        // vocabulary bump, so bare names are ambiguous by design and
1316        // pins resolve explicitly.
1317        let bare = resolve_builtin_ref("software@0.2.0").expect("software pin resolves");
1318        assert_eq!(bare.name, "software");
1319        let pinned = resolve_builtin_ref("planning@0.1.0").expect("explicit pin resolves");
1320        assert_eq!(pinned.name, "planning");
1321        assert_eq!(pinned.version.to_string(), "0.1.0");
1322        resolve_builtin_ref("planning@0.2.0").expect("bumped pin resolves");
1323        resolve_builtin_ref("planning").expect_err("bare planning is ambiguous");
1324        let err = resolve_builtin_ref("not-a-builtin").expect_err("unknown name refuses");
1325        assert_eq!(
1326            err.downcast_ref::<CliError>().unwrap().code,
1327            "SCHEMA_NOT_FOUND",
1328        );
1329    }
1330
1331    /// Installing a built-in by name collects its schema files *and*
1332    /// its `mem-template.json`.
1333    #[test]
1334    fn resolve_source_for_builtin_includes_schema_and_template() {
1335        let (schema_ref, files) =
1336            resolve_source("planning@0.1.0").expect("planning source collects");
1337        assert_eq!(schema_ref.name, "planning");
1338        let paths: Vec<&str> = files.iter().map(|f| f.archive_path.as_str()).collect();
1339        assert!(paths.contains(&"schema.yaml"), "got {paths:?}");
1340        assert!(
1341            paths.contains(&"mem-template.json"),
1342            "built-in install must carry the mem-template.json, got {paths:?}",
1343        );
1344    }
1345
1346    /// `collect_dir_package` + `write_package` round-trip a package
1347    /// (schema.yaml + types + template) onto disk verbatim.
1348    #[test]
1349    fn collect_and_write_package_round_trips() {
1350        let src = tempfile::tempdir().unwrap();
1351        std::fs::create_dir_all(src.path().join("types")).unwrap();
1352        std::fs::write(src.path().join("schema.yaml"), b"name: x\n").unwrap();
1353        std::fs::write(src.path().join("types/doc.yaml"), b"name: doc\n").unwrap();
1354        std::fs::write(src.path().join("mem-template.json"), b"{}\n").unwrap();
1355
1356        let files = collect_dir_package(src.path()).unwrap();
1357        let dest = tempfile::tempdir().unwrap();
1358        let pkg = dest.path().join("x@0.1.0");
1359        write_package(&pkg, &files).unwrap();
1360
1361        // YAML members gain the installed-location directive; bodies and
1362        // non-YAML members (mem-template.json) are preserved.
1363        let schema = std::fs::read_to_string(pkg.join("schema.yaml")).unwrap();
1364        assert_eq!(
1365            schema,
1366            "# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json\nname: x\n",
1367        );
1368        let doc = std::fs::read_to_string(pkg.join("types/doc.yaml")).unwrap();
1369        assert_eq!(
1370            doc,
1371            "# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json\nname: doc\n",
1372        );
1373        assert_eq!(
1374            std::fs::read(pkg.join("mem-template.json")).unwrap(),
1375            b"{}\n"
1376        );
1377        // Idempotent: a second write reproduces identical files.
1378        write_package(&pkg, &files).unwrap();
1379        assert_eq!(
1380            std::fs::read_to_string(pkg.join("schema.yaml")).unwrap(),
1381            schema
1382        );
1383    }
1384
1385    /// The directive retarget replaces an existing leading directive (it
1386    /// does not stack) and prepends one when absent; non-YAML and
1387    /// non-UTF-8 members pass through.
1388    #[test]
1389    fn retarget_yaml_directive_replaces_or_prepends() {
1390        // Existing (repo-relative) directive is replaced, body kept.
1391        let existing = b"# yaml-language-server: $schema=../../../generated/schema-manifest.schema.json\nname: y\n";
1392        let out = String::from_utf8(retarget_yaml_directive("schema.yaml", existing)).unwrap();
1393        assert_eq!(
1394            out,
1395            "# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json\nname: y\n",
1396        );
1397        // Absent directive is prepended.
1398        let bare = retarget_yaml_directive("types/t.yaml", b"name: t\n");
1399        assert_eq!(
1400            String::from_utf8(bare).unwrap(),
1401            "# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json\nname: t\n",
1402        );
1403        // Non-YAML members untouched.
1404        assert_eq!(retarget_yaml_directive("README.md", b"# hi\n"), b"# hi\n");
1405    }
1406}