Skip to main content

memstead_cli/commands/
status.rs

1use std::collections::HashMap;
2
3use memstead_base::Store;
4use memstead_base::ingest::status::{
5    ProjectionStatus, Rollup, projection_rollup, projection_status,
6};
7use serde::Serialize;
8use serde_json::json;
9
10use crate::output::{print_json, print_markdown};
11use crate::setup::{CliContext, CliEngine};
12
13#[derive(Serialize)]
14struct EdgeTypeCount<'a> {
15    #[serde(rename = "type")]
16    rel_type: &'a str,
17    count: usize,
18}
19
20#[derive(Serialize)]
21struct TypeCount<'a> {
22    #[serde(rename = "type")]
23    entity_type: &'a str,
24    count: usize,
25}
26
27/// The `memstead status` JSON payload. The graph-count fields are
28/// byte-compatible with the former `stats` command's payload; `projections` is
29/// the additive per-binding array. `rollup` is the dashboard lead — one verdict
30/// plus the top-three concrete actions derived from the durable findings store
31/// and freshness; the graph counts and `projections` are the drill-down.
32#[derive(Serialize)]
33struct StatusPayload<'a> {
34    rollup: Rollup,
35    total_nodes: usize,
36    real_nodes: usize,
37    stub_nodes: usize,
38    total_edges: usize,
39    edge_types: Vec<EdgeTypeCount<'a>>,
40    type_distribution: Vec<TypeCount<'a>>,
41    projections: Vec<ProjectionStatus>,
42}
43
44pub fn run(ctx: &CliContext) -> anyhow::Result<()> {
45    // The workspace root (for the projection store / advance store reads). The
46    // engine build below fails before this matters when we are outside a
47    // workspace, so a `None` here only ever means "in a workspace that declares
48    // no projections" once we get past `cli_engine()?`.
49    let root = ctx.workspace_shape().map(|(_, r)| r);
50
51    let (status, total, real, schema_counts, projections, rollup) = match ctx.cli_engine()? {
52        #[cfg(feature = "mem-repo")]
53        CliEngine::MemRepo(engine) => {
54            let status = engine.status();
55            let store: &Store = engine.store();
56            let projections = root
57                .as_deref()
58                .map(|r| projection_status(&engine, r))
59                .unwrap_or_default();
60            let rollup = root
61                .as_deref()
62                .map(|r| projection_rollup(&engine, r))
63                .unwrap_or_default();
64            (
65                status,
66                store.len(),
67                store.all_entities().filter(|e| !e.stub).count(),
68                count_by_type(store),
69                projections,
70                rollup,
71            )
72        }
73        CliEngine::Filesystem(engine) => {
74            let status = engine.status();
75            let store: &Store = engine.store();
76            let projections = root
77                .as_deref()
78                .map(|r| projection_status(&engine, r))
79                .unwrap_or_default();
80            let rollup = root
81                .as_deref()
82                .map(|r| projection_rollup(&engine, r))
83                .unwrap_or_default();
84            (
85                status,
86                store.len(),
87                store.all_entities().filter(|e| !e.stub).count(),
88                count_by_type(store),
89                projections,
90                rollup,
91            )
92        }
93    };
94    let stubs = total - real;
95
96    let mut edge_pairs: Vec<_> = status.edge_types.iter().collect();
97    edge_pairs.sort_by(|a, b| b.1.cmp(a.1));
98
99    let mut schema_pairs: Vec<(String, usize)> = schema_counts.into_iter().collect();
100    schema_pairs.sort_by_key(|p| std::cmp::Reverse(p.1));
101
102    if ctx.json {
103        let payload = StatusPayload {
104            rollup,
105            total_nodes: total,
106            real_nodes: real,
107            stub_nodes: stubs,
108            total_edges: status.edge_count,
109            edge_types: edge_pairs
110                .iter()
111                .map(|(t, c)| EdgeTypeCount {
112                    rel_type: t,
113                    count: **c,
114                })
115                .collect(),
116            type_distribution: schema_pairs
117                .iter()
118                .map(|(s, c)| TypeCount {
119                    entity_type: s,
120                    count: *c,
121                })
122                .collect(),
123            projections,
124        };
125        return print_json(&json!(payload));
126    }
127
128    let mut lines = Vec::new();
129
130    // Lead with the dashboard rollup: one verdict + the top-three concrete
131    // actions. The graph counts and per-binding projection detail below are the
132    // drill-down.
133    lines.push("# Status".to_string());
134    lines.push(String::new());
135    lines.push(format!("**Verdict:** {}", rollup.verdict.as_wire()));
136    lines.push(String::new());
137    lines.push(rollup.headline.clone());
138    if !rollup.actions.is_empty() {
139        lines.push(String::new());
140        lines.push("## Do next".to_string());
141        lines.push(String::new());
142        for action in &rollup.actions {
143            lines.push(format!("- {action}"));
144        }
145    }
146    lines.push(String::new());
147
148    lines.push("# Graph status".to_string());
149    lines.push(String::new());
150    lines.push(format!("- Nodes: {total} ({real} real, {stubs} stubs)"));
151    lines.push(format!("- Edges: {}", status.edge_count));
152    if !edge_pairs.is_empty() {
153        let edges: Vec<String> = edge_pairs
154            .iter()
155            .map(|(t, c)| format!("{t} ({c})"))
156            .collect();
157        lines.push(format!("- Edge types: {}", edges.join(", ")));
158    }
159    if !schema_pairs.is_empty() {
160        let schemas: Vec<String> = schema_pairs
161            .iter()
162            .map(|(s, c)| format!("{s} ({c})"))
163            .collect();
164        lines.push(format!("- Types: {}", schemas.join(", ")));
165    }
166    if !projections.is_empty() {
167        lines.push(String::new());
168        lines.push("## Projections".to_string());
169        lines.push(String::new());
170        for p in &projections {
171            lines.push(format!(
172                "- `{}` → `{}` — operations: {}; advance: {} pending, {} disposed",
173                p.binding,
174                p.destination_mem,
175                p.operations.join(", "),
176                p.advance.pending,
177                p.advance.disposed,
178            ));
179            for (facet, state) in &p.state {
180                lines.push(format!(
181                    "  - {facet}: signal {}, synced {}, verified {}",
182                    state.signal,
183                    state.synced.as_deref().unwrap_or("none"),
184                    state.verified.as_deref().unwrap_or("none"),
185                ));
186            }
187        }
188    }
189    print_markdown(&lines.join("\n"));
190    Ok(())
191}
192
193/// Count real (non-stub) entities by `entity_type`. Both engine
194/// flavours expose a `&Store`, so this helper is engine-agnostic.
195fn count_by_type(store: &Store) -> HashMap<String, usize> {
196    let mut counts: HashMap<String, usize> = HashMap::new();
197    for e in store.all_entities().filter(|e| !e.stub) {
198        *counts.entry(e.entity_type.clone()).or_default() += 1;
199    }
200    counts
201}