1use std::path::{Path, PathBuf};
35
36use clap::{Args as ClapArgs, Subcommand};
37use serde_json::json;
38
39use memstead_schema::SchemaRef;
40
41use crate::CliError;
42use crate::output::{ExitKind, print_json, print_markdown};
43use crate::setup::{CliContext, WorkspaceShape};
44
45#[derive(ClapArgs, Debug)]
46#[command(args_conflicts_with_subcommands = true)]
47pub struct Args {
48 #[command(subcommand)]
49 pub command: Option<SchemaCommand>,
50
51 #[arg(value_name = "REF")]
63 pub reference: Option<String>,
64}
65
66#[derive(Subcommand, Debug)]
67pub enum SchemaCommand {
68 New(NewArgs),
73
74 Validate(ValidateArgs),
87
88 Install(InstallArgs),
100}
101
102#[derive(ClapArgs, Debug)]
103pub struct NewArgs {
104 pub name: String,
108}
109
110#[derive(ClapArgs, Debug)]
111pub struct ValidateArgs {
112 pub path: PathBuf,
115}
116
117#[derive(ClapArgs, Debug)]
118pub struct InstallArgs {
119 pub source: String,
122}
123
124pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
125 if matches!(
134 args.command,
135 Some(SchemaCommand::New(_) | SchemaCommand::Validate(_) | SchemaCommand::Install(_))
136 ) && let Some((_, root)) = ctx.workspace_shape()
137 {
138 let _ = memstead_schema::meta_schema::publish_meta_schemas(&root);
139 }
140 match (args.command, args.reference) {
141 (Some(SchemaCommand::New(a)), _) => scaffold_new(ctx, a),
142 (Some(SchemaCommand::Validate(a)), _) => validate(ctx, a),
143 (Some(SchemaCommand::Install(a)), _) => install(ctx, a),
144 (None, Some(reference)) => show_builtin(ctx, &reference),
145 (None, None) => Err(CliError::new(
146 ExitKind::Validation,
147 "INVALID_INPUT",
148 "memstead schema needs a built-in reference to render (`memstead schema \
149 planning@0.4.0`) or a subcommand (`new`, `validate`, `install`); \
150 `memstead schema --help` lists them"
151 .to_string(),
152 )
153 .into()),
154 }
155}
156
157fn show_builtin(ctx: &CliContext, reference: &str) -> anyhow::Result<()> {
160 let schema_ref = resolve_builtin_read_ref(reference)?;
161 let version = schema_ref.version.to_string();
162 let pkg = memstead_schema::builtins::builtin_package(&schema_ref.name, &version).ok_or_else(
163 || {
164 CliError::new(
165 ExitKind::Validation,
166 "SCHEMA_NOT_FOUND",
167 format!("no built-in schema {}", schema_ref.as_display()),
168 )
169 },
170 )?;
171 let readme = pkg
172 .files
173 .iter()
174 .find(|(path, _)| path == memstead_schema::builtins::PACKAGE_README_FILE)
175 .and_then(|(_, bytes)| std::str::from_utf8(bytes).ok())
176 .map(|text| {
177 memstead_schema::builtins::render_package_readme(&pkg.name, &pkg.version, text)
178 });
179 let pin = format!("{}@{}", pkg.name, pkg.version);
180 if ctx.json {
181 print_json(&json!({
182 "schema": pin,
183 "name": pkg.name,
184 "version": pkg.version,
185 "origin": "builtin",
186 "readme": readme,
187 }))?;
188 return Ok(());
189 }
190 match readme {
191 Some(text) => print_markdown(&format!(
192 "<!-- {pin}: built-in package README, rendered for this generation -->\n{text}"
193 )),
194 None => print_markdown(&format!(
195 "`{pin}` is a built-in package that ships no README.\n"
196 )),
197 }
198 Ok(())
199}
200
201fn resolve_builtin_read_ref(reference: &str) -> anyhow::Result<SchemaRef> {
205 if reference.contains('@') {
206 return resolve_builtin_ref(reference);
207 }
208 let reg = memstead_schema::SchemaRegistry::builtin();
209 let mut versions = reg.available_versions(reference);
210 versions.sort();
211 match versions.pop() {
212 Some(v) => Ok(SchemaRef::new(reference.to_string(), v)),
213 None => Err(CliError::new(
214 ExitKind::Validation,
215 "SCHEMA_NOT_FOUND",
216 format!(
217 "no built-in schema named {reference:?}; built-ins: {}",
218 builtin_names_joined(®)
219 ),
220 )
221 .into()),
222 }
223}
224
225fn builtin_names_joined(reg: &memstead_schema::SchemaRegistry) -> String {
226 let mut names: Vec<String> = reg.identities().into_iter().map(|(n, _)| n).collect();
227 names.sort();
228 names.dedup();
229 names.join(", ")
230}
231
232const SCAFFOLD_VERSION: &str = "0.1.0";
234
235fn scaffold_new(ctx: &CliContext, args: NewArgs) -> anyhow::Result<()> {
236 if let Err(reason) = memstead_schema::loader::validate_schema_name(&args.name) {
237 let suggestion = suggest_schema_name(&args.name);
238 return Err(CliError::new(
239 ExitKind::Validation,
240 "INVALID_INPUT",
241 format!(
242 "invalid schema name {name:?}: {reason} (lowercase letter first, \
243 then lowercase letters, digits, hyphens). \
244 Try: memstead schema new {suggestion}",
245 name = args.name,
246 ),
247 )
248 .with_details(json!({
249 "name": args.name,
250 "reason": reason,
251 "suggestion": suggestion,
252 }))
253 .into());
254 }
255
256 let pkg_dir = PathBuf::from(&args.name);
257 if pkg_dir.join("schema.yaml").is_file() {
258 return Err(CliError::new(
259 ExitKind::Validation,
260 "SCHEMA_PACKAGE_EXISTS",
261 format!(
262 "{} already contains a schema package — `memstead schema new` \
263 never overwrites. Check it with: memstead schema validate {}",
264 pkg_dir.display(),
265 args.name,
266 ),
267 )
268 .with_details(json!({ "path": pkg_dir }))
269 .into());
270 }
271 if pkg_dir.is_dir()
272 && let Some(entry) = std::fs::read_dir(&pkg_dir)
273 .map_err(|e| {
274 CliError::new(
275 ExitKind::Generic,
276 "IO_ERROR",
277 format!("read {}: {e}", pkg_dir.display()),
278 )
279 })?
280 .next()
281 .transpose()
282 .map_err(|e| {
283 CliError::new(
284 ExitKind::Generic,
285 "IO_ERROR",
286 format!("read {}: {e}", pkg_dir.display()),
287 )
288 })?
289 {
290 let found = entry.file_name().to_string_lossy().to_string();
291 return Err(CliError::new(
292 ExitKind::Validation,
293 "TARGET_NOT_EMPTY",
294 format!(
295 "{} exists and is not empty (found `{found}`) — clear it or \
296 pick a different name: memstead schema new {}-schema",
297 pkg_dir.display(),
298 args.name,
299 ),
300 )
301 .with_details(json!({ "path": pkg_dir, "found": [found] }))
302 .into());
303 }
304
305 let manifest = scaffold_manifest(&args.name);
306 let example_type = scaffold_example_type();
307 std::fs::create_dir_all(pkg_dir.join("types")).map_err(|e| {
308 CliError::new(
309 ExitKind::Generic,
310 "IO_ERROR",
311 format!("create {}: {e}", pkg_dir.join("types").display()),
312 )
313 })?;
314 for (rel, content) in [
315 ("schema.yaml", &manifest),
316 ("types/note.yaml", &example_type),
317 ] {
318 let dest = pkg_dir.join(rel);
319 std::fs::write(&dest, content).map_err(|e| {
320 CliError::new(
321 ExitKind::Generic,
322 "IO_ERROR",
323 format!("write {}: {e}", dest.display()),
324 )
325 })?;
326 }
327
328 if let Err(e) = memstead_schema::loader::load_schema_from_dir(&pkg_dir)
332 .and_then(|s| memstead_schema::check_reserved_metadata_keys(&s).map(|()| s))
333 .and_then(|s| memstead_schema::check_section_formats(&s).map(|()| s))
334 {
335 return Err(CliError::new(
336 ExitKind::Generic,
337 crate::INTERNAL_CODE,
338 format!(
339 "scaffold bug: generated package at {} fails validation: {e} — \
340 please report this",
341 pkg_dir.display(),
342 ),
343 )
344 .into());
345 }
346
347 let next_steps = scaffold_next_steps(ctx, &args.name);
348 if ctx.json {
349 print_json(&json!({
350 "ok": true,
351 "schema": format!("{}@{SCAFFOLD_VERSION}", args.name),
352 "path": pkg_dir,
353 "files": ["schema.yaml", "types/note.yaml"],
354 "next_steps": next_steps
355 .iter()
356 .map(|s| json!({ "command": s.command, "note": s.note }))
357 .collect::<Vec<_>>(),
358 }))?;
359 } else {
360 let steps: Vec<String> = next_steps
361 .iter()
362 .enumerate()
363 .map(|(i, s)| match &s.note {
364 Some(note) => format!("{}. `{}` — {note}", i + 1, s.command),
365 None => format!("{}. `{}`", i + 1, s.command),
366 })
367 .collect();
368 print_markdown(&format!(
369 "# Schema package scaffolded\n\n`{name}@{SCAFFOLD_VERSION}` at `{dir}` \
370 (schema.yaml + types/note.yaml, one commented example type).\n\n\
371 Edit the package, then:\n\n{steps}\n",
372 name = args.name,
373 dir = pkg_dir.display(),
374 steps = steps.join("\n"),
375 ));
376 }
377 Ok(())
378}
379
380fn scaffold_next_steps(ctx: &CliContext, name: &str) -> Vec<Step> {
391 use memstead_base::workspace::MountCapability;
392 use memstead_base::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
393 let workspace = ctx.workspace_shape().and_then(|(shape, root)| match shape {
394 WorkspaceShape::Filesystem => FileWorkspaceStore::new().load(&root).ok().and_then(|ws| {
395 let mut writable = ws
396 .mounts
397 .iter()
398 .filter(|m| m.capability == MountCapability::Write);
399 match (writable.next(), writable.next()) {
400 (Some(only), None) => Some((only.mem.clone(), root.clone())),
401 _ => None,
402 }
403 }),
404 WorkspaceShape::MemRepo => None,
405 });
406 let mem = workspace
407 .as_ref()
408 .map(|(mem, _)| mem.clone())
409 .unwrap_or_else(|| "<mem>".to_string());
410 let quickstart_seed = workspace
413 .as_ref()
414 .filter(|(_, root)| root.join("welcome-to-memstead.md").is_file())
415 .map(|(mem, _)| format!("{mem}--welcome-to-memstead"));
416 #[cfg(feature = "mem-repo")]
419 {
420 let mut steps = vec![
421 Step::bare(format!("memstead schema validate {name}")),
422 Step::bare(format!("memstead schema install {name}")),
423 ];
424 if let Some(seed_id) = quickstart_seed {
425 steps.push(Step {
426 command: format!("memstead delete {seed_id}"),
427 note: Some(
428 "the quickstart seed — the pin below switches atomically only when \
429 every entity conforms to the new schema"
430 .to_string(),
431 ),
432 });
433 }
434 steps.push(Step::bare(format!(
435 "memstead mem set-schema {mem} {name}@{SCAFFOLD_VERSION}"
436 )));
437 steps
438 }
439 #[cfg(not(feature = "mem-repo"))]
449 {
450 let _ = (mem, quickstart_seed); let (fresh_dir, install_source) = match ctx.workspace_shape() {
452 Some((_, root)) => {
453 let parent = root.parent().unwrap_or(&root).to_path_buf();
454 let pkg = std::env::current_dir().unwrap_or_default().join(name);
455 (
456 format!("\"{}\"", parent.join(format!("{name}-mem")).display()),
457 format!("\"{}\"", pkg.display()),
458 )
459 }
460 None => (format!("{name}-mem"), format!("../{name}")),
461 };
462 vec![
463 Step::bare(format!("memstead schema validate {name}")),
464 Step {
465 command: format!(
466 "mkdir {fresh_dir} && cd {fresh_dir} && memstead init --name {name}-mem \
467 --schema {name}@{SCAFFOLD_VERSION}"
468 ),
469 note: Some(
470 "this binary cannot re-pin an existing mem, so the schema gets a \
471 fresh one"
472 .to_string(),
473 ),
474 },
475 Step {
476 command: format!("memstead schema install {install_source}"),
477 note: Some(
478 "run inside the new folder — the workspace boots once its pinned \
479 schema is installed"
480 .to_string(),
481 ),
482 },
483 ]
484 }
485}
486
487struct Step {
491 command: String,
492 note: Option<String>,
493}
494
495impl Step {
496 fn bare(command: String) -> Self {
497 Step {
498 command,
499 note: None,
500 }
501 }
502}
503
504fn suggest_schema_name(raw: &str) -> String {
509 let mut out = String::with_capacity(raw.len());
510 for c in raw.to_lowercase().chars() {
511 if c.is_ascii_lowercase() || c.is_ascii_digit() {
512 out.push(c);
513 } else if !out.ends_with('-') && !out.is_empty() {
514 out.push('-');
515 }
516 }
517 let trimmed: String = out
518 .trim_matches('-')
519 .chars()
520 .skip_while(|c| !c.is_ascii_lowercase())
521 .collect();
522 let trimmed = trimmed.trim_matches('-');
523 if trimmed.is_empty() {
524 "my-schema".to_string()
525 } else {
526 trimmed.to_string()
527 }
528}
529
530fn scaffold_manifest(name: &str) -> String {
534 format!(
535 r#"# Schema package scaffolded by `memstead schema new`.
536# A schema package is one folder: this manifest plus one YAML file per
537# entity type under types/. Re-check any time with:
538# memstead schema validate {name}
539
540name: {name}
541version: {SCAFFOLD_VERSION}
542
543# Shown in schema catalogues (memstead_overview, the registry).
544description: |
545 Describe the subject this schema models and the types it declares.
546
547# Read by agents (and humans) choosing a schema for a new mem.
548when_to_use: |
549 Say when this schema fits — and when an author should reach for a
550 different one.
551
552# Optional: served to agents working in a mem pinned to this schema.
553system_message: |
554 You are working in a graph using the {name} schema. Prefer precise
555 types, link generously, and keep sections in their declared shape.
556
557# One entry per file under types/ — `note` matches types/note.yaml.
558# Add a type by adding both the file and its entry here.
559types:
560 - note
561
562relationships:
563 # strict: only the definitions below are legal edge types.
564 # open: any UPPER_SNAKE_CASE name is accepted; definitions add weights.
565 mode: strict
566 # Optional relationships-level declarations (engine 0.10.0+):
567 # acyclic_sets — acyclicity over the UNION of a rel-type set, for
568 # cycles no single rel-type contains:
569 # acyclic_sets:
570 # - [GROUNDS, CONCLUDES]
571 # labelling — name the attack rel-types and the engine serves
572 # the grounded labelling (accepted/defeated/
573 # undecided) with evidence; optional support walk
574 # adds chain-shape statistics.
575 definitions:
576 - name: PART_OF
577 description: Hierarchical containment — the source is structurally part of the target.
578 default_weight: 3.0
579 acyclic: true
580 - name: RELATES_TO
581 description: General association between two entities when no sharper type fits.
582 default_weight: 1.0
583 # Every key below is OPTIONAL, but its default is not always the
584 # permissive one — uncomment what you need.
585 #
586 # Per-edge `--description` text. DEFAULT IS `forbidden`: leave this
587 # out and every `memstead relate ... --description` on this type is
588 # REFUSED with DESCRIPTION_NOT_PERMITTED.
589 # per_edge_description: optional # forbidden | optional | required
590 #
591 # Restrict which types this edge may join. Omit for "any type".
592 # source_types: [note]
593 # target_types: [note]
594 #
595 # cardinality_per_source: 1 # at most one such edge per source
596 # manual_authoring: false # true = engine-emitted only
597 - name: REFERENCES
598 description: Soft reference. Auto-emitted from body wiki-links — never author by hand.
599 default_weight: 0.5
600 # Required entry — the fallback weight for any relationship not
601 # listed above.
602 - name: _default
603 description: Fallback weight for any relationship not otherwise specified.
604 default_weight: 1.0
605
606# Body wiki-links `[[target]]` auto-emit as REFERENCES relations.
607# Remove this key to make unbacked wiki-links a validation error instead.
608alias_target_rel_type: REFERENCES
609
610# Community detection (graph clustering) tuning. REQUIRED — the block
611# must be present; the values below are the defaults, keep them unless
612# you know why you are changing them.
613community:
614 resolution: 1.0
615 seed: 42
616
617# The complete key reference for schema packages — every key the loader
618# accepts, with its type and default — is the meta-schema shipped in
619# your workspace at `.memstead/meta-schemas/schema-manifest.schema.json`.
620# This scaffold teaches by example; that file is exhaustive.
621"#
622 )
623}
624
625fn scaffold_example_type() -> String {
629 r#"# One entity type = one file. `name` must match the filename stem
630# and appear in the manifest's `types:` list.
631#
632# Keys marked REQUIRED must be present in every type file — deleting
633# one fails `memstead schema validate`. Everything else is optional.
634
635# REQUIRED.
636name: note
637# REQUIRED.
638description: |
639 A general-purpose note — replace this with your first real type.
640# REQUIRED.
641when_to_use: |
642 Use while sketching the schema; rename or split into sharper types
643 as the domain vocabulary firms up.
644
645# REQUIRED. Sections are the entity's markdown body. `required: true`
646# sections must be present on every create.
647sections:
648 - key: summary
649 heading: Summary
650 required: true
651 search_weight: 40.0
652 write_rules:
653 - "One or two sentences. Must stand alone in a search result."
654 - key: details
655 heading: Details
656 required: false
657 search_weight: 10.0
658 # catch_all: content under unmatched headings lands here.
659 catch_all: true
660 write_rules:
661 - "Everything beyond the summary. Bullets over prose."
662
663# REQUIRED (the key; it may be an empty list). Typed, filterable
664# frontmatter fields — beyond the built-in
665# type / created_date / last_modified / tags.
666# One rule for fields and sections alike: absence of `required` means
667# optional. `required: true` refuses a create that leaves the field
668# unset — unless a default fills it (required + default = always
669# present, never refused).
670metadata_fields:
671 - key: status
672 # required + default_value: every entity carries a status, and the
673 # default means a create never has to supply one.
674 required: true
675 description: Lifecycle state of the note.
676 field_type: string
677 default_value: active
678 enum_values: [active, archived]
679 filterable: equality
680 - key: source
681 # No `required` key: optional — an entity without a source is
682 # admitted. Use health_required_fields or a constraint if missing
683 # values should surface as findings instead.
684 description: Where the note's content came from.
685 field_type: string
686
687# REQUIRED. Search ranking: how much a title match weighs.
688title_weight: 100.0
689# REQUIRED. Sections included in full-text search.
690text_fields: [summary, details]
691# REQUIRED. Which declared relationship expresses hierarchy for this type.
692hierarchy_relationship: PART_OF
693# One effect only: relate refuses a self-loop (from == to) on the rel-types
694# listed here. Nothing propagates; for impact propagation declare a
695# `status_propagation` constraint instead.
696no_self_loop_relationships: [PART_OF]
697# Fields `memstead update` may touch on this type.
698updatable_fields: [title, summary, details, status, tags]
699# Sections the health report treats as required.
700health_required_fields: [summary]
701# Days without modification before health flags the entity stale.
702staleness_threshold_days: 180
703# Further optional type-level declarations (engine 0.10.0+), shapes in
704# the authoring guide and the generated type-definition.schema.json:
705# required_outgoing — edge obligations (cardinality, warn/block
706# severity, optional when_field/when_value pair
707# arming a block on a metadata enum value)
708# must_reach — reachability obligations over a relation set
709# (direction out/in, terminal_types, max_depth);
710# health-sweep only, always warn
711# constraints — the five-form vocabulary (requires_when,
712# unique, enum_from_neighbour, status_propagation
713# with rel_type or rel_types)
714# signals — edge_load counts with notice/warn thresholds,
715# served with contributors on every read
716# Prose guidance served to agents writing entities of this type.
717write_rules:
718 - "Notes are placeholders — split recurring shapes into dedicated types."
719"#
720 .to_string()
721}
722
723fn validate(ctx: &CliContext, args: ValidateArgs) -> anyhow::Result<()> {
724 if args.path.join("schema-format.json").is_file() {
731 return Err(CliError::new(
732 ExitKind::Validation,
733 "SCHEMA_VALIDATION_FAILED",
734 format!(
735 "{} is a sealed schema package (it carries `schema-format.json`, the seal \
736 marker), not authoring input — `schema validate` checks the directories you \
737 author, before sealing. Validate the package's source directory instead, or \
738 install this package directly with `memstead schema install`.",
739 args.path.display(),
740 ),
741 )
742 .with_details(json!({
743 "path": args.path,
744 "reason": "sealed_package",
745 }))
746 .into());
747 }
748 match memstead_schema::loader::load_schema_from_dir(&args.path)
749 .and_then(|s| memstead_schema::check_section_heading_roundtrip(&s).map(|()| s))
750 .and_then(|s| memstead_schema::check_reserved_metadata_keys(&s).map(|()| s))
751 .and_then(|s| memstead_schema::check_section_formats(&s).map(|()| s))
752 {
753 Ok(schema) => {
754 let schema = std::sync::Arc::new(schema);
758 if let Err(defect) = memstead_base::Engine::validate_schema_exemplars(&schema) {
759 return Err(CliError::new(
760 ExitKind::Validation,
761 "SCHEMA_VALIDATION_FAILED",
762 format!("schema at {} is invalid: {defect}", args.path.display()),
763 )
764 .with_details(json!({ "path": args.path, "error": defect }))
765 .into());
766 }
767 let (name, version) = schema.id();
768 let type_count = schema.types.len();
769 if ctx.json {
770 print_json(&json!({
771 "ok": true,
772 "schema": format!("{name}@{version}"),
773 "types": type_count,
774 "path": args.path,
775 }))?;
776 } else {
777 print_markdown(&format!(
778 "# Schema valid\n\n`{name}@{version}` — {type_count} type(s) at `{}`\n",
779 args.path.display(),
780 ));
781 }
782 Ok(())
783 }
784 Err(e) => Err(CliError::new(
785 ExitKind::Validation,
786 "SCHEMA_VALIDATION_FAILED",
787 format!("schema at {} is invalid: {e}", args.path.display()),
788 )
789 .with_details(json!({
790 "path": args.path,
791 "error": e.to_string(),
792 }))
793 .into()),
794 }
795}
796
797fn install(ctx: &CliContext, args: InstallArgs) -> anyhow::Result<()> {
798 let (shape, root) = ctx.workspace_shape().ok_or_else(|| {
799 CliError::new(
800 ExitKind::Generic,
801 "NO_WORKSPACE",
802 "not inside a Memstead workspace (no `.memstead/workspace.toml` in any \
803 ancestor) — cd into your workspace first, or create one: memstead quickstart"
804 .to_string(),
805 )
806 })?;
807 let (schema_ref, files) = resolve_source(&args.source)?;
808
809 match shape {
810 WorkspaceShape::Filesystem => {
811 let pkg_dir = root
817 .join(".memstead")
818 .join("schemas")
819 .join(format!("{}@{}", schema_ref.name, schema_ref.version));
820 write_package(&pkg_dir, &files)?;
821 if ctx.json {
822 print_json(&json!({
823 "ok": true,
824 "schema": format!("{}@{}", schema_ref.name, schema_ref.version),
825 "backend": "folder",
826 "path": pkg_dir,
827 "files": files.iter().map(|f| &f.archive_path).collect::<Vec<_>>(),
828 }))?;
829 } else {
830 print_markdown(&format!(
831 "# Schema installed\n\n`{}@{}` → `{}` ({} file(s))\n",
832 schema_ref.name,
833 schema_ref.version,
834 pkg_dir.display(),
835 files.len(),
836 ));
837 }
838 Ok(())
839 }
840 WorkspaceShape::MemRepo => install_to_git_branch(ctx, &schema_ref, &files),
841 }
842}
843
844#[cfg(feature = "mem-repo")]
856fn install_to_git_branch(
857 ctx: &CliContext,
858 schema_ref: &SchemaRef,
859 files: &[memstead_schema::SchemaSourceFile],
860) -> anyhow::Result<()> {
861 let Some((_shape, root)) = ctx.workspace_shape() else {
862 return Err(crate::setup::workspace_not_initialised_error(
863 "No workspace found. Run from a directory containing `.memstead/workspace.toml`.",
864 )
865 .into());
866 };
867 let pairs: Vec<(String, Vec<u8>)> = files
868 .iter()
869 .map(|f| (f.archive_path.clone(), f.bytes.clone()))
870 .collect();
871 let commit = memstead_git_branch::repair::install_schema_below_boot(
872 &root,
873 &schema_ref.name,
874 &schema_ref.version.to_string(),
875 &pairs,
876 )
877 .map_err(|e| crate::setup::boot_error_to_cli(&root, e))?;
878 if ctx.json {
879 print_json(&json!({
880 "ok": true,
881 "schema": format!("{}@{}", schema_ref.name, schema_ref.version),
882 "backend": "git-branch",
883 "ref": format!("__MEMSTEAD:schemas/{}@{}", schema_ref.name, schema_ref.version),
884 "commit": commit,
885 }))?;
886 } else {
887 print_markdown(&format!(
888 "# Schema installed\n\n`{}@{}` → `__MEMSTEAD:schemas/{}@{}` (commit `{}`)\n",
889 schema_ref.name, schema_ref.version, schema_ref.name, schema_ref.version, commit,
890 ));
891 }
892 Ok(())
893}
894
895#[cfg(not(feature = "mem-repo"))]
896fn install_to_git_branch(
897 _ctx: &CliContext,
898 _schema_ref: &SchemaRef,
899 _files: &[memstead_schema::SchemaSourceFile],
900) -> anyhow::Result<()> {
901 Err(CliError::new(
902 ExitKind::Generic,
903 "MEM_REPO_NOT_SUPPORTED",
904 "this binary was built without git-branch support — use the `memstead` binary to \
905 install a schema into a mem-repo workspace."
906 .to_string(),
907 )
908 .into())
909}
910
911fn resolve_source(
914 source: &str,
915) -> anyhow::Result<(SchemaRef, Vec<memstead_schema::SchemaSourceFile>)> {
916 let as_path = Path::new(source);
917 if as_path.is_dir() {
918 let schema = memstead_schema::load_schema_from_dir(as_path)
925 .and_then(|s| memstead_schema::check_section_heading_roundtrip(&s).map(|()| s))
926 .and_then(|s| memstead_schema::check_reserved_metadata_keys(&s).map(|()| s))
927 .and_then(|s| memstead_schema::check_section_formats(&s).map(|()| s))
928 .map_err(|e| {
929 CliError::new(
930 ExitKind::Validation,
931 "SCHEMA_VALIDATION_FAILED",
932 format!("package at {source} is invalid: {e}"),
933 )
934 .with_details(json!({ "path": source, "error": e.to_string() }))
935 })?;
936 let schema = std::sync::Arc::new(schema);
940 if let Err(defect) = memstead_base::Engine::validate_schema_exemplars(&schema) {
941 return Err(CliError::new(
942 ExitKind::Validation,
943 "SCHEMA_VALIDATION_FAILED",
944 format!("package at {source} is invalid: {defect}"),
945 )
946 .with_details(json!({ "path": source, "error": defect }))
947 .into());
948 }
949 let (name, version) = schema.id();
950 let mut files = collect_dir_package(as_path)?;
951 let authoring_path = as_path
958 .canonicalize()
959 .unwrap_or_else(|_| as_path.to_path_buf());
960 files.push(memstead_schema::SchemaSourceFile {
961 archive_path: memstead_schema::INSTALL_PROVENANCE_FILE.to_string(),
962 bytes: serde_json::to_vec_pretty(&json!({
963 "authoring_path": authoring_path.display().to_string(),
964 }))
965 .expect("provenance stamp serialises"),
966 });
967 let files = marked_package(files);
974 Ok((SchemaRef::new(name, version), files))
975 } else {
976 let schema_ref = resolve_builtin_ref(source)?;
978 let mut files =
979 memstead_schema::collect_schema_source(None, None, &schema_ref).map_err(|e| {
980 CliError::new(
981 ExitKind::Validation,
982 "SCHEMA_NOT_FOUND",
983 format!(
984 "could not collect source for {}: {e}",
985 schema_ref.as_display()
986 ),
987 )
988 })?;
989 if let Some(tpl) = memstead_schema::builtins::builtin_mem_template(&schema_ref.name) {
992 files.push(memstead_schema::SchemaSourceFile {
993 archive_path: "mem-template.json".to_string(),
994 bytes: serde_json::to_vec_pretty(&tpl).unwrap_or_default(),
995 });
996 }
997 Ok((schema_ref, files))
998 }
999}
1000
1001fn resolve_builtin_ref(source: &str) -> anyhow::Result<SchemaRef> {
1004 let reg = memstead_schema::SchemaRegistry::builtin();
1005 if source.contains('@') {
1006 let r: SchemaRef = source.parse().map_err(|e: String| {
1007 CliError::new(
1008 ExitKind::Validation,
1009 "INVALID_INPUT",
1010 format!("invalid schema pin {source:?}: {e}"),
1011 )
1012 })?;
1013 if reg.get(&r.name, &r.version).is_none() {
1014 return Err(CliError::new(
1015 ExitKind::Validation,
1016 "SCHEMA_NOT_FOUND",
1017 format!(
1018 "no built-in schema {source} — pass a path to install a non-built-in package"
1019 ),
1020 )
1021 .into());
1022 }
1023 Ok(r)
1024 } else {
1025 match reg.resolve_by_name(source) {
1026 Ok(Some(s)) => {
1027 let (n, v) = s.id();
1028 Ok(SchemaRef::new(n, v))
1029 }
1030 Ok(None) => Err(CliError::new(
1031 ExitKind::Validation,
1032 "SCHEMA_NOT_FOUND",
1033 format!(
1034 "no built-in schema named {source:?} — pass a path to install a non-built-in \
1035 package, or a `name@version` pin"
1036 ),
1037 )
1038 .into()),
1039 Err(e) => Err(CliError::new(
1040 ExitKind::Validation,
1041 "INVALID_INPUT",
1042 format!("built-in name {source:?} is ambiguous: {e}"),
1043 )
1044 .into()),
1045 }
1046 }
1047}
1048
1049fn collect_dir_package(dir: &Path) -> anyhow::Result<Vec<memstead_schema::SchemaSourceFile>> {
1052 use memstead_schema::SchemaSourceFile;
1053 let mut out = vec![SchemaSourceFile {
1054 archive_path: "schema.yaml".to_string(),
1055 bytes: std::fs::read(dir.join("schema.yaml"))?,
1056 }];
1057 let types = dir.join("types");
1058 if types.is_dir() {
1059 let mut paths: Vec<PathBuf> = std::fs::read_dir(&types)?
1060 .filter_map(|e| e.ok().map(|e| e.path()))
1061 .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("yaml"))
1062 .collect();
1063 paths.sort();
1064 for p in paths {
1065 if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
1066 out.push(SchemaSourceFile {
1067 archive_path: format!("types/{name}"),
1068 bytes: std::fs::read(&p)?,
1069 });
1070 }
1071 }
1072 }
1073 for opt in ["mem-template.json", "README.md"] {
1074 let p = dir.join(opt);
1075 if p.is_file() {
1076 out.push(SchemaSourceFile {
1077 archive_path: opt.to_string(),
1078 bytes: std::fs::read(&p)?,
1079 });
1080 }
1081 }
1082 Ok(out)
1083}
1084
1085fn marked_package(
1091 mut files: Vec<memstead_schema::SchemaSourceFile>,
1092) -> Vec<memstead_schema::SchemaSourceFile> {
1093 let marker = memstead_schema::loader::SCHEMA_FORMAT_MARKER_FILE;
1094 if !files.iter().any(|f| f.archive_path == marker) {
1095 files.push(memstead_schema::SchemaSourceFile {
1096 archive_path: marker.to_string(),
1097 bytes: memstead_schema::loader::SCHEMA_FORMAT_MARKER_CONTENT
1098 .as_bytes()
1099 .to_vec(),
1100 });
1101 }
1102 files
1103}
1104
1105fn write_package(
1112 pkg_dir: &Path,
1113 files: &[memstead_schema::SchemaSourceFile],
1114) -> anyhow::Result<()> {
1115 for f in files {
1116 let dest = pkg_dir.join(&f.archive_path);
1117 if let Some(parent) = dest.parent() {
1118 std::fs::create_dir_all(parent).map_err(|e| {
1119 CliError::new(
1120 ExitKind::Generic,
1121 "IO_ERROR",
1122 format!("could not create {}: {e}", parent.display()),
1123 )
1124 })?;
1125 }
1126 let bytes = retarget_yaml_directive(&f.archive_path, &f.bytes);
1127 std::fs::write(&dest, &bytes).map_err(|e| {
1128 CliError::new(
1129 ExitKind::Generic,
1130 "IO_ERROR",
1131 format!("could not write {}: {e}", dest.display()),
1132 )
1133 })?;
1134 }
1135 Ok(())
1136}
1137
1138fn directive_for(archive_path: &str) -> Option<&'static str> {
1145 if archive_path == "schema.yaml" {
1146 Some("# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json")
1147 } else if archive_path.starts_with("types/") && archive_path.ends_with(".yaml") {
1148 Some("# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json")
1149 } else {
1150 None
1151 }
1152}
1153
1154fn retarget_yaml_directive(archive_path: &str, bytes: &[u8]) -> Vec<u8> {
1158 let Some(directive) = directive_for(archive_path) else {
1159 return bytes.to_vec();
1160 };
1161 let Ok(text) = std::str::from_utf8(bytes) else {
1162 return bytes.to_vec();
1163 };
1164 let body = if text.starts_with("# yaml-language-server:") {
1165 text.split_once('\n').map(|(_, rest)| rest).unwrap_or("")
1166 } else {
1167 text
1168 };
1169 format!("{directive}\n{body}").into_bytes()
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174 use super::*;
1175 use std::path::Path;
1176
1177 fn ctx() -> CliContext {
1178 CliContext {
1179 json: false,
1180 quiet: true,
1181 role: Default::default(),
1182 identity: None,
1183 }
1184 }
1185
1186 #[test]
1197 fn validate_builtin_default_copy_refuses_retired_exemplar_spelling() {
1198 let src = Path::new(env!("CARGO_MANIFEST_DIR"))
1199 .join("../memstead-schema/builtins/schemas/default-1.3");
1200 assert!(src.join("schema.yaml").is_file(), "fixture moved: {src:?}");
1201 let dir = tempfile::tempdir().unwrap();
1202 let dst = dir.path().join("authoring");
1203 copy_dir_without_marker(&src, &dst);
1204 let err = validate(&ctx(), ValidateArgs { path: dst.clone() })
1205 .expect_err("legacy exemplar spelling refuses as authoring input");
1206 assert!(
1207 err.to_string().contains("rel_type"),
1208 "refusal carries the rename pointer: {err}"
1209 );
1210
1211 for entry in std::fs::read_dir(dst.join("types")).unwrap() {
1214 let path = entry.unwrap().path();
1215 let text = std::fs::read_to_string(&path).unwrap();
1216 let converged = text
1217 .replace("\n - to: ", "\n - target: ")
1218 .replace("\n type: ", "\n rel_type: ");
1219 std::fs::write(&path, converged).unwrap();
1220 }
1221 validate(&ctx(), ValidateArgs { path: dst })
1222 .expect("converged default builtin content must validate");
1223 }
1224
1225 fn copy_dir_without_marker(src: &Path, dst: &Path) {
1226 std::fs::create_dir_all(dst).unwrap();
1227 for entry in std::fs::read_dir(src).unwrap() {
1228 let entry = entry.unwrap();
1229 let name = entry.file_name();
1230 if name == "schema-format.json" {
1231 continue;
1232 }
1233 let target = dst.join(&name);
1234 if entry.file_type().unwrap().is_dir() {
1235 copy_dir_without_marker(&entry.path(), &target);
1236 } else {
1237 std::fs::copy(entry.path(), &target).unwrap();
1238 }
1239 }
1240 }
1241
1242 #[test]
1246 fn validate_names_sealed_package() {
1247 let path = Path::new(env!("CARGO_MANIFEST_DIR"))
1248 .join("../memstead-schema/builtins/schemas/default-1.3");
1249 let err = validate(&ctx(), ValidateArgs { path }).expect_err("sealed package must refuse");
1250 let cli = err
1251 .downcast_ref::<CliError>()
1252 .expect("error is a typed CliError");
1253 assert_eq!(cli.code, "SCHEMA_VALIDATION_FAILED");
1254 assert!(
1255 cli.message.contains("sealed schema package"),
1256 "message names the sealed package: {}",
1257 cli.message,
1258 );
1259 assert_eq!(
1260 cli.details.as_ref().unwrap()["reason"],
1261 json!("sealed_package"),
1262 );
1263 }
1264
1265 #[test]
1268 fn validate_rejects_malformed_schema_with_typed_code() {
1269 let dir = tempfile::tempdir().unwrap();
1270 std::fs::write(dir.path().join("schema.yaml"), "name: [unterminated\n").unwrap();
1271 let err = validate(
1272 &ctx(),
1273 ValidateArgs {
1274 path: dir.path().to_path_buf(),
1275 },
1276 )
1277 .expect_err("malformed schema must refuse");
1278 let cli = err
1279 .downcast_ref::<CliError>()
1280 .expect("error is a typed CliError");
1281 assert_eq!(cli.code, "SCHEMA_VALIDATION_FAILED");
1282 assert_eq!(cli.kind, ExitKind::Validation);
1283 assert_eq!(
1284 cli.details.as_ref().unwrap()["path"],
1285 json!(dir.path()),
1286 "details echoes the offending path",
1287 );
1288 }
1289
1290 #[test]
1293 fn resolve_builtin_read_ref_defaults_bare_names_to_newest() {
1294 let newest = resolve_builtin_read_ref("planning").expect("bare planning reads");
1295 let mut all = memstead_schema::SchemaRegistry::builtin().available_versions("planning");
1296 all.sort();
1297 assert_eq!(Some(&newest.version), all.last());
1298 let pinned = resolve_builtin_read_ref("planning@0.1.0").expect("pin reads");
1299 assert_eq!(pinned.version.to_string(), "0.1.0");
1300 let err = resolve_builtin_read_ref("not-a-builtin").expect_err("unknown refuses");
1301 let cli = err.downcast_ref::<CliError>().unwrap();
1302 assert_eq!(cli.code, "SCHEMA_NOT_FOUND");
1303 assert!(
1304 cli.message.contains("planning"),
1305 "names the roster: {}",
1306 cli.message
1307 );
1308 }
1309
1310 #[test]
1313 fn resolve_builtin_ref_handles_name_pin_and_unknown() {
1314 let bare = resolve_builtin_ref("software@0.2.0").expect("software pin resolves");
1318 assert_eq!(bare.name, "software");
1319 let pinned = resolve_builtin_ref("planning@0.1.0").expect("explicit pin resolves");
1320 assert_eq!(pinned.name, "planning");
1321 assert_eq!(pinned.version.to_string(), "0.1.0");
1322 resolve_builtin_ref("planning@0.2.0").expect("bumped pin resolves");
1323 resolve_builtin_ref("planning").expect_err("bare planning is ambiguous");
1324 let err = resolve_builtin_ref("not-a-builtin").expect_err("unknown name refuses");
1325 assert_eq!(
1326 err.downcast_ref::<CliError>().unwrap().code,
1327 "SCHEMA_NOT_FOUND",
1328 );
1329 }
1330
1331 #[test]
1334 fn resolve_source_for_builtin_includes_schema_and_template() {
1335 let (schema_ref, files) =
1336 resolve_source("planning@0.1.0").expect("planning source collects");
1337 assert_eq!(schema_ref.name, "planning");
1338 let paths: Vec<&str> = files.iter().map(|f| f.archive_path.as_str()).collect();
1339 assert!(paths.contains(&"schema.yaml"), "got {paths:?}");
1340 assert!(
1341 paths.contains(&"mem-template.json"),
1342 "built-in install must carry the mem-template.json, got {paths:?}",
1343 );
1344 }
1345
1346 #[test]
1349 fn collect_and_write_package_round_trips() {
1350 let src = tempfile::tempdir().unwrap();
1351 std::fs::create_dir_all(src.path().join("types")).unwrap();
1352 std::fs::write(src.path().join("schema.yaml"), b"name: x\n").unwrap();
1353 std::fs::write(src.path().join("types/doc.yaml"), b"name: doc\n").unwrap();
1354 std::fs::write(src.path().join("mem-template.json"), b"{}\n").unwrap();
1355
1356 let files = collect_dir_package(src.path()).unwrap();
1357 let dest = tempfile::tempdir().unwrap();
1358 let pkg = dest.path().join("x@0.1.0");
1359 write_package(&pkg, &files).unwrap();
1360
1361 let schema = std::fs::read_to_string(pkg.join("schema.yaml")).unwrap();
1364 assert_eq!(
1365 schema,
1366 "# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json\nname: x\n",
1367 );
1368 let doc = std::fs::read_to_string(pkg.join("types/doc.yaml")).unwrap();
1369 assert_eq!(
1370 doc,
1371 "# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json\nname: doc\n",
1372 );
1373 assert_eq!(
1374 std::fs::read(pkg.join("mem-template.json")).unwrap(),
1375 b"{}\n"
1376 );
1377 write_package(&pkg, &files).unwrap();
1379 assert_eq!(
1380 std::fs::read_to_string(pkg.join("schema.yaml")).unwrap(),
1381 schema
1382 );
1383 }
1384
1385 #[test]
1389 fn retarget_yaml_directive_replaces_or_prepends() {
1390 let existing = b"# yaml-language-server: $schema=../../../generated/schema-manifest.schema.json\nname: y\n";
1392 let out = String::from_utf8(retarget_yaml_directive("schema.yaml", existing)).unwrap();
1393 assert_eq!(
1394 out,
1395 "# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json\nname: y\n",
1396 );
1397 let bare = retarget_yaml_directive("types/t.yaml", b"name: t\n");
1399 assert_eq!(
1400 String::from_utf8(bare).unwrap(),
1401 "# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json\nname: t\n",
1402 );
1403 assert_eq!(retarget_yaml_directive("README.md", b"# hi\n"), b"# hi\n");
1405 }
1406}