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