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 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}