Skip to main content

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) = &notice {
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(&td);
53                out.insert_str(0, &format!("**Schema:** `{schema_label}`\n\n"));
54                if let Some(n) = &notice {
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 &notice {
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}