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 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 = engine.into_base();
105    let writable: Vec<&str> = engine.writable_mem_names();
106    let all_loaded: Vec<&str> = engine.mem_names();
107    let resolved_mem: &str = match mem {
108        Some(name) => {
109            // F25: `--mem` resolves against every loaded
110            // mem, not just the writable subset. Schema lookup is
111            // read-only; RO mounts have schemas worth introspecting.
112            if !all_loaded.contains(&name) {
113                let known = if all_loaded.is_empty() {
114                    "no mems loaded".to_string()
115                } else {
116                    format!("known mems: [{}]", all_loaded.join(", "))
117                };
118                return Err(CliError {
119                    code: "UNKNOWN_MEM",
120                    kind: ExitKind::NotFound,
121                    message: format!("unknown mem: {name} — {known}"),
122                    details: Some(json!({ "mem": name, "known_mems": all_loaded })),
123                }
124                .into());
125            }
126            name
127        }
128        None => match writable.len() {
129            0 => return Ok(Schema::builtin_default()),
130            1 => writable[0],
131            _ => {
132                // When every writable mem pins the same schema, the
133                // type definition is identical regardless of which mem
134                // answers — drop the `--mem` ceremony and pick any.
135                // Refuse only when the writable mems pin *different*
136                // schemas (the answer would genuinely depend on the
137                // choice; rendering one mem's type as the answer for
138                // all would be silently wrong).
139                let schemas = engine.schemas();
140                let schema_id = |v: &str| {
141                    schemas
142                        .get(v)
143                        .map(|s| (s.manifest.name.clone(), s.version.clone()))
144                };
145                let first = schema_id(writable[0]);
146                let all_same = first.is_some() && writable.iter().all(|v| schema_id(v) == first);
147                if all_same {
148                    writable[0]
149                } else {
150                    return Err(CliError::new(
151                        ExitKind::Validation,
152                        "AMBIGUOUS_MEM",
153                        format!(
154                            "writable mems pin different schemas ([{}]) — pass `--mem <name>` to pick one",
155                            writable.join(", ")
156                        ),
157                    )
158                    .with_details(json!({ "mems": writable }))
159                    .into());
160                }
161            }
162        },
163    };
164    Ok(engine
165        .schemas()
166        .get(resolved_mem)
167        .cloned()
168        .unwrap_or_else(Schema::builtin_default))
169}