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#[derive(Parser, Debug)]
17pub struct Args {
18 #[arg(long, value_delimiter = ',')]
28 pub include: Vec<String>,
29
30 #[arg(long)]
33 pub target_schema: Option<String>,
34
35 #[arg(long, default_value_t = 10)]
37 pub limit: usize,
38
39 #[arg(long)]
46 pub strict: bool,
47}
48
49pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
50 let include = &args.include;
51 let mut strict_violations: Vec<(&'static str, usize)> = Vec::new();
57
58 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 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 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 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
416type MostConnectedRow = (EntityId, String, usize, usize, usize, usize, usize, usize);
422
423struct GatheredHealth {
427 health: HealthSummary,
428 findings: Vec<memstead_base::ops::integrity::IntegrityFinding>,
432 real_count: usize,
433 orphan_ids: Vec<(EntityId, String)>,
436 stub_pairs: Vec<(EntityId, Vec<EntityId>)>,
437 community_count: usize,
438 orphans_by_schema: std::collections::BTreeMap<String, usize>,
443 communities_by_schema: std::collections::BTreeMap<String, usize>,
444 most_connected_with_titles: Vec<MostConnectedRow>,
446 missing_required_outgoing: Vec<MissingRequiredOutgoingReport>,
447 tag_distribution: Option<(serde_json::Value, serde_json::Value, serde_json::Value)>,
457 dangling_links: Vec<DanglingLink>,
461}
462
463fn 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
543fn 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
551fn 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 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
669fn 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}