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