memstead_cli/commands/overview.rs
1use clap::Parser;
2use serde_json::json;
3
4use memstead_base::chunking::apply_chunking;
5
6use crate::CliError;
7use crate::output::{ExitKind, print_json, print_markdown};
8use crate::setup::{CliContext, CliEngine};
9
10// Lean build: renders the simple in-process cluster overview and defers
11// rich heavy-content to the MCP tool.
12#[cfg(not(feature = "mem-repo"))]
13use memstead_base::{chunking::floor_chunk_budget, render};
14#[cfg(not(feature = "mem-repo"))]
15const DEFAULT_TOKEN_BUDGET: usize = 25_000;
16
17// Full build: routes through the shared engine composer so the CLI
18// renders the same rich content the MCP `memstead_overview` tool emits.
19#[cfg(feature = "mem-repo")]
20use memstead_engine::overview::{
21 ComposeOverviewError, DEFAULT_OVERVIEW_BUDGET, OverviewArgs, Surface, compose_overview,
22};
23#[cfg(feature = "mem-repo")]
24const DEFAULT_CHUNK_BUDGET: usize = 25_000;
25
26/// All clusters with summaries and member lists.
27///
28/// The full build calls the shared composer in `memstead-engine`
29/// (`Surface::Cli`) and renders the same rich content the MCP tool
30/// emits, differing only in inline command-name hints
31/// (`memstead type <ref>` vs `memstead_schema(name=<ref>)`). The lean
32/// build renders the simpler cluster summary in-process and surfaces a
33/// warning when rich `--include` / `--mem` / `--token-budget` flags
34/// are supplied (that content needs the git-backed engine composer).
35#[derive(Parser, Debug)]
36pub struct Args {
37 /// Re-run Louvain community detection before rendering.
38 #[arg(long)]
39 pub rebuild: bool,
40
41 /// 1-based chunk index for large overviews.
42 #[arg(long)]
43 pub chunk: Option<usize>,
44
45 /// Scope schemas + mem inventory to any single visible mem
46 /// (read-only mounts included).
47 #[arg(long)]
48 pub mem: Option<String>,
49
50 /// Opt heavy content into the response: `community_members`,
51 /// `community_bridges`, `mem_distribution`, `dangling_links`.
52 /// Keys listed here are always included even past `token_budget`;
53 /// keys omitted may surface in the `Hints` section instead.
54 /// Repeatable (`--include K --include K`) AND comma-string
55 /// (`--include K1,K2`) forms both parse — uniform with
56 /// `memstead health --include`. Unknown keys emit
57 /// `UNKNOWN_INCLUDE_KEY` warnings.
58 #[arg(long = "include", value_name = "KEY", value_delimiter = ',')]
59 pub include: Vec<String>,
60
61 /// Token budget for heavy content only (`community_members`,
62 /// `community_bridges`, `mem_distribution`, `dangling_links`).
63 /// Hard-required content (mem roster, schema refs, community
64 /// titles, workspace policy) always ships in addition — total
65 /// response size will exceed this budget. Default 8000 (matches
66 /// the MCP tool). Budgets below ~10 tokens are safe but
67 /// unproductive — the response still arrives as a structured
68 /// envelope (`_overview_mode: overbudget`), but no useful
69 /// chunking happens and the full body ships as one chunk.
70 #[arg(long = "token-budget", value_name = "N")]
71 pub token_budget: Option<usize>,
72}
73
74#[cfg(feature = "mem-repo")]
75pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
76 // The full build always activates the mem-repo feature, so both
77 // engine arms are present.
78 let mut engine = match ctx.cli_engine()? {
79 CliEngine::MemRepo(e) => e,
80 CliEngine::Filesystem(e) => e,
81 };
82
83 let composer_args = OverviewArgs {
84 include: &args.include,
85 mem: args.mem.as_deref(),
86 rebuild: args.rebuild && args.chunk.unwrap_or(1) <= 1,
87 token_budget: args.token_budget.unwrap_or(DEFAULT_OVERVIEW_BUDGET),
88 // CLI surface never sees `--operator-mode` — the flag is an
89 // MCP-server boot toggle. CLI callers always see the
90 // agent-mode rendering.
91 operator_mode: false,
92 // The full CLI carries the mem-lifecycle commands, so the section is
93 // truthful here.
94 suppress_lifecycle: false,
95 };
96
97 let out = match compose_overview(&mut engine, composer_args, Surface::Cli) {
98 Ok(o) => o,
99 Err(ComposeOverviewError::InvalidIncludeKeySchemaTypes) => {
100 return Err(CliError {
101 code: "INVALID_INPUT",
102 kind: ExitKind::Validation,
103 message:
104 "include key 'schema_types' was removed; run `memstead type <name>` for full schema bodies."
105 .to_string(),
106 details: None,
107 }
108 .into());
109 }
110 Err(ComposeOverviewError::UnknownMem {
111 name,
112 writable_mems,
113 }) => {
114 return Err(CliError {
115 code: "UNKNOWN_MEM",
116 kind: ExitKind::NotFound,
117 message: format!(
118 "unknown mem: \"{name}\". Writable mems: [{}]",
119 writable_mems.join(", ")
120 ),
121 details: Some(json!({
122 "name": name,
123 "writable_mems": writable_mems,
124 })),
125 }
126 .into());
127 }
128 };
129
130 // Apply chunking at the CLI transport budget. The composer's
131 // `extra_frontmatter` rolls into every chunk's head so an agent
132 // streaming chunks always sees the same anchors.
133 let extra_fm: Vec<(&str, &str)> = out
134 .extra_frontmatter
135 .iter()
136 .map(|(k, v)| (k.as_str(), v.as_str()))
137 .collect();
138 let chunked = apply_chunking(
139 &out.markdown,
140 // Floor the chunk size: `--token-budget` is a content budget
141 // (it shrinks what the composer includes); reusing a tiny value
142 // as the transport chunk size would fragment the always-shipped
143 // hard-required body. The floor keeps small overviews to one chunk.
144 memstead_base::chunking::floor_chunk_budget(
145 args.token_budget.unwrap_or(DEFAULT_CHUNK_BUDGET),
146 ),
147 args.chunk,
148 &extra_fm,
149 )
150 .map_err(|e| CliError::new(ExitKind::Generic, "CHUNK_OUT_OF_RANGE", e))?;
151
152 if ctx.json {
153 let warnings_json: Vec<_> = out
154 .warnings
155 .iter()
156 .map(|w| {
157 json!({
158 "code": w.code(),
159 "message": w.message(),
160 })
161 })
162 .collect();
163 // Promote `overview_mode`, `total_chunks`, and `hints` to structured
164 // envelope siblings so a programmatic consumer branches on the
165 // mode and fetches the next chunk without parsing them out of the
166 // `markdown` string. Additive — `markdown` is unchanged and still
167 // carries the same frontmatter for the human-rendered view.
168 // `total_chunks` reads the value `apply_chunking` injects into the
169 // chunk frontmatter (the CLI parses it once so the consumer
170 // doesn't have to).
171 let total_chunks = parse_total_chunks(&chunked);
172 let body = json!({
173 "markdown": chunked,
174 "cluster_count": out.cluster_count,
175 "overview_mode": out.overview_mode,
176 "total_chunks": total_chunks,
177 "hints": out.hints,
178 "warnings": warnings_json,
179 });
180 print_json(&body)?;
181 } else {
182 print_markdown(&chunked);
183 }
184 Ok(())
185}
186
187/// Read the `_total_chunks: N` value `apply_chunking` always injects
188/// into the chunk's frontmatter. Defaults to 1 — `apply_chunking`
189/// guarantees the marker, but a malformed head degrades to the
190/// single-chunk reading rather than failing the command.
191#[cfg(feature = "mem-repo")]
192fn parse_total_chunks(chunked: &str) -> usize {
193 chunked
194 .lines()
195 .find_map(|l| l.strip_prefix("_total_chunks: "))
196 .and_then(|v| v.trim().parse::<usize>().ok())
197 .unwrap_or(1)
198}
199
200#[cfg(not(feature = "mem-repo"))]
201pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
202 // `--include` parses uniformly with `memstead health --include` — both repeatable and
203 // comma-string shapes accept. Validate keys against the engine's
204 // shared `OVERVIEW_INCLUDE_KEYS` allowlist and emit
205 // `UNKNOWN_INCLUDE_KEY` warnings (same pattern the MCP tool ships).
206 // Rich-content rendering on the lean build is deferred — it always
207 // lists per-cluster members (the `community_members` content) but
208 // `community_bridges`, `mem_distribution`, `dangling_links` need
209 // the shared engine composer, which is absent without the
210 // git-branch backend.
211 let mut include_warnings: Vec<(String, &'static [&'static str])> = Vec::new();
212 for key in &args.include {
213 if !memstead_base::ops::OVERVIEW_INCLUDE_KEYS.contains(&key.as_str()) {
214 include_warnings.push((key.clone(), memstead_base::ops::OVERVIEW_INCLUDE_KEYS));
215 }
216 }
217
218 let mut engine = match ctx.cli_engine()? {
219 CliEngine::Filesystem(e) => e,
220 };
221 if args.rebuild && args.chunk.unwrap_or(1) <= 1 {
222 engine.invalidate_communities();
223 }
224 let output = engine.communities();
225 let cluster_count = output.count;
226 let modularity = output.modularity;
227 let md = render::render_overview_markdown(output, engine.store());
228 let cluster_count_str = cluster_count.to_string();
229 let chunked = apply_chunking(
230 &md,
231 // Floor the chunk size — `--token-budget` is a content budget,
232 // not a transport chunk size; a tiny value must not fragment the
233 // always-shipped body. Small overviews stay one chunk.
234 floor_chunk_budget(args.token_budget.unwrap_or(DEFAULT_TOKEN_BUDGET)),
235 args.chunk,
236 &[("_cluster_count", cluster_count_str.as_str())],
237 )
238 .map_err(|e| CliError::new(ExitKind::Generic, "CHUNK_OUT_OF_RANGE", e))?;
239
240 // Surface a typed warning when the richer flags were supplied —
241 // keeps the parsing-uniformity acceptance without silently
242 // dropping the caller's intent. The lean build's overview renders
243 // the simple cluster summary only; the rich heavy-content
244 // composer lives in `memstead-engine` (reached by the full
245 // `memstead overview` and the MCP `memstead_overview` tool).
246 let pro_only_warning = (!args.include.is_empty()
247 || args.token_budget.is_some()
248 || args.mem.is_some())
249 .then(|| {
250 (
251 "OVERVIEW_RICH_CONTENT_PRO_ONLY",
252 "the lean build renders the simple cluster overview only — rich content (`--mem` scoping, `--include community_bridges` / `mem_distribution` / `dangling_links`, non-default `--token-budget`) requires the full `memstead` build or the `memstead_overview` MCP tool".to_string(),
253 )
254 });
255
256 if ctx.json {
257 let mut warnings_json: Vec<_> = include_warnings
258 .into_iter()
259 .map(|(key, allowed)| {
260 json!({
261 "code": "UNKNOWN_INCLUDE_KEY",
262 "key": key,
263 "allowed": allowed,
264 })
265 })
266 .collect();
267 if let Some((code, message)) = pro_only_warning.as_ref() {
268 warnings_json.push(json!({
269 "code": code,
270 "message": message,
271 }));
272 }
273 let body = json!({
274 "markdown": chunked,
275 "cluster_count": cluster_count,
276 "modularity": modularity,
277 "warnings": warnings_json,
278 });
279 print_json(&body)?;
280 } else {
281 let mut out = chunked;
282 for (key, allowed) in &include_warnings {
283 out.push_str(&format!(
284 "\n\n_WARNING [UNKNOWN_INCLUDE_KEY]: `{key}` — allowed: {:?}_",
285 allowed,
286 ));
287 }
288 if let Some((code, message)) = pro_only_warning.as_ref() {
289 out.push_str(&format!("\n\n_WARNING [{code}]: {message}._"));
290 }
291 print_markdown(&out);
292 }
293 Ok(())
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use clap::{CommandFactory, Parser};
300
301 /// `--include` accepts both repeatable and comma-string shapes.
302 /// Verifies clap parsing produces the same `Vec<String>` regardless
303 /// of which form the caller used.
304 #[test]
305 fn include_accepts_repeated_and_comma_split_forms() {
306 let repeated = Args::try_parse_from([
307 "overview",
308 "--include",
309 "community_members",
310 "--include",
311 "mem_distribution",
312 ])
313 .expect("repeated form parses");
314 assert_eq!(
315 repeated.include,
316 vec!["community_members", "mem_distribution"],
317 );
318
319 let comma = Args::try_parse_from([
320 "overview",
321 "--include",
322 "community_members,mem_distribution",
323 ])
324 .expect("comma form parses");
325 assert_eq!(comma.include, vec!["community_members", "mem_distribution"],);
326 }
327
328 /// The `--include` help text names every known overview include
329 /// key — mirrors the `health` surface's `help_lists_every_include_key`
330 /// test. The full build locks against the engine composer's
331 /// allowlist; the lean build against `memstead-base`'s constant.
332 #[test]
333 fn help_lists_every_overview_include_key() {
334 #[cfg(feature = "mem-repo")]
335 let keys: &[&str] = memstead_engine::overview::ALLOWED_OVERVIEW_INCLUDE_KEYS;
336 #[cfg(not(feature = "mem-repo"))]
337 let keys: &[&str] = memstead_base::ops::OVERVIEW_INCLUDE_KEYS;
338
339 let cmd = Args::command();
340 let arg = cmd
341 .get_arguments()
342 .find(|a| a.get_id() == "include")
343 .expect("--include arg must exist");
344 let help = arg
345 .get_help()
346 .expect("--include must have help text")
347 .to_string();
348 for key in keys {
349 assert!(
350 help.contains(key),
351 "`memstead overview --help` must name include key `{key}` (got: {help})"
352 );
353 }
354 }
355}