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