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        if engine.quarantine_reason(id.mem()).is_some() {
54            return CliError::from_engine_op(engine.unknown_mem_error(id.mem()));
55        }
56        CliError::new(
57            ExitKind::NotFound,
58            "ENTITY_NOT_FOUND",
59            format!("Entity not found: {}", args.id),
60        )
61        .with_details(serde_json::json!({ "id": args.id }))
62    };
63    // Snapshot the store's outgoing edges alongside the entity so the
64    // JSON envelope can resolve each relationship's `source` label after
65    // the engine goes out of scope. The outgoing edges carry the
66    // authoritative `EdgeSource` discriminator (`Explicit` /
67    // `BodyLink` / `Hierarchy`); the entity's `relationships` vec
68    // doesn't encode it.
69    // Derived mutation-provenance block (agent-trust plan 13), only
70    // when `--provenance` asked for it — default output stays
71    // byte-unchanged. Unavailability (an archive seam with no
72    // history) is stated, never fabricated.
73    let provenance_block = |engine: &memstead_base::Engine| -> Option<serde_json::Value> {
74        if !args.provenance {
75            return None;
76        }
77        Some(match engine.entity_provenance(id.mem(), id.as_ref()) {
78            Ok(prov) => serde_json::to_value(&prov).unwrap_or(serde_json::Value::Null),
79            Err(e) => serde_json::json!({ "unavailable": e.to_string() }),
80        })
81    };
82    let (entity, output, outgoing_snapshot, incoming_snapshot, origin, provenance) =
83        match ctx.cli_engine()? {
84            #[cfg(feature = "mem-repo")]
85            CliEngine::MemRepo(engine) => {
86                let entity = engine
87                    .get_entity(&id)
88                    .cloned()
89                    .ok_or_else(|| miss(&engine))?;
90                let md = render_with_optional_relations(&entity, &id, engine.store(), &args);
91                let outgoing = engine.store().outgoing(&id).to_vec();
92                let incoming = args
93                    .include_relations
94                    .then(|| engine.store().incoming(&id).to_vec());
95                let origin = engine.mem_origin_class(id.mem());
96                let prov = provenance_block(&engine);
97                (entity, md, outgoing, incoming, origin, prov)
98            }
99            CliEngine::Filesystem(engine) => {
100                let entity = engine
101                    .get_entity(&id)
102                    .cloned()
103                    .ok_or_else(|| miss(&engine))?;
104                let md = render_with_optional_relations(&entity, &id, engine.store(), &args);
105                let outgoing = engine.store().outgoing(&id).to_vec();
106                let incoming = args
107                    .include_relations
108                    .then(|| engine.store().incoming(&id).to_vec());
109                let origin = engine.mem_origin_class(id.mem());
110                let prov = provenance_block(&engine);
111                (entity, md, outgoing, incoming, origin, prov)
112            }
113        };
114
115    let chunked = match args.token_budget {
116        Some(budget) => apply_chunking(
117            &output,
118            budget,
119            args.chunk,
120            &[("_hash", &entity.content_hash)],
121        )
122        .map_err(|e| CliError::new(ExitKind::Generic, "CHUNK_OUT_OF_RANGE", e))?,
123        None => output.to_string(),
124    };
125
126    if ctx.json {
127        // The CLI `--json`
128        // shape mirrors the MCP `structured_content` envelope —
129        // typed fields (`_hash`, `id`, `mem`, `type`, sections,
130        // relationships) rather than a
131        // `{ markdown: "..." }` flat shape that would force agents to
132        // string-scrape frontmatter for `_hash`. Greenfield
133        // justifies the wire shape break.
134        let sections_filter = if args.sections.is_empty() {
135            None
136        } else {
137            Some(args.sections.as_slice())
138        };
139        let rendered_body_tokens = memstead_base::chunking::estimate_tokens(&output);
140        let full_tokens = if sections_filter.is_some() {
141            let full_body = render::render_entity_markdown(&entity, None);
142            Some(memstead_base::chunking::estimate_tokens(&full_body))
143        } else {
144            None
145        };
146        let mut envelope = render::build_entity_envelope(
147            &entity,
148            rendered_body_tokens,
149            full_tokens,
150            sections_filter,
151            None,
152            origin,
153            &outgoing_snapshot,
154            incoming_snapshot.as_deref(),
155        );
156        if let (Some(prov), Some(obj)) = (&provenance, envelope.as_object_mut()) {
157            obj.insert("mutation_provenance".into(), prov.clone());
158        }
159        crate::output::print_json(&envelope)?;
160    } else {
161        let mut text = chunked.clone();
162        if let Some(prov) = &provenance {
163            let render_rec = |label: &str, key: &str| -> Option<String> {
164                let r = prov.get(key)?;
165                Some(format!(
166                    "- {label}: {} ({}), role {}, at {}",
167                    r["client"].as_str().unwrap_or("unknown client"),
168                    r["actor"].as_str().unwrap_or("unknown actor"),
169                    r["role"].as_str().unwrap_or("unspecified"),
170                    r["timestamp"],
171                ))
172            };
173            text.push_str(
174                "
175
176## Mutation provenance
177",
178            );
179            match prov.get("unavailable") {
180                Some(reason) => {
181                    text.push_str(&format!(
182                        "- unavailable: {}
183",
184                        reason.as_str().unwrap_or("")
185                    ));
186                }
187                None => {
188                    if let Some(l) = render_rec("created by", "created_by") {
189                        text.push_str(&l);
190                        text.push('\n');
191                    } else {
192                        text.push_str(
193                            "- created by: not recorded (story truncated)
194",
195                        );
196                    }
197                    if let Some(l) = render_rec("last modified by", "last_modified_by") {
198                        text.push_str(&l);
199                        text.push('\n');
200                    }
201                    if let Some(state) = prov.get("check_state").and_then(|v| v.as_str()) {
202                        text.push_str(&format!("- check state: {state}\n"));
203                    }
204                }
205            }
206        }
207        print_markdown(&text);
208    }
209    Ok(())
210}
211
212/// Render an entity's markdown body and, when `--include-relations` is
213/// set, append the outgoing/incoming JSON block. Engine-agnostic: both
214/// flavours expose a `&Store`.
215fn render_with_optional_relations(
216    entity: &Entity,
217    id: &EntityId,
218    store: &Store,
219    args: &Args,
220) -> String {
221    let sections_filter = if args.sections.is_empty() {
222        None
223    } else {
224        Some(args.sections.as_slice())
225    };
226    let mut md = render::render_entity_markdown(entity, sections_filter);
227    if args.include_relations {
228        let outgoing = store.outgoing(id).to_vec();
229        let incoming = store.incoming(id).to_vec();
230        let rel_json = render::render_relations_json(id.as_ref(), &outgoing, &incoming);
231        md.push_str("\n## Relations (JSON)\n\n```json\n");
232        md.push_str(&serde_json::to_string_pretty(&rel_json).unwrap_or_else(|_| "{}".to_string()));
233        md.push_str("\n```\n");
234    }
235    md
236}