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),
78}
79
80#[derive(ClapArgs, Debug)]
81pub struct NewArgs {
82 pub name: String,
86}
87
88#[derive(ClapArgs, Debug)]
89pub struct ValidateArgs {
90 pub path: PathBuf,
93}
94
95#[derive(ClapArgs, Debug)]
96pub struct InstallArgs {
97 pub source: String,
100}
101
102pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
103 match args.command {
104 SchemaCommand::New(a) => scaffold_new(ctx, a),
105 SchemaCommand::Validate(a) => validate(ctx, a),
106 SchemaCommand::Install(a) => install(ctx, a),
107 }
108}
109
110const SCAFFOLD_VERSION: &str = "0.1.0";
112
113fn scaffold_new(ctx: &CliContext, args: NewArgs) -> anyhow::Result<()> {
114 if let Err(reason) = memstead_schema::loader::validate_schema_name(&args.name) {
115 let suggestion = suggest_schema_name(&args.name);
116 return Err(CliError::new(
117 ExitKind::Validation,
118 "INVALID_INPUT",
119 format!(
120 "invalid schema name {name:?}: {reason} (lowercase letter first, \
121 then lowercase letters, digits, hyphens). \
122 Try: memstead schema new {suggestion}",
123 name = args.name,
124 ),
125 )
126 .with_details(json!({
127 "name": args.name,
128 "reason": reason,
129 "suggestion": suggestion,
130 }))
131 .into());
132 }
133
134 let pkg_dir = PathBuf::from(&args.name);
135 if pkg_dir.join("schema.yaml").is_file() {
136 return Err(CliError::new(
137 ExitKind::Validation,
138 "SCHEMA_PACKAGE_EXISTS",
139 format!(
140 "{} already contains a schema package — `memstead schema new` \
141 never overwrites. Check it with: memstead schema validate {}",
142 pkg_dir.display(),
143 args.name,
144 ),
145 )
146 .with_details(json!({ "path": pkg_dir }))
147 .into());
148 }
149 if pkg_dir.is_dir()
150 && let Some(entry) = std::fs::read_dir(&pkg_dir)
151 .map_err(|e| {
152 CliError::new(
153 ExitKind::Generic,
154 "IO_ERROR",
155 format!("read {}: {e}", pkg_dir.display()),
156 )
157 })?
158 .next()
159 .transpose()
160 .map_err(|e| {
161 CliError::new(
162 ExitKind::Generic,
163 "IO_ERROR",
164 format!("read {}: {e}", pkg_dir.display()),
165 )
166 })?
167 {
168 let found = entry.file_name().to_string_lossy().to_string();
169 return Err(CliError::new(
170 ExitKind::Validation,
171 "TARGET_NOT_EMPTY",
172 format!(
173 "{} exists and is not empty (found `{found}`) — clear it or \
174 pick a different name: memstead schema new {}-schema",
175 pkg_dir.display(),
176 args.name,
177 ),
178 )
179 .with_details(json!({ "path": pkg_dir, "found": [found] }))
180 .into());
181 }
182
183 let manifest = scaffold_manifest(&args.name);
184 let example_type = scaffold_example_type();
185 std::fs::create_dir_all(pkg_dir.join("types")).map_err(|e| {
186 CliError::new(
187 ExitKind::Generic,
188 "IO_ERROR",
189 format!("create {}: {e}", pkg_dir.join("types").display()),
190 )
191 })?;
192 for (rel, content) in [
193 ("schema.yaml", &manifest),
194 ("types/note.yaml", &example_type),
195 ] {
196 let dest = pkg_dir.join(rel);
197 std::fs::write(&dest, content).map_err(|e| {
198 CliError::new(
199 ExitKind::Generic,
200 "IO_ERROR",
201 format!("write {}: {e}", dest.display()),
202 )
203 })?;
204 }
205
206 if let Err(e) = memstead_schema::loader::load_schema_from_dir(&pkg_dir)
210 .and_then(|s| memstead_schema::check_reserved_metadata_keys(&s).map(|()| s))
211 .and_then(|s| memstead_schema::check_section_formats(&s).map(|()| s))
212 {
213 return Err(CliError::new(
214 ExitKind::Generic,
215 crate::INTERNAL_CODE,
216 format!(
217 "scaffold bug: generated package at {} fails validation: {e} — \
218 please report this",
219 pkg_dir.display(),
220 ),
221 )
222 .into());
223 }
224
225 let next_steps = scaffold_next_steps(ctx, &args.name);
226 if ctx.json {
227 print_json(&json!({
228 "ok": true,
229 "schema": format!("{}@{SCAFFOLD_VERSION}", args.name),
230 "path": pkg_dir,
231 "files": ["schema.yaml", "types/note.yaml"],
232 "next_steps": next_steps
233 .iter()
234 .map(|s| json!({ "command": s.command, "note": s.note }))
235 .collect::<Vec<_>>(),
236 }))?;
237 } else {
238 let steps: Vec<String> = next_steps
239 .iter()
240 .enumerate()
241 .map(|(i, s)| match &s.note {
242 Some(note) => format!("{}. `{}` — {note}", i + 1, s.command),
243 None => format!("{}. `{}`", i + 1, s.command),
244 })
245 .collect();
246 print_markdown(&format!(
247 "# Schema package scaffolded\n\n`{name}@{SCAFFOLD_VERSION}` at `{dir}` \
248 (schema.yaml + types/note.yaml, one commented example type).\n\n\
249 Edit the package, then:\n\n{steps}\n",
250 name = args.name,
251 dir = pkg_dir.display(),
252 steps = steps.join("\n"),
253 ));
254 }
255 Ok(())
256}
257
258fn scaffold_next_steps(ctx: &CliContext, name: &str) -> Vec<Step> {
269 use memstead_base::workspace::MountCapability;
270 use memstead_base::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
271 let workspace = ctx.workspace_shape().and_then(|(shape, root)| match shape {
272 WorkspaceShape::Filesystem => FileWorkspaceStore::new().load(&root).ok().and_then(|ws| {
273 let mut writable = ws
274 .mounts
275 .iter()
276 .filter(|m| m.capability == MountCapability::Write);
277 match (writable.next(), writable.next()) {
278 (Some(only), None) => Some((only.mem.clone(), root.clone())),
279 _ => None,
280 }
281 }),
282 WorkspaceShape::MemRepo => None,
283 });
284 let mem = workspace
285 .as_ref()
286 .map(|(mem, _)| mem.clone())
287 .unwrap_or_else(|| "<mem>".to_string());
288 let quickstart_seed = workspace
291 .as_ref()
292 .filter(|(_, root)| root.join("welcome-to-memstead.md").is_file())
293 .map(|(mem, _)| format!("{mem}--welcome-to-memstead"));
294 #[cfg(feature = "mem-repo")]
297 {
298 let mut steps = vec![
299 Step::bare(format!("memstead schema validate {name}")),
300 Step::bare(format!("memstead schema install {name}")),
301 ];
302 if let Some(seed_id) = quickstart_seed {
303 steps.push(Step {
304 command: format!("memstead delete {seed_id}"),
305 note: Some(
306 "the quickstart seed — the pin below switches atomically only when \
307 every entity conforms to the new schema"
308 .to_string(),
309 ),
310 });
311 }
312 steps.push(Step::bare(format!(
313 "memstead mem set-schema {mem} {name}@{SCAFFOLD_VERSION}"
314 )));
315 steps
316 }
317 #[cfg(not(feature = "mem-repo"))]
327 {
328 let _ = (mem, quickstart_seed); let (fresh_dir, install_source) = match ctx.workspace_shape() {
330 Some((_, root)) => {
331 let parent = root.parent().unwrap_or(&root).to_path_buf();
332 let pkg = std::env::current_dir().unwrap_or_default().join(name);
333 (
334 format!("\"{}\"", parent.join(format!("{name}-mem")).display()),
335 format!("\"{}\"", pkg.display()),
336 )
337 }
338 None => (format!("{name}-mem"), format!("../{name}")),
339 };
340 vec![
341 Step::bare(format!("memstead schema validate {name}")),
342 Step {
343 command: format!(
344 "mkdir {fresh_dir} && cd {fresh_dir} && memstead init --name {name}-mem \
345 --schema {name}@{SCAFFOLD_VERSION}"
346 ),
347 note: Some(
348 "this binary cannot re-pin an existing mem, so the schema gets a \
349 fresh one"
350 .to_string(),
351 ),
352 },
353 Step {
354 command: format!("memstead schema install {install_source}"),
355 note: Some(
356 "run inside the new folder — the workspace boots once its pinned \
357 schema is installed"
358 .to_string(),
359 ),
360 },
361 ]
362 }
363}
364
365struct Step {
369 command: String,
370 note: Option<String>,
371}
372
373impl Step {
374 fn bare(command: String) -> Self {
375 Step {
376 command,
377 note: None,
378 }
379 }
380}
381
382fn suggest_schema_name(raw: &str) -> String {
387 let mut out = String::with_capacity(raw.len());
388 for c in raw.to_lowercase().chars() {
389 if c.is_ascii_lowercase() || c.is_ascii_digit() {
390 out.push(c);
391 } else if !out.ends_with('-') && !out.is_empty() {
392 out.push('-');
393 }
394 }
395 let trimmed: String = out
396 .trim_matches('-')
397 .chars()
398 .skip_while(|c| !c.is_ascii_lowercase())
399 .collect();
400 let trimmed = trimmed.trim_matches('-');
401 if trimmed.is_empty() {
402 "my-schema".to_string()
403 } else {
404 trimmed.to_string()
405 }
406}
407
408fn scaffold_manifest(name: &str) -> String {
412 format!(
413 r#"# Schema package scaffolded by `memstead schema new`.
414# A schema package is one folder: this manifest plus one YAML file per
415# entity type under types/. Re-check any time with:
416# memstead schema validate {name}
417
418name: {name}
419version: {SCAFFOLD_VERSION}
420
421# Shown in schema catalogues (memstead_overview, the registry).
422description: |
423 Describe the subject this schema models and the types it declares.
424
425# Read by agents (and humans) choosing a schema for a new mem.
426when_to_use: |
427 Say when this schema fits — and when an author should reach for a
428 different one.
429
430# Optional: served to agents working in a mem pinned to this schema.
431system_message: |
432 You are working in a graph using the {name} schema. Prefer precise
433 types, link generously, and keep sections in their declared shape.
434
435# One entry per file under types/ — `note` matches types/note.yaml.
436# Add a type by adding both the file and its entry here.
437types:
438 - note
439
440relationships:
441 # strict: only the definitions below are legal edge types.
442 # open: any UPPER_SNAKE_CASE name is accepted; definitions add weights.
443 mode: strict
444 definitions:
445 - name: PART_OF
446 description: Hierarchical containment — the source is structurally part of the target.
447 default_weight: 3.0
448 acyclic: true
449 - name: RELATES_TO
450 description: General association between two entities when no sharper type fits.
451 default_weight: 1.0
452 # Every key below is OPTIONAL, but its default is not always the
453 # permissive one — uncomment what you need.
454 #
455 # Per-edge `--description` text. DEFAULT IS `forbidden`: leave this
456 # out and every `memstead relate ... --description` on this type is
457 # REFUSED with DESCRIPTION_NOT_PERMITTED.
458 # per_edge_description: optional # forbidden | optional | required
459 #
460 # Restrict which types this edge may join. Omit for "any type".
461 # source_types: [note]
462 # target_types: [note]
463 #
464 # cardinality_per_source: 1 # at most one such edge per source
465 # manual_authoring: false # true = engine-emitted only
466 - name: REFERENCES
467 description: Soft reference. Auto-emitted from body wiki-links — never author by hand.
468 default_weight: 0.5
469 # Required entry — the fallback weight for any relationship not
470 # listed above.
471 - name: _default
472 description: Fallback weight for any relationship not otherwise specified.
473 default_weight: 1.0
474
475# Body wiki-links `[[target]]` auto-emit as REFERENCES relations.
476# Remove this key to make unbacked wiki-links a validation error instead.
477alias_target_rel_type: REFERENCES
478
479# Community detection (graph clustering) tuning. REQUIRED — the block
480# must be present; the values below are the defaults, keep them unless
481# you know why you are changing them.
482community:
483 resolution: 1.0
484 seed: 42
485
486# The complete key reference for schema packages — every key the loader
487# accepts, with its type and default — is the meta-schema shipped in
488# your workspace at `.memstead/meta-schemas/schema-manifest.schema.json`.
489# This scaffold teaches by example; that file is exhaustive.
490"#
491 )
492}
493
494fn scaffold_example_type() -> String {
498 r#"# One entity type = one file. `name` must match the filename stem
499# and appear in the manifest's `types:` list.
500#
501# Keys marked REQUIRED must be present in every type file — deleting
502# one fails `memstead schema validate`. Everything else is optional.
503
504# REQUIRED.
505name: note
506# REQUIRED.
507description: |
508 A general-purpose note — replace this with your first real type.
509# REQUIRED.
510when_to_use: |
511 Use while sketching the schema; rename or split into sharper types
512 as the domain vocabulary firms up.
513
514# REQUIRED. Sections are the entity's markdown body. `required: true`
515# sections must be present on every create.
516sections:
517 - key: summary
518 heading: Summary
519 required: true
520 search_weight: 40.0
521 write_rules:
522 - "One or two sentences. Must stand alone in a search result."
523 - key: details
524 heading: Details
525 required: false
526 search_weight: 10.0
527 # catch_all: content under unmatched headings lands here.
528 catch_all: true
529 write_rules:
530 - "Everything beyond the summary. Bullets over prose."
531
532# REQUIRED (the key; it may be an empty list). Typed, filterable
533# frontmatter fields — beyond the built-in
534# type / created_date / last_modified / tags.
535# One rule for fields and sections alike: absence of `required` means
536# optional. `required: true` refuses a create that leaves the field
537# unset — unless a default fills it (required + default = always
538# present, never refused).
539metadata_fields:
540 - key: status
541 # required + default_value: every entity carries a status, and the
542 # default means a create never has to supply one.
543 required: true
544 description: Lifecycle state of the note.
545 field_type: string
546 default_value: active
547 enum_values: [active, archived]
548 filterable: equality
549 - key: source
550 # No `required` key: optional — an entity without a source is
551 # admitted. Use health_required_fields or a constraint if missing
552 # values should surface as findings instead.
553 description: Where the note's content came from.
554 field_type: string
555
556# REQUIRED. Search ranking: how much a title match weighs.
557title_weight: 100.0
558# REQUIRED. Sections included in full-text search.
559text_fields: [summary, details]
560# REQUIRED. Which declared relationship expresses hierarchy for this type.
561hierarchy_relationship: PART_OF
562# One effect only: relate refuses a self-loop (from == to) on the rel-types
563# listed here. Nothing propagates; for impact propagation declare a
564# `status_propagation` constraint instead.
565no_self_loop_relationships: [PART_OF]
566# Fields `memstead update` may touch on this type.
567updatable_fields: [title, summary, details, status, tags]
568# Sections the health report treats as required.
569health_required_fields: [summary]
570# Days without modification before health flags the entity stale.
571staleness_threshold_days: 180
572# Prose guidance served to agents writing entities of this type.
573write_rules:
574 - "Notes are placeholders — split recurring shapes into dedicated types."
575"#
576 .to_string()
577}
578
579fn validate(ctx: &CliContext, args: ValidateArgs) -> anyhow::Result<()> {
580 match memstead_schema::loader::load_schema_from_dir(&args.path)
581 .and_then(|s| memstead_schema::check_section_heading_roundtrip(&s).map(|()| s))
582 .and_then(|s| memstead_schema::check_reserved_metadata_keys(&s).map(|()| s))
583 .and_then(|s| memstead_schema::check_section_formats(&s).map(|()| s))
584 {
585 Ok(schema) => {
586 let schema = std::sync::Arc::new(schema);
590 if let Err(defect) = memstead_base::Engine::validate_schema_exemplars(&schema) {
591 return Err(CliError::new(
592 ExitKind::Validation,
593 "SCHEMA_VALIDATION_FAILED",
594 format!("schema at {} is invalid: {defect}", args.path.display()),
595 )
596 .with_details(json!({ "path": args.path, "error": defect }))
597 .into());
598 }
599 let (name, version) = schema.id();
600 let type_count = schema.types.len();
601 if ctx.json {
602 print_json(&json!({
603 "ok": true,
604 "schema": format!("{name}@{version}"),
605 "types": type_count,
606 "path": args.path,
607 }))?;
608 } else {
609 print_markdown(&format!(
610 "# Schema valid\n\n`{name}@{version}` — {type_count} type(s) at `{}`\n",
611 args.path.display(),
612 ));
613 }
614 Ok(())
615 }
616 Err(e) => Err(CliError::new(
617 ExitKind::Validation,
618 "SCHEMA_VALIDATION_FAILED",
619 format!("schema at {} is invalid: {e}", args.path.display()),
620 )
621 .with_details(json!({
622 "path": args.path,
623 "error": e.to_string(),
624 }))
625 .into()),
626 }
627}
628
629fn install(ctx: &CliContext, args: InstallArgs) -> anyhow::Result<()> {
630 let (shape, root) = ctx.workspace_shape().ok_or_else(|| {
631 CliError::new(
632 ExitKind::Generic,
633 "NO_WORKSPACE",
634 "not inside a Memstead workspace (no `.memstead/workspace.toml` in any \
635 ancestor) — cd into your workspace first, or create one: memstead quickstart"
636 .to_string(),
637 )
638 })?;
639 let (schema_ref, files) = resolve_source(&args.source)?;
640
641 match shape {
642 WorkspaceShape::Filesystem => {
643 let files = marked_package(files);
648 let pkg_dir = root
649 .join(".memstead")
650 .join("schemas")
651 .join(format!("{}@{}", schema_ref.name, schema_ref.version));
652 write_package(&pkg_dir, &files)?;
653 if ctx.json {
654 print_json(&json!({
655 "ok": true,
656 "schema": format!("{}@{}", schema_ref.name, schema_ref.version),
657 "backend": "folder",
658 "path": pkg_dir,
659 "files": files.iter().map(|f| &f.archive_path).collect::<Vec<_>>(),
660 }))?;
661 } else {
662 print_markdown(&format!(
663 "# Schema installed\n\n`{}@{}` → `{}` ({} file(s))\n",
664 schema_ref.name,
665 schema_ref.version,
666 pkg_dir.display(),
667 files.len(),
668 ));
669 }
670 Ok(())
671 }
672 WorkspaceShape::MemRepo => install_to_git_branch(ctx, &schema_ref, &files),
673 }
674}
675
676#[cfg(feature = "mem-repo")]
688fn install_to_git_branch(
689 ctx: &CliContext,
690 schema_ref: &SchemaRef,
691 files: &[memstead_schema::SchemaSourceFile],
692) -> anyhow::Result<()> {
693 let Some((_shape, root)) = ctx.workspace_shape() else {
694 return Err(crate::setup::workspace_not_initialised_error(
695 "No workspace found. Run from a directory containing `.memstead/workspace.toml`.",
696 )
697 .into());
698 };
699 let pairs: Vec<(String, Vec<u8>)> = files
700 .iter()
701 .map(|f| (f.archive_path.clone(), f.bytes.clone()))
702 .collect();
703 let commit = memstead_git_branch::repair::install_schema_below_boot(
704 &root,
705 &schema_ref.name,
706 &schema_ref.version.to_string(),
707 &pairs,
708 )
709 .map_err(|e| crate::setup::boot_error_to_cli(&root, e))?;
710 if ctx.json {
711 print_json(&json!({
712 "ok": true,
713 "schema": format!("{}@{}", schema_ref.name, schema_ref.version),
714 "backend": "git-branch",
715 "ref": format!("__MEMSTEAD:schemas/{}@{}", schema_ref.name, schema_ref.version),
716 "commit": commit,
717 }))?;
718 } else {
719 print_markdown(&format!(
720 "# Schema installed\n\n`{}@{}` → `__MEMSTEAD:schemas/{}@{}` (commit `{}`)\n",
721 schema_ref.name, schema_ref.version, schema_ref.name, schema_ref.version, commit,
722 ));
723 }
724 Ok(())
725}
726
727#[cfg(not(feature = "mem-repo"))]
728fn install_to_git_branch(
729 _ctx: &CliContext,
730 _schema_ref: &SchemaRef,
731 _files: &[memstead_schema::SchemaSourceFile],
732) -> anyhow::Result<()> {
733 Err(CliError::new(
734 ExitKind::Generic,
735 "MEM_REPO_NOT_SUPPORTED",
736 "this binary was built without git-branch support — use the `memstead` binary to \
737 install a schema into a mem-repo workspace."
738 .to_string(),
739 )
740 .into())
741}
742
743fn resolve_source(
746 source: &str,
747) -> anyhow::Result<(SchemaRef, Vec<memstead_schema::SchemaSourceFile>)> {
748 let as_path = Path::new(source);
749 if as_path.is_dir() {
750 let schema = memstead_schema::load_schema_from_dir(as_path)
757 .and_then(|s| memstead_schema::check_section_heading_roundtrip(&s).map(|()| s))
758 .and_then(|s| memstead_schema::check_reserved_metadata_keys(&s).map(|()| s))
759 .and_then(|s| memstead_schema::check_section_formats(&s).map(|()| s))
760 .map_err(|e| {
761 CliError::new(
762 ExitKind::Validation,
763 "SCHEMA_VALIDATION_FAILED",
764 format!("package at {source} is invalid: {e}"),
765 )
766 .with_details(json!({ "path": source, "error": e.to_string() }))
767 })?;
768 let schema = std::sync::Arc::new(schema);
772 if let Err(defect) = memstead_base::Engine::validate_schema_exemplars(&schema) {
773 return Err(CliError::new(
774 ExitKind::Validation,
775 "SCHEMA_VALIDATION_FAILED",
776 format!("package at {source} is invalid: {defect}"),
777 )
778 .with_details(json!({ "path": source, "error": defect }))
779 .into());
780 }
781 let (name, version) = schema.id();
782 let mut files = collect_dir_package(as_path)?;
783 let authoring_path = as_path
790 .canonicalize()
791 .unwrap_or_else(|_| as_path.to_path_buf());
792 files.push(memstead_schema::SchemaSourceFile {
793 archive_path: memstead_schema::INSTALL_PROVENANCE_FILE.to_string(),
794 bytes: serde_json::to_vec_pretty(&json!({
795 "authoring_path": authoring_path.display().to_string(),
796 }))
797 .expect("provenance stamp serialises"),
798 });
799 Ok((SchemaRef::new(name, version), files))
800 } else {
801 let schema_ref = resolve_builtin_ref(source)?;
803 let mut files =
804 memstead_schema::collect_schema_source(None, None, &schema_ref).map_err(|e| {
805 CliError::new(
806 ExitKind::Validation,
807 "SCHEMA_NOT_FOUND",
808 format!(
809 "could not collect source for {}: {e}",
810 schema_ref.as_display()
811 ),
812 )
813 })?;
814 if let Some(tpl) = memstead_schema::builtins::builtin_mem_template(&schema_ref.name) {
817 files.push(memstead_schema::SchemaSourceFile {
818 archive_path: "mem-template.json".to_string(),
819 bytes: serde_json::to_vec_pretty(&tpl).unwrap_or_default(),
820 });
821 }
822 Ok((schema_ref, files))
823 }
824}
825
826fn resolve_builtin_ref(source: &str) -> anyhow::Result<SchemaRef> {
829 let reg = memstead_schema::SchemaRegistry::builtin();
830 if source.contains('@') {
831 let r: SchemaRef = source.parse().map_err(|e: String| {
832 CliError::new(
833 ExitKind::Validation,
834 "INVALID_INPUT",
835 format!("invalid schema pin {source:?}: {e}"),
836 )
837 })?;
838 if reg.get(&r.name, &r.version).is_none() {
839 return Err(CliError::new(
840 ExitKind::Validation,
841 "SCHEMA_NOT_FOUND",
842 format!(
843 "no built-in schema {source} — pass a path to install a non-built-in package"
844 ),
845 )
846 .into());
847 }
848 Ok(r)
849 } else {
850 match reg.resolve_by_name(source) {
851 Ok(Some(s)) => {
852 let (n, v) = s.id();
853 Ok(SchemaRef::new(n, v))
854 }
855 Ok(None) => Err(CliError::new(
856 ExitKind::Validation,
857 "SCHEMA_NOT_FOUND",
858 format!(
859 "no built-in schema named {source:?} — pass a path to install a non-built-in \
860 package, or a `name@version` pin"
861 ),
862 )
863 .into()),
864 Err(e) => Err(CliError::new(
865 ExitKind::Validation,
866 "INVALID_INPUT",
867 format!("built-in name {source:?} is ambiguous: {e}"),
868 )
869 .into()),
870 }
871 }
872}
873
874fn collect_dir_package(dir: &Path) -> anyhow::Result<Vec<memstead_schema::SchemaSourceFile>> {
877 use memstead_schema::SchemaSourceFile;
878 let mut out = vec![SchemaSourceFile {
879 archive_path: "schema.yaml".to_string(),
880 bytes: std::fs::read(dir.join("schema.yaml"))?,
881 }];
882 let types = dir.join("types");
883 if types.is_dir() {
884 let mut paths: Vec<PathBuf> = std::fs::read_dir(&types)?
885 .filter_map(|e| e.ok().map(|e| e.path()))
886 .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("yaml"))
887 .collect();
888 paths.sort();
889 for p in paths {
890 if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
891 out.push(SchemaSourceFile {
892 archive_path: format!("types/{name}"),
893 bytes: std::fs::read(&p)?,
894 });
895 }
896 }
897 }
898 for opt in ["mem-template.json", "README.md"] {
899 let p = dir.join(opt);
900 if p.is_file() {
901 out.push(SchemaSourceFile {
902 archive_path: opt.to_string(),
903 bytes: std::fs::read(&p)?,
904 });
905 }
906 }
907 Ok(out)
908}
909
910fn marked_package(
915 mut files: Vec<memstead_schema::SchemaSourceFile>,
916) -> Vec<memstead_schema::SchemaSourceFile> {
917 let marker = memstead_schema::loader::SCHEMA_FORMAT_MARKER_FILE;
918 if !files.iter().any(|f| f.archive_path == marker) {
919 files.push(memstead_schema::SchemaSourceFile {
920 archive_path: marker.to_string(),
921 bytes: memstead_schema::loader::SCHEMA_FORMAT_MARKER_CONTENT
922 .as_bytes()
923 .to_vec(),
924 });
925 }
926 files
927}
928
929fn write_package(
936 pkg_dir: &Path,
937 files: &[memstead_schema::SchemaSourceFile],
938) -> anyhow::Result<()> {
939 for f in files {
940 let dest = pkg_dir.join(&f.archive_path);
941 if let Some(parent) = dest.parent() {
942 std::fs::create_dir_all(parent).map_err(|e| {
943 CliError::new(
944 ExitKind::Generic,
945 "IO_ERROR",
946 format!("could not create {}: {e}", parent.display()),
947 )
948 })?;
949 }
950 let bytes = retarget_yaml_directive(&f.archive_path, &f.bytes);
951 std::fs::write(&dest, &bytes).map_err(|e| {
952 CliError::new(
953 ExitKind::Generic,
954 "IO_ERROR",
955 format!("could not write {}: {e}", dest.display()),
956 )
957 })?;
958 }
959 Ok(())
960}
961
962fn directive_for(archive_path: &str) -> Option<&'static str> {
968 if archive_path == "schema.yaml" {
969 Some("# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json")
970 } else if archive_path.starts_with("types/") && archive_path.ends_with(".yaml") {
971 Some("# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json")
972 } else {
973 None
974 }
975}
976
977fn retarget_yaml_directive(archive_path: &str, bytes: &[u8]) -> Vec<u8> {
981 let Some(directive) = directive_for(archive_path) else {
982 return bytes.to_vec();
983 };
984 let Ok(text) = std::str::from_utf8(bytes) else {
985 return bytes.to_vec();
986 };
987 let body = if text.starts_with("# yaml-language-server:") {
988 text.split_once('\n').map(|(_, rest)| rest).unwrap_or("")
989 } else {
990 text
991 };
992 format!("{directive}\n{body}").into_bytes()
993}
994
995#[cfg(test)]
996mod tests {
997 use super::*;
998 use std::path::Path;
999
1000 fn ctx() -> CliContext {
1001 CliContext {
1002 json: false,
1003 quiet: true,
1004 role: Default::default(),
1005 }
1006 }
1007
1008 #[test]
1011 fn validate_accepts_builtin_default_schema() {
1012 let path = Path::new(env!("CARGO_MANIFEST_DIR"))
1016 .join("../memstead-schema/builtins/schemas/default-1.3");
1017 assert!(
1018 path.join("schema.yaml").is_file(),
1019 "fixture moved: {path:?}"
1020 );
1021 validate(&ctx(), ValidateArgs { path }).expect("default builtin must validate");
1022 }
1023
1024 #[test]
1027 fn validate_rejects_malformed_schema_with_typed_code() {
1028 let dir = tempfile::tempdir().unwrap();
1029 std::fs::write(dir.path().join("schema.yaml"), "name: [unterminated\n").unwrap();
1030 let err = validate(
1031 &ctx(),
1032 ValidateArgs {
1033 path: dir.path().to_path_buf(),
1034 },
1035 )
1036 .expect_err("malformed schema must refuse");
1037 let cli = err
1038 .downcast_ref::<CliError>()
1039 .expect("error is a typed CliError");
1040 assert_eq!(cli.code, "SCHEMA_VALIDATION_FAILED");
1041 assert_eq!(cli.kind, ExitKind::Validation);
1042 assert_eq!(
1043 cli.details.as_ref().unwrap()["path"],
1044 json!(dir.path()),
1045 "details echoes the offending path",
1046 );
1047 }
1048
1049 #[test]
1052 fn resolve_builtin_ref_handles_name_pin_and_unknown() {
1053 let bare = resolve_builtin_ref("software@0.2.0").expect("software pin resolves");
1057 assert_eq!(bare.name, "software");
1058 let pinned = resolve_builtin_ref("planning@0.1.0").expect("explicit pin resolves");
1059 assert_eq!(pinned.name, "planning");
1060 assert_eq!(pinned.version.to_string(), "0.1.0");
1061 resolve_builtin_ref("planning@0.2.0").expect("bumped pin resolves");
1062 resolve_builtin_ref("planning").expect_err("bare planning is ambiguous");
1063 let err = resolve_builtin_ref("not-a-builtin").expect_err("unknown name refuses");
1064 assert_eq!(
1065 err.downcast_ref::<CliError>().unwrap().code,
1066 "SCHEMA_NOT_FOUND",
1067 );
1068 }
1069
1070 #[test]
1073 fn resolve_source_for_builtin_includes_schema_and_template() {
1074 let (schema_ref, files) =
1075 resolve_source("planning@0.1.0").expect("planning source collects");
1076 assert_eq!(schema_ref.name, "planning");
1077 let paths: Vec<&str> = files.iter().map(|f| f.archive_path.as_str()).collect();
1078 assert!(paths.contains(&"schema.yaml"), "got {paths:?}");
1079 assert!(
1080 paths.contains(&"mem-template.json"),
1081 "built-in install must carry the mem-template.json, got {paths:?}",
1082 );
1083 }
1084
1085 #[test]
1088 fn collect_and_write_package_round_trips() {
1089 let src = tempfile::tempdir().unwrap();
1090 std::fs::create_dir_all(src.path().join("types")).unwrap();
1091 std::fs::write(src.path().join("schema.yaml"), b"name: x\n").unwrap();
1092 std::fs::write(src.path().join("types/doc.yaml"), b"name: doc\n").unwrap();
1093 std::fs::write(src.path().join("mem-template.json"), b"{}\n").unwrap();
1094
1095 let files = collect_dir_package(src.path()).unwrap();
1096 let dest = tempfile::tempdir().unwrap();
1097 let pkg = dest.path().join("x@0.1.0");
1098 write_package(&pkg, &files).unwrap();
1099
1100 let schema = std::fs::read_to_string(pkg.join("schema.yaml")).unwrap();
1103 assert_eq!(
1104 schema,
1105 "# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json\nname: x\n",
1106 );
1107 let doc = std::fs::read_to_string(pkg.join("types/doc.yaml")).unwrap();
1108 assert_eq!(
1109 doc,
1110 "# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json\nname: doc\n",
1111 );
1112 assert_eq!(
1113 std::fs::read(pkg.join("mem-template.json")).unwrap(),
1114 b"{}\n"
1115 );
1116 write_package(&pkg, &files).unwrap();
1118 assert_eq!(
1119 std::fs::read_to_string(pkg.join("schema.yaml")).unwrap(),
1120 schema
1121 );
1122 }
1123
1124 #[test]
1128 fn retarget_yaml_directive_replaces_or_prepends() {
1129 let existing = b"# yaml-language-server: $schema=../../../generated/schema-manifest.schema.json\nname: y\n";
1131 let out = String::from_utf8(retarget_yaml_directive("schema.yaml", existing)).unwrap();
1132 assert_eq!(
1133 out,
1134 "# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json\nname: y\n",
1135 );
1136 let bare = retarget_yaml_directive("types/t.yaml", b"name: t\n");
1138 assert_eq!(
1139 String::from_utf8(bare).unwrap(),
1140 "# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json\nname: t\n",
1141 );
1142 assert_eq!(retarget_yaml_directive("README.md", b"# hi\n"), b"# hi\n");
1144 }
1145}