memstead_cli/commands/
context.rs1use clap::Parser;
2use serde_json::json;
3
4use memstead_base::EntityId;
5use memstead_base::chunking::apply_chunking;
6use memstead_base::ops::{ContextResult, Query, SearchScope};
7use memstead_base::render;
8
9use crate::CliError;
10use crate::output::{ExitKind, print_json, print_markdown};
11use crate::setup::{CliContext, CliEngine};
12
13const DEFAULT_TOKEN_BUDGET: usize = 25_000;
14
15#[derive(Parser, Debug)]
17pub struct Args {
18 pub id_or_query: String,
20
21 #[arg(long)]
23 pub chunk: Option<usize>,
24}
25
26pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
27 let id = EntityId::canonical(&args.id_or_query);
28 let outcome: ContextOutcome = match ctx.cli_engine()? {
29 #[cfg(feature = "mem-repo")]
30 CliEngine::MemRepo(engine) => resolve_context_mem_repo(&engine, &id, ctx, &args)?,
31 CliEngine::Filesystem(engine) => resolve_context_filesystem(&engine, &id, ctx, &args)?,
32 };
33
34 match outcome {
35 ContextOutcome::Resolved(Some(result)) => {
36 let cluster_id = result.community.as_deref().unwrap_or("unknown").to_string();
37 let md = render::render_context_markdown(&result, &cluster_id);
38 let chunked = apply_chunking(
39 &md,
40 DEFAULT_TOKEN_BUDGET,
41 args.chunk,
42 &[("_cluster_id", cluster_id.as_str())],
43 )
44 .map_err(|e| CliError::new(ExitKind::Generic, "CHUNK_OUT_OF_RANGE", e))?;
45 if ctx.json {
46 print_json(&json!({ "markdown": chunked, "cluster_id": cluster_id }))?;
47 } else {
48 print_markdown(&chunked);
49 }
50 Ok(())
51 }
52 ContextOutcome::Resolved(None) => Err(CliError::new(
53 ExitKind::Generic,
54 "CONTEXT_NOT_COMPUTABLE",
55 "Could not compute context",
56 )
57 .into()),
58 ContextOutcome::NotFound { query } => Err(CliError::new(
59 ExitKind::NotFound,
60 "ENTITY_NOT_FOUND",
61 format!("no entity found for: {query}"),
62 )
63 .with_details(json!({ "id": query }))
64 .into()),
65 ContextOutcome::Ambiguous { query, candidates } => Err(CliError::new(
66 ExitKind::Validation,
67 "AMBIGUOUS_QUERY",
68 format!(
69 "ambiguous query: {} candidates match `{query}` — pass an exact entity id",
70 candidates.len()
71 ),
72 )
73 .with_details(json!({ "query": query, "candidates": candidates }))
74 .into()),
75 }
76}
77
78enum ContextOutcome {
83 Resolved(Option<ContextResult>),
84 NotFound {
85 query: String,
86 },
87 Ambiguous {
88 query: String,
89 candidates: Vec<serde_json::Value>,
90 },
91}
92
93#[cfg(feature = "mem-repo")]
99fn resolve_context_mem_repo(
100 engine: &memstead_base::Engine,
101 id: &EntityId,
102 ctx: &CliContext,
103 args: &Args,
104) -> anyhow::Result<ContextOutcome> {
105 if engine.get_entity(id).is_some() {
106 return Ok(ContextOutcome::Resolved(engine.context(id)));
107 }
108 let search = engine.search(&fuzzy_scope(&args.id_or_query))?;
109 if let Some(miss) = handle_id_or_query_miss(ctx, &args.id_or_query, &search.hits)? {
110 return Ok(miss);
111 }
112 Ok(ContextOutcome::Resolved(engine.context(&search.hits[0].id)))
113}
114
115fn resolve_context_filesystem(
117 engine: &memstead_base::Engine,
118 id: &EntityId,
119 ctx: &CliContext,
120 args: &Args,
121) -> anyhow::Result<ContextOutcome> {
122 if engine.get_entity(id).is_some() {
123 return Ok(ContextOutcome::Resolved(engine.context(id)));
124 }
125 let search = engine.search(&fuzzy_scope(&args.id_or_query))?;
126 if let Some(miss) = handle_id_or_query_miss(ctx, &args.id_or_query, &search.hits)? {
127 return Ok(miss);
128 }
129 Ok(ContextOutcome::Resolved(engine.context(&search.hits[0].id)))
130}
131
132fn fuzzy_scope(query_text: &str) -> SearchScope {
135 SearchScope {
136 query: Some(Query {
137 any: vec![query_text.to_string()],
138 ..Default::default()
139 }),
140 limit: Some(5),
141 ..Default::default()
142 }
143}
144
145fn handle_id_or_query_miss(
150 ctx: &CliContext,
151 query_text: &str,
152 hits: &[memstead_base::SearchHit],
153) -> anyhow::Result<Option<ContextOutcome>> {
154 if hits.is_empty() {
155 let msg = format!("No entity found for: {query_text}");
156 if ctx.json {
157 print_json(&json!({ "found": false, "message": msg }))?;
158 } else {
159 print_markdown(&format!("_{msg}_"));
160 }
161 return Ok(Some(ContextOutcome::NotFound {
162 query: query_text.to_string(),
163 }));
164 }
165 if hits.len() > 1 {
166 let candidates: Vec<_> = hits
167 .iter()
168 .map(|h| json!({ "id": h.id.to_string(), "title": &h.title }))
169 .collect();
170 if ctx.json {
171 print_json(&json!({ "ambiguous": true, "candidates": &candidates }))?;
172 } else {
173 let mut lines = vec!["# Ambiguous match".to_string(), String::new()];
174 for h in hits {
175 lines.push(format!("- {} — {}", h.id, h.title));
176 }
177 print_markdown(&lines.join("\n"));
178 }
179 return Ok(Some(ContextOutcome::Ambiguous {
180 query: query_text.to_string(),
181 candidates,
182 }));
183 }
184 Ok(None)
185}