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, provenance) = match ctx.cli_engine()? {
83 #[cfg(feature = "mem-repo")]
84 CliEngine::MemRepo(engine) => {
85 let entity = engine
86 .get_entity(&id)
87 .cloned()
88 .ok_or_else(|| miss(&engine))?;
89 let md = render_with_optional_relations(&entity, &id, engine.store(), &args);
90 let outgoing = engine.store().outgoing(&id).to_vec();
91 let prov = provenance_block(&engine);
92 (entity, md, outgoing, prov)
93 }
94 CliEngine::Filesystem(engine) => {
95 let entity = engine
96 .get_entity(&id)
97 .cloned()
98 .ok_or_else(|| miss(&engine))?;
99 let md = render_with_optional_relations(&entity, &id, engine.store(), &args);
100 let outgoing = engine.store().outgoing(&id).to_vec();
101 let prov = provenance_block(&engine);
102 (entity, md, outgoing, prov)
103 }
104 };
105
106 let chunked = match args.token_budget {
107 Some(budget) => apply_chunking(
108 &output,
109 budget,
110 args.chunk,
111 &[("_hash", &entity.content_hash)],
112 )
113 .map_err(|e| CliError::new(ExitKind::Generic, "CHUNK_OUT_OF_RANGE", e))?,
114 None => output.to_string(),
115 };
116
117 if ctx.json {
118 let sections_filter = if args.sections.is_empty() {
126 None
127 } else {
128 Some(args.sections.as_slice())
129 };
130 let rendered_body_tokens = memstead_base::chunking::estimate_tokens(&output);
131 let full_tokens = if sections_filter.is_some() {
132 let full_body = render::render_entity_markdown(&entity, None);
133 Some(memstead_base::chunking::estimate_tokens(&full_body))
134 } else {
135 None
136 };
137 let mut envelope = render::build_entity_envelope(
138 &entity,
139 rendered_body_tokens,
140 full_tokens,
141 sections_filter,
142 None,
143 &outgoing_snapshot,
144 );
145 if let (Some(prov), Some(obj)) = (&provenance, envelope.as_object_mut()) {
146 obj.insert("mutation_provenance".into(), prov.clone());
147 }
148 crate::output::print_json(&envelope)?;
149 } else {
150 let mut text = chunked.clone();
151 if let Some(prov) = &provenance {
152 let render_rec = |label: &str, key: &str| -> Option<String> {
153 let r = prov.get(key)?;
154 Some(format!(
155 "- {label}: {} ({}), role {}, at {}",
156 r["client"].as_str().unwrap_or("unknown client"),
157 r["actor"].as_str().unwrap_or("unknown actor"),
158 r["role"].as_str().unwrap_or("unspecified"),
159 r["timestamp"],
160 ))
161 };
162 text.push_str(
163 "
164
165## Mutation provenance
166",
167 );
168 match prov.get("unavailable") {
169 Some(reason) => {
170 text.push_str(&format!(
171 "- unavailable: {}
172",
173 reason.as_str().unwrap_or("")
174 ));
175 }
176 None => {
177 if let Some(l) = render_rec("created by", "created_by") {
178 text.push_str(&l);
179 text.push('\n');
180 } else {
181 text.push_str(
182 "- created by: not recorded (story truncated)
183",
184 );
185 }
186 if let Some(l) = render_rec("last modified by", "last_modified_by") {
187 text.push_str(&l);
188 text.push('\n');
189 }
190 if let Some(state) = prov.get("check_state").and_then(|v| v.as_str()) {
191 text.push_str(&format!("- check state: {state}\n"));
192 }
193 }
194 }
195 }
196 print_markdown(&text);
197 }
198 Ok(())
199}
200
201fn render_with_optional_relations(
205 entity: &Entity,
206 id: &EntityId,
207 store: &Store,
208 args: &Args,
209) -> String {
210 let sections_filter = if args.sections.is_empty() {
211 None
212 } else {
213 Some(args.sections.as_slice())
214 };
215 let mut md = render::render_entity_markdown(entity, sections_filter);
216 if args.include_relations {
217 let outgoing = store.outgoing(id).to_vec();
218 let incoming = store.incoming(id).to_vec();
219 let rel_json = render::render_relations_json(id.as_ref(), &outgoing, &incoming);
220 md.push_str("\n## Relations (JSON)\n\n```json\n");
221 md.push_str(&serde_json::to_string_pretty(&rel_json).unwrap_or_else(|_| "{}".to_string()));
222 md.push_str("\n```\n");
223 }
224 md
225}