Skip to main content

memstead_cli/commands/
entity.rs

1use clap::Parser;
2
3use memstead_base::Entity;
4use memstead_base::EntityId;
5use memstead_base::Store;
6use memstead_base::chunking::apply_chunking;
7use memstead_base::render;
8
9use crate::CliError;
10use crate::output::{ExitKind, print_markdown};
11use crate::setup::{CliContext, CliEngine};
12
13/// Read one entity as markdown.
14#[derive(Parser, Debug)]
15pub struct Args {
16    /// Entity ID (e.g. `specs--my-entity`).
17    pub id: String,
18
19    /// Restrict output to specific section keys (repeatable).
20    #[arg(long = "section", value_name = "KEY")]
21    pub sections: Vec<String>,
22
23    /// Append relations as a trailing JSON code block.
24    #[arg(long)]
25    pub include_relations: bool,
26
27    /// Token budget for chunking. Omit for no chunking.
28    #[arg(long)]
29    pub token_budget: Option<usize>,
30
31    /// 1-based chunk index to return (requires `--token-budget`).
32    #[arg(long)]
33    pub chunk: Option<usize>,
34
35    /// Append the derived mutation-provenance block: created-by and
36    /// last-modified-by with actor, client, declared role (or
37    /// `unspecified`), and timestamp — read from the append-only
38    /// mutation record, which no verb can edit after the fact.
39    #[arg(long = "provenance")]
40    pub provenance: bool,
41}
42
43pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
44    let id = EntityId::canonical(&args.id);
45    // The engine's `get_entity` returns `Option<&Entity>`, so the
46    // typed `ENTITY_NOT_FOUND` code lives on the CLI side here —
47    // pin it explicitly so the wire envelope matches what the engine
48    // would emit from a write-path miss.
49    let miss = |engine: &memstead_base::Engine| {
50        // A quarantined mem's entities are deliberately absent — the
51        // read names the quarantine (MEM_QUARANTINED with the boot
52        // reason), not a phantom ENTITY_NOT_FOUND.
53        // Likewise a mem that left the roster: the read names the
54        // unmount (MEM_UNMOUNTED), not a phantom ENTITY_NOT_FOUND.
55        if engine.quarantine_reason(id.mem()).is_some() || engine.recently_unmounted(id.mem()) {
56            return CliError::from_engine_op(engine.unknown_mem_error(id.mem()));
57        }
58        CliError::new(
59            ExitKind::NotFound,
60            "ENTITY_NOT_FOUND",
61            format!("Entity not found: {}", args.id),
62        )
63        .with_details(serde_json::json!({ "id": args.id }))
64    };
65    // Snapshot the store's outgoing edges alongside the entity so the
66    // JSON envelope can resolve each relationship's `source` label after
67    // the engine goes out of scope. The outgoing edges carry the
68    // authoritative `EdgeSource` discriminator (`Explicit` /
69    // `BodyLink` / `Hierarchy`); the entity's `relationships` vec
70    // doesn't encode it.
71    // Derived mutation-provenance block (agent-trust plan 13), only
72    // when `--provenance` asked for it — default output stays
73    // byte-unchanged. Unavailability (an archive seam with no
74    // history) is stated, never fabricated.
75    let provenance_block = |engine: &memstead_base::Engine| -> Option<serde_json::Value> {
76        if !args.provenance {
77            return None;
78        }
79        Some(match engine.entity_provenance(id.mem(), id.as_ref()) {
80            Ok(prov) => serde_json::to_value(&prov).unwrap_or(serde_json::Value::Null),
81            Err(e) => serde_json::json!({ "unavailable": e.to_string() }),
82        })
83    };
84    // Load scope: everything this command renders lives in the target
85    // mem's slice of the store — the entity, its sections, and its
86    // OUTGOING edges (carried by its own source record) — plus mount
87    // metadata. So a lazy workspace pays only the target mem's load,
88    // the cold-path cut the plan names. Three forms are cross-mem and
89    // take the full load, never a partial answer:
90    // `--include-relations` (INCOMING edges can originate in any mem),
91    // declared signals (an `in`-direction signal reads incoming edges,
92    // and a neighbour pair reads the counterpart record either side of
93    // the edge), and the labelling view (it counts the cross-mem edges
94    // it excludes, and the support walk may cross mems). The scoped
95    // cold path stays for every mem whose schema declares neither.
96    let declares_cross_mem_serving = |schema: Option<std::sync::Arc<memstead_schema::Schema>>| {
97        schema.is_some_and(|s| {
98            s.manifest.relationships.labelling.is_some()
99                || s.types.values().any(|td| !td.signals.is_empty())
100        })
101    };
102    let engine_handle = if args.include_relations {
103        ctx.cli_engine()?
104    } else {
105        let scoped = ctx.cli_engine_scoped(id.mem())?;
106        let escalate = match &scoped {
107            #[cfg(feature = "mem-repo")]
108            CliEngine::MemRepo(engine) => declares_cross_mem_serving(engine.schema_for(id.mem())),
109            CliEngine::Filesystem(engine) => {
110                declares_cross_mem_serving(engine.schema_for(id.mem()))
111            }
112        };
113        if escalate { ctx.cli_engine()? } else { scoped }
114    };
115    let (
116        entity,
117        output,
118        outgoing_snapshot,
119        incoming_snapshot,
120        origin,
121        provenance,
122        signals,
123        labelling,
124        sidecar_error,
125    ) = match engine_handle {
126        #[cfg(feature = "mem-repo")]
127        CliEngine::MemRepo(engine) => {
128            let entity = engine
129                .get_entity(&id)
130                .cloned()
131                .ok_or_else(|| miss(&engine))?;
132            let signals = engine.computed_signals(&entity);
133            let labelling = engine.computed_labelling(&entity);
134            let md = render_with_optional_relations(
135                &entity,
136                &id,
137                engine.store(),
138                &args,
139                signals.as_deref(),
140                labelling.as_ref(),
141            );
142            let outgoing = engine.store().outgoing(&id).to_vec();
143            let incoming = args
144                .include_relations
145                .then(|| engine.store().incoming(&id).to_vec());
146            let origin = engine.mem_origin_class(id.mem());
147            let prov = provenance_block(&engine);
148            // The provenance layer's readability rides every entity read:
149            // an unreadable sidecar means this entity's anchors are unknown,
150            // and a read that stayed silent would pass for "none".
151            let sidecar = engine.anchors_sidecar_error(id.mem());
152            (
153                entity, md, outgoing, incoming, origin, prov, signals, labelling, sidecar,
154            )
155        }
156        CliEngine::Filesystem(engine) => {
157            let entity = engine
158                .get_entity(&id)
159                .cloned()
160                .ok_or_else(|| miss(&engine))?;
161            let signals = engine.computed_signals(&entity);
162            let labelling = engine.computed_labelling(&entity);
163            let md = render_with_optional_relations(
164                &entity,
165                &id,
166                engine.store(),
167                &args,
168                signals.as_deref(),
169                labelling.as_ref(),
170            );
171            let outgoing = engine.store().outgoing(&id).to_vec();
172            let incoming = args
173                .include_relations
174                .then(|| engine.store().incoming(&id).to_vec());
175            let origin = engine.mem_origin_class(id.mem());
176            let prov = provenance_block(&engine);
177            // The provenance layer's readability rides every entity read:
178            // an unreadable sidecar means this entity's anchors are unknown,
179            // and a read that stayed silent would pass for "none".
180            let sidecar = engine.anchors_sidecar_error(id.mem());
181            (
182                entity, md, outgoing, incoming, origin, prov, signals, labelling, sidecar,
183            )
184        }
185    };
186
187    let chunked = match args.token_budget {
188        Some(budget) => apply_chunking(
189            &output,
190            budget,
191            args.chunk,
192            &[("_hash", &entity.content_hash)],
193        )
194        .map_err(|e| CliError::new(ExitKind::Generic, "CHUNK_OUT_OF_RANGE", e))?,
195        None => output.to_string(),
196    };
197
198    if ctx.json {
199        // The CLI `--json`
200        // shape mirrors the MCP `structured_content` envelope —
201        // typed fields (`_hash`, `id`, `mem`, `type`, sections,
202        // relationships) rather than a
203        // `{ markdown: "..." }` flat shape that would force agents to
204        // string-scrape frontmatter for `_hash`. Greenfield
205        // justifies the wire shape break.
206        let sections_filter = if args.sections.is_empty() {
207            None
208        } else {
209            Some(args.sections.as_slice())
210        };
211        let rendered_body_tokens = memstead_base::chunking::estimate_tokens(&output);
212        let full_tokens = if sections_filter.is_some() {
213            let full_body = render::render_entity_markdown(&entity, None);
214            Some(memstead_base::chunking::estimate_tokens(&full_body))
215        } else {
216            None
217        };
218        let mut envelope = render::build_entity_envelope(
219            &entity,
220            rendered_body_tokens,
221            full_tokens,
222            sections_filter,
223            None,
224            origin,
225            &outgoing_snapshot,
226            incoming_snapshot.as_deref(),
227            signals.as_deref(),
228            labelling.as_ref(),
229        );
230        if let (Some(prov), Some(obj)) = (&provenance, envelope.as_object_mut()) {
231            obj.insert("mutation_provenance".into(), prov.clone());
232        }
233        if let (Some(why), Some(obj)) = (&sidecar_error, envelope.as_object_mut()) {
234            obj.insert(
235                "anchors_sidecar_error".into(),
236                serde_json::json!({
237                    "code": "ANCHORS_SIDECAR_UNREADABLE",
238                    "mem": id.mem(),
239                    "reason": why,
240                }),
241            );
242        }
243        crate::output::print_json(&envelope)?;
244    } else {
245        let mut text = chunked.clone();
246        if let Some(why) = &sidecar_error {
247            text.push_str(&format!(
248                "\n\n> **ANCHORS_SIDECAR_UNREADABLE** — mem `{}`: {why}. This entity's provenance \
249                 anchors are unknown, not absent.\n",
250                id.mem()
251            ));
252        }
253        if let Some(prov) = &provenance {
254            let render_rec = |label: &str, key: &str| -> Option<String> {
255                let r = prov.get(key)?;
256                Some(format!(
257                    "- {label}: {} ({}), role {}, at {}",
258                    r["client"].as_str().unwrap_or("unknown client"),
259                    r["actor"].as_str().unwrap_or("unknown actor"),
260                    r["role"].as_str().unwrap_or("unspecified"),
261                    r["timestamp"],
262                ))
263            };
264            text.push_str(
265                "
266
267## Mutation provenance
268",
269            );
270            match prov.get("unavailable") {
271                Some(reason) => {
272                    text.push_str(&format!(
273                        "- unavailable: {}
274",
275                        reason.as_str().unwrap_or("")
276                    ));
277                }
278                None => {
279                    if let Some(l) = render_rec("created by", "created_by") {
280                        text.push_str(&l);
281                        text.push('\n');
282                    } else {
283                        text.push_str(
284                            "- created by: not recorded (story truncated)
285",
286                        );
287                    }
288                    if let Some(l) = render_rec("last modified by", "last_modified_by") {
289                        text.push_str(&l);
290                        text.push('\n');
291                    }
292                    if let Some(state) = prov.get("check_state").and_then(|v| v.as_str()) {
293                        text.push_str(&format!("- check state: {state}\n"));
294                    }
295                }
296            }
297        }
298        print_markdown(&text);
299    }
300    Ok(())
301}
302
303/// Render an entity's markdown body and, when `--include-relations` is
304/// set, append the outgoing/incoming JSON block. Engine-agnostic: both
305/// flavours expose a `&Store`.
306fn render_with_optional_relations(
307    entity: &Entity,
308    id: &EntityId,
309    store: &Store,
310    args: &Args,
311    signals: Option<&[memstead_base::ops::signals::ComputedSignal]>,
312    labelling: Option<&memstead_base::ops::labelling::LabellingView>,
313) -> String {
314    let sections_filter = if args.sections.is_empty() {
315        None
316    } else {
317        Some(args.sections.as_slice())
318    };
319    let mut md =
320        render::render_entity_markdown_with_signals(entity, sections_filter, signals, labelling);
321    if args.include_relations {
322        let outgoing = store.outgoing(id).to_vec();
323        let incoming = store.incoming(id).to_vec();
324        let rel_json = render::render_relations_json(id.as_ref(), &outgoing, &incoming);
325        md.push_str("\n## Relations (JSON)\n\n```json\n");
326        md.push_str(&serde_json::to_string_pretty(&rel_json).unwrap_or_else(|_| "{}".to_string()));
327        md.push_str("\n```\n");
328    }
329    md
330}