Skip to main content

memstead_cli/commands/
health.rs

1use clap::Parser;
2use serde_json::json;
3
4use memstead_base::EntityId;
5use memstead_base::Store;
6use memstead_base::ops::{
7    DanglingLink, HealthSummary, health::HEALTH_INCLUDE_KEYS, health::MissingRequiredOutgoingReport,
8};
9
10use crate::output::{ExitKind, print_json, print_markdown};
11use crate::setup::{CliContext, CliEngine};
12
13/// Graph health summary.
14///
15/// Default: counts only. Pass `--include` to drill into details.
16#[derive(Parser, Debug)]
17pub struct Args {
18    /// Opt heavy content into the response: orphans, stubs,
19    /// most_connected, missing_fields, stale, dangling_links, tags,
20    /// missing_required_outgoing, conformance, integrity. `conformance`
21    /// lints every entity against the effective schema into a
22    /// `findings` array (write-time typed codes); `integrity` adds the
23    /// consistency axis (dangling links, stubs) to the same list.
24    /// Repeatable (`--include K --include K`)
25    /// AND comma-string (`--include K1,K2`) forms both parse — uniform
26    /// with `memstead overview --include`.
27    #[arg(long, value_delimiter = ',')]
28    pub include: Vec<String>,
29
30    /// Schema ref (`name@x.y.z`) the conformance/integrity includes
31    /// lint against instead of each mem's current pin.
32    #[arg(long)]
33    pub target_schema: Option<String>,
34
35    /// Max rows for `most_connected` and `tag_distribution` (default: 10).
36    #[arg(long, default_value_t = 10)]
37    pub limit: usize,
38
39    /// Exit non-zero (1) when any included Tier-2 warning kind has
40    /// present violations. The output is rendered first, then the
41    /// non-zero exit fires. Today only `missing_required_outgoing`
42    /// participates; new Tier-2 codes opt in additively without
43    /// breaking the flag's semantics. With no Tier-2 `--include`
44    /// token, `--strict` is a no-op.
45    #[arg(long)]
46    pub strict: bool,
47}
48
49pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
50    let include = &args.include;
51    // Tier-2 violation tally, populated as the corresponding `--include`
52    // tokens are processed. Consulted at the end when `--strict` is set
53    // to decide between exit 0 and exit 1. Per-code so a future
54    // expansion (e.g. `cardinality_violations`) can list which codes
55    // tripped without re-walking the report JSON.
56    let mut strict_violations: Vec<(&'static str, usize)> = Vec::new();
57
58    // Validate include-keys against the shared catalogue. Unknown keys
59    // emit `UNKNOWN_INCLUDE_KEY` warnings the operator sees in both
60    // markdown and JSON output — matches the MCP sibling's behaviour
61    // and gives a typo zero-feedback path a typed signal instead.
62    let mut include_warnings: Vec<(String, Vec<String>)> = Vec::new();
63    for key in include {
64        if !HEALTH_INCLUDE_KEYS.contains(&key.as_str()) {
65            include_warnings.push((
66                key.clone(),
67                HEALTH_INCLUDE_KEYS.iter().map(|s| s.to_string()).collect(),
68            ));
69        }
70    }
71
72    let GatheredHealth {
73        health,
74        real_count,
75        orphan_ids,
76        stub_pairs,
77        community_count,
78        orphans_by_schema,
79        communities_by_schema,
80        most_connected_with_titles,
81        missing_required_outgoing,
82        tag_distribution,
83        dangling_links,
84        findings,
85    } = match ctx.cli_engine()? {
86        #[cfg(feature = "mem-repo")]
87        CliEngine::MemRepo(mut engine) => {
88            let mut g = gather_mem_repo(&mut engine, args.limit, include);
89            g.findings = gather_findings(&engine, include, args.target_schema.as_deref())?;
90            g
91        }
92        CliEngine::Filesystem(mut engine) => {
93            let mut g = gather_filesystem(&mut engine, args.limit, include);
94            g.findings = gather_findings(&engine, include, args.target_schema.as_deref())?;
95            g
96        }
97    };
98
99    let mut result = json!({
100        "summary": {
101            "total_entities": real_count,
102            "total_orphans": orphan_ids.len(),
103            "total_stubs": stub_pairs.len(),
104            "total_stale": health.stale_entities.len(),
105            "total_missing_fields": health.missing_fields.len(),
106            "total_communities": community_count,
107            "orphans_by_schema": orphans_by_schema,
108            "communities_by_schema": communities_by_schema,
109        },
110    });
111    let obj = result.as_object_mut().unwrap();
112
113    if include.iter().any(|s| s == "orphans") {
114        let list: Vec<_> = orphan_ids
115            .iter()
116            .map(|(id, title)| json!({ "id": id.to_string(), "title": title }))
117            .collect();
118        obj.insert("orphans".into(), json!(list));
119    }
120    if include.iter().any(|s| s == "stubs") {
121        let list: Vec<_> = stub_pairs
122            .iter()
123            .map(|(id, refs)| {
124                json!({
125                    "id": id.to_string(),
126                    "referenced_by": refs.iter().map(|r| r.to_string()).collect::<Vec<_>>(),
127                })
128            })
129            .collect();
130        obj.insert("stubs".into(), json!(list));
131    }
132    if include.iter().any(|s| s == "most_connected") {
133        let connected: Vec<_> = most_connected_with_titles
134            .iter()
135            .map(
136                |(
137                    id,
138                    title,
139                    total,
140                    incoming,
141                    outgoing,
142                    typed_total,
143                    typed_incoming,
144                    typed_outgoing,
145                )| {
146                    json!({
147                        "id": id.to_string(),
148                        "title": title,
149                        "total": total,
150                        "incoming": incoming,
151                        "outgoing": outgoing,
152                        "typed_total": typed_total,
153                        "typed_incoming": typed_incoming,
154                        "typed_outgoing": typed_outgoing,
155                    })
156                },
157            )
158            .collect();
159        obj.insert("most_connected".into(), json!(connected));
160    }
161    if include.iter().any(|s| s == "missing_fields") {
162        let list: Vec<_> = health
163            .missing_fields
164            .iter()
165            .map(|h| {
166                let missing: Vec<&str> = h.issues.iter().map(|i| i.field.as_str()).collect();
167                json!({ "id": h.id.to_string(), "title": h.title, "missing": missing })
168            })
169            .collect();
170        obj.insert("missing_fields".into(), json!(list));
171    }
172    if include.iter().any(|s| s == "stale") {
173        let list: Vec<_> = health
174            .stale_entities
175            .iter()
176            .map(|e| {
177                json!({
178                    "id": e.id.to_string(),
179                    "title": e.title,
180                    "days_since_modified": e.days_since_modified,
181                })
182            })
183            .collect();
184        obj.insert("stale".into(), json!(list));
185    }
186    if include.iter().any(|s| s == "missing_required_outgoing") {
187        if !missing_required_outgoing.is_empty() {
188            strict_violations.push(("missing_required_outgoing", missing_required_outgoing.len()));
189        }
190        obj.insert(
191            "missing_required_outgoing".into(),
192            serde_json::to_value(&missing_required_outgoing)?,
193        );
194    }
195    if include.iter().any(|s| s == "dangling_links") {
196        let arr: Vec<serde_json::Value> = dangling_links
197            .iter()
198            .map(|dl| serde_json::to_value(dl).unwrap_or(serde_json::Value::Null))
199            .collect();
200        obj.insert("dangling_links".into(), json!(arr));
201    }
202    if include
203        .iter()
204        .any(|s| s == "conformance" || s == "integrity")
205    {
206        obj.insert("findings".into(), serde_json::to_value(&findings)?);
207    }
208    if include.iter().any(|s| s == "tags")
209        && let Some((distribution, folded, untagged)) = tag_distribution
210    {
211        obj.insert("tag_distribution".into(), distribution);
212        obj.insert("tag_distribution_folded".into(), folded);
213        obj.insert("untagged_entities".into(), untagged);
214    }
215
216    // Typed warnings array — agents see `UNKNOWN_INCLUDE_KEY` here in
217    // the same shape MCP emits on `warnings[]`. Empty when every key
218    // resolved.
219    if !include_warnings.is_empty() {
220        let warning_payload: Vec<serde_json::Value> = include_warnings
221            .iter()
222            .map(|(key, allowed)| {
223                json!({
224                    "code": "UNKNOWN_INCLUDE_KEY",
225                    "message": format!(
226                        "unknown include key: \"{key}\". Allowed: {}",
227                        allowed.join(", ")
228                    ),
229                    "details": { "key": key, "allowed": allowed },
230                })
231            })
232            .collect();
233        obj.insert("warnings".into(), json!(warning_payload));
234    }
235
236    if ctx.json {
237        print_json(&result)?;
238        return strict_exit(args.strict, &strict_violations);
239    }
240
241    // Markdown rendering
242    let mut lines = Vec::new();
243    lines.push("# Graph health".to_string());
244    lines.push(String::new());
245    lines.push(format!("- Entities: {real_count}"));
246    if orphans_by_schema.len() > 1 {
247        // Attribute the orphan headline per schema so by-design isolates
248        // (ingest mems) aren't read as uniform debt.
249        let by: Vec<String> = orphans_by_schema
250            .iter()
251            .map(|(s, n)| format!("{}: {n}", if s.is_empty() { "(unpinned)" } else { s }))
252            .collect();
253        lines.push(format!(
254            "- Orphans: {} ({})",
255            orphan_ids.len(),
256            by.join(", ")
257        ));
258    } else {
259        lines.push(format!("- Orphans: {}", orphan_ids.len()));
260    }
261    lines.push(format!("- Stubs: {}", stub_pairs.len()));
262    lines.push(format!("- Stale: {}", health.stale_entities.len()));
263    lines.push(format!("- Missing fields: {}", health.missing_fields.len()));
264    lines.push(format!("- Communities: {community_count}"));
265    lines.push(String::new());
266
267    if let Some(v) = obj.get("orphans").and_then(|v| v.as_array()) {
268        lines.push("## Orphans".to_string());
269        for item in v {
270            lines.push(format!(
271                "- {} — {}",
272                item["id"].as_str().unwrap_or(""),
273                item["title"].as_str().unwrap_or("")
274            ));
275        }
276        lines.push(String::new());
277    }
278    if let Some(v) = obj.get("stubs").and_then(|v| v.as_array()) {
279        lines.push("## Stubs".to_string());
280        for item in v {
281            lines.push(format!("- {}", item["id"].as_str().unwrap_or("")));
282        }
283        lines.push(String::new());
284    }
285    if let Some(v) = obj.get("most_connected").and_then(|v| v.as_array()) {
286        lines.push("## Most connected".to_string());
287        lines.push("(ranked by typed dependency degree; total keeps mention edges)".to_string());
288        for item in v {
289            lines.push(format!(
290                "- {} — {} (typed {}, total {}, in {}, out {})",
291                item["id"].as_str().unwrap_or(""),
292                item["title"].as_str().unwrap_or(""),
293                item["typed_total"].as_u64().unwrap_or(0),
294                item["total"].as_u64().unwrap_or(0),
295                item["incoming"].as_u64().unwrap_or(0),
296                item["outgoing"].as_u64().unwrap_or(0),
297            ));
298        }
299        lines.push(String::new());
300    }
301    if let Some(v) = obj.get("missing_fields").and_then(|v| v.as_array()) {
302        lines.push("## Missing fields".to_string());
303        for item in v {
304            let missing: Vec<&str> = item["missing"]
305                .as_array()
306                .map(|a| a.iter().filter_map(|s| s.as_str()).collect())
307                .unwrap_or_default();
308            lines.push(format!(
309                "- {} — {} (missing: {})",
310                item["id"].as_str().unwrap_or(""),
311                item["title"].as_str().unwrap_or(""),
312                missing.join(", ")
313            ));
314        }
315        lines.push(String::new());
316    }
317    if let Some(v) = obj.get("stale").and_then(|v| v.as_array()) {
318        lines.push("## Stale entities".to_string());
319        for item in v {
320            lines.push(format!(
321                "- {} — {} ({} days)",
322                item["id"].as_str().unwrap_or(""),
323                item["title"].as_str().unwrap_or(""),
324                item["days_since_modified"].as_u64().unwrap_or(0)
325            ));
326        }
327        lines.push(String::new());
328    }
329    if let Some(v) = obj
330        .get("missing_required_outgoing")
331        .and_then(|v| v.as_array())
332    {
333        lines.push("## Missing required outgoing".to_string());
334        for item in v {
335            let blocks: Vec<String> = item["missing"]
336                .as_array()
337                .map(|arr| {
338                    arr.iter()
339                        .map(|b| {
340                            let rels: Vec<&str> = b["relationships"]
341                                .as_array()
342                                .map(|a| a.iter().filter_map(|s| s.as_str()).collect())
343                                .unwrap_or_default();
344                            format!(
345                                "[{}] {}",
346                                rels.join(", "),
347                                b["cardinality"].as_str().unwrap_or("")
348                            )
349                        })
350                        .collect()
351                })
352                .unwrap_or_default();
353            lines.push(format!(
354                "- {} — {} (missing: {})",
355                item["id"].as_str().unwrap_or(""),
356                item["title"].as_str().unwrap_or(""),
357                blocks.join("; ")
358            ));
359        }
360        lines.push(String::new());
361    }
362    if let Some(v) = obj.get("dangling_links").and_then(|v| v.as_array()) {
363        lines.push("## Dangling links".to_string());
364        for item in v {
365            lines.push(format!(
366                "- {} → {} (section: {})",
367                item["from"].as_str().unwrap_or(""),
368                item["target_id"].as_str().unwrap_or(""),
369                item["section"].as_str().unwrap_or("(none)")
370            ));
371        }
372        lines.push(String::new());
373    }
374    if let Some(v) = obj.get("tag_distribution").and_then(|v| v.as_array()) {
375        lines.push("## Tags".to_string());
376        for item in v {
377            lines.push(format!(
378                "- {} ({})",
379                item["tag"].as_str().unwrap_or(""),
380                item["count"].as_u64().unwrap_or(0)
381            ));
382        }
383        lines.push(String::new());
384    }
385    if let Some(v) = obj.get("warnings").and_then(|v| v.as_array()) {
386        lines.push("## Warnings".to_string());
387        for w in v {
388            lines.push(format!(
389                "- {} — {}",
390                w["code"].as_str().unwrap_or(""),
391                w["message"].as_str().unwrap_or("")
392            ));
393        }
394        lines.push(String::new());
395    }
396    if let Some(u) = obj.get("untagged_entities") {
397        lines.push("## Untagged".to_string());
398        lines.push(format!("- Total: {}", u["total"].as_u64().unwrap_or(0)));
399        if let Some(by_type) = u["by_entity_type"].as_object() {
400            let mut entries: Vec<(&String, u64)> = by_type
401                .iter()
402                .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
403                .collect();
404            entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
405            for (kind, count) in entries {
406                lines.push(format!("  - {kind}: {count}"));
407            }
408        }
409        lines.push(String::new());
410    }
411
412    print_markdown(&lines.join("\n"));
413    strict_exit(args.strict, &strict_violations)
414}
415
416/// Aggregated health data, engine-flavour-agnostic. Both
417/// One `most_connected` row resolved at gather time:
418/// `(id, title, total, incoming, outgoing, typed_total, typed_incoming,
419/// typed_outgoing)`. `typed_*` excludes auto-emitted mention edges so the
420/// ranking reflects dependency, not co-mention.
421type MostConnectedRow = (EntityId, String, usize, usize, usize, usize, usize, usize);
422
423/// mem-repo and filesystem gather paths populate this struct
424/// with the same shape so the rendering / JSON-envelope code below
425/// runs once.
426struct GatheredHealth {
427    health: HealthSummary,
428    /// Integrity findings (`{id, axis, code, detail}`) — populated by
429    /// the caller (engine-shaped, so outside `gather_from_store`) when
430    /// `--include conformance` / `--include integrity` is requested.
431    findings: Vec<memstead_base::ops::integrity::IntegrityFinding>,
432    real_count: usize,
433    /// `(id, title)` pairs — title resolved at gather time so the
434    /// rendering layer doesn't need to keep the engine alive.
435    orphan_ids: Vec<(EntityId, String)>,
436    stub_pairs: Vec<(EntityId, Vec<EntityId>)>,
437    community_count: usize,
438    /// #49: orphan/community counts attributed per pinned schema, so a
439    /// blended headline isn't read as uniform debt (ingest-mem isolates
440    /// are orphans by design; code-mem orphans are debt). Filled by the
441    /// engine-aware gather wrappers — `gather_from_store` leaves them empty.
442    orphans_by_schema: std::collections::BTreeMap<String, usize>,
443    communities_by_schema: std::collections::BTreeMap<String, usize>,
444    /// [`MostConnectedRow`] tuples — same reasoning as `orphan_ids`.
445    most_connected_with_titles: Vec<MostConnectedRow>,
446    missing_required_outgoing: Vec<MissingRequiredOutgoingReport>,
447    /// `Some(...)` when the caller asked for `--include tags`,
448    /// `None` otherwise. The triple is `(distribution, folded,
449    /// untagged)` mirroring `collect_tag_distribution`'s return
450    /// shape.
451    /// Pre-serialised tag triple: `(distribution, folded, untagged)`
452    /// already converted to `serde_json::Value`. Keeps the gather
453    /// step engine-flavour-agnostic without exposing the
454    /// `memstead_base::ops::health` private tag types through this
455    /// crate's public surface.
456    tag_distribution: Option<(serde_json::Value, serde_json::Value, serde_json::Value)>,
457    /// Populated when `--include dangling_links` is set; empty
458    /// otherwise. Matches the MCP `memstead_health` tool's response
459    /// shape — `{from, target_id, target_path, section}` per entry.
460    dangling_links: Vec<DanglingLink>,
461}
462
463/// Conformance/integrity findings across every mounted mem, in
464/// sorted mem order. Engine-shaped (needs schema resolution), so it
465/// runs beside `gather_from_store`, not inside it. `target_schema`
466/// parse and resolution failures surface as typed CLI errors — the
467/// same codes the MCP surface refuses with.
468fn gather_findings(
469    engine: &memstead_base::Engine,
470    include: &[String],
471    target_schema: Option<&str>,
472) -> anyhow::Result<Vec<memstead_base::ops::integrity::IntegrityFinding>> {
473    let wants_conformance = include
474        .iter()
475        .any(|s| s == "conformance" || s == "integrity");
476    if !wants_conformance {
477        return Ok(Vec::new());
478    }
479    let target: Option<memstead_schema::SchemaRef> = match target_schema {
480        None => None,
481        Some(raw) => Some(
482            raw.parse::<memstead_schema::SchemaRef>()
483                .map_err(|reason| anyhow::anyhow!("invalid --target-schema {raw:?}: {reason}"))?,
484        ),
485    };
486    let mut mems: Vec<String> = engine.schemas().keys().cloned().collect();
487    mems.sort();
488    let mut findings = Vec::new();
489    for v in &mems {
490        findings.extend(
491            engine
492                .conformance_findings(v, target.as_ref())
493                .map_err(crate::CliError::from_engine_op)?,
494        );
495        if include.iter().any(|s| s == "integrity") {
496            findings.extend(
497                engine
498                    .consistency_findings(v)
499                    .map_err(crate::CliError::from_engine_op)?,
500            );
501        }
502    }
503    Ok(findings)
504}
505
506#[cfg(feature = "mem-repo")]
507fn gather_mem_repo(
508    engine: &mut memstead_base::Engine,
509    limit: usize,
510    include: &[String],
511) -> GatheredHealth {
512    let mut g = gather_from_store(
513        engine.health(),
514        engine.store(),
515        engine.communities().count,
516        limit,
517        include,
518        |limit| engine_most_connected_mem_repo(engine, limit),
519        || engine.missing_required_outgoing(None),
520    );
521    fill_schema_breakdowns(engine, &mut g);
522    g
523}
524
525fn gather_filesystem(
526    engine: &mut memstead_base::Engine,
527    limit: usize,
528    include: &[String],
529) -> GatheredHealth {
530    let mut g = gather_from_store(
531        engine.health(),
532        engine.store(),
533        engine.communities().count,
534        limit,
535        include,
536        |limit| engine_most_connected_filesystem(engine, limit),
537        || engine.missing_required_outgoing(None),
538    );
539    fill_schema_breakdowns(engine, &mut g);
540    g
541}
542
543/// #49: attribute the orphan / community headlines per pinned schema (the
544/// engine-aware step `gather_from_store` can't do off a bare `&Store`).
545fn fill_schema_breakdowns(engine: &memstead_base::Engine, g: &mut GatheredHealth) {
546    let mems: Vec<String> = engine.mounts().iter().map(|m| m.mem.clone()).collect();
547    g.orphans_by_schema = engine.orphans_by_schema(&engine.orphans());
548    g.communities_by_schema = engine.communities_by_schema(&mems);
549}
550
551/// Engine-agnostic gather pipeline. The two engine-shaped callbacks
552/// (`most_connected_fn`, `missing_required_outgoing_fn`) handle the
553/// surfaces that are not available off the bare `&Store`.
554fn gather_from_store(
555    health: HealthSummary,
556    store: &Store,
557    community_count: usize,
558    limit: usize,
559    include: &[String],
560    most_connected_fn: impl FnOnce(usize) -> Vec<MostConnectedRow>,
561    missing_required_outgoing_fn: impl FnOnce() -> Vec<MissingRequiredOutgoingReport>,
562) -> GatheredHealth {
563    let real_count = store.all_entities().filter(|e| !e.stub).count();
564    let orphan_ids: Vec<(EntityId, String)> = memstead_base::graph::query::find_orphans(store)
565        .into_iter()
566        .map(|id| {
567            let title = store.get(&id).map(|e| e.title.clone()).unwrap_or_default();
568            (id, title)
569        })
570        .collect();
571    let stub_pairs = memstead_base::graph::query::find_stubs(store);
572    let most_connected_with_titles = if include.iter().any(|s| s == "most_connected") {
573        most_connected_fn(limit)
574    } else {
575        Vec::new()
576    };
577    let missing_required_outgoing = if include.iter().any(|s| s == "missing_required_outgoing") {
578        missing_required_outgoing_fn()
579    } else {
580        Vec::new()
581    };
582    let tag_distribution = if include.iter().any(|s| s == "tags") {
583        let (distribution, folded, untagged) =
584            memstead_base::ops::health::collect_tag_distribution(store, None, limit);
585        Some((
586            serde_json::to_value(&distribution).unwrap_or(serde_json::Value::Null),
587            serde_json::to_value(&folded).unwrap_or(serde_json::Value::Null),
588            serde_json::to_value(&untagged).unwrap_or(serde_json::Value::Null),
589        ))
590    } else {
591        None
592    };
593    let dangling_links = if include.iter().any(|s| s == "dangling_links") {
594        memstead_base::ops::health::collect_dangling_links(store, None)
595    } else {
596        Vec::new()
597    };
598    GatheredHealth {
599        health,
600        findings: Vec::new(),
601        real_count,
602        orphan_ids,
603        stub_pairs,
604        community_count,
605        // Engine-agnostic path can't resolve schema pins; the engine-aware
606        // wrappers (`gather_mem_repo` / `gather_filesystem`) fill these.
607        orphans_by_schema: std::collections::BTreeMap::new(),
608        communities_by_schema: std::collections::BTreeMap::new(),
609        most_connected_with_titles,
610        missing_required_outgoing,
611        tag_distribution,
612        dangling_links,
613    }
614}
615
616#[cfg(feature = "mem-repo")]
617fn engine_most_connected_mem_repo(
618    engine: &memstead_base::Engine,
619    limit: usize,
620) -> Vec<MostConnectedRow> {
621    engine
622        .most_connected(limit)
623        .into_iter()
624        .map(|c| {
625            let title = engine
626                .get_entity(&c.id)
627                .map(|e| e.title.clone())
628                .unwrap_or_default();
629            (
630                c.id,
631                title,
632                c.total,
633                c.incoming,
634                c.outgoing,
635                c.typed_total,
636                c.typed_incoming,
637                c.typed_outgoing,
638            )
639        })
640        .collect()
641}
642
643fn engine_most_connected_filesystem(
644    engine: &memstead_base::Engine,
645    limit: usize,
646) -> Vec<MostConnectedRow> {
647    engine
648        .most_connected(limit)
649        .into_iter()
650        .map(|c| {
651            let title = engine
652                .get_entity(&c.id)
653                .map(|e| e.title.clone())
654                .unwrap_or_default();
655            (
656                c.id,
657                title,
658                c.total,
659                c.incoming,
660                c.outgoing,
661                c.typed_total,
662                c.typed_incoming,
663                c.typed_outgoing,
664            )
665        })
666        .collect()
667}
668
669/// Translate the strict-violation tally into an exit code. With
670/// `--strict` set and any Tier-2 violations recorded, return a
671/// `CliError(Generic)` so `main` exits 1 after the report has been
672/// written to stdout. When `--strict` is unset, or when no Tier-2
673/// `--include` token was supplied, this is a no-op.
674fn strict_exit(strict: bool, violations: &[(&'static str, usize)]) -> anyhow::Result<()> {
675    if !strict || violations.is_empty() {
676        return Ok(());
677    }
678    let summary = violations
679        .iter()
680        .map(|(code, n)| format!("{code}: {n}"))
681        .collect::<Vec<_>>()
682        .join(", ");
683    Err(crate::CliError::new(
684        ExitKind::Generic,
685        "HEALTH_STRICT_VIOLATIONS",
686        format!("strict mode: tier-2 violations present ({summary})"),
687    )
688    .into())
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694    use clap::CommandFactory;
695
696    #[test]
697    fn help_lists_every_include_key() {
698        let cmd = Args::command();
699        let arg = cmd
700            .get_arguments()
701            .find(|a| a.get_id() == "include")
702            .expect("--include arg must exist");
703        let help = arg
704            .get_help()
705            .expect("--include must have help text")
706            .to_string();
707        for key in HEALTH_INCLUDE_KEYS {
708            assert!(
709                help.contains(key),
710                "`memstead health --help` must name include key `{key}` (got: {help})"
711            );
712        }
713    }
714}