Skip to main content

memstead_cli/commands/
context.rs

1use 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/// Read an entity's community cluster.
16#[derive(Parser, Debug)]
17pub struct Args {
18    /// Entity ID (exact match preferred) or search text (fallback).
19    pub id_or_query: String,
20
21    /// 1-based chunk index for large contexts.
22    #[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
78/// Outcome of an `id_or_query` lookup. Resolved carries the engine's
79/// `context()` reply (which may itself be `None` if a found entity has
80/// no computable cluster); NotFound and Ambiguous carry the typed
81/// recovery payload after their user-facing body has already printed.
82enum 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/// Resolve context for an id-or-query against the mem-repo engine.
94/// On a successful unique resolution returns `Ok(Some(_))`. On
95/// not-found / ambiguous results, prints the user-facing notice and
96/// returns `Ok(None)` so the caller exits cleanly without invoking
97/// the markdown render path.
98#[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
115/// Mirror of [`resolve_context_mem_repo`] for the filesystem engine.
116fn 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
132/// Build the fallback search scope used when the user-supplied
133/// `id_or_query` doesn't resolve to an exact entity id.
134fn 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
145/// Print the not-found / ambiguous body, then return the typed miss
146/// disposition so the caller can build the recovery envelope. Returns
147/// `None` when the search produced exactly one hit and the caller
148/// should proceed with that hit.
149fn 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}