use clap::Parser;
use memstead_base::Entity;
use memstead_base::EntityId;
use memstead_base::Store;
use memstead_base::chunking::apply_chunking;
use memstead_base::render;
use crate::CliError;
use crate::output::{ExitKind, print_markdown};
use crate::setup::{CliContext, CliEngine};
#[derive(Parser, Debug)]
pub struct Args {
pub id: String,
#[arg(long = "section", value_name = "KEY")]
pub sections: Vec<String>,
#[arg(long)]
pub include_relations: bool,
#[arg(long)]
pub token_budget: Option<usize>,
#[arg(long)]
pub chunk: Option<usize>,
#[arg(long = "provenance")]
pub provenance: bool,
}
pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
let id = EntityId::canonical(&args.id);
let miss = |engine: &memstead_base::Engine| {
if engine.quarantine_reason(id.mem()).is_some() {
return CliError::from_engine_op(engine.unknown_mem_error(id.mem()));
}
CliError::new(
ExitKind::NotFound,
"ENTITY_NOT_FOUND",
format!("Entity not found: {}", args.id),
)
.with_details(serde_json::json!({ "id": args.id }))
};
let provenance_block = |engine: &memstead_base::Engine| -> Option<serde_json::Value> {
if !args.provenance {
return None;
}
Some(match engine.entity_provenance(id.mem(), id.as_ref()) {
Ok(prov) => serde_json::to_value(&prov).unwrap_or(serde_json::Value::Null),
Err(e) => serde_json::json!({ "unavailable": e.to_string() }),
})
};
let (entity, output, outgoing_snapshot, provenance) = match ctx.cli_engine()? {
#[cfg(feature = "mem-repo")]
CliEngine::MemRepo(engine) => {
let entity = engine
.get_entity(&id)
.cloned()
.ok_or_else(|| miss(&engine))?;
let md = render_with_optional_relations(&entity, &id, engine.store(), &args);
let outgoing = engine.store().outgoing(&id).to_vec();
let prov = provenance_block(&engine);
(entity, md, outgoing, prov)
}
CliEngine::Filesystem(engine) => {
let entity = engine
.get_entity(&id)
.cloned()
.ok_or_else(|| miss(&engine))?;
let md = render_with_optional_relations(&entity, &id, engine.store(), &args);
let outgoing = engine.store().outgoing(&id).to_vec();
let prov = provenance_block(&engine);
(entity, md, outgoing, prov)
}
};
let chunked = match args.token_budget {
Some(budget) => apply_chunking(
&output,
budget,
args.chunk,
&[("_hash", &entity.content_hash)],
)
.map_err(|e| CliError::new(ExitKind::Generic, "CHUNK_OUT_OF_RANGE", e))?,
None => output.to_string(),
};
if ctx.json {
let sections_filter = if args.sections.is_empty() {
None
} else {
Some(args.sections.as_slice())
};
let rendered_body_tokens = memstead_base::chunking::estimate_tokens(&output);
let full_tokens = if sections_filter.is_some() {
let full_body = render::render_entity_markdown(&entity, None);
Some(memstead_base::chunking::estimate_tokens(&full_body))
} else {
None
};
let mut envelope = render::build_entity_envelope(
&entity,
rendered_body_tokens,
full_tokens,
sections_filter,
None,
&outgoing_snapshot,
);
if let (Some(prov), Some(obj)) = (&provenance, envelope.as_object_mut()) {
obj.insert("mutation_provenance".into(), prov.clone());
}
crate::output::print_json(&envelope)?;
} else {
let mut text = chunked.clone();
if let Some(prov) = &provenance {
let render_rec = |label: &str, key: &str| -> Option<String> {
let r = prov.get(key)?;
Some(format!(
"- {label}: {} ({}), role {}, at {}",
r["client"].as_str().unwrap_or("unknown client"),
r["actor"].as_str().unwrap_or("unknown actor"),
r["role"].as_str().unwrap_or("unspecified"),
r["timestamp"],
))
};
text.push_str(
"
## Mutation provenance
",
);
match prov.get("unavailable") {
Some(reason) => {
text.push_str(&format!(
"- unavailable: {}
",
reason.as_str().unwrap_or("")
));
}
None => {
if let Some(l) = render_rec("created by", "created_by") {
text.push_str(&l);
text.push('\n');
} else {
text.push_str(
"- created by: not recorded (story truncated)
",
);
}
if let Some(l) = render_rec("last modified by", "last_modified_by") {
text.push_str(&l);
text.push('\n');
}
if let Some(state) = prov.get("check_state").and_then(|v| v.as_str()) {
text.push_str(&format!("- check state: {state}\n"));
}
}
}
}
print_markdown(&text);
}
Ok(())
}
fn render_with_optional_relations(
entity: &Entity,
id: &EntityId,
store: &Store,
args: &Args,
) -> String {
let sections_filter = if args.sections.is_empty() {
None
} else {
Some(args.sections.as_slice())
};
let mut md = render::render_entity_markdown(entity, sections_filter);
if args.include_relations {
let outgoing = store.outgoing(id).to_vec();
let incoming = store.incoming(id).to_vec();
let rel_json = render::render_relations_json(id.as_ref(), &outgoing, &incoming);
md.push_str("\n## Relations (JSON)\n\n```json\n");
md.push_str(&serde_json::to_string_pretty(&rel_json).unwrap_or_else(|_| "{}".to_string()));
md.push_str("\n```\n");
}
md
}