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() {
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 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 declares_cross_mem_serving = |schema: Option<std::sync::Arc<memstead_schema::Schema>>| {
95 schema.is_some_and(|s| {
96 s.manifest.relationships.labelling.is_some()
97 || s.types.values().any(|td| !td.signals.is_empty())
98 })
99 };
100 let engine_handle = if args.include_relations {
101 ctx.cli_engine()?
102 } else {
103 let scoped = ctx.cli_engine_scoped(id.mem())?;
104 let escalate = match &scoped {
105 #[cfg(feature = "mem-repo")]
106 CliEngine::MemRepo(engine) => declares_cross_mem_serving(engine.schema_for(id.mem())),
107 CliEngine::Filesystem(engine) => {
108 declares_cross_mem_serving(engine.schema_for(id.mem()))
109 }
110 };
111 if escalate { ctx.cli_engine()? } else { scoped }
112 };
113 let (
114 entity,
115 output,
116 outgoing_snapshot,
117 incoming_snapshot,
118 origin,
119 provenance,
120 signals,
121 labelling,
122 ) = match engine_handle {
123 #[cfg(feature = "mem-repo")]
124 CliEngine::MemRepo(engine) => {
125 let entity = engine
126 .get_entity(&id)
127 .cloned()
128 .ok_or_else(|| miss(&engine))?;
129 let signals = engine.computed_signals(&entity);
130 let labelling = engine.computed_labelling(&entity);
131 let md = render_with_optional_relations(
132 &entity,
133 &id,
134 engine.store(),
135 &args,
136 signals.as_deref(),
137 labelling.as_ref(),
138 );
139 let outgoing = engine.store().outgoing(&id).to_vec();
140 let incoming = args
141 .include_relations
142 .then(|| engine.store().incoming(&id).to_vec());
143 let origin = engine.mem_origin_class(id.mem());
144 let prov = provenance_block(&engine);
145 (
146 entity, md, outgoing, incoming, origin, prov, signals, labelling,
147 )
148 }
149 CliEngine::Filesystem(engine) => {
150 let entity = engine
151 .get_entity(&id)
152 .cloned()
153 .ok_or_else(|| miss(&engine))?;
154 let signals = engine.computed_signals(&entity);
155 let labelling = engine.computed_labelling(&entity);
156 let md = render_with_optional_relations(
157 &entity,
158 &id,
159 engine.store(),
160 &args,
161 signals.as_deref(),
162 labelling.as_ref(),
163 );
164 let outgoing = engine.store().outgoing(&id).to_vec();
165 let incoming = args
166 .include_relations
167 .then(|| engine.store().incoming(&id).to_vec());
168 let origin = engine.mem_origin_class(id.mem());
169 let prov = provenance_block(&engine);
170 (
171 entity, md, outgoing, incoming, origin, prov, signals, labelling,
172 )
173 }
174 };
175
176 let chunked = match args.token_budget {
177 Some(budget) => apply_chunking(
178 &output,
179 budget,
180 args.chunk,
181 &[("_hash", &entity.content_hash)],
182 )
183 .map_err(|e| CliError::new(ExitKind::Generic, "CHUNK_OUT_OF_RANGE", e))?,
184 None => output.to_string(),
185 };
186
187 if ctx.json {
188 let sections_filter = if args.sections.is_empty() {
196 None
197 } else {
198 Some(args.sections.as_slice())
199 };
200 let rendered_body_tokens = memstead_base::chunking::estimate_tokens(&output);
201 let full_tokens = if sections_filter.is_some() {
202 let full_body = render::render_entity_markdown(&entity, None);
203 Some(memstead_base::chunking::estimate_tokens(&full_body))
204 } else {
205 None
206 };
207 let mut envelope = render::build_entity_envelope(
208 &entity,
209 rendered_body_tokens,
210 full_tokens,
211 sections_filter,
212 None,
213 origin,
214 &outgoing_snapshot,
215 incoming_snapshot.as_deref(),
216 signals.as_deref(),
217 labelling.as_ref(),
218 );
219 if let (Some(prov), Some(obj)) = (&provenance, envelope.as_object_mut()) {
220 obj.insert("mutation_provenance".into(), prov.clone());
221 }
222 crate::output::print_json(&envelope)?;
223 } else {
224 let mut text = chunked.clone();
225 if let Some(prov) = &provenance {
226 let render_rec = |label: &str, key: &str| -> Option<String> {
227 let r = prov.get(key)?;
228 Some(format!(
229 "- {label}: {} ({}), role {}, at {}",
230 r["client"].as_str().unwrap_or("unknown client"),
231 r["actor"].as_str().unwrap_or("unknown actor"),
232 r["role"].as_str().unwrap_or("unspecified"),
233 r["timestamp"],
234 ))
235 };
236 text.push_str(
237 "
238
239## Mutation provenance
240",
241 );
242 match prov.get("unavailable") {
243 Some(reason) => {
244 text.push_str(&format!(
245 "- unavailable: {}
246",
247 reason.as_str().unwrap_or("")
248 ));
249 }
250 None => {
251 if let Some(l) = render_rec("created by", "created_by") {
252 text.push_str(&l);
253 text.push('\n');
254 } else {
255 text.push_str(
256 "- created by: not recorded (story truncated)
257",
258 );
259 }
260 if let Some(l) = render_rec("last modified by", "last_modified_by") {
261 text.push_str(&l);
262 text.push('\n');
263 }
264 if let Some(state) = prov.get("check_state").and_then(|v| v.as_str()) {
265 text.push_str(&format!("- check state: {state}\n"));
266 }
267 }
268 }
269 }
270 print_markdown(&text);
271 }
272 Ok(())
273}
274
275fn render_with_optional_relations(
279 entity: &Entity,
280 id: &EntityId,
281 store: &Store,
282 args: &Args,
283 signals: Option<&[memstead_base::ops::signals::ComputedSignal]>,
284 labelling: Option<&memstead_base::ops::labelling::LabellingView>,
285) -> String {
286 let sections_filter = if args.sections.is_empty() {
287 None
288 } else {
289 Some(args.sections.as_slice())
290 };
291 let mut md =
292 render::render_entity_markdown_with_signals(entity, sections_filter, signals, labelling);
293 if args.include_relations {
294 let outgoing = store.outgoing(id).to_vec();
295 let incoming = store.incoming(id).to_vec();
296 let rel_json = render::render_relations_json(id.as_ref(), &outgoing, &incoming);
297 md.push_str("\n## Relations (JSON)\n\n```json\n");
298 md.push_str(&serde_json::to_string_pretty(&rel_json).unwrap_or_else(|_| "{}".to_string()));
299 md.push_str("\n```\n");
300 }
301 md
302}