1use std::path::{Path, PathBuf};
29
30use clap::{Args as ClapArgs, Subcommand};
31use serde_json::json;
32
33use memstead_schema::SchemaRef;
34
35use crate::CliError;
36use crate::output::{ExitKind, print_json, print_markdown};
37use crate::setup::{CliContext, WorkspaceShape};
38
39#[derive(ClapArgs, Debug)]
40pub struct Args {
41 #[command(subcommand)]
42 pub command: SchemaCommand,
43}
44
45#[derive(Subcommand, Debug)]
46pub enum SchemaCommand {
47 New(NewArgs),
52
53 Validate(ValidateArgs),
60
61 Install(InstallArgs),
66}
67
68#[derive(ClapArgs, Debug)]
69pub struct NewArgs {
70 pub name: String,
74}
75
76#[derive(ClapArgs, Debug)]
77pub struct ValidateArgs {
78 pub path: PathBuf,
81}
82
83#[derive(ClapArgs, Debug)]
84pub struct InstallArgs {
85 pub source: String,
88}
89
90pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
91 match args.command {
92 SchemaCommand::New(a) => scaffold_new(ctx, a),
93 SchemaCommand::Validate(a) => validate(ctx, a),
94 SchemaCommand::Install(a) => install(ctx, a),
95 }
96}
97
98const SCAFFOLD_VERSION: &str = "0.1.0";
100
101fn scaffold_new(ctx: &CliContext, args: NewArgs) -> anyhow::Result<()> {
102 if let Err(reason) = memstead_schema::loader::validate_schema_name(&args.name) {
103 let suggestion = suggest_schema_name(&args.name);
104 return Err(CliError::new(
105 ExitKind::Validation,
106 "INVALID_INPUT",
107 format!(
108 "invalid schema name {name:?}: {reason} (lowercase letter first, \
109 then lowercase letters, digits, hyphens). \
110 Try: memstead schema new {suggestion}",
111 name = args.name,
112 ),
113 )
114 .with_details(json!({
115 "name": args.name,
116 "reason": reason,
117 "suggestion": suggestion,
118 }))
119 .into());
120 }
121
122 let pkg_dir = PathBuf::from(&args.name);
123 if pkg_dir.join("schema.yaml").is_file() {
124 return Err(CliError::new(
125 ExitKind::Validation,
126 "SCHEMA_PACKAGE_EXISTS",
127 format!(
128 "{} already contains a schema package — `memstead schema new` \
129 never overwrites. Check it with: memstead schema validate {}",
130 pkg_dir.display(),
131 args.name,
132 ),
133 )
134 .with_details(json!({ "path": pkg_dir }))
135 .into());
136 }
137 if pkg_dir.is_dir()
138 && let Some(entry) = std::fs::read_dir(&pkg_dir)
139 .map_err(|e| {
140 CliError::new(
141 ExitKind::Generic,
142 "IO_ERROR",
143 format!("read {}: {e}", pkg_dir.display()),
144 )
145 })?
146 .next()
147 .transpose()
148 .map_err(|e| {
149 CliError::new(
150 ExitKind::Generic,
151 "IO_ERROR",
152 format!("read {}: {e}", pkg_dir.display()),
153 )
154 })?
155 {
156 let found = entry.file_name().to_string_lossy().to_string();
157 return Err(CliError::new(
158 ExitKind::Validation,
159 "TARGET_NOT_EMPTY",
160 format!(
161 "{} exists and is not empty (found `{found}`) — clear it or \
162 pick a different name: memstead schema new {}-schema",
163 pkg_dir.display(),
164 args.name,
165 ),
166 )
167 .with_details(json!({ "path": pkg_dir, "found": [found] }))
168 .into());
169 }
170
171 let manifest = scaffold_manifest(&args.name);
172 let example_type = scaffold_example_type();
173 std::fs::create_dir_all(pkg_dir.join("types")).map_err(|e| {
174 CliError::new(
175 ExitKind::Generic,
176 "IO_ERROR",
177 format!("create {}: {e}", pkg_dir.join("types").display()),
178 )
179 })?;
180 for (rel, content) in [
181 ("schema.yaml", &manifest),
182 ("types/note.yaml", &example_type),
183 ] {
184 let dest = pkg_dir.join(rel);
185 std::fs::write(&dest, content).map_err(|e| {
186 CliError::new(
187 ExitKind::Generic,
188 "IO_ERROR",
189 format!("write {}: {e}", dest.display()),
190 )
191 })?;
192 }
193
194 if let Err(e) = memstead_schema::loader::load_schema_from_dir(&pkg_dir) {
198 return Err(CliError::new(
199 ExitKind::Generic,
200 crate::INTERNAL_CODE,
201 format!(
202 "scaffold bug: generated package at {} fails validation: {e} — \
203 please report this",
204 pkg_dir.display(),
205 ),
206 )
207 .into());
208 }
209
210 let next_steps = scaffold_next_steps(ctx, &args.name);
211 if ctx.json {
212 print_json(&json!({
213 "ok": true,
214 "schema": format!("{}@{SCAFFOLD_VERSION}", args.name),
215 "path": pkg_dir,
216 "files": ["schema.yaml", "types/note.yaml"],
217 "next_steps": next_steps
218 .iter()
219 .map(|s| json!({ "command": s.command, "note": s.note }))
220 .collect::<Vec<_>>(),
221 }))?;
222 } else {
223 let steps: Vec<String> = next_steps
224 .iter()
225 .enumerate()
226 .map(|(i, s)| match &s.note {
227 Some(note) => format!("{}. `{}` — {note}", i + 1, s.command),
228 None => format!("{}. `{}`", i + 1, s.command),
229 })
230 .collect();
231 print_markdown(&format!(
232 "# Schema package scaffolded\n\n`{name}@{SCAFFOLD_VERSION}` at `{dir}` \
233 (schema.yaml + types/note.yaml, one commented example type).\n\n\
234 Edit the package, then:\n\n{steps}\n",
235 name = args.name,
236 dir = pkg_dir.display(),
237 steps = steps.join("\n"),
238 ));
239 }
240 Ok(())
241}
242
243fn scaffold_next_steps(ctx: &CliContext, name: &str) -> Vec<Step> {
254 use memstead_base::workspace::MountCapability;
255 use memstead_base::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
256 let workspace = ctx.workspace_shape().and_then(|(shape, root)| match shape {
257 WorkspaceShape::Filesystem => FileWorkspaceStore::new().load(&root).ok().and_then(|ws| {
258 let mut writable = ws
259 .mounts
260 .iter()
261 .filter(|m| m.capability == MountCapability::Write);
262 match (writable.next(), writable.next()) {
263 (Some(only), None) => Some((only.mem.clone(), root.clone())),
264 _ => None,
265 }
266 }),
267 WorkspaceShape::MemRepo => None,
268 });
269 let mem = workspace
270 .as_ref()
271 .map(|(mem, _)| mem.clone())
272 .unwrap_or_else(|| "<mem>".to_string());
273 let quickstart_seed = workspace
276 .as_ref()
277 .filter(|(_, root)| root.join("welcome-to-memstead.md").is_file())
278 .map(|(mem, _)| format!("{mem}--welcome-to-memstead"));
279 #[cfg(feature = "mem-repo")]
282 {
283 let mut steps = vec![
284 Step::bare(format!("memstead schema validate {name}")),
285 Step::bare(format!("memstead schema install {name}")),
286 ];
287 if let Some(seed_id) = quickstart_seed {
288 steps.push(Step {
289 command: format!("memstead delete {seed_id}"),
290 note: Some(
291 "the quickstart seed — the pin below switches atomically only when \
292 every entity conforms to the new schema"
293 .to_string(),
294 ),
295 });
296 }
297 steps.push(Step::bare(format!(
298 "memstead mem set-schema {mem} {name}@{SCAFFOLD_VERSION}"
299 )));
300 steps
301 }
302 #[cfg(not(feature = "mem-repo"))]
312 {
313 let _ = (mem, quickstart_seed); let (fresh_dir, install_source) = match ctx.workspace_shape() {
315 Some((_, root)) => {
316 let parent = root.parent().unwrap_or(&root).to_path_buf();
317 let pkg = std::env::current_dir().unwrap_or_default().join(name);
318 (
319 format!("\"{}\"", parent.join(format!("{name}-mem")).display()),
320 format!("\"{}\"", pkg.display()),
321 )
322 }
323 None => (format!("{name}-mem"), format!("../{name}")),
324 };
325 vec![
326 Step::bare(format!("memstead schema validate {name}")),
327 Step {
328 command: format!(
329 "mkdir {fresh_dir} && cd {fresh_dir} && memstead init --name {name}-mem \
330 --schema {name}@{SCAFFOLD_VERSION}"
331 ),
332 note: Some(
333 "this binary cannot re-pin an existing mem, so the schema gets a \
334 fresh one"
335 .to_string(),
336 ),
337 },
338 Step {
339 command: format!("memstead schema install {install_source}"),
340 note: Some(
341 "run inside the new folder — the workspace boots once its pinned \
342 schema is installed"
343 .to_string(),
344 ),
345 },
346 ]
347 }
348}
349
350struct Step {
354 command: String,
355 note: Option<String>,
356}
357
358impl Step {
359 fn bare(command: String) -> Self {
360 Step {
361 command,
362 note: None,
363 }
364 }
365}
366
367fn suggest_schema_name(raw: &str) -> String {
372 let mut out = String::with_capacity(raw.len());
373 for c in raw.to_lowercase().chars() {
374 if c.is_ascii_lowercase() || c.is_ascii_digit() {
375 out.push(c);
376 } else if !out.ends_with('-') && !out.is_empty() {
377 out.push('-');
378 }
379 }
380 let trimmed: String = out
381 .trim_matches('-')
382 .chars()
383 .skip_while(|c| !c.is_ascii_lowercase())
384 .collect();
385 let trimmed = trimmed.trim_matches('-');
386 if trimmed.is_empty() {
387 "my-schema".to_string()
388 } else {
389 trimmed.to_string()
390 }
391}
392
393fn scaffold_manifest(name: &str) -> String {
397 format!(
398 r#"# Schema package scaffolded by `memstead schema new`.
399# A schema package is one folder: this manifest plus one YAML file per
400# entity type under types/. Re-check any time with:
401# memstead schema validate {name}
402
403name: {name}
404version: {SCAFFOLD_VERSION}
405
406# Shown in schema catalogues (memstead_overview, the registry).
407description: |
408 Describe the subject this schema models and the types it declares.
409
410# Read by agents (and humans) choosing a schema for a new mem.
411when_to_use: |
412 Say when this schema fits — and when an author should reach for a
413 different one.
414
415# Optional: served to agents working in a mem pinned to this schema.
416system_message: |
417 You are working in a graph using the {name} schema. Prefer precise
418 types, link generously, and keep sections in their declared shape.
419
420# One entry per file under types/ — `note` matches types/note.yaml.
421# Add a type by adding both the file and its entry here.
422types:
423 - note
424
425relationships:
426 # strict: only the definitions below are legal edge types.
427 # open: any UPPER_SNAKE_CASE name is accepted; definitions add weights.
428 mode: strict
429 definitions:
430 - name: PART_OF
431 description: Hierarchical containment — the source is structurally part of the target.
432 default_weight: 3.0
433 acyclic: true
434 - name: RELATES_TO
435 description: General association between two entities when no sharper type fits.
436 default_weight: 1.0
437 - name: REFERENCES
438 description: Soft reference. Auto-emitted from body wiki-links — never author by hand.
439 default_weight: 0.5
440 # Required entry — the fallback weight for any relationship not
441 # listed above.
442 - name: _default
443 description: Fallback weight for any relationship not otherwise specified.
444 default_weight: 1.0
445
446# Body wiki-links `[[target]]` auto-emit as REFERENCES relations.
447# Remove this key to make unbacked wiki-links a validation error instead.
448alias_target_rel_type: REFERENCES
449
450# Community detection (graph clustering) tuning — the defaults are fine.
451community:
452 resolution: 1.0
453 seed: 42
454"#
455 )
456}
457
458fn scaffold_example_type() -> String {
462 r#"# One entity type = one file. `name` must match the filename stem
463# and appear in the manifest's `types:` list.
464
465name: note
466description: |
467 A general-purpose note — replace this with your first real type.
468when_to_use: |
469 Use while sketching the schema; rename or split into sharper types
470 as the domain vocabulary firms up.
471
472# Sections are the entity's markdown body. `required: true` sections
473# must be present on every create.
474sections:
475 - key: summary
476 heading: Summary
477 required: true
478 search_weight: 40.0
479 write_rules:
480 - "One or two sentences. Must stand alone in a search result."
481 - key: details
482 heading: Details
483 required: false
484 search_weight: 10.0
485 # catch_all: content under unmatched headings lands here.
486 catch_all: true
487 write_rules:
488 - "Everything beyond the summary. Bullets over prose."
489
490# Typed, filterable frontmatter fields — beyond the built-in
491# type / created_date / last_modified / tags.
492metadata_fields:
493 - key: status
494 description: Lifecycle state of the note.
495 field_type: string
496 default_value: active
497 enum_values: [active, archived]
498 filterable: equality
499
500# Search ranking: how much a title match weighs.
501title_weight: 100.0
502# Sections included in full-text search.
503text_fields: [summary, details]
504# Which declared relationship expresses hierarchy for this type.
505hierarchy_relationship: PART_OF
506# Edge types whose community signal propagates through this type.
507propagating_relationships: [PART_OF]
508# Fields `memstead update` may touch on this type.
509updatable_fields: [title, summary, details, status, tags]
510# Sections the health report treats as required.
511health_required_fields: [summary]
512# Days without modification before health flags the entity stale.
513staleness_threshold_days: 180
514# Prose guidance served to agents writing entities of this type.
515write_rules:
516 - "Notes are placeholders — split recurring shapes into dedicated types."
517"#
518 .to_string()
519}
520
521fn validate(ctx: &CliContext, args: ValidateArgs) -> anyhow::Result<()> {
522 match memstead_schema::loader::load_schema_from_dir(&args.path) {
523 Ok(schema) => {
524 let (name, version) = schema.id();
525 let type_count = schema.types.len();
526 if ctx.json {
527 print_json(&json!({
528 "ok": true,
529 "schema": format!("{name}@{version}"),
530 "types": type_count,
531 "path": args.path,
532 }))?;
533 } else {
534 print_markdown(&format!(
535 "# Schema valid\n\n`{name}@{version}` — {type_count} type(s) at `{}`\n",
536 args.path.display(),
537 ));
538 }
539 Ok(())
540 }
541 Err(e) => Err(CliError::new(
542 ExitKind::Validation,
543 "SCHEMA_VALIDATION_FAILED",
544 format!("schema at {} is invalid: {e}", args.path.display()),
545 )
546 .with_details(json!({
547 "path": args.path,
548 "error": e.to_string(),
549 }))
550 .into()),
551 }
552}
553
554fn install(ctx: &CliContext, args: InstallArgs) -> anyhow::Result<()> {
555 let (shape, root) = ctx.workspace_shape().ok_or_else(|| {
556 CliError::new(
557 ExitKind::Generic,
558 "NO_WORKSPACE",
559 "not inside a Memstead workspace (no `.memstead/workspace.toml` in any \
560 ancestor) — cd into your workspace first, or create one: memstead quickstart"
561 .to_string(),
562 )
563 })?;
564 let (schema_ref, files) = resolve_source(&args.source)?;
565
566 match shape {
567 WorkspaceShape::Filesystem => {
568 let pkg_dir = root
570 .join(".memstead")
571 .join("schemas")
572 .join(format!("{}@{}", schema_ref.name, schema_ref.version));
573 write_package(&pkg_dir, &files)?;
574 if ctx.json {
575 print_json(&json!({
576 "ok": true,
577 "schema": format!("{}@{}", schema_ref.name, schema_ref.version),
578 "backend": "folder",
579 "path": pkg_dir,
580 "files": files.iter().map(|f| &f.archive_path).collect::<Vec<_>>(),
581 }))?;
582 } else {
583 print_markdown(&format!(
584 "# Schema installed\n\n`{}@{}` → `{}` ({} file(s))\n",
585 schema_ref.name,
586 schema_ref.version,
587 pkg_dir.display(),
588 files.len(),
589 ));
590 }
591 Ok(())
592 }
593 WorkspaceShape::MemRepo => install_to_git_branch(ctx, &schema_ref, &files),
594 }
595}
596
597#[cfg(feature = "mem-repo")]
602fn install_to_git_branch(
603 ctx: &CliContext,
604 schema_ref: &SchemaRef,
605 files: &[memstead_schema::SchemaSourceFile],
606) -> anyhow::Result<()> {
607 use crate::setup::CliEngine;
608 let engine = match ctx.cli_engine()? {
609 CliEngine::MemRepo(e) => e,
610 CliEngine::Filesystem(_) => {
611 return Err(CliError::new(
612 ExitKind::Generic,
613 "INTERNAL",
614 "workspace resolved as mem-repo but engine came back filesystem".to_string(),
615 )
616 .into());
617 }
618 };
619 let pairs: Vec<(String, Vec<u8>)> = files
620 .iter()
621 .map(|f| (f.archive_path.clone(), f.bytes.clone()))
622 .collect();
623 let commit = engine
624 .install_schema(&schema_ref.name, &schema_ref.version.to_string(), &pairs)
625 .map_err(|e| {
626 CliError::new(ExitKind::Generic, e.code(), e.to_string()).with_details(e.details())
627 })?;
628 if ctx.json {
629 print_json(&json!({
630 "ok": true,
631 "schema": format!("{}@{}", schema_ref.name, schema_ref.version),
632 "backend": "git-branch",
633 "ref": format!("__MEMSTEAD:schemas/{}@{}", schema_ref.name, schema_ref.version),
634 "commit": commit,
635 }))?;
636 } else {
637 print_markdown(&format!(
638 "# Schema installed\n\n`{}@{}` → `__MEMSTEAD:schemas/{}@{}` (commit `{}`)\n",
639 schema_ref.name, schema_ref.version, schema_ref.name, schema_ref.version, commit,
640 ));
641 }
642 Ok(())
643}
644
645#[cfg(not(feature = "mem-repo"))]
646fn install_to_git_branch(
647 _ctx: &CliContext,
648 _schema_ref: &SchemaRef,
649 _files: &[memstead_schema::SchemaSourceFile],
650) -> anyhow::Result<()> {
651 Err(CliError::new(
652 ExitKind::Generic,
653 "MEM_REPO_NOT_SUPPORTED",
654 "this binary was built without git-branch support — use the `memstead` binary to \
655 install a schema into a mem-repo workspace."
656 .to_string(),
657 )
658 .into())
659}
660
661fn resolve_source(
664 source: &str,
665) -> anyhow::Result<(SchemaRef, Vec<memstead_schema::SchemaSourceFile>)> {
666 let as_path = Path::new(source);
667 if as_path.is_dir() {
668 let schema = memstead_schema::load_schema_from_dir(as_path).map_err(|e| {
670 CliError::new(
671 ExitKind::Validation,
672 "SCHEMA_VALIDATION_FAILED",
673 format!("package at {source} is invalid: {e}"),
674 )
675 .with_details(json!({ "path": source, "error": e.to_string() }))
676 })?;
677 let (name, version) = schema.id();
678 let files = collect_dir_package(as_path)?;
679 Ok((SchemaRef::new(name, version), files))
680 } else {
681 let schema_ref = resolve_builtin_ref(source)?;
683 let mut files =
684 memstead_schema::collect_schema_source(None, None, &schema_ref).map_err(|e| {
685 CliError::new(
686 ExitKind::Validation,
687 "SCHEMA_NOT_FOUND",
688 format!(
689 "could not collect source for {}: {e}",
690 schema_ref.as_display()
691 ),
692 )
693 })?;
694 if let Some(tpl) = memstead_schema::builtins::builtin_mem_template(&schema_ref.name) {
697 files.push(memstead_schema::SchemaSourceFile {
698 archive_path: "mem-template.json".to_string(),
699 bytes: serde_json::to_vec_pretty(&tpl).unwrap_or_default(),
700 });
701 }
702 Ok((schema_ref, files))
703 }
704}
705
706fn resolve_builtin_ref(source: &str) -> anyhow::Result<SchemaRef> {
709 let reg = memstead_schema::SchemaRegistry::builtin();
710 if source.contains('@') {
711 let r: SchemaRef = source.parse().map_err(|e: String| {
712 CliError::new(
713 ExitKind::Validation,
714 "INVALID_INPUT",
715 format!("invalid schema pin {source:?}: {e}"),
716 )
717 })?;
718 if reg.get(&r.name, &r.version).is_none() {
719 return Err(CliError::new(
720 ExitKind::Validation,
721 "SCHEMA_NOT_FOUND",
722 format!(
723 "no built-in schema {source} — pass a path to install a non-built-in package"
724 ),
725 )
726 .into());
727 }
728 Ok(r)
729 } else {
730 match reg.resolve_by_name(source) {
731 Ok(Some(s)) => {
732 let (n, v) = s.id();
733 Ok(SchemaRef::new(n, v))
734 }
735 Ok(None) => Err(CliError::new(
736 ExitKind::Validation,
737 "SCHEMA_NOT_FOUND",
738 format!(
739 "no built-in schema named {source:?} — pass a path to install a non-built-in \
740 package, or a `name@version` pin"
741 ),
742 )
743 .into()),
744 Err(e) => Err(CliError::new(
745 ExitKind::Validation,
746 "INVALID_INPUT",
747 format!("built-in name {source:?} is ambiguous: {e}"),
748 )
749 .into()),
750 }
751 }
752}
753
754fn collect_dir_package(dir: &Path) -> anyhow::Result<Vec<memstead_schema::SchemaSourceFile>> {
757 use memstead_schema::SchemaSourceFile;
758 let mut out = vec![SchemaSourceFile {
759 archive_path: "schema.yaml".to_string(),
760 bytes: std::fs::read(dir.join("schema.yaml"))?,
761 }];
762 let types = dir.join("types");
763 if types.is_dir() {
764 let mut paths: Vec<PathBuf> = std::fs::read_dir(&types)?
765 .filter_map(|e| e.ok().map(|e| e.path()))
766 .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("yaml"))
767 .collect();
768 paths.sort();
769 for p in paths {
770 if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
771 out.push(SchemaSourceFile {
772 archive_path: format!("types/{name}"),
773 bytes: std::fs::read(&p)?,
774 });
775 }
776 }
777 }
778 for opt in ["mem-template.json", "README.md"] {
779 let p = dir.join(opt);
780 if p.is_file() {
781 out.push(SchemaSourceFile {
782 archive_path: opt.to_string(),
783 bytes: std::fs::read(&p)?,
784 });
785 }
786 }
787 Ok(out)
788}
789
790fn write_package(
797 pkg_dir: &Path,
798 files: &[memstead_schema::SchemaSourceFile],
799) -> anyhow::Result<()> {
800 for f in files {
801 let dest = pkg_dir.join(&f.archive_path);
802 if let Some(parent) = dest.parent() {
803 std::fs::create_dir_all(parent).map_err(|e| {
804 CliError::new(
805 ExitKind::Generic,
806 "IO_ERROR",
807 format!("could not create {}: {e}", parent.display()),
808 )
809 })?;
810 }
811 let bytes = retarget_yaml_directive(&f.archive_path, &f.bytes);
812 std::fs::write(&dest, &bytes).map_err(|e| {
813 CliError::new(
814 ExitKind::Generic,
815 "IO_ERROR",
816 format!("could not write {}: {e}", dest.display()),
817 )
818 })?;
819 }
820 Ok(())
821}
822
823fn directive_for(archive_path: &str) -> Option<&'static str> {
829 if archive_path == "schema.yaml" {
830 Some("# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json")
831 } else if archive_path.starts_with("types/") && archive_path.ends_with(".yaml") {
832 Some("# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json")
833 } else {
834 None
835 }
836}
837
838fn retarget_yaml_directive(archive_path: &str, bytes: &[u8]) -> Vec<u8> {
842 let Some(directive) = directive_for(archive_path) else {
843 return bytes.to_vec();
844 };
845 let Ok(text) = std::str::from_utf8(bytes) else {
846 return bytes.to_vec();
847 };
848 let body = if text.starts_with("# yaml-language-server:") {
849 text.split_once('\n').map(|(_, rest)| rest).unwrap_or("")
850 } else {
851 text
852 };
853 format!("{directive}\n{body}").into_bytes()
854}
855
856#[cfg(test)]
857mod tests {
858 use super::*;
859 use std::path::Path;
860
861 fn ctx() -> CliContext {
862 CliContext {
863 json: false,
864 quiet: true,
865 }
866 }
867
868 #[test]
871 fn validate_accepts_builtin_default_schema() {
872 let path = Path::new(env!("CARGO_MANIFEST_DIR"))
873 .join("../memstead-schema/builtins/schemas/default");
874 assert!(
875 path.join("schema.yaml").is_file(),
876 "fixture moved: {path:?}"
877 );
878 validate(&ctx(), ValidateArgs { path }).expect("default builtin must validate");
879 }
880
881 #[test]
884 fn validate_rejects_malformed_schema_with_typed_code() {
885 let dir = tempfile::tempdir().unwrap();
886 std::fs::write(dir.path().join("schema.yaml"), "name: [unterminated\n").unwrap();
887 let err = validate(
888 &ctx(),
889 ValidateArgs {
890 path: dir.path().to_path_buf(),
891 },
892 )
893 .expect_err("malformed schema must refuse");
894 let cli = err
895 .downcast_ref::<CliError>()
896 .expect("error is a typed CliError");
897 assert_eq!(cli.code, "SCHEMA_VALIDATION_FAILED");
898 assert_eq!(cli.kind, ExitKind::Validation);
899 assert_eq!(
900 cli.details.as_ref().unwrap()["path"],
901 json!(dir.path()),
902 "details echoes the offending path",
903 );
904 }
905
906 #[test]
909 fn resolve_builtin_ref_handles_name_pin_and_unknown() {
910 let bare = resolve_builtin_ref("planning").expect("planning resolves");
911 assert_eq!(bare.name, "planning");
912 let pinned = resolve_builtin_ref("planning@0.1.0").expect("explicit pin resolves");
913 assert_eq!(pinned, bare);
914 let err = resolve_builtin_ref("not-a-builtin").expect_err("unknown name refuses");
915 assert_eq!(
916 err.downcast_ref::<CliError>().unwrap().code,
917 "SCHEMA_NOT_FOUND",
918 );
919 }
920
921 #[test]
924 fn resolve_source_for_builtin_includes_schema_and_template() {
925 let (schema_ref, files) = resolve_source("planning").expect("planning source collects");
926 assert_eq!(schema_ref.name, "planning");
927 let paths: Vec<&str> = files.iter().map(|f| f.archive_path.as_str()).collect();
928 assert!(paths.contains(&"schema.yaml"), "got {paths:?}");
929 assert!(
930 paths.contains(&"mem-template.json"),
931 "built-in install must carry the mem-template.json, got {paths:?}",
932 );
933 }
934
935 #[test]
938 fn collect_and_write_package_round_trips() {
939 let src = tempfile::tempdir().unwrap();
940 std::fs::create_dir_all(src.path().join("types")).unwrap();
941 std::fs::write(src.path().join("schema.yaml"), b"name: x\n").unwrap();
942 std::fs::write(src.path().join("types/doc.yaml"), b"name: doc\n").unwrap();
943 std::fs::write(src.path().join("mem-template.json"), b"{}\n").unwrap();
944
945 let files = collect_dir_package(src.path()).unwrap();
946 let dest = tempfile::tempdir().unwrap();
947 let pkg = dest.path().join("x@0.1.0");
948 write_package(&pkg, &files).unwrap();
949
950 let schema = std::fs::read_to_string(pkg.join("schema.yaml")).unwrap();
953 assert_eq!(
954 schema,
955 "# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json\nname: x\n",
956 );
957 let doc = std::fs::read_to_string(pkg.join("types/doc.yaml")).unwrap();
958 assert_eq!(
959 doc,
960 "# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json\nname: doc\n",
961 );
962 assert_eq!(
963 std::fs::read(pkg.join("mem-template.json")).unwrap(),
964 b"{}\n"
965 );
966 write_package(&pkg, &files).unwrap();
968 assert_eq!(
969 std::fs::read_to_string(pkg.join("schema.yaml")).unwrap(),
970 schema
971 );
972 }
973
974 #[test]
978 fn retarget_yaml_directive_replaces_or_prepends() {
979 let existing = b"# yaml-language-server: $schema=../../../generated/schema-manifest.schema.json\nname: y\n";
981 let out = String::from_utf8(retarget_yaml_directive("schema.yaml", existing)).unwrap();
982 assert_eq!(
983 out,
984 "# yaml-language-server: $schema=../../meta-schemas/schema-manifest.schema.json\nname: y\n",
985 );
986 let bare = retarget_yaml_directive("types/t.yaml", b"name: t\n");
988 assert_eq!(
989 String::from_utf8(bare).unwrap(),
990 "# yaml-language-server: $schema=../../../meta-schemas/type-definition.schema.json\nname: t\n",
991 );
992 assert_eq!(retarget_yaml_directive("README.md", b"# hi\n"), b"# hi\n");
994 }
995}