1use std::collections::HashMap;
18
19#[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#[derive(Debug, Clone)]
36pub struct HealthConfig {
37 pub mutations: serde_json::Value,
38 pub plugin: serde_json::Value,
39}
40
41#[derive(Debug, thiserror::Error)]
45pub enum ComposeHealthError {
46 #[error("unknown mem: \"{name}\"")]
50 UnknownMem {
51 name: String,
52 writable_mems: Vec<String>,
53 },
54 #[error("invalid target_schema {raw:?}: {reason}")]
58 InvalidTargetSchema { raw: String, reason: String },
59 #[error(transparent)]
62 Engine(#[from] memstead_base::EngineError),
63}
64
65pub 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 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 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 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 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 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 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 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 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 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 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 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 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 if args.include_config {
561 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
627pub 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 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
703fn 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
725fn 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}