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
501name: note
502description: |
503 A general-purpose note — replace this with your first real type.
504when_to_use: |
505 Use while sketching the schema; rename or split into sharper types
506 as the domain vocabulary firms up.
507
508# Sections are the entity's markdown body. `required: true` sections
509# must be present on every create.
510sections:
511 - key: summary
512 heading: Summary
513 required: true
514 search_weight: 40.0
515 write_rules:
516 - "One or two sentences. Must stand alone in a search result."
517 - key: details
518 heading: Details
519 required: false
520 search_weight: 10.0
521 # catch_all: content under unmatched headings lands here.
522 catch_all: true
523 write_rules:
524 - "Everything beyond the summary. Bullets over prose."
525
526# Typed, filterable frontmatter fields — beyond the built-in
527# type / created_date / last_modified / tags.
528# One rule for fields and sections alike: absence of `required` means
529# optional. `required: true` refuses a create that leaves the field
530# unset — unless a default fills it (required + default = always
531# present, never refused).
532metadata_fields:
533 - key: status
534 # required + default_value: every entity carries a status, and the
535 # default means a create never has to supply one.
536 required: true
537 description: Lifecycle state of the note.
538 field_type: string
539 default_value: active
540 enum_values: [active, archived]
541 filterable: equality
542 - key: source
543 # No `required` key: optional — an entity without a source is
544 # admitted. Use health_required_fields or a constraint if missing
545 # values should surface as findings instead.
546 description: Where the note's content came from.
547 field_type: string
548
549# Search ranking: how much a title match weighs.
550title_weight: 100.0
551# Sections included in full-text search.
552text_fields: [summary, details]
553# Which declared relationship expresses hierarchy for this type.
554hierarchy_relationship: PART_OF
555# One effect only: relate refuses a self-loop (from == to) on the rel-types
556# listed here. Nothing propagates; for impact propagation declare a
557# `status_propagation` constraint instead.
558no_self_loop_relationships: [PART_OF]
559# Fields `memstead update` may touch on this type.
560updatable_fields: [title, summary, details, status, tags]
561# Sections the health report treats as required.
562health_required_fields: [summary]
563# Days without modification before health flags the entity stale.
564staleness_threshold_days: 180
565# Prose guidance served to agents writing entities of this type.
566write_rules:
567 - "Notes are placeholders — split recurring shapes into dedicated types."
568"#
569 .to_string()
570}
571
572fn validate(ctx: &CliContext, args: ValidateArgs) -> anyhow::Result<()> {
573 match memstead_schema::loader::load_schema_from_dir(&args.path)
574 .and_then(|s| memstead_schema::check_section_heading_roundtrip(&s).map(|()| s))
575 .and_then(|s| memstead_schema::check_reserved_metadata_keys(&s).map(|()| s))
576 .and_then(|s| memstead_schema::check_section_formats(&s).map(|()| s))
577 {
578 Ok(schema) => {
579 let schema = std::sync::Arc::new(schema);
583 if let Err(defect) = memstead_base::Engine::validate_schema_exemplars(&schema) {
584 return Err(CliError::new(
585 ExitKind::Validation,
586 "SCHEMA_VALIDATION_FAILED",
587 format!("schema at {} is invalid: {defect}", args.path.display()),
588 )
589 .with_details(json!({ "path": args.path, "error": defect }))
590 .into());
591 }
592 let (name, version) = schema.id();
593 let type_count = schema.types.len();
594 if ctx.json {
595 print_json(&json!({
596 "ok": true,
597 "schema": format!("{name}@{version}"),
598 "types": type_count,
599 "path": args.path,
600 }))?;
601 } else {
602 print_markdown(&format!(
603 "# Schema valid\n\n`{name}@{version}` — {type_count} type(s) at `{}`\n",
604 args.path.display(),
605 ));
606 }
607 Ok(())
608 }
609 Err(e) => Err(CliError::new(
610 ExitKind::Validation,
611 "SCHEMA_VALIDATION_FAILED",
612 format!("schema at {} is invalid: {e}", args.path.display()),
613 )
614 .with_details(json!({
615 "path": args.path,
616 "error": e.to_string(),
617 }))
618 .into()),
619 }
620}
621
622fn install(ctx: &CliContext, args: InstallArgs) -> anyhow::Result<()> {
623 let (shape, root) = ctx.workspace_shape().ok_or_else(|| {
624 CliError::new(
625 ExitKind::Generic,
626 "NO_WORKSPACE",
627 "not inside a Memstead workspace (no `.memstead/workspace.toml` in any \
628 ancestor) — cd into your workspace first, or create one: memstead quickstart"
629 .to_string(),
630 )
631 })?;
632 let (schema_ref, files) = resolve_source(&args.source)?;
633
634 match shape {
635 WorkspaceShape::Filesystem => {
636 let files = marked_package(files);
641 let pkg_dir = root
642 .join(".memstead")
643 .join("schemas")
644 .join(format!("{}@{}", schema_ref.name, schema_ref.version));
645 write_package(&pkg_dir, &files)?;
646 if ctx.json {
647 print_json(&json!({
648 "ok": true,
649 "schema": format!("{}@{}", schema_ref.name, schema_ref.version),
650 "backend": "folder",
651 "path": pkg_dir,
652 "files": files.iter().map(|f| &f.archive_path).collect::<Vec<_>>(),
653 }))?;
654 } else {
655 print_markdown(&format!(
656 "# Schema installed\n\n`{}@{}` → `{}` ({} file(s))\n",
657 schema_ref.name,
658 schema_ref.version,
659 pkg_dir.display(),
660 files.len(),
661 ));
662 }
663 Ok(())
664 }
665 WorkspaceShape::MemRepo => install_to_git_branch(ctx, &schema_ref, &files),
666 }
667}
668
669#[cfg(feature = "mem-repo")]
681fn install_to_git_branch(
682 ctx: &CliContext,
683 schema_ref: &SchemaRef,
684 files: &[memstead_schema::SchemaSourceFile],
685) -> anyhow::Result<()> {
686 let Some((_shape, root)) = ctx.workspace_shape() else {
687 return Err(crate::setup::workspace_not_initialised_error(
688 "No workspace found. Run from a directory containing `.memstead/workspace.toml`.",
689 )
690 .into());
691 };
692 let pairs: Vec<(String, Vec<u8>)> = files
693 .iter()
694 .map(|f| (f.archive_path.clone(), f.bytes.clone()))
695 .collect();
696 let commit = memstead_git_branch::repair::install_schema_below_boot(
697 &root,
698 &schema_ref.name,
699 &schema_ref.version.to_string(),
700 &pairs,
701 )
702 .map_err(|e| crate::setup::boot_error_to_cli(&root, e))?;
703 if ctx.json {
704 print_json(&json!({
705 "ok": true,
706 "schema": format!("{}@{}", schema_ref.name, schema_ref.version),
707 "backend": "git-branch",
708 "ref": format!("__MEMSTEAD:schemas/{}@{}", schema_ref.name, schema_ref.version),
709 "commit": commit,
710 }))?;
711 } else {
712 print_markdown(&format!(
713 "# Schema installed\n\n`{}@{}` → `__MEMSTEAD:schemas/{}@{}` (commit `{}`)\n",
714 schema_ref.name, schema_ref.version, schema_ref.name, schema_ref.version, commit,
715 ));
716 }
717 Ok(())
718}
719
720#[cfg(not(feature = "mem-repo"))]
721fn install_to_git_branch(
722 _ctx: &CliContext,
723 _schema_ref: &SchemaRef,
724 _files: &[memstead_schema::SchemaSourceFile],
725) -> anyhow::Result<()> {
726 Err(CliError::new(
727 ExitKind::Generic,
728 "MEM_REPO_NOT_SUPPORTED",
729 "this binary was built without git-branch support — use the `memstead` binary to \
730 install a schema into a mem-repo workspace."
731 .to_string(),
732 )
733 .into())
734}
735
736fn resolve_source(
739 source: &str,
740) -> anyhow::Result<(SchemaRef, Vec<memstead_schema::SchemaSourceFile>)> {
741 let as_path = Path::new(source);
742 if as_path.is_dir() {
743 let schema = memstead_schema::load_schema_from_dir(as_path)
750 .and_then(|s| memstead_schema::check_section_heading_roundtrip(&s).map(|()| s))
751 .and_then(|s| memstead_schema::check_reserved_metadata_keys(&s).map(|()| s))
752 .and_then(|s| memstead_schema::check_section_formats(&s).map(|()| s))
753 .map_err(|e| {
754 CliError::new(
755 ExitKind::Validation,
756 "SCHEMA_VALIDATION_FAILED",
757 format!("package at {source} is invalid: {e}"),
758 )
759 .with_details(json!({ "path": source, "error": e.to_string() }))
760 })?;
761 let schema = std::sync::Arc::new(schema);
765 if let Err(defect) = memstead_base::Engine::validate_schema_exemplars(&schema) {
766 return Err(CliError::new(
767 ExitKind::Validation,
768 "SCHEMA_VALIDATION_FAILED",
769 format!("package at {source} is invalid: {defect}"),
770 )
771 .with_details(json!({ "path": source, "error": defect }))
772 .into());
773 }
774 let (name, version) = schema.id();
775 let mut files = collect_dir_package(as_path)?;
776 let authoring_path = as_path
783 .canonicalize()
784 .unwrap_or_else(|_| as_path.to_path_buf());
785 files.push(memstead_schema::SchemaSourceFile {
786 archive_path: memstead_schema::INSTALL_PROVENANCE_FILE.to_string(),
787 bytes: serde_json::to_vec_pretty(&json!({
788 "authoring_path": authoring_path.display().to_string(),
789 }))
790 .expect("provenance stamp serialises"),
791 });
792 Ok((SchemaRef::new(name, version), files))
793 } else {
794 let schema_ref = resolve_builtin_ref(source)?;
796 let mut files =
797 memstead_schema::collect_schema_source(None, None, &schema_ref).map_err(|e| {
798 CliError::new(
799 ExitKind::Validation,
800 "SCHEMA_NOT_FOUND",
801 format!(
802 "could not collect source for {}: {e}",
803 schema_ref.as_display()
804 ),
805 )
806 })?;
807 if let Some(tpl) = memstead_schema::builtins::builtin_mem_template(&schema_ref.name) {
810 files.push(memstead_schema::SchemaSourceFile {
811 archive_path: "mem-template.json".to_string(),
812 bytes: serde_json::to_vec_pretty(&tpl).unwrap_or_default(),
813 });
814 }
815 Ok((schema_ref, files))
816 }
817}
818
819fn resolve_builtin_ref(source: &str) -> anyhow::Result<SchemaRef> {
822 let reg = memstead_schema::SchemaRegistry::builtin();
823 if source.contains('@') {
824 let r: SchemaRef = source.parse().map_err(|e: String| {
825 CliError::new(
826 ExitKind::Validation,
827 "INVALID_INPUT",
828 format!("invalid schema pin {source:?}: {e}"),
829 )
830 })?;
831 if reg.get(&r.name, &r.version).is_none() {
832 return Err(CliError::new(
833 ExitKind::Validation,
834 "SCHEMA_NOT_FOUND",
835 format!(
836 "no built-in schema {source} — pass a path to install a non-built-in package"
837 ),
838 )
839 .into());
840 }
841 Ok(r)
842 } else {
843 match reg.resolve_by_name(source) {
844 Ok(Some(s)) => {
845 let (n, v) = s.id();
846 Ok(SchemaRef::new(n, v))
847 }
848 Ok(None) => Err(CliError::new(
849 ExitKind::Validation,
850 "SCHEMA_NOT_FOUND",
851 format!(
852 "no built-in schema named {source:?} — pass a path to install a non-built-in \
853 package, or a `name@version` pin"
854 ),
855 )
856 .into()),
857 Err(e) => Err(CliError::new(
858 ExitKind::Validation,
859 "INVALID_INPUT",
860 format!("built-in name {source:?} is ambiguous: {e}"),
861 )
862 .into()),
863 }
864 }
865}
866
867fn collect_dir_package(dir: &Path) -> anyhow::Result<Vec<memstead_schema::SchemaSourceFile>> {
870 use memstead_schema::SchemaSourceFile;
871 let mut out = vec![SchemaSourceFile {
872 archive_path: "schema.yaml".to_string(),
873 bytes: std::fs::read(dir.join("schema.yaml"))?,
874 }];
875 let types = dir.join("types");
876 if types.is_dir() {
877 let mut paths: Vec<PathBuf> = std::fs::read_dir(&types)?
878 .filter_map(|e| e.ok().map(|e| e.path()))
879 .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("yaml"))
880 .collect();
881 paths.sort();
882 for p in paths {
883 if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
884 out.push(SchemaSourceFile {
885 archive_path: format!("types/{name}"),
886 bytes: std::fs::read(&p)?,
887 });
888 }
889 }
890 }
891 for opt in ["mem-template.json", "README.md"] {
892 let p = dir.join(opt);
893 if p.is_file() {
894 out.push(SchemaSourceFile {
895 archive_path: opt.to_string(),
896 bytes: std::fs::read(&p)?,
897 });
898 }
899 }
900 Ok(out)
901}
902
903fn marked_package(
908 mut files: Vec<memstead_schema::SchemaSourceFile>,
909) -> Vec<memstead_schema::SchemaSourceFile> {
910 let marker = memstead_schema::loader::SCHEMA_FORMAT_MARKER_FILE;
911 if !files.iter().any(|f| f.archive_path == marker) {
912 files.push(memstead_schema::SchemaSourceFile {
913 archive_path: marker.to_string(),
914 bytes: memstead_schema::loader::SCHEMA_FORMAT_MARKER_CONTENT
915 .as_bytes()
916 .to_vec(),
917 });
918 }
919 files
920}
921
922fn write_package(
929 pkg_dir: &Path,
930 files: &[memstead_schema::SchemaSourceFile],
931) -> anyhow::Result<()> {
932 for f in files {
933 let dest = pkg_dir.join(&f.archive_path);
934 if let Some(parent) = dest.parent() {
935 std::fs::create_dir_all(parent).map_err(|e| {
936 CliError::new(
937 ExitKind::Generic,
938 "IO_ERROR",
939 format!("could not create {}: {e}", parent.display()),
940 )
941 })?;
942 }
943 let bytes = retarget_yaml_directive(&f.archive_path, &f.bytes);
944 std::fs::write(&dest, &bytes).map_err(|e| {
945 CliError::new(
946 ExitKind::Generic,
947 "IO_ERROR",
948 format!("could not write {}: {e}", dest.display()),
949 )
950 })?;
951 }
952 Ok(())
953}
954
955fn directive_for(archive_path: &str) -> Option<&'static str> {
961 if archive_path == "schema.yaml" {
962 Some("# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json")
963 } else if archive_path.starts_with("types/") && archive_path.ends_with(".yaml") {
964 Some("# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json")
965 } else {
966 None
967 }
968}
969
970fn retarget_yaml_directive(archive_path: &str, bytes: &[u8]) -> Vec<u8> {
974 let Some(directive) = directive_for(archive_path) else {
975 return bytes.to_vec();
976 };
977 let Ok(text) = std::str::from_utf8(bytes) else {
978 return bytes.to_vec();
979 };
980 let body = if text.starts_with("# yaml-language-server:") {
981 text.split_once('\n').map(|(_, rest)| rest).unwrap_or("")
982 } else {
983 text
984 };
985 format!("{directive}\n{body}").into_bytes()
986}
987
988#[cfg(test)]
989mod tests {
990 use super::*;
991 use std::path::Path;
992
993 fn ctx() -> CliContext {
994 CliContext {
995 json: false,
996 quiet: true,
997 role: Default::default(),
998 }
999 }
1000
1001 #[test]
1004 fn validate_accepts_builtin_default_schema() {
1005 let path = Path::new(env!("CARGO_MANIFEST_DIR"))
1009 .join("../memstead-schema/builtins/schemas/default-1.3");
1010 assert!(
1011 path.join("schema.yaml").is_file(),
1012 "fixture moved: {path:?}"
1013 );
1014 validate(&ctx(), ValidateArgs { path }).expect("default builtin must validate");
1015 }
1016
1017 #[test]
1020 fn validate_rejects_malformed_schema_with_typed_code() {
1021 let dir = tempfile::tempdir().unwrap();
1022 std::fs::write(dir.path().join("schema.yaml"), "name: [unterminated\n").unwrap();
1023 let err = validate(
1024 &ctx(),
1025 ValidateArgs {
1026 path: dir.path().to_path_buf(),
1027 },
1028 )
1029 .expect_err("malformed schema must refuse");
1030 let cli = err
1031 .downcast_ref::<CliError>()
1032 .expect("error is a typed CliError");
1033 assert_eq!(cli.code, "SCHEMA_VALIDATION_FAILED");
1034 assert_eq!(cli.kind, ExitKind::Validation);
1035 assert_eq!(
1036 cli.details.as_ref().unwrap()["path"],
1037 json!(dir.path()),
1038 "details echoes the offending path",
1039 );
1040 }
1041
1042 #[test]
1045 fn resolve_builtin_ref_handles_name_pin_and_unknown() {
1046 let bare = resolve_builtin_ref("software@0.2.0").expect("software pin resolves");
1050 assert_eq!(bare.name, "software");
1051 let pinned = resolve_builtin_ref("planning@0.1.0").expect("explicit pin resolves");
1052 assert_eq!(pinned.name, "planning");
1053 assert_eq!(pinned.version.to_string(), "0.1.0");
1054 resolve_builtin_ref("planning@0.2.0").expect("bumped pin resolves");
1055 resolve_builtin_ref("planning").expect_err("bare planning is ambiguous");
1056 let err = resolve_builtin_ref("not-a-builtin").expect_err("unknown name refuses");
1057 assert_eq!(
1058 err.downcast_ref::<CliError>().unwrap().code,
1059 "SCHEMA_NOT_FOUND",
1060 );
1061 }
1062
1063 #[test]
1066 fn resolve_source_for_builtin_includes_schema_and_template() {
1067 let (schema_ref, files) =
1068 resolve_source("planning@0.1.0").expect("planning source collects");
1069 assert_eq!(schema_ref.name, "planning");
1070 let paths: Vec<&str> = files.iter().map(|f| f.archive_path.as_str()).collect();
1071 assert!(paths.contains(&"schema.yaml"), "got {paths:?}");
1072 assert!(
1073 paths.contains(&"mem-template.json"),
1074 "built-in install must carry the mem-template.json, got {paths:?}",
1075 );
1076 }
1077
1078 #[test]
1081 fn collect_and_write_package_round_trips() {
1082 let src = tempfile::tempdir().unwrap();
1083 std::fs::create_dir_all(src.path().join("types")).unwrap();
1084 std::fs::write(src.path().join("schema.yaml"), b"name: x\n").unwrap();
1085 std::fs::write(src.path().join("types/doc.yaml"), b"name: doc\n").unwrap();
1086 std::fs::write(src.path().join("mem-template.json"), b"{}\n").unwrap();
1087
1088 let files = collect_dir_package(src.path()).unwrap();
1089 let dest = tempfile::tempdir().unwrap();
1090 let pkg = dest.path().join("x@0.1.0");
1091 write_package(&pkg, &files).unwrap();
1092
1093 let schema = std::fs::read_to_string(pkg.join("schema.yaml")).unwrap();
1096 assert_eq!(
1097 schema,
1098 "# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json\nname: x\n",
1099 );
1100 let doc = std::fs::read_to_string(pkg.join("types/doc.yaml")).unwrap();
1101 assert_eq!(
1102 doc,
1103 "# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json\nname: doc\n",
1104 );
1105 assert_eq!(
1106 std::fs::read(pkg.join("mem-template.json")).unwrap(),
1107 b"{}\n"
1108 );
1109 write_package(&pkg, &files).unwrap();
1111 assert_eq!(
1112 std::fs::read_to_string(pkg.join("schema.yaml")).unwrap(),
1113 schema
1114 );
1115 }
1116
1117 #[test]
1121 fn retarget_yaml_directive_replaces_or_prepends() {
1122 let existing = b"# yaml-language-server: $schema=../../../generated/schema-manifest.schema.json\nname: y\n";
1124 let out = String::from_utf8(retarget_yaml_directive("schema.yaml", existing)).unwrap();
1125 assert_eq!(
1126 out,
1127 "# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json\nname: y\n",
1128 );
1129 let bare = retarget_yaml_directive("types/t.yaml", b"name: t\n");
1131 assert_eq!(
1132 String::from_utf8(bare).unwrap(),
1133 "# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json\nname: t\n",
1134 );
1135 assert_eq!(retarget_yaml_directive("README.md", b"# hi\n"), b"# hi\n");
1137 }
1138}