Skip to main content

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