memstead_cli/commands/type_cmd.rs
1use std::sync::Arc;
2
3use clap::Parser;
4use serde_json::json;
5
6use memstead_base::render;
7use memstead_schema::Schema;
8
9use crate::CliError;
10use crate::output::{ExitKind, print_json, print_markdown};
11use crate::setup::{CliContext, CliEngine};
12
13/// Describe one type, or list all types when no name is given.
14///
15/// Resolves the schema against the workspace's writable mem when
16/// exactly one is loaded (so the catalogue agents read matches the
17/// schema `memstead create` will validate against). Multi-mem workspaces
18/// pin the choice via `--mem <name>`. Workspaces with zero writable
19/// mems fall back to the engine built-in default so the cold-start
20/// probe-from-scratch flow keeps working.
21#[derive(Parser, Debug)]
22pub struct Args {
23 pub name: Option<String>,
24
25 /// Resolve the schema from this writable mem's pin. Required
26 /// when the workspace has more than one writable mem; defaults
27 /// to the lone writable mem otherwise.
28 #[arg(long)]
29 pub mem: Option<String>,
30}
31
32pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
33 let schema = resolve_schema(ctx, args.mem.as_deref())?;
34 let (schema_name, schema_version) = schema.id();
35 let schema_label = format!("{schema_name}@{schema_version}");
36
37 let md = match args.name.as_deref() {
38 None | Some("") => {
39 let mut out = render::render_type_catalog_markdown_for(&schema);
40 out.insert_str(0, &format!("**Schema:** `{schema_label}`\n\n"));
41 out
42 }
43 Some(name) => match schema.get_type(name) {
44 Some(td) => {
45 let mut out = render::render_type_info_markdown(&td);
46 out.insert_str(0, &format!("**Schema:** `{schema_label}`\n\n"));
47 out
48 }
49 None => {
50 let mut known: Vec<&str> = schema.types.keys().map(String::as_str).collect();
51 known.sort();
52 return Err(CliError::new(
53 ExitKind::Generic,
54 "UNKNOWN_ENTITY_TYPE",
55 format!(
56 "Unknown type: {name} (schema {schema_label}). \
57 Known types: {}",
58 known.join(", ")
59 ),
60 )
61 .with_details(json!({
62 "name": name,
63 "schema_ref": schema_label,
64 "declared": known,
65 }))
66 .into());
67 }
68 },
69 };
70
71 if ctx.json {
72 print_json(&json!({
73 "markdown": md,
74 "schema": schema_label,
75 }))?;
76 } else {
77 print_markdown(&md);
78 }
79 Ok(())
80}
81
82/// Resolve which schema `memstead type` describes.
83///
84/// Resolution order:
85/// 1. `--mem <name>` supplied: error if the name matches no loaded
86/// mem (writable OR RO); otherwise use that mem's schema.
87/// Schema introspection is a read-only operation — RO mounts are
88/// first-class read targets, so resolving against them is admitted.
89/// 2. Exactly one writable mem loaded: use its schema (the common case
90/// for the bare `memstead type` invocation, since the implicit-mem
91/// default still picks a writable target — RO mounts are explicit-
92/// only via `--mem`).
93/// 3. Multiple writable mems loaded: error with an actionable message
94/// listing them and pointing at `--mem`.
95/// 4. Zero writable mems (no workspace, cold-start probe): fall back
96/// to the engine built-in default so the catalogue is still readable.
97fn resolve_schema(ctx: &CliContext, mem: Option<&str>) -> anyhow::Result<Arc<Schema>> {
98 let engine = match ctx.cli_engine() {
99 Ok(e) => e,
100 // No workspace at all: cold-start probe — fall through to
101 // built-in default.
102 Err(_) => return Ok(Schema::builtin_default()),
103 };
104 let engine: memstead_base::Engine = match engine {
105 #[cfg(feature = "mem-repo")]
106 CliEngine::MemRepo(e) => e,
107 CliEngine::Filesystem(e) => e,
108 };
109 let writable: Vec<&str> = engine.writable_mem_names();
110 let all_loaded: Vec<&str> = engine.mem_names();
111 let resolved_mem: &str = match mem {
112 Some(name) => {
113 // F25: `--mem` resolves against every loaded
114 // mem, not just the writable subset. Schema lookup is
115 // read-only; RO mounts have schemas worth introspecting.
116 if !all_loaded.contains(&name) {
117 let known = if all_loaded.is_empty() {
118 "no mems loaded".to_string()
119 } else {
120 format!("known mems: [{}]", all_loaded.join(", "))
121 };
122 return Err(CliError {
123 code: "UNKNOWN_MEM",
124 kind: ExitKind::NotFound,
125 message: format!("unknown mem: {name} — {known}"),
126 details: Some(json!({ "mem": name, "known_mems": all_loaded })),
127 }
128 .into());
129 }
130 name
131 }
132 None => match writable.len() {
133 0 => return Ok(Schema::builtin_default()),
134 1 => writable[0],
135 _ => {
136 // When every writable mem pins the same schema, the
137 // type definition is identical regardless of which mem
138 // answers — drop the `--mem` ceremony and pick any.
139 // Refuse only when the writable mems pin *different*
140 // schemas (the answer would genuinely depend on the
141 // choice; rendering one mem's type as the answer for
142 // all would be silently wrong).
143 let schemas = engine.schemas();
144 let schema_id = |v: &str| {
145 schemas
146 .get(v)
147 .map(|s| (s.manifest.name.clone(), s.version.clone()))
148 };
149 let first = schema_id(writable[0]);
150 let all_same = first.is_some() && writable.iter().all(|v| schema_id(v) == first);
151 if all_same {
152 writable[0]
153 } else {
154 return Err(CliError::new(
155 ExitKind::Validation,
156 "AMBIGUOUS_MEM",
157 format!(
158 "writable mems pin different schemas ([{}]) — pass `--mem <name>` to pick one",
159 writable.join(", ")
160 ),
161 )
162 .with_details(json!({ "mems": writable }))
163 .into());
164 }
165 }
166 },
167 };
168 Ok(engine
169 .schemas()
170 .get(resolved_mem)
171 .cloned()
172 .unwrap_or_else(Schema::builtin_default))
173}