1use std::path::{Path, PathBuf};
43
44use clap::{Args as ClapArgs, Subcommand};
45use serde_json::json;
46
47use memstead_schema::SchemaRef;
48
49use crate::CliError;
50use crate::output::{ExitKind, print_json, print_markdown};
51use crate::setup::{CliContext, WorkspaceShape};
52
53#[derive(ClapArgs, Debug)]
54#[command(args_conflicts_with_subcommands = true)]
55pub struct Args {
56 #[command(subcommand)]
57 pub command: Option<SchemaCommand>,
58
59 #[arg(value_name = "REF")]
71 pub reference: Option<String>,
72}
73
74#[derive(Subcommand, Debug)]
75pub enum SchemaCommand {
76 New(NewArgs),
81
82 Validate(ValidateArgs),
95
96 Install(InstallArgs),
108
109 Migrate(MigrateArgs),
127}
128
129#[derive(ClapArgs, Debug)]
130pub struct MigrateArgs {
131 pub path: PathBuf,
134
135 #[arg(long)]
138 pub write: bool,
139}
140
141#[derive(ClapArgs, Debug)]
142pub struct NewArgs {
143 pub name: String,
147}
148
149#[derive(ClapArgs, Debug)]
150pub struct ValidateArgs {
151 pub path: PathBuf,
154}
155
156#[derive(ClapArgs, Debug)]
157pub struct InstallArgs {
158 pub source: String,
161}
162
163pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
164 if matches!(
173 args.command,
174 Some(
175 SchemaCommand::New(_)
176 | SchemaCommand::Validate(_)
177 | SchemaCommand::Install(_)
178 | SchemaCommand::Migrate(_)
179 )
180 ) && let Some((_, root)) = ctx.workspace_shape()
181 {
182 let _ = memstead_schema::meta_schema::publish_meta_schemas(&root);
183 }
184 match (args.command, args.reference) {
185 (Some(SchemaCommand::New(a)), _) => scaffold_new(ctx, a),
186 (Some(SchemaCommand::Validate(a)), _) => validate(ctx, a),
187 (Some(SchemaCommand::Install(a)), _) => install(ctx, a),
188 (Some(SchemaCommand::Migrate(a)), _) => migrate(ctx, a),
189 (None, Some(reference)) => show_builtin(ctx, &reference),
190 (None, None) => Err(CliError::new(
191 ExitKind::Validation,
192 "INVALID_INPUT",
193 "memstead schema needs a built-in reference to render (`memstead schema \
194 planning@0.4.0`) or a subcommand (`new`, `validate`, `install`); \
195 `memstead schema --help` lists them"
196 .to_string(),
197 )
198 .into()),
199 }
200}
201
202fn show_builtin(ctx: &CliContext, reference: &str) -> anyhow::Result<()> {
211 let schema_ref = resolve_builtin_read_ref(reference)?;
212 let version = schema_ref.version.to_string();
213 let (origin, name, ver, readme_bytes) =
214 match memstead_schema::builtins::builtin_package(&schema_ref.name, &version) {
215 Some(pkg) => {
216 let bytes = pkg
217 .files
218 .iter()
219 .find(|(path, _)| path == memstead_schema::builtins::PACKAGE_README_FILE)
220 .map(|(_, bytes)| bytes.to_vec());
221 (
222 "builtin",
223 pkg.name.to_string(),
224 pkg.version.to_string(),
225 bytes,
226 )
227 }
228 None => match workspace_installed_readme(ctx, &schema_ref) {
229 Some(bytes) => (
230 "workspace",
231 schema_ref.name.clone(),
232 version.clone(),
233 Some(bytes),
234 ),
235 None => {
236 return Err(CliError::new(
237 ExitKind::Validation,
238 "SCHEMA_NOT_FOUND",
239 format!(
240 "no built-in or workspace-installed schema {}",
241 schema_ref.as_display()
242 ),
243 )
244 .into());
245 }
246 },
247 };
248 let readme = readme_bytes
249 .as_deref()
250 .and_then(|bytes| std::str::from_utf8(bytes).ok())
251 .map(|text| memstead_schema::builtins::render_package_readme(&name, &ver, text));
252 let pin = format!("{name}@{ver}");
253 if ctx.json {
254 print_json(&json!({
255 "schema": pin,
256 "name": name,
257 "version": ver,
258 "origin": origin,
259 "readme": readme,
260 }))?;
261 return Ok(());
262 }
263 let label = if origin == "builtin" {
265 "built-in"
266 } else {
267 "workspace-installed"
268 };
269 match readme {
270 Some(text) => print_markdown(&format!(
271 "<!-- {pin}: {label} package README, rendered for this generation -->\n{text}"
272 )),
273 None => print_markdown(&format!(
274 "`{pin}` is a {label} package that ships no README.\n"
275 )),
276 }
277 Ok(())
278}
279
280fn workspace_installed_readme(ctx: &CliContext, schema_ref: &SchemaRef) -> Option<Vec<u8>> {
286 let (_, root) = ctx.workspace_shape()?;
287 let fs_readme = root
288 .join(".memstead")
289 .join("schemas")
290 .join(format!("{}@{}", schema_ref.name, schema_ref.version))
291 .join("README.md");
292 if let Ok(bytes) = std::fs::read(&fs_readme) {
293 return Some(bytes);
294 }
295 #[cfg(feature = "mem-repo")]
296 {
297 let gitdir = root.join("mem-repo").join(".git");
298 if gitdir.is_dir()
299 && let Ok(Some(bytes)) =
300 memstead_git_branch::storage_memstead::read_schema_file_from_memstead_ref(
301 &gitdir,
302 &schema_ref.name,
303 &schema_ref.version.to_string(),
304 "README.md",
305 )
306 {
307 return Some(bytes);
308 }
309 }
310 None
311}
312
313fn resolve_builtin_read_ref(reference: &str) -> anyhow::Result<SchemaRef> {
323 if reference.contains('@') {
324 return reference.parse::<SchemaRef>().map_err(|e: String| {
325 CliError::new(
326 ExitKind::Validation,
327 "INVALID_INPUT",
328 format!("invalid schema pin {reference:?}: {e}"),
329 )
330 .into()
331 });
332 }
333 let reg = memstead_schema::SchemaRegistry::builtin();
334 let mut versions = reg.available_versions(reference);
335 versions.sort();
336 match versions.pop() {
337 Some(v) => Ok(SchemaRef::new(reference.to_string(), v)),
338 None => Err(CliError::new(
339 ExitKind::Validation,
340 "SCHEMA_NOT_FOUND",
341 format!(
342 "no built-in schema named {reference:?}; built-ins: {}",
343 builtin_names_joined(®)
344 ),
345 )
346 .into()),
347 }
348}
349
350fn builtin_names_joined(reg: &memstead_schema::SchemaRegistry) -> String {
351 let mut names: Vec<String> = reg.identities().into_iter().map(|(n, _)| n).collect();
352 names.sort();
353 names.dedup();
354 names.join(", ")
355}
356
357const SCAFFOLD_VERSION: &str = "0.1.0";
359
360fn scaffold_new(ctx: &CliContext, args: NewArgs) -> anyhow::Result<()> {
361 if let Err(reason) = memstead_schema::loader::validate_schema_name(&args.name) {
362 let suggestion = suggest_schema_name(&args.name);
363 return Err(CliError::new(
364 ExitKind::Validation,
365 "INVALID_INPUT",
366 format!(
367 "invalid schema name {name:?}: {reason} (lowercase letter first, \
368 then lowercase letters, digits, hyphens). \
369 Try: memstead schema new {suggestion}",
370 name = args.name,
371 ),
372 )
373 .with_details(json!({
374 "name": args.name,
375 "reason": reason,
376 "suggestion": suggestion,
377 }))
378 .into());
379 }
380
381 let pkg_dir = PathBuf::from(&args.name);
382 if pkg_dir.join("schema.yaml").is_file() {
383 return Err(CliError::new(
384 ExitKind::Validation,
385 "SCHEMA_PACKAGE_EXISTS",
386 format!(
387 "{} already contains a schema package — `memstead schema new` \
388 never overwrites. Check it with: memstead schema validate {}",
389 pkg_dir.display(),
390 args.name,
391 ),
392 )
393 .with_details(json!({ "path": pkg_dir }))
394 .into());
395 }
396 if pkg_dir.is_dir()
397 && let Some(entry) = std::fs::read_dir(&pkg_dir)
398 .map_err(|e| {
399 CliError::new(
400 ExitKind::Generic,
401 "IO_ERROR",
402 format!("read {}: {e}", pkg_dir.display()),
403 )
404 })?
405 .next()
406 .transpose()
407 .map_err(|e| {
408 CliError::new(
409 ExitKind::Generic,
410 "IO_ERROR",
411 format!("read {}: {e}", pkg_dir.display()),
412 )
413 })?
414 {
415 let found = entry.file_name().to_string_lossy().to_string();
416 return Err(CliError::new(
417 ExitKind::Validation,
418 "TARGET_NOT_EMPTY",
419 format!(
420 "{} exists and is not empty (found `{found}`) — clear it or \
421 pick a different name: memstead schema new {}-schema",
422 pkg_dir.display(),
423 args.name,
424 ),
425 )
426 .with_details(json!({ "path": pkg_dir, "found": [found] }))
427 .into());
428 }
429
430 let manifest = scaffold_manifest(&args.name);
431 let example_type = scaffold_example_type();
432 std::fs::create_dir_all(pkg_dir.join("types")).map_err(|e| {
433 CliError::new(
434 ExitKind::Generic,
435 "IO_ERROR",
436 format!("create {}: {e}", pkg_dir.join("types").display()),
437 )
438 })?;
439 for (rel, content) in [
440 ("schema.yaml", &manifest),
441 ("types/note.yaml", &example_type),
442 ] {
443 let dest = pkg_dir.join(rel);
444 std::fs::write(&dest, content).map_err(|e| {
445 CliError::new(
446 ExitKind::Generic,
447 "IO_ERROR",
448 format!("write {}: {e}", dest.display()),
449 )
450 })?;
451 }
452
453 if let Err(e) = memstead_schema::loader::load_schema_from_dir(&pkg_dir)
457 .and_then(|s| memstead_schema::check_reserved_metadata_keys(&s).map(|()| s))
458 .and_then(|s| memstead_schema::check_section_formats(&s).map(|()| s))
459 {
460 return Err(CliError::new(
461 ExitKind::Generic,
462 crate::INTERNAL_CODE,
463 format!(
464 "scaffold bug: generated package at {} fails validation: {e} — \
465 please report this",
466 pkg_dir.display(),
467 ),
468 )
469 .into());
470 }
471
472 let next_steps = scaffold_next_steps(ctx, &args.name);
473 if ctx.json {
474 print_json(&json!({
475 "ok": true,
476 "schema": format!("{}@{SCAFFOLD_VERSION}", args.name),
477 "path": pkg_dir,
478 "files": ["schema.yaml", "types/note.yaml"],
479 "next_steps": next_steps
480 .iter()
481 .map(|s| json!({ "command": s.command, "note": s.note }))
482 .collect::<Vec<_>>(),
483 }))?;
484 } else {
485 let steps: Vec<String> = next_steps
486 .iter()
487 .enumerate()
488 .map(|(i, s)| match &s.note {
489 Some(note) => format!("{}. `{}` — {note}", i + 1, s.command),
490 None => format!("{}. `{}`", i + 1, s.command),
491 })
492 .collect();
493 print_markdown(&format!(
494 "# Schema package scaffolded\n\n`{name}@{SCAFFOLD_VERSION}` at `{dir}` \
495 (schema.yaml + types/note.yaml, one commented example type).\n\n\
496 Edit the package, then:\n\n{steps}\n",
497 name = args.name,
498 dir = pkg_dir.display(),
499 steps = steps.join("\n"),
500 ));
501 }
502 Ok(())
503}
504
505fn scaffold_next_steps(ctx: &CliContext, name: &str) -> Vec<Step> {
516 use memstead_base::workspace::MountCapability;
517 use memstead_base::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
518 let workspace = ctx.workspace_shape().and_then(|(shape, root)| match shape {
519 WorkspaceShape::Filesystem => FileWorkspaceStore::new().load(&root).ok().and_then(|ws| {
520 let mut writable = ws
521 .mounts
522 .iter()
523 .filter(|m| m.capability == MountCapability::Write);
524 match (writable.next(), writable.next()) {
525 (Some(only), None) => Some((only.mem.clone(), root.clone())),
526 _ => None,
527 }
528 }),
529 WorkspaceShape::MemRepo => None,
530 });
531 let mem = workspace
532 .as_ref()
533 .map(|(mem, _)| mem.clone())
534 .unwrap_or_else(|| "<mem>".to_string());
535 let quickstart_seed = workspace
538 .as_ref()
539 .filter(|(_, root)| root.join("welcome-to-memstead.md").is_file())
540 .map(|(mem, _)| format!("{mem}--welcome-to-memstead"));
541 #[cfg(feature = "mem-repo")]
544 {
545 let mut steps = vec![
546 Step::bare(format!("memstead schema validate {name}")),
547 Step::bare(format!("memstead schema install {name}")),
548 ];
549 if let Some(seed_id) = quickstart_seed {
550 steps.push(Step {
551 command: format!("memstead delete {seed_id}"),
552 note: Some(
553 "the quickstart seed — the pin below switches atomically only when \
554 every entity conforms to the new schema"
555 .to_string(),
556 ),
557 });
558 }
559 steps.push(Step::bare(format!(
560 "memstead mem set-schema {mem} {name}@{SCAFFOLD_VERSION}"
561 )));
562 steps
563 }
564 #[cfg(not(feature = "mem-repo"))]
574 {
575 let _ = (mem, quickstart_seed); let (fresh_dir, install_source) = match ctx.workspace_shape() {
577 Some((_, root)) => {
578 let parent = root.parent().unwrap_or(&root).to_path_buf();
579 let pkg = std::env::current_dir().unwrap_or_default().join(name);
580 (
581 format!("\"{}\"", parent.join(format!("{name}-mem")).display()),
582 format!("\"{}\"", pkg.display()),
583 )
584 }
585 None => (format!("{name}-mem"), format!("../{name}")),
586 };
587 vec![
588 Step::bare(format!("memstead schema validate {name}")),
589 Step {
590 command: format!(
591 "mkdir {fresh_dir} && cd {fresh_dir} && memstead init --name {name}-mem \
592 --schema {name}@{SCAFFOLD_VERSION}"
593 ),
594 note: Some(
595 "this binary cannot re-pin an existing mem, so the schema gets a \
596 fresh one"
597 .to_string(),
598 ),
599 },
600 Step {
601 command: format!("memstead schema install {install_source}"),
602 note: Some(
603 "run inside the new folder — the workspace boots once its pinned \
604 schema is installed"
605 .to_string(),
606 ),
607 },
608 ]
609 }
610}
611
612struct Step {
616 command: String,
617 note: Option<String>,
618}
619
620impl Step {
621 fn bare(command: String) -> Self {
622 Step {
623 command,
624 note: None,
625 }
626 }
627}
628
629fn suggest_schema_name(raw: &str) -> String {
634 let mut out = String::with_capacity(raw.len());
635 for c in raw.to_lowercase().chars() {
636 if c.is_ascii_lowercase() || c.is_ascii_digit() {
637 out.push(c);
638 } else if !out.ends_with('-') && !out.is_empty() {
639 out.push('-');
640 }
641 }
642 let trimmed: String = out
643 .trim_matches('-')
644 .chars()
645 .skip_while(|c| !c.is_ascii_lowercase())
646 .collect();
647 let trimmed = trimmed.trim_matches('-');
648 if trimmed.is_empty() {
649 "my-schema".to_string()
650 } else {
651 trimmed.to_string()
652 }
653}
654
655fn scaffold_manifest(name: &str) -> String {
659 format!(
660 r#"# Schema package scaffolded by `memstead schema new`.
661# A schema package is one folder: this manifest plus one YAML file per
662# entity type under types/. Re-check any time with:
663# memstead schema validate {name}
664
665name: {name}
666version: {SCAFFOLD_VERSION}
667
668# Shown in schema catalogues (memstead_overview, the registry).
669description: |
670 Describe the subject this schema models and the types it declares.
671
672# Read by agents (and humans) choosing a schema for a new mem.
673when_to_use: |
674 Say when this schema fits — and when an author should reach for a
675 different one.
676
677# Optional: served to agents working in a mem pinned to this schema.
678system_message: |
679 You are working in a graph using the {name} schema. Prefer precise
680 types, link generously, and keep sections in their declared shape.
681
682# One entry per file under types/ — `note` matches types/note.yaml.
683# Add a type by adding both the file and its entry here.
684types:
685 - note
686
687relationships:
688 # strict: only the definitions below are legal edge types.
689 # open: any UPPER_SNAKE_CASE name is accepted; definitions add weights.
690 mode: strict
691 # Optional relationships-level declarations (engine 0.10.0+):
692 # acyclic_sets — acyclicity over the UNION of a rel-type set, for
693 # cycles no single rel-type contains:
694 # acyclic_sets:
695 # - [GROUNDS, CONCLUDES]
696 # labelling — name the attack rel-types and the engine serves
697 # the grounded labelling (accepted/defeated/
698 # undecided) with evidence; optional support walk
699 # adds chain-shape statistics.
700 definitions:
701 - name: PART_OF
702 description: Hierarchical containment — the source is structurally part of the target.
703 default_weight: 3.0
704 acyclic: true
705 - name: RELATES_TO
706 description: General association between two entities when no sharper type fits.
707 default_weight: 1.0
708 # Every key below is OPTIONAL, but its default is not always the
709 # permissive one — uncomment what you need.
710 #
711 # Per-edge `--description` text. DEFAULT IS `forbidden`: leave this
712 # out and every `memstead relate ... --description` on this type is
713 # REFUSED with DESCRIPTION_NOT_PERMITTED.
714 # per_edge_description: optional # forbidden | optional | required
715 #
716 # Restrict which types this edge may join. Omit for "any type".
717 # source_types: [note]
718 # target_types: [note]
719 #
720 # cardinality_per_source: 1 # at most one such edge per source
721 # manual_authoring: false # true = engine-emitted only
722 - name: REFERENCES
723 description: Soft reference. Auto-emitted from body wiki-links — never author by hand.
724 default_weight: 0.5
725 # Required entry — the fallback weight for any relationship not
726 # listed above.
727 - name: _default
728 description: Fallback weight for any relationship not otherwise specified.
729 default_weight: 1.0
730
731# Body wiki-links `[[target]]` auto-emit as REFERENCES relations.
732# Remove this key to make unbacked wiki-links a validation error instead.
733alias_target_rel_type: REFERENCES
734
735# Community detection (graph clustering) tuning. REQUIRED — the block
736# must be present; the values below are the defaults, keep them unless
737# you know why you are changing them.
738community:
739 resolution: 1.0
740 seed: 42
741
742# The complete key reference for schema packages — every key the loader
743# accepts, with its type and default — is the meta-schema shipped in
744# your workspace at `.memstead/meta-schemas/schema-manifest.schema.json`.
745# This scaffold teaches by example; that file is exhaustive.
746"#
747 )
748}
749
750fn scaffold_example_type() -> String {
754 r#"# One entity type = one file. `name` must match the filename stem
755# and appear in the manifest's `types:` list.
756#
757# Keys marked REQUIRED must be present in every type file — deleting
758# one fails `memstead schema validate`. Everything else is optional.
759
760# REQUIRED.
761name: note
762# REQUIRED.
763description: |
764 A general-purpose note — replace this with your first real type.
765# REQUIRED.
766when_to_use: |
767 Use while sketching the schema; rename or split into sharper types
768 as the domain vocabulary firms up.
769
770# REQUIRED. Sections are the entity's markdown body. `required: true`
771# sections must be present on every create.
772sections:
773 - key: summary
774 heading: Summary
775 required: true
776 search_weight: 40.0
777 write_rules:
778 - "One or two sentences. Must stand alone in a search result."
779 - key: details
780 heading: Details
781 required: false
782 search_weight: 10.0
783 # catch_all: content under unmatched headings lands here.
784 catch_all: true
785 write_rules:
786 - "Everything beyond the summary. Bullets over prose."
787
788# REQUIRED (the key; it may be an empty list). Typed, filterable
789# frontmatter fields — beyond the built-in
790# type / created_date / last_modified / tags.
791# One rule for fields and sections alike: absence of `required` means
792# optional. `required: true` refuses a create that leaves the field
793# unset — unless a default fills it (required + default = always
794# present, never refused).
795metadata_fields:
796 - key: status
797 # required + default_value: every entity carries a status, and the
798 # default means a create never has to supply one.
799 required: true
800 description: Lifecycle state of the note.
801 field_type: string
802 default_value: active
803 enum_values: [active, archived]
804 filterable: equality
805 - key: source
806 # No `required` key: optional — an entity without a source is
807 # admitted. Use health_required_fields or a constraint if missing
808 # values should surface as findings instead.
809 description: Where the note's content came from.
810 field_type: string
811
812# REQUIRED. Search ranking: how much a title match weighs.
813title_weight: 100.0
814# REQUIRED. Sections included in full-text search.
815text_fields: [summary, details]
816# REQUIRED. Which declared relationship expresses hierarchy for this type.
817hierarchy_relationship: PART_OF
818# One effect only: relate refuses a self-loop (from == to) on the rel-types
819# listed here. Nothing propagates; for impact propagation declare a
820# `status_propagation` constraint instead.
821no_self_loop_relationships: [PART_OF]
822# Fields `memstead update` may touch on this type.
823updatable_fields: [title, summary, details, status, tags]
824# Sections the health report treats as required.
825health_required_fields: [summary]
826# Days without modification before health flags the entity stale.
827staleness_threshold_days: 180
828# Further optional type-level declarations (engine 0.10.0+), shapes in
829# the authoring guide and the generated type-definition.schema.json:
830# required_outgoing — edge obligations (cardinality, warn/block
831# severity, optional when_field/when_value pair
832# arming a block on a metadata enum value)
833# must_reach — reachability obligations over a relation set
834# (direction out/in, terminal_types, max_depth);
835# health-sweep only, always warn
836# constraints — the five-form vocabulary (requires_when,
837# unique, enum_from_neighbour, status_propagation
838# with rel_type or rel_types)
839# signals — edge_load counts with notice/warn thresholds,
840# served with contributors on every read
841# Prose guidance served to agents writing entities of this type.
842write_rules:
843 - "Notes are placeholders — split recurring shapes into dedicated types."
844"#
845 .to_string()
846}
847
848fn validate(ctx: &CliContext, args: ValidateArgs) -> anyhow::Result<()> {
849 if args.path.join("schema-format.json").is_file() {
856 return Err(CliError::new(
857 ExitKind::Validation,
858 "SCHEMA_VALIDATION_FAILED",
859 format!(
860 "{} is a sealed schema package (it carries `schema-format.json`, the seal \
861 marker), not authoring input — `schema validate` checks the directories you \
862 author, before sealing. Validate the package's source directory instead, or \
863 install this package directly with `memstead schema install`.",
864 args.path.display(),
865 ),
866 )
867 .with_details(json!({
868 "path": args.path,
869 "reason": "sealed_package",
870 }))
871 .into());
872 }
873 match memstead_schema::loader::load_schema_from_dir(&args.path)
874 .and_then(|s| memstead_schema::check_section_heading_roundtrip(&s).map(|()| s))
875 .and_then(|s| memstead_schema::check_reserved_metadata_keys(&s).map(|()| s))
876 .and_then(|s| memstead_schema::check_section_formats(&s).map(|()| s))
877 {
878 Ok(schema) => {
879 let schema = std::sync::Arc::new(schema);
883 if let Err(defect) = memstead_base::Engine::validate_schema_exemplars(&schema) {
884 return Err(CliError::new(
885 ExitKind::Validation,
886 "SCHEMA_VALIDATION_FAILED",
887 format!("schema at {} is invalid: {defect}", args.path.display()),
888 )
889 .with_details(json!({ "path": args.path, "error": defect }))
890 .into());
891 }
892 let (name, version) = schema.id();
893 let type_count = schema.types.len();
894 if ctx.json {
895 print_json(&json!({
896 "ok": true,
897 "schema": format!("{name}@{version}"),
898 "types": type_count,
899 "path": args.path,
900 }))?;
901 } else {
902 print_markdown(&format!(
903 "# Schema valid\n\n`{name}@{version}` — {type_count} type(s) at `{}`\n",
904 args.path.display(),
905 ));
906 }
907 Ok(())
908 }
909 Err(e) => Err(CliError::new(
910 ExitKind::Validation,
911 "SCHEMA_VALIDATION_FAILED",
912 format!("schema at {} is invalid: {e}", args.path.display()),
913 )
914 .with_details(json!({
915 "path": args.path,
916 "error": e.to_string(),
917 }))
918 .into()),
919 }
920}
921
922fn migrate(ctx: &CliContext, args: MigrateArgs) -> anyhow::Result<()> {
923 use memstead_schema::migrate::{MigrateError, migrate_package, next_steps, write_migration};
924
925 let map_err = |e: MigrateError| -> anyhow::Error {
926 let reason = match &e {
927 MigrateError::NotAPackage { .. } => "not_a_package",
928 MigrateError::SealedPackage { .. } => "sealed_package",
929 MigrateError::Io { .. } | MigrateError::NotUtf8 { .. } => "io",
930 MigrateError::UnmigratableValue { .. } => "unmigratable_value",
931 MigrateError::PackageDoesNotLoad { .. } => "package_does_not_load",
932 MigrateError::RewriteLeavesViolations { .. } => "rewrite_leaves_violations",
933 MigrateError::Unfaithful { .. } => "unfaithful_rewrite",
934 };
935 CliError::new(
936 ExitKind::Validation,
937 "SCHEMA_MIGRATE_FAILED",
938 format!("schema migrate at {}: {e}", args.path.display()),
939 )
940 .with_details(json!({ "path": args.path, "reason": reason, "error": e.to_string() }))
941 .into()
942 };
943
944 let report = migrate_package(&args.path).map_err(map_err)?;
945 let wrote = args.write && !report.is_noop();
946 if wrote {
947 write_migration(&report).map_err(map_err)?;
948 }
949 let steps = next_steps(&args.path, &report.schema);
950
951 if ctx.json {
952 let rewrites: Vec<serde_json::Value> = report
953 .files
954 .iter()
955 .flat_map(|f| {
956 f.rewrites.iter().map(move |r| {
957 json!({
958 "file": f.rel_path,
959 "line": r.line,
960 "key": r.key,
961 "path": r.path,
962 "action": r.action.to_string(),
963 })
964 })
965 })
966 .collect();
967 print_json(&json!({
968 "ok": true,
969 "schema": report.schema,
970 "path": report.package,
971 "dry_run": !args.write,
972 "wrote": wrote,
973 "rewrites": rewrites,
974 "files_changed": report.changed_files().map(|f| &f.rel_path).collect::<Vec<_>>(),
975 "legacy_polarity": report.legacy_polarity,
976 "required_added": report.required_added.iter().map(|b| json!({
977 "type": b.type_name, "field": b.field,
978 })).collect::<Vec<_>>(),
979 "next_steps": if report.is_noop() { Vec::new() } else { steps },
980 }))?;
981 return Ok(());
982 }
983
984 let mut out = String::new();
985 if report.is_noop() {
986 out.push_str(&format!(
987 "# Schema migrate
988
989Nothing to migrate: `{}` at `{}` carries no retired key. No file written.
990",
991 report.schema,
992 report.package.display(),
993 ));
994 print_markdown(&out);
995 return Ok(());
996 }
997 let mode = if wrote {
998 "written"
999 } else {
1000 "dry run, nothing written"
1001 };
1002 out.push_str(&format!(
1003 "# Schema migrate — {mode}
1004
1005`{}` at `{}` — {} rewrite(s) in {} file(s)
1006",
1007 report.schema,
1008 report.package.display(),
1009 report.rewrite_count(),
1010 report.changed_files().count(),
1011 ));
1012 for file in report.changed_files() {
1013 out.push_str(&format!(
1014 "
1015## {}
1016
1017",
1018 file.rel_path
1019 ));
1020 for r in &file.rewrites {
1021 out.push_str(&format!(
1022 "- {r}
1023"
1024 ));
1025 }
1026 }
1027 if report.legacy_polarity {
1028 out.push_str(
1029 "
1030## Polarity
1031
1032This package carries the retired `optional:` key, so it was written when an absent key meant required — the meaning its sealed copies still have. The rewrite keeps that meaning: every metadata field declaring neither key gets `required: true`. Delete the line where you did not mean it.
1033",
1034 );
1035 }
1036 out.push_str(
1037 "
1038## Next steps
1039
1040",
1041 );
1042 if !wrote {
1043 out.push_str(&format!(
1044 "1. Review the rewrites above, then apply them: `memstead schema migrate {} --write`
1045",
1046 args.path.display()
1047 ));
1048 }
1049 let offset = if wrote { 1 } else { 2 };
1050 for (i, step) in steps.iter().enumerate() {
1051 out.push_str(&format!(
1052 "{}. `{step}`
1053",
1054 i + offset
1055 ));
1056 }
1057 out.push_str(
1058 "
1059`version` was not bumped: whether a spelling migration deserves a new version is your call. Sealed copies inside mems are untouched; they keep loading as they are.
1060",
1061 );
1062 print_markdown(&out);
1063 Ok(())
1064}
1065
1066fn install(ctx: &CliContext, args: InstallArgs) -> anyhow::Result<()> {
1067 let (shape, root) = ctx.workspace_shape().ok_or_else(|| {
1068 CliError::new(
1069 ExitKind::Generic,
1070 "NO_WORKSPACE",
1071 "not inside a Memstead workspace (no `.memstead/workspace.toml` in any \
1072 ancestor) — cd into your workspace first, or create one: memstead quickstart"
1073 .to_string(),
1074 )
1075 })?;
1076 let (schema_ref, files) = resolve_source(
1077 &args.source,
1078 ctx.workspace_shape().map(|(_, r)| r).as_deref(),
1079 )?;
1080
1081 match shape {
1082 WorkspaceShape::Filesystem => {
1083 let pkg_dir = root
1089 .join(".memstead")
1090 .join("schemas")
1091 .join(format!("{}@{}", schema_ref.name, schema_ref.version));
1092 write_package(&pkg_dir, &files)?;
1093 if ctx.json {
1094 print_json(&json!({
1095 "ok": true,
1096 "schema": format!("{}@{}", schema_ref.name, schema_ref.version),
1097 "backend": "folder",
1098 "path": pkg_dir,
1099 "files": files.iter().map(|f| &f.archive_path).collect::<Vec<_>>(),
1100 }))?;
1101 } else {
1102 print_markdown(&format!(
1103 "# Schema installed\n\n`{}@{}` → `{}` ({} file(s))\n",
1104 schema_ref.name,
1105 schema_ref.version,
1106 pkg_dir.display(),
1107 files.len(),
1108 ));
1109 }
1110 Ok(())
1111 }
1112 WorkspaceShape::MemRepo => install_to_git_branch(ctx, &schema_ref, &files),
1113 }
1114}
1115
1116#[cfg(feature = "mem-repo")]
1128fn install_to_git_branch(
1129 ctx: &CliContext,
1130 schema_ref: &SchemaRef,
1131 files: &[memstead_schema::SchemaSourceFile],
1132) -> anyhow::Result<()> {
1133 let Some((_shape, root)) = ctx.workspace_shape() else {
1134 return Err(crate::setup::workspace_not_initialised_error(
1135 "No workspace found. Run from a directory containing `.memstead/workspace.toml`.",
1136 )
1137 .into());
1138 };
1139 let pairs: Vec<(String, Vec<u8>)> = files
1140 .iter()
1141 .map(|f| (f.archive_path.clone(), f.bytes.clone()))
1142 .collect();
1143 let commit = memstead_git_branch::repair::install_schema_below_boot(
1144 &root,
1145 &schema_ref.name,
1146 &schema_ref.version.to_string(),
1147 &pairs,
1148 )
1149 .map_err(|e| crate::setup::boot_error_to_cli(&root, e))?;
1150 if ctx.json {
1151 print_json(&json!({
1152 "ok": true,
1153 "schema": format!("{}@{}", schema_ref.name, schema_ref.version),
1154 "backend": "git-branch",
1155 "ref": format!("__MEMSTEAD:schemas/{}@{}", schema_ref.name, schema_ref.version),
1156 "commit": commit,
1157 }))?;
1158 } else {
1159 print_markdown(&format!(
1160 "# Schema installed\n\n`{}@{}` → `__MEMSTEAD:schemas/{}@{}` (commit `{}`)\n",
1161 schema_ref.name, schema_ref.version, schema_ref.name, schema_ref.version, commit,
1162 ));
1163 }
1164 Ok(())
1165}
1166
1167#[cfg(not(feature = "mem-repo"))]
1168fn install_to_git_branch(
1169 _ctx: &CliContext,
1170 _schema_ref: &SchemaRef,
1171 _files: &[memstead_schema::SchemaSourceFile],
1172) -> anyhow::Result<()> {
1173 Err(CliError::new(
1174 ExitKind::Generic,
1175 "MEM_REPO_NOT_SUPPORTED",
1176 "this binary was built without git-branch support — use the `memstead` binary to \
1177 install a schema into a mem-repo workspace."
1178 .to_string(),
1179 )
1180 .into())
1181}
1182
1183fn resolve_source(
1191 source: &str,
1192 workspace_root: Option<&Path>,
1193) -> anyhow::Result<(SchemaRef, Vec<memstead_schema::SchemaSourceFile>)> {
1194 let as_path = Path::new(source);
1195 if as_path.is_dir() {
1196 let schema = memstead_schema::load_schema_from_dir(as_path)
1203 .and_then(|s| memstead_schema::check_section_heading_roundtrip(&s).map(|()| s))
1204 .and_then(|s| memstead_schema::check_reserved_metadata_keys(&s).map(|()| s))
1205 .and_then(|s| memstead_schema::check_section_formats(&s).map(|()| s))
1206 .map_err(|e| {
1207 CliError::new(
1208 ExitKind::Validation,
1209 "SCHEMA_VALIDATION_FAILED",
1210 format!("package at {source} is invalid: {e}"),
1211 )
1212 .with_details(json!({ "path": source, "error": e.to_string() }))
1213 })?;
1214 let schema = std::sync::Arc::new(schema);
1218 if let Err(defect) = memstead_base::Engine::validate_schema_exemplars(&schema) {
1219 return Err(CliError::new(
1220 ExitKind::Validation,
1221 "SCHEMA_VALIDATION_FAILED",
1222 format!("package at {source} is invalid: {defect}"),
1223 )
1224 .with_details(json!({ "path": source, "error": defect }))
1225 .into());
1226 }
1227 let (name, version) = schema.id();
1228 let mut files = collect_dir_package(as_path)?;
1229 let authoring_path = as_path
1236 .canonicalize()
1237 .unwrap_or_else(|_| as_path.to_path_buf());
1238 let stamped = workspace_root
1242 .and_then(|root| root.canonicalize().ok())
1243 .and_then(|root| {
1244 authoring_path
1245 .strip_prefix(&root)
1246 .ok()
1247 .map(|rel| rel.display().to_string())
1248 })
1249 .unwrap_or_else(|| authoring_path.display().to_string());
1250 files.push(memstead_schema::SchemaSourceFile {
1251 archive_path: memstead_schema::INSTALL_PROVENANCE_FILE.to_string(),
1252 bytes: serde_json::to_vec_pretty(&json!({
1253 "authoring_path": stamped,
1254 }))
1255 .expect("provenance stamp serialises"),
1256 });
1257 let files = marked_package(files);
1264 Ok((SchemaRef::new(name, version), files))
1265 } else {
1266 let schema_ref = resolve_builtin_ref(source)?;
1268 let mut files =
1269 memstead_schema::collect_schema_source(None, None, &schema_ref).map_err(|e| {
1270 CliError::new(
1271 ExitKind::Validation,
1272 "SCHEMA_NOT_FOUND",
1273 format!(
1274 "could not collect source for {}: {e}",
1275 schema_ref.as_display()
1276 ),
1277 )
1278 })?;
1279 if let Some(tpl) = memstead_schema::builtins::builtin_mem_template(&schema_ref.name) {
1282 files.push(memstead_schema::SchemaSourceFile {
1283 archive_path: "mem-template.json".to_string(),
1284 bytes: serde_json::to_vec_pretty(&tpl).unwrap_or_default(),
1285 });
1286 }
1287 Ok((schema_ref, files))
1288 }
1289}
1290
1291fn resolve_builtin_ref(source: &str) -> anyhow::Result<SchemaRef> {
1294 let reg = memstead_schema::SchemaRegistry::builtin();
1295 if source.contains('@') {
1296 let r: SchemaRef = source.parse().map_err(|e: String| {
1297 CliError::new(
1298 ExitKind::Validation,
1299 "INVALID_INPUT",
1300 format!("invalid schema pin {source:?}: {e}"),
1301 )
1302 })?;
1303 if reg.get(&r.name, &r.version).is_none() {
1304 return Err(CliError::new(
1305 ExitKind::Validation,
1306 "SCHEMA_NOT_FOUND",
1307 format!(
1308 "no built-in schema {source} — pass a path to install a non-built-in package"
1309 ),
1310 )
1311 .into());
1312 }
1313 Ok(r)
1314 } else {
1315 match reg.resolve_by_name(source) {
1316 Ok(Some(s)) => {
1317 let (n, v) = s.id();
1318 Ok(SchemaRef::new(n, v))
1319 }
1320 Ok(None) => Err(CliError::new(
1321 ExitKind::Validation,
1322 "SCHEMA_NOT_FOUND",
1323 format!(
1324 "no built-in schema named {source:?} — pass a path to install a non-built-in \
1325 package, or a `name@version` pin"
1326 ),
1327 )
1328 .into()),
1329 Err(e) => Err(CliError::new(
1330 ExitKind::Validation,
1331 "INVALID_INPUT",
1332 format!("built-in name {source:?} is ambiguous: {e}"),
1333 )
1334 .into()),
1335 }
1336 }
1337}
1338
1339fn collect_dir_package(dir: &Path) -> anyhow::Result<Vec<memstead_schema::SchemaSourceFile>> {
1342 use memstead_schema::SchemaSourceFile;
1343 let mut out = vec![SchemaSourceFile {
1344 archive_path: "schema.yaml".to_string(),
1345 bytes: std::fs::read(dir.join("schema.yaml"))?,
1346 }];
1347 let types = dir.join("types");
1348 if types.is_dir() {
1349 let mut paths: Vec<PathBuf> = std::fs::read_dir(&types)?
1350 .filter_map(|e| e.ok().map(|e| e.path()))
1351 .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("yaml"))
1352 .collect();
1353 paths.sort();
1354 for p in paths {
1355 if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
1356 out.push(SchemaSourceFile {
1357 archive_path: format!("types/{name}"),
1358 bytes: std::fs::read(&p)?,
1359 });
1360 }
1361 }
1362 }
1363 for opt in ["mem-template.json", "README.md"] {
1364 let p = dir.join(opt);
1365 if p.is_file() {
1366 out.push(SchemaSourceFile {
1367 archive_path: opt.to_string(),
1368 bytes: std::fs::read(&p)?,
1369 });
1370 }
1371 }
1372 Ok(out)
1373}
1374
1375fn marked_package(
1381 mut files: Vec<memstead_schema::SchemaSourceFile>,
1382) -> Vec<memstead_schema::SchemaSourceFile> {
1383 let marker = memstead_schema::loader::SCHEMA_FORMAT_MARKER_FILE;
1384 if !files.iter().any(|f| f.archive_path == marker) {
1385 files.push(memstead_schema::SchemaSourceFile {
1386 archive_path: marker.to_string(),
1387 bytes: memstead_schema::loader::SCHEMA_FORMAT_MARKER_CONTENT
1388 .as_bytes()
1389 .to_vec(),
1390 });
1391 }
1392 files
1393}
1394
1395fn write_package(
1402 pkg_dir: &Path,
1403 files: &[memstead_schema::SchemaSourceFile],
1404) -> anyhow::Result<()> {
1405 for f in files {
1406 let dest = pkg_dir.join(&f.archive_path);
1407 if let Some(parent) = dest.parent() {
1408 std::fs::create_dir_all(parent).map_err(|e| {
1409 CliError::new(
1410 ExitKind::Generic,
1411 "IO_ERROR",
1412 format!("could not create {}: {e}", parent.display()),
1413 )
1414 })?;
1415 }
1416 let bytes = retarget_yaml_directive(&f.archive_path, &f.bytes);
1417 std::fs::write(&dest, &bytes).map_err(|e| {
1418 CliError::new(
1419 ExitKind::Generic,
1420 "IO_ERROR",
1421 format!("could not write {}: {e}", dest.display()),
1422 )
1423 })?;
1424 }
1425 Ok(())
1426}
1427
1428fn directive_for(archive_path: &str) -> Option<&'static str> {
1435 if archive_path == "schema.yaml" {
1436 Some("# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json")
1437 } else if archive_path.starts_with("types/") && archive_path.ends_with(".yaml") {
1438 Some("# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json")
1439 } else {
1440 None
1441 }
1442}
1443
1444fn retarget_yaml_directive(archive_path: &str, bytes: &[u8]) -> Vec<u8> {
1448 let Some(directive) = directive_for(archive_path) else {
1449 return bytes.to_vec();
1450 };
1451 let Ok(text) = std::str::from_utf8(bytes) else {
1452 return bytes.to_vec();
1453 };
1454 let body = if text.starts_with("# yaml-language-server:") {
1455 text.split_once('\n').map(|(_, rest)| rest).unwrap_or("")
1456 } else {
1457 text
1458 };
1459 format!("{directive}\n{body}").into_bytes()
1460}
1461
1462#[cfg(test)]
1463mod tests {
1464 use super::*;
1465 use std::path::Path;
1466
1467 fn ctx() -> CliContext {
1468 CliContext {
1469 json: false,
1470 quiet: true,
1471 role: Default::default(),
1472 identity: None,
1473 }
1474 }
1475
1476 #[test]
1487 fn validate_builtin_default_copy_refuses_retired_exemplar_spelling() {
1488 let src = Path::new(env!("CARGO_MANIFEST_DIR"))
1489 .join("../memstead-schema/builtins/schemas/default-1.3");
1490 assert!(src.join("schema.yaml").is_file(), "fixture moved: {src:?}");
1491 let dir = tempfile::tempdir().unwrap();
1492 let dst = dir.path().join("authoring");
1493 copy_dir_without_marker(&src, &dst);
1494 let err = validate(&ctx(), ValidateArgs { path: dst.clone() })
1495 .expect_err("legacy exemplar spelling refuses as authoring input");
1496 assert!(
1497 err.to_string().contains("rel_type"),
1498 "refusal carries the rename pointer: {err}"
1499 );
1500
1501 for entry in std::fs::read_dir(dst.join("types")).unwrap() {
1504 let path = entry.unwrap().path();
1505 let text = std::fs::read_to_string(&path).unwrap();
1506 let converged = text
1507 .replace("\n - to: ", "\n - target: ")
1508 .replace("\n type: ", "\n rel_type: ");
1509 std::fs::write(&path, converged).unwrap();
1510 }
1511 validate(&ctx(), ValidateArgs { path: dst })
1512 .expect("converged default builtin content must validate");
1513 }
1514
1515 fn copy_dir_without_marker(src: &Path, dst: &Path) {
1516 std::fs::create_dir_all(dst).unwrap();
1517 for entry in std::fs::read_dir(src).unwrap() {
1518 let entry = entry.unwrap();
1519 let name = entry.file_name();
1520 if name == "schema-format.json" {
1521 continue;
1522 }
1523 let target = dst.join(&name);
1524 if entry.file_type().unwrap().is_dir() {
1525 copy_dir_without_marker(&entry.path(), &target);
1526 } else {
1527 std::fs::copy(entry.path(), &target).unwrap();
1528 }
1529 }
1530 }
1531
1532 #[test]
1536 fn migrate_dry_run_then_write_makes_legacy_copy_validate() {
1537 let src = Path::new(env!("CARGO_MANIFEST_DIR"))
1538 .join("../memstead-schema/builtins/schemas/default-1.3");
1539 let dir = tempfile::tempdir().unwrap();
1540 let dst = dir.path().join("authoring");
1541 copy_dir_without_marker(&src, &dst);
1542 let snapshot = |d: &Path| -> Vec<(String, Vec<u8>)> {
1543 let mut files = Vec::new();
1544 for entry in std::fs::read_dir(d.join("types")).unwrap() {
1545 let entry = entry.unwrap();
1546 files.push((
1547 entry.file_name().to_string_lossy().into_owned(),
1548 std::fs::read(entry.path()).unwrap(),
1549 ));
1550 }
1551 files.sort();
1552 files
1553 };
1554 let before = snapshot(&dst);
1555 validate(&ctx(), ValidateArgs { path: dst.clone() })
1556 .expect_err("legacy copy refuses before migration");
1557
1558 migrate(
1559 &ctx(),
1560 MigrateArgs {
1561 path: dst.clone(),
1562 write: false,
1563 },
1564 )
1565 .expect("dry run computes");
1566 assert_eq!(snapshot(&dst), before, "dry run writes nothing");
1567
1568 migrate(
1569 &ctx(),
1570 MigrateArgs {
1571 path: dst.clone(),
1572 write: true,
1573 },
1574 )
1575 .expect("write applies");
1576 assert_ne!(snapshot(&dst), before, "--write rewrites the files");
1577 validate(&ctx(), ValidateArgs { path: dst.clone() }).expect("migrated copy validates");
1578
1579 let after = snapshot(&dst);
1581 migrate(
1582 &ctx(),
1583 MigrateArgs {
1584 path: dst.clone(),
1585 write: true,
1586 },
1587 )
1588 .expect("noop run");
1589 assert_eq!(snapshot(&dst), after);
1590 }
1591
1592 #[test]
1595 fn migrate_refuses_sealed_package() {
1596 let path = Path::new(env!("CARGO_MANIFEST_DIR"))
1597 .join("../memstead-schema/builtins/schemas/default-1.3");
1598 let err = migrate(&ctx(), MigrateArgs { path, write: true })
1599 .expect_err("sealed package must refuse");
1600 let cli = err.downcast_ref::<CliError>().expect("typed CLI error");
1601 assert_eq!(cli.code, "SCHEMA_MIGRATE_FAILED");
1602 assert_eq!(cli.details.as_ref().unwrap()["reason"], "sealed_package");
1603 }
1604
1605 #[test]
1609 fn validate_names_sealed_package() {
1610 let path = Path::new(env!("CARGO_MANIFEST_DIR"))
1611 .join("../memstead-schema/builtins/schemas/default-1.3");
1612 let err = validate(&ctx(), ValidateArgs { path }).expect_err("sealed package must refuse");
1613 let cli = err
1614 .downcast_ref::<CliError>()
1615 .expect("error is a typed CliError");
1616 assert_eq!(cli.code, "SCHEMA_VALIDATION_FAILED");
1617 assert!(
1618 cli.message.contains("sealed schema package"),
1619 "message names the sealed package: {}",
1620 cli.message,
1621 );
1622 assert_eq!(
1623 cli.details.as_ref().unwrap()["reason"],
1624 json!("sealed_package"),
1625 );
1626 }
1627
1628 #[test]
1631 fn validate_rejects_malformed_schema_with_typed_code() {
1632 let dir = tempfile::tempdir().unwrap();
1633 std::fs::write(dir.path().join("schema.yaml"), "name: [unterminated\n").unwrap();
1634 let err = validate(
1635 &ctx(),
1636 ValidateArgs {
1637 path: dir.path().to_path_buf(),
1638 },
1639 )
1640 .expect_err("malformed schema must refuse");
1641 let cli = err
1642 .downcast_ref::<CliError>()
1643 .expect("error is a typed CliError");
1644 assert_eq!(cli.code, "SCHEMA_VALIDATION_FAILED");
1645 assert_eq!(cli.kind, ExitKind::Validation);
1646 assert_eq!(
1647 cli.details.as_ref().unwrap()["path"],
1648 json!(dir.path()),
1649 "details echoes the offending path",
1650 );
1651 }
1652
1653 #[test]
1656 fn resolve_builtin_read_ref_defaults_bare_names_to_newest() {
1657 let newest = resolve_builtin_read_ref("planning").expect("bare planning reads");
1658 let mut all = memstead_schema::SchemaRegistry::builtin().available_versions("planning");
1659 all.sort();
1660 assert_eq!(Some(&newest.version), all.last());
1661 let pinned = resolve_builtin_read_ref("planning@0.1.0").expect("pin reads");
1662 assert_eq!(pinned.version.to_string(), "0.1.0");
1663 let err = resolve_builtin_read_ref("not-a-builtin").expect_err("unknown refuses");
1664 let cli = err.downcast_ref::<CliError>().unwrap();
1665 assert_eq!(cli.code, "SCHEMA_NOT_FOUND");
1666 assert!(
1667 cli.message.contains("planning"),
1668 "names the roster: {}",
1669 cli.message
1670 );
1671 }
1672
1673 #[test]
1676 fn resolve_builtin_ref_handles_name_pin_and_unknown() {
1677 let bare = resolve_builtin_ref("software@0.2.0").expect("software pin resolves");
1681 assert_eq!(bare.name, "software");
1682 let pinned = resolve_builtin_ref("planning@0.1.0").expect("explicit pin resolves");
1683 assert_eq!(pinned.name, "planning");
1684 assert_eq!(pinned.version.to_string(), "0.1.0");
1685 resolve_builtin_ref("planning@0.2.0").expect("bumped pin resolves");
1686 resolve_builtin_ref("planning").expect_err("bare planning is ambiguous");
1687 let err = resolve_builtin_ref("not-a-builtin").expect_err("unknown name refuses");
1688 assert_eq!(
1689 err.downcast_ref::<CliError>().unwrap().code,
1690 "SCHEMA_NOT_FOUND",
1691 );
1692 }
1693
1694 #[test]
1697 fn resolve_source_for_builtin_includes_schema_and_template() {
1698 let (schema_ref, files) =
1699 resolve_source("planning@0.1.0", None).expect("planning source collects");
1700 assert_eq!(schema_ref.name, "planning");
1701 let paths: Vec<&str> = files.iter().map(|f| f.archive_path.as_str()).collect();
1702 assert!(paths.contains(&"schema.yaml"), "got {paths:?}");
1703 assert!(
1704 paths.contains(&"mem-template.json"),
1705 "built-in install must carry the mem-template.json, got {paths:?}",
1706 );
1707 }
1708
1709 #[test]
1712 fn collect_and_write_package_round_trips() {
1713 let src = tempfile::tempdir().unwrap();
1714 std::fs::create_dir_all(src.path().join("types")).unwrap();
1715 std::fs::write(src.path().join("schema.yaml"), b"name: x\n").unwrap();
1716 std::fs::write(src.path().join("types/doc.yaml"), b"name: doc\n").unwrap();
1717 std::fs::write(src.path().join("mem-template.json"), b"{}\n").unwrap();
1718
1719 let files = collect_dir_package(src.path()).unwrap();
1720 let dest = tempfile::tempdir().unwrap();
1721 let pkg = dest.path().join("x@0.1.0");
1722 write_package(&pkg, &files).unwrap();
1723
1724 let schema = std::fs::read_to_string(pkg.join("schema.yaml")).unwrap();
1727 assert_eq!(
1728 schema,
1729 "# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json\nname: x\n",
1730 );
1731 let doc = std::fs::read_to_string(pkg.join("types/doc.yaml")).unwrap();
1732 assert_eq!(
1733 doc,
1734 "# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json\nname: doc\n",
1735 );
1736 assert_eq!(
1737 std::fs::read(pkg.join("mem-template.json")).unwrap(),
1738 b"{}\n"
1739 );
1740 write_package(&pkg, &files).unwrap();
1742 assert_eq!(
1743 std::fs::read_to_string(pkg.join("schema.yaml")).unwrap(),
1744 schema
1745 );
1746 }
1747
1748 #[test]
1752 fn retarget_yaml_directive_replaces_or_prepends() {
1753 let existing = b"# yaml-language-server: $schema=../../../generated/schema-manifest.schema.json\nname: y\n";
1755 let out = String::from_utf8(retarget_yaml_directive("schema.yaml", existing)).unwrap();
1756 assert_eq!(
1757 out,
1758 "# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json\nname: y\n",
1759 );
1760 let bare = retarget_yaml_directive("types/t.yaml", b"name: t\n");
1762 assert_eq!(
1763 String::from_utf8(bare).unwrap(),
1764 "# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json\nname: t\n",
1765 );
1766 assert_eq!(retarget_yaml_directive("README.md", b"# hi\n"), b"# hi\n");
1768 }
1769}