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#
501# Keys marked REQUIRED must be present in every type file — deleting
502# one fails `memstead schema validate`. Everything else is optional.
503
504# REQUIRED.
505name: note
506# REQUIRED.
507description: |
508  A general-purpose note — replace this with your first real type.
509# REQUIRED.
510when_to_use: |
511  Use while sketching the schema; rename or split into sharper types
512  as the domain vocabulary firms up.
513
514# REQUIRED. Sections are the entity's markdown body. `required: true`
515# sections must be present on every create.
516sections:
517  - key: summary
518    heading: Summary
519    required: true
520    search_weight: 40.0
521    write_rules:
522      - "One or two sentences. Must stand alone in a search result."
523  - key: details
524    heading: Details
525    required: false
526    search_weight: 10.0
527    # catch_all: content under unmatched headings lands here.
528    catch_all: true
529    write_rules:
530      - "Everything beyond the summary. Bullets over prose."
531
532# REQUIRED (the key; it may be an empty list). Typed, filterable
533# frontmatter fields — beyond the built-in
534# type / created_date / last_modified / tags.
535# One rule for fields and sections alike: absence of `required` means
536# optional. `required: true` refuses a create that leaves the field
537# unset — unless a default fills it (required + default = always
538# present, never refused).
539metadata_fields:
540  - key: status
541    # required + default_value: every entity carries a status, and the
542    # default means a create never has to supply one.
543    required: true
544    description: Lifecycle state of the note.
545    field_type: string
546    default_value: active
547    enum_values: [active, archived]
548    filterable: equality
549  - key: source
550    # No `required` key: optional — an entity without a source is
551    # admitted. Use health_required_fields or a constraint if missing
552    # values should surface as findings instead.
553    description: Where the note's content came from.
554    field_type: string
555
556# REQUIRED. Search ranking: how much a title match weighs.
557title_weight: 100.0
558# REQUIRED. Sections included in full-text search.
559text_fields: [summary, details]
560# REQUIRED. Which declared relationship expresses hierarchy for this type.
561hierarchy_relationship: PART_OF
562# One effect only: relate refuses a self-loop (from == to) on the rel-types
563# listed here. Nothing propagates; for impact propagation declare a
564# `status_propagation` constraint instead.
565no_self_loop_relationships: [PART_OF]
566# Fields `memstead update` may touch on this type.
567updatable_fields: [title, summary, details, status, tags]
568# Sections the health report treats as required.
569health_required_fields: [summary]
570# Days without modification before health flags the entity stale.
571staleness_threshold_days: 180
572# Prose guidance served to agents writing entities of this type.
573write_rules:
574  - "Notes are placeholders — split recurring shapes into dedicated types."
575"#
576    .to_string()
577}
578
579fn validate(ctx: &CliContext, args: ValidateArgs) -> anyhow::Result<()> {
580    match memstead_schema::loader::load_schema_from_dir(&args.path)
581        .and_then(|s| memstead_schema::check_section_heading_roundtrip(&s).map(|()| s))
582        .and_then(|s| memstead_schema::check_reserved_metadata_keys(&s).map(|()| s))
583        .and_then(|s| memstead_schema::check_section_formats(&s).map(|()| s))
584    {
585        Ok(schema) => {
586            // Exemplar gate — same validator the install/seal path
587            // runs; the authoring pre-flight must not pass a package
588            // installation would refuse.
589            let schema = std::sync::Arc::new(schema);
590            if let Err(defect) = memstead_base::Engine::validate_schema_exemplars(&schema) {
591                return Err(CliError::new(
592                    ExitKind::Validation,
593                    "SCHEMA_VALIDATION_FAILED",
594                    format!("schema at {} is invalid: {defect}", args.path.display()),
595                )
596                .with_details(json!({ "path": args.path, "error": defect }))
597                .into());
598            }
599            let (name, version) = schema.id();
600            let type_count = schema.types.len();
601            if ctx.json {
602                print_json(&json!({
603                    "ok": true,
604                    "schema": format!("{name}@{version}"),
605                    "types": type_count,
606                    "path": args.path,
607                }))?;
608            } else {
609                print_markdown(&format!(
610                    "# Schema valid\n\n`{name}@{version}` — {type_count} type(s) at `{}`\n",
611                    args.path.display(),
612                ));
613            }
614            Ok(())
615        }
616        Err(e) => Err(CliError::new(
617            ExitKind::Validation,
618            "SCHEMA_VALIDATION_FAILED",
619            format!("schema at {} is invalid: {e}", args.path.display()),
620        )
621        .with_details(json!({
622            "path": args.path,
623            "error": e.to_string(),
624        }))
625        .into()),
626    }
627}
628
629fn install(ctx: &CliContext, args: InstallArgs) -> anyhow::Result<()> {
630    let (shape, root) = ctx.workspace_shape().ok_or_else(|| {
631        CliError::new(
632            ExitKind::Generic,
633            "NO_WORKSPACE",
634            "not inside a Memstead workspace (no `.memstead/workspace.toml` in any \
635             ancestor) — cd into your workspace first, or create one: memstead quickstart"
636                .to_string(),
637        )
638    })?;
639    let (schema_ref, files) = resolve_source(&args.source)?;
640
641    match shape {
642        WorkspaceShape::Filesystem => {
643            // Folder backend: write the package under `.memstead/schemas/`.
644            // Marked: the install gate validates the current language, so
645            // the installed copy carries its generation for every later
646            // seal (export/publish collects it as-found).
647            let files = marked_package(files);
648            let pkg_dir = root
649                .join(".memstead")
650                .join("schemas")
651                .join(format!("{}@{}", schema_ref.name, schema_ref.version));
652            write_package(&pkg_dir, &files)?;
653            if ctx.json {
654                print_json(&json!({
655                    "ok": true,
656                    "schema": format!("{}@{}", schema_ref.name, schema_ref.version),
657                    "backend": "folder",
658                    "path": pkg_dir,
659                    "files": files.iter().map(|f| &f.archive_path).collect::<Vec<_>>(),
660                }))?;
661            } else {
662                print_markdown(&format!(
663                    "# Schema installed\n\n`{}@{}` → `{}` ({} file(s))\n",
664                    schema_ref.name,
665                    schema_ref.version,
666                    pkg_dir.display(),
667                    files.len(),
668                ));
669            }
670            Ok(())
671        }
672        WorkspaceShape::MemRepo => install_to_git_branch(ctx, &schema_ref, &files),
673    }
674}
675
676/// Install onto the git-branch backend — write the package onto the
677/// workspace's `__MEMSTEAD:schemas/` ref through the engine's
678/// below-boot repair surface (`memstead_git_branch::repair`), which
679/// runs the same `validate_schema_package` gate and the same ref
680/// writer as the booted `Engine::install_schema`. Deliberately never
681/// boots the workspace: `schema install` is a named remedy for
682/// boot-blocking states (an unresolvable pin whose package was never
683/// installed), so it must work on exactly the workspace whose boot it
684/// repairs — the plenum outage's failed escape route. Only present in
685/// the `mem-repo`-featured build; the lean binary refuses (it has no
686/// git-branch ref writer).
687#[cfg(feature = "mem-repo")]
688fn install_to_git_branch(
689    ctx: &CliContext,
690    schema_ref: &SchemaRef,
691    files: &[memstead_schema::SchemaSourceFile],
692) -> anyhow::Result<()> {
693    let Some((_shape, root)) = ctx.workspace_shape() else {
694        return Err(crate::setup::workspace_not_initialised_error(
695            "No workspace found. Run from a directory containing `.memstead/workspace.toml`.",
696        )
697        .into());
698    };
699    let pairs: Vec<(String, Vec<u8>)> = files
700        .iter()
701        .map(|f| (f.archive_path.clone(), f.bytes.clone()))
702        .collect();
703    let commit = memstead_git_branch::repair::install_schema_below_boot(
704        &root,
705        &schema_ref.name,
706        &schema_ref.version.to_string(),
707        &pairs,
708    )
709    .map_err(|e| crate::setup::boot_error_to_cli(&root, e))?;
710    if ctx.json {
711        print_json(&json!({
712            "ok": true,
713            "schema": format!("{}@{}", schema_ref.name, schema_ref.version),
714            "backend": "git-branch",
715            "ref": format!("__MEMSTEAD:schemas/{}@{}", schema_ref.name, schema_ref.version),
716            "commit": commit,
717        }))?;
718    } else {
719        print_markdown(&format!(
720            "# Schema installed\n\n`{}@{}` → `__MEMSTEAD:schemas/{}@{}` (commit `{}`)\n",
721            schema_ref.name, schema_ref.version, schema_ref.name, schema_ref.version, commit,
722        ));
723    }
724    Ok(())
725}
726
727#[cfg(not(feature = "mem-repo"))]
728fn install_to_git_branch(
729    _ctx: &CliContext,
730    _schema_ref: &SchemaRef,
731    _files: &[memstead_schema::SchemaSourceFile],
732) -> anyhow::Result<()> {
733    Err(CliError::new(
734        ExitKind::Generic,
735        "MEM_REPO_NOT_SUPPORTED",
736        "this binary was built without git-branch support — use the `memstead` binary to \
737         install a schema into a mem-repo workspace."
738            .to_string(),
739    )
740    .into())
741}
742
743/// Resolve `<source>` (a path to a package dir, or a built-in name /
744/// `name@version`) to its pin and the package files to write.
745fn resolve_source(
746    source: &str,
747) -> anyhow::Result<(SchemaRef, Vec<memstead_schema::SchemaSourceFile>)> {
748    let as_path = Path::new(source);
749    if as_path.is_dir() {
750        // Path source — validate with the engine loader before copying.
751        // Includes the heading round-trip and reserved-metadata-key
752        // gates: install is an authoring path, so a schema whose
753        // headings cannot derive back to their keys, or that declares
754        // a reserved `type`/`mem`/`id` metadata field, is refused
755        // here, never sealed.
756        let schema = memstead_schema::load_schema_from_dir(as_path)
757            .and_then(|s| memstead_schema::check_section_heading_roundtrip(&s).map(|()| s))
758            .and_then(|s| memstead_schema::check_reserved_metadata_keys(&s).map(|()| s))
759            .and_then(|s| memstead_schema::check_section_formats(&s).map(|()| s))
760            .map_err(|e| {
761                CliError::new(
762                    ExitKind::Validation,
763                    "SCHEMA_VALIDATION_FAILED",
764                    format!("package at {source} is invalid: {e}"),
765                )
766                .with_details(json!({ "path": source, "error": e.to_string() }))
767            })?;
768        // Exemplar gate — the same real-create-path validation the
769        // mem-repo install runs via `validate_schema_package`; a
770        // non-conformant exemplar never seals on either backend.
771        let schema = std::sync::Arc::new(schema);
772        if let Err(defect) = memstead_base::Engine::validate_schema_exemplars(&schema) {
773            return Err(CliError::new(
774                ExitKind::Validation,
775                "SCHEMA_VALIDATION_FAILED",
776                format!("package at {source} is invalid: {defect}"),
777            )
778            .with_details(json!({ "path": source, "error": defect }))
779            .into());
780        }
781        let (name, version) = schema.id();
782        let mut files = collect_dir_package(as_path)?;
783        // Stamp the seal with its authoring provenance: the canonical
784        // path this package was installed from. Detection basis for
785        // the authoring-drift health axis; only path-sourced installs
786        // get one (a built-in or archive source has no authoring dir).
787        // The stamp stays workspace-local — package collectors and the
788        // export paths exclude it by name.
789        let authoring_path = as_path
790            .canonicalize()
791            .unwrap_or_else(|_| as_path.to_path_buf());
792        files.push(memstead_schema::SchemaSourceFile {
793            archive_path: memstead_schema::INSTALL_PROVENANCE_FILE.to_string(),
794            bytes: serde_json::to_vec_pretty(&json!({
795                "authoring_path": authoring_path.display().to_string(),
796            }))
797            .expect("provenance stamp serialises"),
798        });
799        Ok((SchemaRef::new(name, version), files))
800    } else {
801        // Name source — resolve against the built-in catalogue.
802        let schema_ref = resolve_builtin_ref(source)?;
803        let mut files =
804            memstead_schema::collect_schema_source(None, None, &schema_ref).map_err(|e| {
805                CliError::new(
806                    ExitKind::Validation,
807                    "SCHEMA_NOT_FOUND",
808                    format!(
809                        "could not collect source for {}: {e}",
810                        schema_ref.as_display()
811                    ),
812                )
813            })?;
814        // Built-in packages may ship a `mem-template.json`; install it
815        // alongside the schema so the scaffolding travels with the fork.
816        if let Some(tpl) = memstead_schema::builtins::builtin_mem_template(&schema_ref.name) {
817            files.push(memstead_schema::SchemaSourceFile {
818                archive_path: "mem-template.json".to_string(),
819                bytes: serde_json::to_vec_pretty(&tpl).unwrap_or_default(),
820            });
821        }
822        Ok((schema_ref, files))
823    }
824}
825
826/// Resolve a built-in source string (`planning` or `planning@0.1.0`) to
827/// a concrete pin against the embedded catalogue.
828fn resolve_builtin_ref(source: &str) -> anyhow::Result<SchemaRef> {
829    let reg = memstead_schema::SchemaRegistry::builtin();
830    if source.contains('@') {
831        let r: SchemaRef = source.parse().map_err(|e: String| {
832            CliError::new(
833                ExitKind::Validation,
834                "INVALID_INPUT",
835                format!("invalid schema pin {source:?}: {e}"),
836            )
837        })?;
838        if reg.get(&r.name, &r.version).is_none() {
839            return Err(CliError::new(
840                ExitKind::Validation,
841                "SCHEMA_NOT_FOUND",
842                format!(
843                    "no built-in schema {source} — pass a path to install a non-built-in package"
844                ),
845            )
846            .into());
847        }
848        Ok(r)
849    } else {
850        match reg.resolve_by_name(source) {
851            Ok(Some(s)) => {
852                let (n, v) = s.id();
853                Ok(SchemaRef::new(n, v))
854            }
855            Ok(None) => Err(CliError::new(
856                ExitKind::Validation,
857                "SCHEMA_NOT_FOUND",
858                format!(
859                    "no built-in schema named {source:?} — pass a path to install a non-built-in \
860                     package, or a `name@version` pin"
861                ),
862            )
863            .into()),
864            Err(e) => Err(CliError::new(
865                ExitKind::Validation,
866                "INVALID_INPUT",
867                format!("built-in name {source:?} is ambiguous: {e}"),
868            )
869            .into()),
870        }
871    }
872}
873
874/// Collect the package files from an on-disk directory: `schema.yaml`,
875/// `types/*.yaml`, and the optional `mem-template.json` / `README.md`.
876fn collect_dir_package(dir: &Path) -> anyhow::Result<Vec<memstead_schema::SchemaSourceFile>> {
877    use memstead_schema::SchemaSourceFile;
878    let mut out = vec![SchemaSourceFile {
879        archive_path: "schema.yaml".to_string(),
880        bytes: std::fs::read(dir.join("schema.yaml"))?,
881    }];
882    let types = dir.join("types");
883    if types.is_dir() {
884        let mut paths: Vec<PathBuf> = std::fs::read_dir(&types)?
885            .filter_map(|e| e.ok().map(|e| e.path()))
886            .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("yaml"))
887            .collect();
888        paths.sort();
889        for p in paths {
890            if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
891                out.push(SchemaSourceFile {
892                    archive_path: format!("types/{name}"),
893                    bytes: std::fs::read(&p)?,
894                });
895            }
896        }
897    }
898    for opt in ["mem-template.json", "README.md"] {
899        let p = dir.join(opt);
900        if p.is_file() {
901            out.push(SchemaSourceFile {
902                archive_path: opt.to_string(),
903                bytes: std::fs::read(&p)?,
904            });
905        }
906    }
907    Ok(out)
908}
909
910/// Append the sealed format marker to a resolved package's file list
911/// if absent — the install gate validates the current language, so the
912/// installed copy carries its metadata-polarity generation for every
913/// later seal (export/publish collect it as-found).
914fn marked_package(
915    mut files: Vec<memstead_schema::SchemaSourceFile>,
916) -> Vec<memstead_schema::SchemaSourceFile> {
917    let marker = memstead_schema::loader::SCHEMA_FORMAT_MARKER_FILE;
918    if !files.iter().any(|f| f.archive_path == marker) {
919        files.push(memstead_schema::SchemaSourceFile {
920            archive_path: marker.to_string(),
921            bytes: memstead_schema::loader::SCHEMA_FORMAT_MARKER_CONTENT
922                .as_bytes()
923                .to_vec(),
924        });
925    }
926    files
927}
928
929/// Write the resolved package files under `pkg_dir`, creating parent
930/// directories. The `# yaml-language-server:` directive on each YAML is
931/// rewritten to the installed-location form so an editor resolves it
932/// against the workspace's published `.memstead/meta-schemas/` rather
933/// than the package source's repo-relative path. Idempotent —
934/// re-running reproduces identical files.
935fn write_package(
936    pkg_dir: &Path,
937    files: &[memstead_schema::SchemaSourceFile],
938) -> anyhow::Result<()> {
939    for f in files {
940        let dest = pkg_dir.join(&f.archive_path);
941        if let Some(parent) = dest.parent() {
942            std::fs::create_dir_all(parent).map_err(|e| {
943                CliError::new(
944                    ExitKind::Generic,
945                    "IO_ERROR",
946                    format!("could not create {}: {e}", parent.display()),
947                )
948            })?;
949        }
950        let bytes = retarget_yaml_directive(&f.archive_path, &f.bytes);
951        std::fs::write(&dest, &bytes).map_err(|e| {
952            CliError::new(
953                ExitKind::Generic,
954                "IO_ERROR",
955                format!("could not write {}: {e}", dest.display()),
956            )
957        })?;
958    }
959    Ok(())
960}
961
962/// The installed-location `# yaml-language-server:` directive for a
963/// package member, or `None` for non-YAML members (README,
964/// mem-template.json). Paths are relative to the member's location
965/// under `.memstead/schemas/<name>@<version>/` and resolve to the
966/// workspace's `.memstead/meta-schemas/` published by engine boot.
967fn directive_for(archive_path: &str) -> Option<&'static str> {
968    if archive_path == "schema.yaml" {
969        Some("# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json")
970    } else if archive_path.starts_with("types/") && archive_path.ends_with(".yaml") {
971        Some("# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json")
972    } else {
973        None
974    }
975}
976
977/// Replace a leading `# yaml-language-server:` directive (or prepend one)
978/// so the installed YAML points at the workspace-published meta-schema.
979/// Non-YAML members and non-UTF-8 bytes pass through verbatim.
980fn retarget_yaml_directive(archive_path: &str, bytes: &[u8]) -> Vec<u8> {
981    let Some(directive) = directive_for(archive_path) else {
982        return bytes.to_vec();
983    };
984    let Ok(text) = std::str::from_utf8(bytes) else {
985        return bytes.to_vec();
986    };
987    let body = if text.starts_with("# yaml-language-server:") {
988        text.split_once('\n').map(|(_, rest)| rest).unwrap_or("")
989    } else {
990        text
991    };
992    format!("{directive}\n{body}").into_bytes()
993}
994
995#[cfg(test)]
996mod tests {
997    use super::*;
998    use std::path::Path;
999
1000    fn ctx() -> CliContext {
1001        CliContext {
1002            json: false,
1003            quiet: true,
1004            role: Default::default(),
1005        }
1006    }
1007
1008    /// A shipped built-in package validates cleanly — the loader the
1009    /// command runs is the same one the engine boots with.
1010    #[test]
1011    fn validate_accepts_builtin_default_schema() {
1012        // The CURRENT generation — older sealed generations use the
1013        // retired `optional:` key and legitimately refuse under the
1014        // authoring gate this command runs.
1015        let path = Path::new(env!("CARGO_MANIFEST_DIR"))
1016            .join("../memstead-schema/builtins/schemas/default-1.3");
1017        assert!(
1018            path.join("schema.yaml").is_file(),
1019            "fixture moved: {path:?}"
1020        );
1021        validate(&ctx(), ValidateArgs { path }).expect("default builtin must validate");
1022    }
1023
1024    /// A malformed `schema.yaml` refuses with the typed
1025    /// `SCHEMA_VALIDATION_FAILED` code carrying the path in `details`.
1026    #[test]
1027    fn validate_rejects_malformed_schema_with_typed_code() {
1028        let dir = tempfile::tempdir().unwrap();
1029        std::fs::write(dir.path().join("schema.yaml"), "name: [unterminated\n").unwrap();
1030        let err = validate(
1031            &ctx(),
1032            ValidateArgs {
1033                path: dir.path().to_path_buf(),
1034            },
1035        )
1036        .expect_err("malformed schema must refuse");
1037        let cli = err
1038            .downcast_ref::<CliError>()
1039            .expect("error is a typed CliError");
1040        assert_eq!(cli.code, "SCHEMA_VALIDATION_FAILED");
1041        assert_eq!(cli.kind, ExitKind::Validation);
1042        assert_eq!(
1043            cli.details.as_ref().unwrap()["path"],
1044            json!(dir.path()),
1045            "details echoes the offending path",
1046        );
1047    }
1048
1049    /// A bare built-in name resolves to its concrete pin; an explicit
1050    /// `name@version` is accepted; an unknown name refuses typed.
1051    #[test]
1052    fn resolve_builtin_ref_handles_name_pin_and_unknown() {
1053        // Every built-in ships multiple versions since the plan-06
1054        // vocabulary bump, so bare names are ambiguous by design and
1055        // pins resolve explicitly.
1056        let bare = resolve_builtin_ref("software@0.2.0").expect("software pin resolves");
1057        assert_eq!(bare.name, "software");
1058        let pinned = resolve_builtin_ref("planning@0.1.0").expect("explicit pin resolves");
1059        assert_eq!(pinned.name, "planning");
1060        assert_eq!(pinned.version.to_string(), "0.1.0");
1061        resolve_builtin_ref("planning@0.2.0").expect("bumped pin resolves");
1062        resolve_builtin_ref("planning").expect_err("bare planning is ambiguous");
1063        let err = resolve_builtin_ref("not-a-builtin").expect_err("unknown name refuses");
1064        assert_eq!(
1065            err.downcast_ref::<CliError>().unwrap().code,
1066            "SCHEMA_NOT_FOUND",
1067        );
1068    }
1069
1070    /// Installing a built-in by name collects its schema files *and*
1071    /// its `mem-template.json`.
1072    #[test]
1073    fn resolve_source_for_builtin_includes_schema_and_template() {
1074        let (schema_ref, files) =
1075            resolve_source("planning@0.1.0").expect("planning source collects");
1076        assert_eq!(schema_ref.name, "planning");
1077        let paths: Vec<&str> = files.iter().map(|f| f.archive_path.as_str()).collect();
1078        assert!(paths.contains(&"schema.yaml"), "got {paths:?}");
1079        assert!(
1080            paths.contains(&"mem-template.json"),
1081            "built-in install must carry the mem-template.json, got {paths:?}",
1082        );
1083    }
1084
1085    /// `collect_dir_package` + `write_package` round-trip a package
1086    /// (schema.yaml + types + template) onto disk verbatim.
1087    #[test]
1088    fn collect_and_write_package_round_trips() {
1089        let src = tempfile::tempdir().unwrap();
1090        std::fs::create_dir_all(src.path().join("types")).unwrap();
1091        std::fs::write(src.path().join("schema.yaml"), b"name: x\n").unwrap();
1092        std::fs::write(src.path().join("types/doc.yaml"), b"name: doc\n").unwrap();
1093        std::fs::write(src.path().join("mem-template.json"), b"{}\n").unwrap();
1094
1095        let files = collect_dir_package(src.path()).unwrap();
1096        let dest = tempfile::tempdir().unwrap();
1097        let pkg = dest.path().join("x@0.1.0");
1098        write_package(&pkg, &files).unwrap();
1099
1100        // YAML members gain the installed-location directive; bodies and
1101        // non-YAML members (mem-template.json) are preserved.
1102        let schema = std::fs::read_to_string(pkg.join("schema.yaml")).unwrap();
1103        assert_eq!(
1104            schema,
1105            "# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json\nname: x\n",
1106        );
1107        let doc = std::fs::read_to_string(pkg.join("types/doc.yaml")).unwrap();
1108        assert_eq!(
1109            doc,
1110            "# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json\nname: doc\n",
1111        );
1112        assert_eq!(
1113            std::fs::read(pkg.join("mem-template.json")).unwrap(),
1114            b"{}\n"
1115        );
1116        // Idempotent: a second write reproduces identical files.
1117        write_package(&pkg, &files).unwrap();
1118        assert_eq!(
1119            std::fs::read_to_string(pkg.join("schema.yaml")).unwrap(),
1120            schema
1121        );
1122    }
1123
1124    /// The directive retarget replaces an existing leading directive (it
1125    /// does not stack) and prepends one when absent; non-YAML and
1126    /// non-UTF-8 members pass through.
1127    #[test]
1128    fn retarget_yaml_directive_replaces_or_prepends() {
1129        // Existing (repo-relative) directive is replaced, body kept.
1130        let existing = b"# yaml-language-server: $schema=../../../generated/schema-manifest.schema.json\nname: y\n";
1131        let out = String::from_utf8(retarget_yaml_directive("schema.yaml", existing)).unwrap();
1132        assert_eq!(
1133            out,
1134            "# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json\nname: y\n",
1135        );
1136        // Absent directive is prepended.
1137        let bare = retarget_yaml_directive("types/t.yaml", b"name: t\n");
1138        assert_eq!(
1139            String::from_utf8(bare).unwrap(),
1140            "# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json\nname: t\n",
1141        );
1142        // Non-YAML members untouched.
1143        assert_eq!(retarget_yaml_directive("README.md", b"# hi\n"), b"# hi\n");
1144    }
1145}