Skip to main content

memstead_cli/commands/
entity.rs

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/// Read one entity as markdown.
14#[derive(Parser, Debug)]
15pub struct Args {
16    /// Entity ID (e.g. `specs--my-entity`).
17    pub id: String,
18
19    /// Restrict output to specific section keys (repeatable).
20    #[arg(long = "section", value_name = "KEY")]
21    pub sections: Vec<String>,
22
23    /// Append relations as a trailing JSON code block.
24    #[arg(long)]
25    pub include_relations: bool,
26
27    /// Token budget for chunking. Omit for no chunking.
28    #[arg(long)]
29    pub token_budget: Option<usize>,
30
31    /// 1-based chunk index to return (requires `--token-budget`).
32    #[arg(long)]
33    pub chunk: Option<usize>,
34}
35
36pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
37    let id = EntityId::canonical(&args.id);
38    // The engine's `get_entity` returns `Option<&Entity>`, so the
39    // typed `ENTITY_NOT_FOUND` code lives on the CLI side here —
40    // pin it explicitly so the wire envelope matches what the engine
41    // would emit from a write-path miss.
42    let not_found = || {
43        CliError::new(
44            ExitKind::NotFound,
45            "ENTITY_NOT_FOUND",
46            format!("Entity not found: {}", args.id),
47        )
48        .with_details(serde_json::json!({ "id": args.id }))
49    };
50    // Snapshot the store's outgoing edges alongside the entity so the
51    // JSON envelope can resolve each relationship's `source` label after
52    // the engine goes out of scope. The outgoing edges carry the
53    // authoritative `EdgeSource` discriminator (`Explicit` /
54    // `BodyLink` / `Hierarchy`); the entity's `relationships` vec
55    // doesn't encode it.
56    let (entity, output, outgoing_snapshot) = match ctx.cli_engine()? {
57        #[cfg(feature = "mem-repo")]
58        CliEngine::MemRepo(engine) => {
59            let entity = engine.get_entity(&id).cloned().ok_or_else(not_found)?;
60            let md = render_with_optional_relations(&entity, &id, engine.store(), &args);
61            let outgoing = engine.store().outgoing(&id).to_vec();
62            (entity, md, outgoing)
63        }
64        CliEngine::Filesystem(engine) => {
65            let entity = engine.get_entity(&id).cloned().ok_or_else(not_found)?;
66            let md = render_with_optional_relations(&entity, &id, engine.store(), &args);
67            let outgoing = engine.store().outgoing(&id).to_vec();
68            (entity, md, outgoing)
69        }
70    };
71
72    let chunked = match args.token_budget {
73        Some(budget) => apply_chunking(
74            &output,
75            budget,
76            args.chunk,
77            &[("_hash", &entity.content_hash)],
78        )
79        .map_err(|e| CliError::new(ExitKind::Generic, "CHUNK_OUT_OF_RANGE", e))?,
80        None => output.to_string(),
81    };
82
83    if ctx.json {
84        // The CLI `--json`
85        // shape mirrors the MCP `structured_content` envelope —
86        // typed fields (`_hash`, `id`, `mem`, `type`, sections,
87        // relationships) rather than a
88        // `{ markdown: "..." }` flat shape that would force agents to
89        // string-scrape frontmatter for `_hash`. Greenfield
90        // justifies the wire shape break.
91        let sections_filter = if args.sections.is_empty() {
92            None
93        } else {
94            Some(args.sections.as_slice())
95        };
96        let rendered_body_tokens = memstead_base::chunking::estimate_tokens(&output);
97        let full_tokens = if sections_filter.is_some() {
98            let full_body = render::render_entity_markdown(&entity, None);
99            Some(memstead_base::chunking::estimate_tokens(&full_body))
100        } else {
101            None
102        };
103        let envelope = render::build_entity_envelope(
104            &entity,
105            rendered_body_tokens,
106            full_tokens,
107            sections_filter,
108            None,
109            &outgoing_snapshot,
110        );
111        crate::output::print_json(&envelope)?;
112    } else {
113        print_markdown(&chunked);
114    }
115    Ok(())
116}
117
118/// Render an entity's markdown body and, when `--include-relations` is
119/// set, append the outgoing/incoming JSON block. Engine-agnostic: both
120/// flavours expose a `&Store`.
121fn render_with_optional_relations(
122    entity: &Entity,
123    id: &EntityId,
124    store: &Store,
125    args: &Args,
126) -> String {
127    let sections_filter = if args.sections.is_empty() {
128        None
129    } else {
130        Some(args.sections.as_slice())
131    };
132    let mut md = render::render_entity_markdown(entity, sections_filter);
133    if args.include_relations {
134        let outgoing = store.outgoing(id).to_vec();
135        let incoming = store.incoming(id).to_vec();
136        let rel_json = render::render_relations_json(id.as_ref(), &outgoing, &incoming);
137        md.push_str("\n## Relations (JSON)\n\n```json\n");
138        md.push_str(&serde_json::to_string_pretty(&rel_json).unwrap_or_else(|_| "{}".to_string()));
139        md.push_str("\n```\n");
140    }
141    md
142}