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