memstead_cli/commands/
entity.rs1use 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 (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 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
212fn 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}