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)]
46pub struct Args {
47 #[command(subcommand)]
48 pub command: SchemaCommand,
49}
50
51#[derive(Subcommand, Debug)]
52pub enum SchemaCommand {
53 New(NewArgs),
58
59 Validate(ValidateArgs),
72
73 Install(InstallArgs),
85}
86
87#[derive(ClapArgs, Debug)]
88pub struct NewArgs {
89 pub name: String,
93}
94
95#[derive(ClapArgs, Debug)]
96pub struct ValidateArgs {
97 pub path: PathBuf,
100}
101
102#[derive(ClapArgs, Debug)]
103pub struct InstallArgs {
104 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
117const 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 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
265fn 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 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 #[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 #[cfg(not(feature = "mem-repo"))]
334 {
335 let _ = (mem, quickstart_seed); 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
372struct 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
389fn 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
415fn 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
510fn 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 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 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 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#[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
796fn 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 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 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 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 let files = marked_package(files);
859 Ok((SchemaRef::new(name, version), files))
860 } else {
861 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 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
886fn 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
934fn 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
970fn 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
990fn 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
1023fn 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
1038fn 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 #[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 #[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 #[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 #[test]
1154 fn resolve_builtin_ref_handles_name_pin_and_unknown() {
1155 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 #[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 #[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 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 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 #[test]
1230 fn retarget_yaml_directive_replaces_or_prepends() {
1231 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 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 assert_eq!(retarget_yaml_directive("README.md", b"# hi\n"), b"# hi\n");
1246 }
1247}