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