Skip to main content

memstead_engine/
health.rs

1//! Shared health composer used by the `memstead_health` MCP tool and any
2//! non-MCP caller (CLI, a future HTTP surface).
3//!
4//! Lifted from `memstead-mcp/src/server.rs::memstead_health_unified` so the
5//! health read-envelope is produced by one transport-neutral builder with no
6//! rmcp type in the path — the MCP wrapper handles drift collection, the
7//! schema anchor, the `mem_changed` notice channel, and `CallToolResult`
8//! wrapping; none of that lives here.
9//!
10//! The composer returns the complete health payload as a `serde_json::Value`
11//! (warnings embedded, every `include` detail section applied). Surface state
12//! the engine does not own — the `[mutations]` posture and the opaque
13//! `[plugin.*]` map — is passed in via [`HealthConfig`] as prebuilt JSON so
14//! this crate stays free of the MCP server's config types and the wire bytes
15//! stay identical to the pre-lift handler.
16
17use std::collections::HashMap;
18
19/// Composer input — packed from the MCP `HealthParams` (or a CLI `Args`) at
20/// the call site. Mirrors the field set the pre-lift handler read off
21/// `HealthParams`.
22#[derive(Debug)]
23pub struct HealthArgs<'a> {
24    pub mem: Option<&'a str>,
25    pub include: &'a [String],
26    pub limit: Option<usize>,
27    pub target_schema: Option<&'a str>,
28    pub include_config: bool,
29}
30
31/// Surface-owned config the engine does not carry — supplied prebuilt so the
32/// composer inserts the bytes verbatim. `mutations` is `{"require_notes": …}`;
33/// `plugin` is the opaque `[plugin.*]` pass-through object. Only consulted
34/// when `args.include_config` is set.
35#[derive(Debug, Clone)]
36pub struct HealthConfig {
37    pub mutations: serde_json::Value,
38    pub plugin: serde_json::Value,
39}
40
41/// Typed input failures the composer surfaces. The MCP wrapper maps each
42/// variant to its existing envelope (`UNKNOWN_MEM`, `INVALID_INPUT`) and
43/// the engine fault to its typed translator, so the wire `code` stays put.
44#[derive(Debug, thiserror::Error)]
45pub enum ComposeHealthError {
46    /// `args.mem` names a mem that isn't writable in this workspace. The
47    /// composer surfaces the sorted writable roster so the wrapper can echo
48    /// it in the `UNKNOWN_MEM` envelope.
49    #[error("unknown mem: \"{name}\"")]
50    UnknownMem {
51        name: String,
52        writable_mems: Vec<String>,
53    },
54    /// `args.target_schema` did not parse as a `name@x.y.z` ref. `reason` is
55    /// the parser's message, surfaced verbatim in the `INVALID_INPUT`
56    /// envelope's `details.reason`.
57    #[error("invalid target_schema {raw:?}: {reason}")]
58    InvalidTargetSchema { raw: String, reason: String },
59    /// A backend fault from the conformance / consistency scan. The wrapper
60    /// routes it through the typed `EngineError` translator unchanged.
61    #[error(transparent)]
62    Engine(#[from] memstead_base::EngineError),
63}
64
65/// Build the complete health payload. `drift_warnings` are the reload warnings
66/// the wrapper collected before calling in; the composer extends them with the
67/// health report's own warnings, the limit-clamp notice, and unknown-include
68/// notices, then embeds the lot under `warnings`.
69pub fn compose_health(
70    engine: &mut memstead_base::Engine,
71    args: &HealthArgs,
72    drift_warnings: Vec<memstead_base::WarningHint>,
73    config: &HealthConfig,
74) -> Result<serde_json::Value, ComposeHealthError> {
75    let health = engine.health();
76    let stats = engine.status();
77    let include = args.include;
78    const HEALTH_LIMIT_MAX: usize = 100;
79    let requested_limit = args.limit.unwrap_or(10);
80    let limit = requested_limit.min(HEALTH_LIMIT_MAX);
81
82    let mut warnings: Vec<memstead_base::WarningHint> = drift_warnings;
83    warnings.extend(health.warnings.clone());
84    if requested_limit > HEALTH_LIMIT_MAX {
85        warnings.push(memstead_base::WarningHint::LimitClamped {
86            requested: requested_limit,
87            actual: HEALTH_LIMIT_MAX,
88        });
89    }
90
91    for key in include {
92        if !memstead_base::ops::health::HEALTH_INCLUDE_KEYS.contains(&key.as_str()) {
93            warnings.push(memstead_base::WarningHint::UnknownIncludeKey {
94                key: key.clone(),
95                allowed: memstead_base::ops::health::HEALTH_INCLUDE_KEYS
96                    .iter()
97                    .map(|s| s.to_string())
98                    .collect(),
99            });
100        }
101    }
102
103    // Mem filter validation — only writable mems accepted.
104    let mem_filter: Option<String> = match args.mem {
105        Some(v) if engine.mem_router().is_writable(v) => Some(v.to_string()),
106        Some(v) => {
107            let mut names: Vec<String> = engine
108                .mem_router()
109                .writable_mems()
110                .iter()
111                .cloned()
112                .collect();
113            names.sort();
114            return Err(ComposeHealthError::UnknownMem {
115                name: v.to_string(),
116                writable_mems: names,
117            });
118        }
119        None => None,
120    };
121    let vf = mem_filter.as_deref();
122
123    // Symmetric with the data filter below: mem-attributable warnings
124    // (SUSPICIOUS_NESTED_PREFIX, DUPLICATE_SECTION_HEADING, etc.) drop out when
125    // their source mem isn't the scoped one. Workspace- and request-scoped
126    // warnings (OUTER_REPO_…, UNKNOWN_INCLUDE_KEY, LIMIT_CLAMPED) report `None`
127    // from `source_mem()` and stay visible — agents should see them
128    // regardless of which mem they're scoping to.
129    if let Some(v) = vf {
130        warnings.retain(|w| w.source_mem().is_none_or(|wv| wv == v));
131    }
132
133    let in_mem = |e: &memstead_base::Entity| -> bool {
134        match vf {
135            Some(v) => e.mem == v,
136            None => true,
137        }
138    };
139    let real_count = engine
140        .store()
141        .all_entities()
142        .filter(|e| !e.stub && in_mem(e))
143        .count();
144    let stub_count = engine
145        .store()
146        .all_entities()
147        .filter(|e| e.stub && in_mem(e))
148        .count();
149    let total_count = real_count + stub_count;
150
151    let orphan_ids: Vec<memstead_base::EntityId> = engine
152        .orphans()
153        .into_iter()
154        .filter(|id| match vf {
155            Some(v) => engine.store().get(id).map(|e| e.mem == v).unwrap_or(false),
156            None => true,
157        })
158        .collect();
159    let stub_pairs: Vec<(memstead_base::EntityId, Vec<memstead_base::EntityId>)> = engine
160        .stubs()
161        .into_iter()
162        .filter(|(id, _)| match vf {
163            Some(v) => engine.store().get(id).map(|e| e.mem == v).unwrap_or(false),
164            None => true,
165        })
166        .collect();
167
168    // Under a `mem` filter, scope the community count to clusters with ≥1
169    // member in that mem (filtering the global partition, not re-running
170    // detection) so it can't contradict the scoped `total_entities` — e.g. an
171    // empty mem reports 0 entities and 0 communities. Mirrors
172    // `memstead_overview` via the shared helper.
173    let community_count = match vf {
174        Some(v) => memstead_base::graph::community::clusters_in_mem(
175            engine.store(),
176            engine.communities(),
177            v,
178        )
179        .len(),
180        None => engine.communities().count,
181    };
182
183    // Edge counts: under a mem filter, count only source-in-mem edges
184    // (asymmetric — matches the legacy contract).
185    let (edge_count, edge_types) = {
186        if let Some(v) = vf {
187            let mut counts: HashMap<String, usize> = HashMap::new();
188            let mut total: usize = 0;
189            for id in engine.store().all_ids() {
190                let source_mem = engine.store().get(id).map(|e| e.mem.clone());
191                if let Some(source) = source_mem.as_deref()
192                    && source != v
193                {
194                    continue;
195                }
196                for edge in engine.store().outgoing(id) {
197                    *counts.entry(edge.rel_type.clone()).or_insert(0) += 1;
198                    total += 1;
199                }
200            }
201            let mut pairs: Vec<_> = counts.into_iter().collect();
202            pairs.sort_by_key(|p| std::cmp::Reverse(p.1));
203            let arr: Vec<serde_json::Value> = pairs
204                .into_iter()
205                .map(|(t, c)| serde_json::json!({"type": t, "count": c}))
206                .collect();
207            (total, arr)
208        } else {
209            let mut pairs: Vec<_> = stats.edge_types.iter().collect();
210            pairs.sort_by(|a, b| b.1.cmp(a.1));
211            let arr: Vec<serde_json::Value> = pairs
212                .into_iter()
213                .map(|(t, c)| serde_json::json!({"type": t, "count": c}))
214                .collect();
215            (stats.edge_count, arr)
216        }
217    };
218
219    let type_distribution: Vec<serde_json::Value> = {
220        let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
221        for e in engine
222            .store()
223            .all_entities()
224            .filter(|e| !e.stub && in_mem(e))
225        {
226            *counts.entry(&e.entity_type).or_default() += 1;
227        }
228        let mut pairs: Vec<_> = counts.into_iter().collect();
229        pairs.sort_by_key(|p| std::cmp::Reverse(p.1));
230        pairs
231            .into_iter()
232            .map(|(s, c)| serde_json::json!({"type": s, "count": c}))
233            .collect()
234    };
235
236    let writable_mems: Vec<String> = {
237        let mut names: Vec<String> = engine
238            .mem_router()
239            .writable_mems()
240            .iter()
241            .cloned()
242            .collect();
243        names.sort();
244        names
245    };
246    // The stable default an omitted-`mem` mutation lands in — the first
247    // writable mount in declaration order, not `writable_mems[0]` of this
248    // alphabetically-sorted roster. Surfaced so an omitted-`mem` write is
249    // predictable.
250    let default_writable_mem: Option<String> = engine.default_writable_mem().map(|s| s.to_string());
251    let read_mems: Vec<String> = {
252        let writable_set: std::collections::HashSet<&String> =
253            engine.mem_router().writable_mems().iter().collect();
254        let mut names: Vec<String> = engine
255            .mem_router()
256            .visible_mems()
257            .iter()
258            .filter(|n| !writable_set.contains(*n))
259            .cloned()
260            .collect();
261        names.sort();
262        names
263    };
264
265    // Per-mem schema pins. Source from `engine.mount(name).schema` which
266    // carries the pinned `SchemaRef`; render via `as_display()` to get the
267    // same `name@version` form full emits.
268    //
269    // Every *visible* mem appears — writable and read-only alike — each
270    // carrying an explicit `writable` attribute. A read-only mount's pinned
271    // schema is real; surfacing it here (rather than filtering to writable
272    // mems) is what keeps health from reporting "no schema" while the
273    // discovery manifest names one. Writable mems render first (sorted),
274    // then read-only ones (sorted), so a normal writable workspace — which
275    // has no read mems — keeps its existing entry order, gaining only the
276    // `writable: true` attribute.
277    let mem_schemas: Vec<serde_json::Value> = {
278        let mut entries: Vec<serde_json::Value> = Vec::new();
279        let writable_set: std::collections::HashSet<&String> = writable_mems.iter().collect();
280        for name in writable_mems.iter().chain(read_mems.iter()) {
281            if let Some(v) = vf
282                && name != v
283            {
284                continue;
285            }
286            if let Some(m) = engine.mount(name) {
287                // The mem's *settled* pin — `Mount.schema` (now an optional
288                // assertion). During a dual-pin migration this stays the
289                // settled pin; the in-flight target is the separate
290                // `migration_target` surface below.
291                let schema_ref = m
292                    .schema
293                    .as_ref()
294                    .map(|s| s.as_display())
295                    .unwrap_or_default();
296                let mut entry = serde_json::json!({
297                    "mem": name,
298                    "schema": schema_ref,
299                    "writable": writable_set.contains(name),
300                });
301                // Dual-pin confirmation surface: present only while a
302                // migration is in flight, so settled mems' entries stay
303                // byte-identical to before.
304                if let Some(target) = &m.migration_target {
305                    entry["migration_target"] = serde_json::json!(target.as_display());
306                }
307                entries.push(entry);
308            }
309        }
310        entries
311    };
312
313    // #49: segment the orphan / community headlines by the owning mem's
314    // schema. A blended total mixes schemas with opposite norms — ingest
315    // mems, where each finding is an isolated entity (orphan by design),
316    // versus code/spec mems, where an orphan is real debt — so a bare
317    // "54 orphans" reads as 54 units of debt when most are by-design
318    // isolates. The raw `total_orphans` / `total_communities` are retained
319    // in `summary`, and a `mem`-scoped call still exposes per-mem counts
320    // (the refusal AC); these maps only attribute the totals by schema.
321    // `orphan_ids` is already mem-scoped above; scope the community
322    // attribution to the same mem set.
323    let orphans_by_schema = engine.orphans_by_schema(&orphan_ids);
324    let scope_mems: Vec<String> = match vf {
325        Some(v) => vec![v.to_string()],
326        None => writable_mems
327            .iter()
328            .chain(read_mems.iter())
329            .cloned()
330            .collect(),
331    };
332    let communities_by_schema = engine.communities_by_schema(&scope_mems);
333
334    let mut result = serde_json::json!({
335        "mem": mem_filter,
336        "summary": {
337            "total_entities": real_count,
338            "total_orphans": orphan_ids.len(),
339            "total_stubs": stub_pairs.len(),
340            "total_stale": health.stale_entities.iter().filter(|e| match vf {
341                Some(v) => engine.store().get(&e.id).map(|ent| ent.mem == v).unwrap_or(false),
342                None => true,
343            }).count(),
344            "total_missing_fields": health.missing_fields.iter().filter(|h| match vf {
345                Some(v) => engine.store().get(&h.id).map(|ent| ent.mem == v).unwrap_or(false),
346                None => true,
347            }).count(),
348            "total_communities": community_count,
349            "orphans_by_schema": orphans_by_schema,
350            "communities_by_schema": communities_by_schema,
351        },
352        "total_nodes": total_count,
353        "real_nodes": real_count,
354        "stub_nodes": stub_count,
355        "total_edges": edge_count,
356        "edge_types": edge_types,
357        "type_distribution": type_distribution,
358        "writable_mems": writable_mems,
359        "default_writable_mem": default_writable_mem,
360        "read_mems": read_mems,
361        "mem_schemas": mem_schemas,
362    });
363    let obj = result.as_object_mut().unwrap();
364    if !warnings.is_empty() {
365        obj.insert("warnings".into(), serde_json::json!(warnings));
366    }
367
368    if include.iter().any(|s| s == "orphans") {
369        let orphans_list: Vec<serde_json::Value> = orphan_ids
370            .into_iter()
371            .map(|id| {
372                let title = engine
373                    .get_entity(&id)
374                    .map(|e| e.title.clone())
375                    .unwrap_or_default();
376                serde_json::json!({"id": id.to_string(), "title": title})
377            })
378            .collect();
379        obj.insert("orphans".into(), serde_json::json!(orphans_list));
380    }
381    if include.iter().any(|s| s == "stubs") {
382        let stubs_list: Vec<serde_json::Value> = stub_pairs
383            .into_iter()
384            .map(|(id, refs)| {
385                serde_json::json!({
386                    "id": id.to_string(),
387                    "referenced_by": refs.iter().map(|r| r.to_string()).collect::<Vec<_>>(),
388                })
389            })
390            .collect();
391        obj.insert("stubs".into(), serde_json::json!(stubs_list));
392    }
393    if include.iter().any(|s| s == "most_connected") {
394        use memstead_base::graph::query::{Connectivity, cmp_by_dependency, connectivity_for};
395        // `typed_*` is the dependency degree (excludes auto-emitted mention
396        // edges); the list is ranked by it so a co-mention hub doesn't
397        // outrank a real dependency hub. `total`/`incoming`/`outgoing` keep
398        // the mentions and stay available — mention degree = total - typed.
399        let to_json = |c: Connectivity| {
400            let title = engine
401                .get_entity(&c.id)
402                .map(|e| e.title.clone())
403                .unwrap_or_default();
404            serde_json::json!({
405                "id": c.id.to_string(),
406                "title": title,
407                "total": c.total,
408                "incoming": c.incoming,
409                "outgoing": c.outgoing,
410                "typed_total": c.typed_total,
411                "typed_incoming": c.typed_incoming,
412                "typed_outgoing": c.typed_outgoing,
413            })
414        };
415        let connected: Vec<serde_json::Value> = if let Some(v) = vf {
416            // Source-in-mem scoping, to match this response's `edge_types`
417            // / `total_edges`. The node is in-mem, so all of its outgoing
418            // edges are source-in-mem and counted; an incoming edge counts
419            // only when its source is also in-mem, so a cross-mem edge
420            // the aggregate excluded does not inflate the node's degree here.
421            let mut entries: Vec<Connectivity> = engine
422                .store()
423                .all_entities()
424                .filter(|e| !e.stub && e.mem == v)
425                .map(|e| connectivity_for(engine.store(), &e.id, |in_edge| in_edge.from.mem() == v))
426                .collect();
427            entries.sort_by(cmp_by_dependency);
428            entries.truncate(limit);
429            entries.into_iter().map(to_json).collect()
430        } else {
431            engine
432                .most_connected(limit)
433                .into_iter()
434                .map(to_json)
435                .collect()
436        };
437        obj.insert("most_connected".into(), serde_json::json!(connected));
438    }
439    if include.iter().any(|s| s == "missing_fields") {
440        let missing_fields: Vec<serde_json::Value> = health
441            .missing_fields
442            .iter()
443            .filter(|h| match vf {
444                Some(v) => engine
445                    .store()
446                    .get(&h.id)
447                    .map(|e| e.mem == v)
448                    .unwrap_or(false),
449                None => true,
450            })
451            .map(|h| {
452                let missing: Vec<&str> = h.issues.iter().map(|i| i.field.as_str()).collect();
453                serde_json::json!({"id": h.id.to_string(), "title": h.title, "missing": missing})
454            })
455            .collect();
456        obj.insert("missing_fields".into(), serde_json::json!(missing_fields));
457    }
458    if include.iter().any(|s| s == "stale") {
459        let stale: Vec<serde_json::Value> = health
460            .stale_entities
461            .iter()
462            .filter(|e| match vf {
463                Some(v) => engine
464                    .store()
465                    .get(&e.id)
466                    .map(|ent| ent.mem == v)
467                    .unwrap_or(false),
468                None => true,
469            })
470            .map(|e| {
471                serde_json::json!({
472                    "id": e.id.to_string(),
473                    "title": e.title,
474                    "days_since_modified": e.days_since_modified,
475                })
476            })
477            .collect();
478        obj.insert("stale".into(), serde_json::json!(stale));
479    }
480    if include.iter().any(|s| s == "dangling_links") {
481        let dangling = memstead_base::ops::health::collect_dangling_links(engine.store(), vf);
482        let arr: Vec<serde_json::Value> = dangling
483            .into_iter()
484            .map(|dl| serde_json::to_value(&dl).unwrap())
485            .collect();
486        obj.insert("dangling_links".into(), serde_json::json!(arr));
487    }
488    if include.iter().any(|s| s == "missing_required_outgoing") {
489        let reports = engine.missing_required_outgoing(vf);
490        let arr: Vec<serde_json::Value> = reports
491            .into_iter()
492            .map(|r| serde_json::to_value(&r).unwrap())
493            .collect();
494        obj.insert("missing_required_outgoing".into(), serde_json::json!(arr));
495    }
496    if include.iter().any(|s| s == "tags") {
497        let (distribution, folded, untagged) =
498            memstead_base::ops::health::collect_tag_distribution(engine.store(), vf, limit);
499        obj.insert(
500            "tag_distribution".into(),
501            serde_json::to_value(&distribution).unwrap(),
502        );
503        obj.insert(
504            "tag_distribution_folded".into(),
505            serde_json::to_value(&folded).unwrap(),
506        );
507        obj.insert(
508            "untagged_entities".into(),
509            serde_json::to_value(&untagged).unwrap(),
510        );
511    }
512    // Conformance axis (`conformance`), or both axes (`integrity`). Findings
513    // ride one flat `findings` list in the pinned `{ id, axis, code, detail }`
514    // shape; ids are mem-qualified so the flat list stays unambiguous when
515    // unscoped. Mems scan in sorted order and each mem's findings are
516    // deterministic, so the whole list is.
517    let wants_conformance = include
518        .iter()
519        .any(|s| s == "conformance" || s == "integrity");
520    if wants_conformance {
521        let wants_consistency = include.iter().any(|s| s == "integrity");
522        let target: Option<memstead_schema::SchemaRef> = match args.target_schema {
523            None => None,
524            Some(raw) => match raw.parse::<memstead_schema::SchemaRef>() {
525                Ok(r) => Some(r),
526                Err(reason) => {
527                    return Err(ComposeHealthError::InvalidTargetSchema {
528                        raw: raw.to_string(),
529                        reason,
530                    });
531                }
532            },
533        };
534        let scan_mems: Vec<String> = match vf {
535            Some(v) => vec![v.to_string()],
536            None => {
537                let mut all = writable_mems.clone();
538                all.sort();
539                all
540            }
541        };
542        let mut findings = Vec::new();
543        for v in &scan_mems {
544            findings.extend(engine.conformance_findings(v, target.as_ref())?);
545            if wants_consistency {
546                findings.extend(engine.consistency_findings(v)?);
547            }
548        }
549        obj.insert("findings".into(), serde_json::to_value(&findings).unwrap());
550    }
551
552    // Workspace policy surface — opt-in `include_config: true`. Per-mem
553    // `vcs: { gitdir?, worktree?, head? }` uses `gitdir_for` / `worktree_for`
554    // / `mem_head_sha`. Each sub-field is conditionally present — folder
555    // mounts have a worktree but no per-mem gitdir; freshly-created mems
556    // have no head yet — and the `vcs` object emits whenever at least one of
557    // them is available. `write_guidance` + `extra` come from
558    // `mem_config_for`. `mutations` + `plugin` are passed in via
559    // [`HealthConfig`] (server state the engine does not own).
560    if args.include_config {
561        // Per-mem storage backend → durability marker, derived from the
562        // mount's `MountStorage` kind. Lives alongside `vcs` so an agent
563        // reading per-mem config learns whether a `commit_sha` this mem
564        // returns is durable-on-disk or volatile-in-RAM.
565        let backend_by_mem: std::collections::HashMap<&str, (&'static str, bool)> = engine
566            .mounts()
567            .iter()
568            .map(|m| {
569                (
570                    m.mem.as_str(),
571                    (m.storage.backend_id(), m.storage.is_durable()),
572                )
573            })
574            .collect();
575        let mems_detail: Vec<serde_json::Value> = writable_mems
576            .iter()
577            .map(|name| {
578                let origin = engine
579                    .mem_router()
580                    .origin_for_mem(name)
581                    .map(|o| o.kind())
582                    .unwrap_or("explicit");
583                let mut entry = serde_json::Map::new();
584                entry.insert("name".into(), serde_json::json!(name));
585                entry.insert("origin".into(), serde_json::json!(origin));
586                if let Some((storage, durable)) = backend_by_mem.get(name.as_str()).copied() {
587                    entry.insert("storage".into(), serde_json::json!(storage));
588                    entry.insert("durable".into(), serde_json::json!(durable));
589                }
590                let mut vcs_obj = serde_json::Map::new();
591                if let Ok(gitdir) = engine.gitdir_for(name) {
592                    vcs_obj.insert("gitdir".into(), serde_json::json!(gitdir));
593                }
594                if let Ok(worktree) = engine.worktree_for(name) {
595                    vcs_obj.insert("worktree".into(), serde_json::json!(worktree));
596                }
597                if let Some(sha) = engine.mem_head_sha(name).ok().flatten() {
598                    vcs_obj.insert("head".into(), serde_json::json!(sha));
599                }
600                if !vcs_obj.is_empty() {
601                    entry.insert("vcs".into(), serde_json::Value::Object(vcs_obj));
602                }
603                if let Some(cfg) = engine.mem_config_for(name) {
604                    let guidance = serde_json::Map::from_iter(
605                        cfg.write_guidance
606                            .iter()
607                            .map(|(k, v)| (k.clone(), v.clone())),
608                    );
609                    entry.insert("write_guidance".into(), serde_json::Value::Object(guidance));
610                    let extra = serde_json::Map::from_iter(
611                        cfg.extra.iter().map(|(k, v)| (k.clone(), v.clone())),
612                    );
613                    entry.insert("extra".into(), serde_json::Value::Object(extra));
614                }
615                serde_json::Value::Object(entry)
616            })
617            .collect();
618        obj.insert("mems".into(), serde_json::json!(mems_detail));
619
620        obj.insert("mutations".into(), config.mutations.clone());
621        obj.insert("plugin".into(), config.plugin.clone());
622    }
623
624    Ok(result)
625}
626
627/// Render a composed health payload as a human-readable markdown report
628/// for the MCP text channel. `structured_content` remains the source of
629/// truth (this is never parsed back); the markdown exists so the text
630/// channel is *chunkable* like `memstead_overview` instead of a wall of
631/// JSON that overflows the response cap under several includes. The
632/// size-driving include arrays each render as their own section so the
633/// chunker can split a large report cleanly.
634pub fn render_health_markdown(v: &serde_json::Value) -> String {
635    use std::fmt::Write as _;
636    let mut s = String::new();
637    let _ = writeln!(s, "# Graph health");
638    if let Some(mem) = v.get("mem").and_then(|x| x.as_str()) {
639        let _ = writeln!(s, "\nMem filter: `{mem}`");
640    }
641
642    if let Some(sum) = v.get("summary").and_then(|x| x.as_object()) {
643        let _ = writeln!(s, "\n## Summary");
644        for key in [
645            "total_entities",
646            "total_orphans",
647            "total_stubs",
648            "total_stale",
649            "total_missing_fields",
650            "total_communities",
651        ] {
652            if let Some(n) = sum.get(key).and_then(|x| x.as_u64()) {
653                let _ = writeln!(s, "- {}: {n}", key.replace('_', " "));
654            }
655        }
656        render_count_map(&mut s, sum.get("orphans_by_schema"), "Orphans by schema");
657        render_count_map(
658            &mut s,
659            sum.get("communities_by_schema"),
660            "Communities by schema",
661        );
662    }
663
664    for key in ["total_nodes", "real_nodes", "stub_nodes", "total_edges"] {
665        if let Some(n) = v.get(key).and_then(|x| x.as_u64()) {
666            let _ = writeln!(s, "- {}: {n}", key.replace('_', " "));
667        }
668    }
669
670    // Size-driving include arrays — one section each so chunking splits them.
671    for (key, title) in [
672        ("orphans", "Orphans"),
673        ("stubs", "Stubs"),
674        ("most_connected", "Most connected"),
675        ("missing_fields", "Missing fields"),
676        ("stale", "Stale"),
677        ("dangling_links", "Dangling links"),
678        ("missing_required_outgoing", "Missing required outgoing"),
679        ("findings", "Findings"),
680    ] {
681        if let Some(arr) = v.get(key).and_then(|x| x.as_array()) {
682            let _ = writeln!(s, "\n## {title} ({})", arr.len());
683            for item in arr {
684                let _ = writeln!(s, "- {}", summarize_health_item(item));
685            }
686        }
687    }
688
689    if let Some(arr) = v.get("warnings").and_then(|x| x.as_array())
690        && !arr.is_empty()
691    {
692        let _ = writeln!(s, "\n## Warnings ({})", arr.len());
693        for w in arr {
694            let code = w.get("code").and_then(|x| x.as_str()).unwrap_or("");
695            let msg = w.get("message").and_then(|x| x.as_str()).unwrap_or("");
696            let _ = writeln!(s, "- [{code}] {msg}");
697        }
698    }
699
700    s
701}
702
703/// Render a `{ key: count }` map as an indented sub-list under `title`,
704/// skipping an empty/missing map. The empty-string schema key (an unpinned
705/// mem) renders as `(unpinned)`.
706fn render_count_map(s: &mut String, val: Option<&serde_json::Value>, title: &str) {
707    use std::fmt::Write as _;
708    let Some(map) = val.and_then(|x| x.as_object()) else {
709        return;
710    };
711    if map.is_empty() {
712        return;
713    }
714    let _ = writeln!(s, "- {title}:");
715    for (k, n) in map {
716        let label = if k.is_empty() {
717            "(unpinned)"
718        } else {
719            k.as_str()
720        };
721        let _ = writeln!(s, "  - {label}: {}", n.as_u64().unwrap_or(0));
722    }
723}
724
725/// One-line summary of a health detail item: prefer `id` (+ `title`),
726/// else a dangling-link `from → target_id`, else the compact JSON.
727fn summarize_health_item(item: &serde_json::Value) -> String {
728    if let Some(id) = item.get("id").and_then(|x| x.as_str()) {
729        match item.get("title").and_then(|x| x.as_str()) {
730            Some(t) if !t.is_empty() => format!("{id} — {t}"),
731            _ => id.to_string(),
732        }
733    } else if let Some(from) = item.get("from").and_then(|x| x.as_str()) {
734        let target = item.get("target_id").and_then(|x| x.as_str()).unwrap_or("");
735        format!("{from} → {target}")
736    } else {
737        serde_json::to_string(item).unwrap_or_default()
738    }
739}