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