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#[derive(Parser, Debug)]
15pub struct Args {
16 pub id: String,
18
19 #[arg(long = "section", value_name = "KEY")]
21 pub sections: Vec<String>,
22
23 #[arg(long)]
25 pub include_relations: bool,
26
27 #[arg(long)]
29 pub token_budget: Option<usize>,
30
31 #[arg(long)]
33 pub chunk: Option<usize>,
34
35 #[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 let miss = |engine: &memstead_base::Engine| {
50 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 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 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 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 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 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
303fn 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}