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;
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 resolved = resolve_schema(ctx, args.mem.as_deref())?;
34 let schema = &resolved.schema;
35 let (schema_name, schema_version) = schema.id();
36 let schema_label = format!("{schema_name}@{schema_version}");
37 // The condition line rides ABOVE the schema label, so a reader meets
38 // "this is not your workspace's schema" before the catalogue itself.
39 let notice = resolved.condition.as_ref().map(|c| c.notice());
40
41 let md = match args.name.as_deref() {
42 None | Some("") => {
43 let mut out = render::render_type_catalog_markdown_for(schema);
44 out.insert_str(0, &format!("**Schema:** `{schema_label}`\n\n"));
45 if let Some(n) = ¬ice {
46 out.insert_str(0, &format!("{n}\n\n"));
47 }
48 out
49 }
50 Some(name) => match schema.get_type(name) {
51 Some(td) => {
52 let mut out = render::render_type_info_markdown_in(&td, Some(schema));
53 out.insert_str(0, &format!("**Schema:** `{schema_label}`\n\n"));
54 if let Some(n) = ¬ice {
55 out.insert_str(0, &format!("{n}\n\n"));
56 }
57 out
58 }
59 None => {
60 let mut known: Vec<&str> = schema.types.keys().map(String::as_str).collect();
61 known.sort();
62 // The refusal arm needs the condition MORE than the success
63 // arms do, not less. Without it a user whose own schema
64 // declares the type is told it does not exist, attributed to
65 // a schema that is not theirs, with the quarantine unmentioned
66 // on both channels — the sharpest form of the defect this
67 // command was fixed for.
68 let message = match ¬ice {
69 Some(n) => format!(
70 "Unknown type: {name} (schema {schema_label}). Known types: {}\n\n{n}",
71 known.join(", ")
72 ),
73 None => format!(
74 "Unknown type: {name} (schema {schema_label}). Known types: {}",
75 known.join(", ")
76 ),
77 };
78 return Err(
79 CliError::new(ExitKind::Generic, "UNKNOWN_ENTITY_TYPE", message)
80 .with_details(json!({
81 "name": name,
82 "schema_ref": schema_label,
83 "declared": known,
84 "fallback": resolved.condition.as_ref().map(|c| json!({
85 "code": c.code(),
86 "detail": c.notice(),
87 })),
88 }))
89 .into(),
90 );
91 }
92 },
93 };
94
95 if ctx.json {
96 print_json(&json!({
97 "markdown": md,
98 "schema": schema_label,
99 // Absent on the healthy path and on the cold-start probe; present
100 // whenever the schema above is a stand-in, so a machine consumer
101 // branches on the code rather than parsing the prose.
102 "fallback": resolved.condition.as_ref().map(|c| json!({
103 "code": c.code(),
104 "detail": c.notice(),
105 })),
106 }))?;
107 } else {
108 print_markdown(&md);
109 }
110 Ok(())
111}
112
113/// Resolve which schema `memstead type` describes.
114///
115/// Resolution order:
116/// 1. `--mem <name>` supplied: error if the name matches no loaded
117/// mem (writable OR RO); otherwise use that mem's schema.
118/// Schema introspection is a read-only operation — RO mounts are
119/// first-class read targets, so resolving against them is admitted.
120/// 2. Exactly one writable mem loaded: use its schema (the common case
121/// for the bare `memstead type` invocation, since the implicit-mem
122/// default still picks a writable target — RO mounts are explicit-
123/// only via `--mem`).
124/// 3. Multiple writable mems loaded: error with an actionable message
125/// listing them and pointing at `--mem`.
126/// 4. Zero writable mems (no workspace, cold-start probe): fall back
127/// to the engine built-in default so the catalogue is still readable.
128///
129/// Every fallback reports the condition that produced it, because the
130/// three are not one situation. A fallback may stand in for an ABSENT
131/// workspace; it may not stand in for a workspace whose mems the engine
132/// refused to load. Those two were indistinguishable here until
133/// 2026-08-27 — both yield zero writable mems — and collapsing them is
134/// what turned a correct, loud quarantine into a quiet wrong answer three
135/// surfaces later: the command printed the built-in default's name,
136/// version and whole type catalogue over a workspace whose only mem was
137/// quarantined, saying nothing about it.
138fn resolve_schema(ctx: &CliContext, mem: Option<&str>) -> anyhow::Result<Resolved> {
139 let engine = match ctx.cli_engine() {
140 Ok(e) => e,
141 // No workspace at all: the cold-start probe. Silent by design —
142 // here the built-in default IS the answer, not a stand-in, and a
143 // warning on the healthy path trains readers to ignore warnings.
144 Err(_) => return Ok(Resolved::cold_start()),
145 };
146 let engine: memstead_base::Engine = engine.into_base();
147 let writable: Vec<&str> = engine.writable_mem_names();
148 let all_loaded: Vec<&str> = engine.mem_names();
149 let resolved_mem: &str = match mem {
150 Some(name) => {
151 // F25: `--mem` resolves against every loaded
152 // mem, not just the writable subset. Schema lookup is
153 // read-only; RO mounts have schemas worth introspecting.
154 if !all_loaded.contains(&name) {
155 // A mem the engine refused to load is not an unknown
156 // mem. This arm used to test the loaded roster only, so
157 // a user explicitly naming their quarantined mem was
158 // told "no mems loaded" in the exact phrasing an empty
159 // workspace produces, while the engine held the reason
160 // and the repair the whole time. The engine's own
161 // adjudicator serves MEM_QUARANTINED with both.
162 if engine.quarantine_reason(name).is_some() {
163 return Err(CliError::from_engine_op(engine.unknown_mem_error(name)).into());
164 }
165 let known = if all_loaded.is_empty() {
166 "no mems loaded".to_string()
167 } else {
168 format!("known mems: [{}]", all_loaded.join(", "))
169 };
170 return Err(CliError {
171 code: "UNKNOWN_MEM",
172 kind: ExitKind::NotFound,
173 message: format!("unknown mem: {name} — {known}"),
174 details: Some(json!({ "mem": name, "known_mems": all_loaded })),
175 }
176 .into());
177 }
178 name
179 }
180 None => match writable.len() {
181 0 => {
182 // Inside a workspace with nothing writable loaded. If the
183 // engine quarantined mems, that is the reason, and the
184 // engine already produced the typed reason and the repair
185 // command — surface them rather than restating them.
186 let quarantined: Vec<QuarantineNote> = engine
187 .quarantined_mems()
188 .iter()
189 .map(|q| QuarantineNote {
190 mem: q.mount.mem.clone(),
191 reason_code: q.reason_code.clone(),
192 reason: q.reason_message.clone(),
193 })
194 .collect();
195 return Ok(Resolved {
196 schema: Schema::builtin_default(),
197 condition: if quarantined.is_empty() {
198 Some(FallbackCondition::NoWritableMem)
199 } else {
200 Some(FallbackCondition::AllQuarantined { quarantined })
201 },
202 });
203 }
204 1 => writable[0],
205 _ => {
206 // When every writable mem pins the same schema, the
207 // type definition is identical regardless of which mem
208 // answers — drop the `--mem` ceremony and pick any.
209 // Refuse only when the writable mems pin *different*
210 // schemas (the answer would genuinely depend on the
211 // choice; rendering one mem's type as the answer for
212 // all would be silently wrong).
213 let schemas = engine.schemas();
214 let schema_id = |v: &str| {
215 schemas
216 .get(v)
217 .map(|s| (s.manifest.name.clone(), s.version.clone()))
218 };
219 let first = schema_id(writable[0]);
220 let all_same = first.is_some() && writable.iter().all(|v| schema_id(v) == first);
221 if all_same {
222 writable[0]
223 } else {
224 return Err(CliError::new(
225 ExitKind::Validation,
226 "AMBIGUOUS_MEM",
227 format!(
228 "writable mems pin different schemas ([{}]) — pass `--mem <name>` to pick one",
229 writable.join(", ")
230 ),
231 )
232 .with_details(json!({ "mems": writable }))
233 .into());
234 }
235 }
236 },
237 };
238 match engine.schemas().get(resolved_mem).cloned() {
239 Some(schema) => Ok(Resolved {
240 schema,
241 condition: None,
242 }),
243 // The third fallback, silent in exactly the same way: a mem
244 // resolved fine but carries no schema entry. Named here because a
245 // fix aimed only at the two paths the field report hit would leave
246 // this one printing a default as though it were the mem's own.
247 None => Ok(Resolved {
248 schema: Schema::builtin_default(),
249 condition: Some(FallbackCondition::MemHasNoSchema {
250 mem: resolved_mem.to_string(),
251 }),
252 }),
253 }
254}
255
256/// A resolved schema plus the condition that produced it, when the
257/// answer is a fallback rather than the workspace's own pin.
258struct Resolved {
259 schema: Arc<Schema>,
260 /// `None` when the schema is genuinely the resolved mem's own.
261 condition: Option<FallbackCondition>,
262}
263
264impl Resolved {
265 /// The cold-start probe: no workspace, so the built-in default is the
266 /// answer rather than a stand-in for one.
267 fn cold_start() -> Self {
268 Self {
269 schema: Schema::builtin_default(),
270 condition: None,
271 }
272 }
273}
274
275/// One quarantined mem, as the engine reported it.
276struct QuarantineNote {
277 mem: String,
278 reason_code: String,
279 /// The engine's own message, repair command included.
280 reason: String,
281}
282
283/// Why a fallback schema is being shown instead of a workspace's own.
284enum FallbackCondition {
285 /// A workspace loaded, its mems are quarantined, and these are they.
286 AllQuarantined { quarantined: Vec<QuarantineNote> },
287 /// A workspace loaded with no writable mem and nothing quarantined.
288 NoWritableMem,
289 /// A mem resolved but carries no schema entry.
290 MemHasNoSchema { mem: String },
291}
292
293impl FallbackCondition {
294 /// The line the command prints above the catalogue, so a reader never
295 /// takes a fallback for the workspace's pinned schema.
296 fn notice(&self) -> String {
297 match self {
298 Self::AllQuarantined { quarantined } => {
299 let mut out = String::from(
300 "**No mem is serving in this workspace** — the schema below is the \
301 engine built-in default, not this workspace's own. \
302 Quarantined:\n",
303 );
304 for q in quarantined {
305 out.push_str(&format!(
306 "\n- `{}` ({}): {}\n",
307 q.mem, q.reason_code, q.reason
308 ));
309 }
310 out
311 }
312 Self::NoWritableMem => "**No writable mem is loaded in this workspace** — the \
313 schema below is the engine built-in default, not this workspace's own."
314 .to_string(),
315 Self::MemHasNoSchema { mem } => format!(
316 "**Mem `{mem}` carries no schema entry** — the schema below is the engine \
317 built-in default, not this mem's own."
318 ),
319 }
320 }
321
322 /// Machine-readable twin for the `--json` envelope.
323 fn code(&self) -> &'static str {
324 match self {
325 Self::AllQuarantined { .. } => "ALL_MEMS_QUARANTINED",
326 Self::NoWritableMem => "NO_WRITABLE_MEM",
327 Self::MemHasNoSchema { .. } => "MEM_HAS_NO_SCHEMA",
328 }
329 }
330}