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;
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    let mut engine = ctx.cli_engine()?.into_base();
77
78    let composer_args = OverviewArgs {
79        include: &args.include,
80        mem: args.mem.as_deref(),
81        rebuild: args.rebuild && args.chunk.unwrap_or(1) <= 1,
82        token_budget: args.token_budget.unwrap_or(DEFAULT_OVERVIEW_BUDGET),
83        // CLI surface never sees `--operator-mode` — the flag is an
84        // MCP-server boot toggle. CLI callers always see the
85        // agent-mode rendering.
86        operator_mode: false,
87        // The full CLI carries the mem-lifecycle commands, so the section is
88        // truthful here.
89        suppress_lifecycle: false,
90    };
91
92    let out = match compose_overview(&mut engine, composer_args, Surface::Cli) {
93        Ok(o) => o,
94        Err(ComposeOverviewError::InvalidIncludeKeySchemaTypes) => {
95            return Err(CliError {
96                code: "INVALID_INPUT",
97                kind: ExitKind::Validation,
98                message:
99                    "include key 'schema_types' was removed; run `memstead type <name>` for full schema bodies."
100                        .to_string(),
101                details: None,
102            }
103            .into());
104        }
105        Err(ComposeOverviewError::MemQuarantined(name)) => {
106            return Err(crate::CliError::from_engine_op(engine.unknown_mem_error(&name)).into());
107        }
108        Err(ComposeOverviewError::UnknownMem {
109            name,
110            writable_mems,
111        }) => {
112            return Err(CliError {
113                code: "UNKNOWN_MEM",
114                kind: ExitKind::NotFound,
115                message: format!(
116                    "unknown mem: \"{name}\". Writable mems: [{}]",
117                    writable_mems.join(", ")
118                ),
119                details: Some(json!({
120                    "name": name,
121                    "writable_mems": writable_mems,
122                })),
123            }
124            .into());
125        }
126    };
127
128    // Apply chunking at the CLI transport budget. The composer's
129    // `extra_frontmatter` rolls into every chunk's head so an agent
130    // streaming chunks always sees the same anchors.
131    let extra_fm: Vec<(&str, &str)> = out
132        .extra_frontmatter
133        .iter()
134        .map(|(k, v)| (k.as_str(), v.as_str()))
135        .collect();
136    let chunked = apply_chunking(
137        &out.markdown,
138        // Floor the chunk size: `--token-budget` is a content budget
139        // (it shrinks what the composer includes); reusing a tiny value
140        // as the transport chunk size would fragment the always-shipped
141        // hard-required body. The floor keeps small overviews to one chunk.
142        memstead_base::chunking::floor_chunk_budget(
143            args.token_budget.unwrap_or(DEFAULT_CHUNK_BUDGET),
144        ),
145        args.chunk,
146        &extra_fm,
147    )
148    .map_err(|e| CliError::new(ExitKind::Generic, "CHUNK_OUT_OF_RANGE", e))?;
149
150    if ctx.json {
151        let warnings_json: Vec<_> = out
152            .warnings
153            .iter()
154            .map(|w| {
155                json!({
156                    "code": w.code(),
157                    "message": w.message(),
158                })
159            })
160            .collect();
161        // Promote `overview_mode`, `total_chunks`, and `hints` to structured
162        // envelope siblings so a programmatic consumer branches on the
163        // mode and fetches the next chunk without parsing them out of the
164        // `markdown` string. Additive — `markdown` is unchanged and still
165        // carries the same frontmatter for the human-rendered view.
166        // `total_chunks` reads the value `apply_chunking` injects into the
167        // chunk frontmatter (the CLI parses it once so the consumer
168        // doesn't have to).
169        let total_chunks = parse_total_chunks(&chunked);
170        let body = json!({
171            "markdown": chunked,
172            "cluster_count": out.cluster_count,
173            "overview_mode": out.overview_mode,
174            "total_chunks": total_chunks,
175            "hints": out.hints,
176            "warnings": warnings_json,
177        });
178        print_json(&body)?;
179    } else {
180        print_markdown(&chunked);
181    }
182    Ok(())
183}
184
185/// Read the `_total_chunks: N` value `apply_chunking` always injects
186/// into the chunk's frontmatter. Defaults to 1 — `apply_chunking`
187/// guarantees the marker, but a malformed head degrades to the
188/// single-chunk reading rather than failing the command.
189#[cfg(feature = "mem-repo")]
190fn parse_total_chunks(chunked: &str) -> usize {
191    chunked
192        .lines()
193        .find_map(|l| l.strip_prefix("_total_chunks: "))
194        .and_then(|v| v.trim().parse::<usize>().ok())
195        .unwrap_or(1)
196}
197
198#[cfg(not(feature = "mem-repo"))]
199pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
200    // `--include` parses uniformly with `memstead health --include` — both repeatable and
201    // comma-string shapes accept. Validate keys against the engine's
202    // shared `OVERVIEW_INCLUDE_KEYS` allowlist and emit
203    // `UNKNOWN_INCLUDE_KEY` warnings (same pattern the MCP tool ships).
204    // Rich-content rendering on the lean build is deferred — it always
205    // lists per-cluster members (the `community_members` content) but
206    // `community_bridges`, `mem_distribution`, `dangling_links` need
207    // the shared engine composer, which is absent without the
208    // git-branch backend.
209    let mut include_warnings: Vec<(String, &'static [&'static str])> = Vec::new();
210    for key in &args.include {
211        if !memstead_base::ops::OVERVIEW_INCLUDE_KEYS.contains(&key.as_str()) {
212            include_warnings.push((key.clone(), memstead_base::ops::OVERVIEW_INCLUDE_KEYS));
213        }
214    }
215
216    let mut engine = ctx.cli_engine()?.into_base();
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 full_only_warning = (!args.include.is_empty()
243        || args.token_budget.is_some()
244        || args.mem.is_some())
245        .then(|| {
246            (
247                "OVERVIEW_RICH_CONTENT_FULL_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)) = full_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)) = full_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}