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#[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#[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#[derive(Parser, Debug)]
36pub struct Args {
37 #[arg(long)]
39 pub rebuild: bool,
40
41 #[arg(long)]
43 pub chunk: Option<usize>,
44
45 #[arg(long)]
48 pub mem: Option<String>,
49
50 #[arg(long = "include", value_name = "KEY", value_delimiter = ',')]
59 pub include: Vec<String>,
60
61 #[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 operator_mode: false,
87 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 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 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 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#[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 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_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 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 #[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 #[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}