Skip to main content

memstead_cli/commands/
status.rs

1use std::collections::BTreeMap;
2
3use memstead_base::Store;
4use memstead_base::ingest::status::{ProjectionStatus, Rollup, projection_overview};
5use serde::Serialize;
6use serde_json::json;
7
8use crate::output::{print_json, print_markdown};
9use crate::setup::{CliContext, CliEngine};
10
11#[derive(Serialize)]
12struct EdgeTypeCount<'a> {
13    /// Spelled `rel_type`, like every other relation surface. The
14    /// sibling `TypeCount` below keeps `type` because ITS field is an
15    /// entity type — a different concept that owns the word.
16    rel_type: &'a str,
17    count: usize,
18}
19
20#[derive(Serialize)]
21struct TypeCount<'a> {
22    // `entity_type` on the wire since the 2026-08-28 batch: one concept,
23    // one spelling, matching the entity envelope and the wasm read surface.
24    entity_type: &'a str,
25    count: usize,
26}
27
28/// The `memstead status` JSON payload. The graph-count fields are
29/// byte-compatible with the former `stats` command's payload; `projections` is
30/// the additive per-binding array. `rollup` is the dashboard lead — one verdict
31/// plus the top-three concrete actions derived from the durable findings store
32/// and freshness; the graph counts and `projections` are the drill-down.
33#[derive(Serialize)]
34struct StatusPayload<'a> {
35    /// The coverage rule (memstead_base::ops::coverage): the axes the
36    /// rollup verdict answers for, from the CLI's registry row.
37    verdict_coverage: String,
38    rollup: Rollup,
39    mems: Vec<MemDurability>,
40    total_nodes: usize,
41    real_nodes: usize,
42    stub_nodes: usize,
43    total_edges: usize,
44    edge_types: Vec<EdgeTypeCount<'a>>,
45    type_distribution: Vec<TypeCount<'a>>,
46    projections: Vec<ProjectionStatus>,
47    /// Boot-honesty roster, present whenever non-empty and never behind
48    /// an opt-in — the same rule `health` follows. On a filesystem
49    /// workspace `status` is the roster surface the `mem list` refusal
50    /// points at, so a quarantine this payload omitted was simply
51    /// invisible: the graph counts read as a small healthy workspace.
52    #[serde(skip_serializing_if = "Vec::is_empty")]
53    quarantined: Vec<QuarantineLine>,
54}
55
56/// One quarantined mem: attached, refused at load, and the engine holds
57/// the typed reason and the repair — surfaced, never restated.
58#[derive(Serialize)]
59struct QuarantineLine {
60    mem: String,
61    reason_code: String,
62    reason: String,
63}
64
65fn quarantine_roster(engine: &memstead_base::Engine) -> Vec<QuarantineLine> {
66    engine
67        .quarantined_mems()
68        .iter()
69        .map(|q| QuarantineLine {
70            mem: q.mount.mem.clone(),
71            reason_code: q.reason_code.clone(),
72            reason: q.reason_message.clone(),
73        })
74        .collect()
75}
76
77/// One mem's durability line: what the engine can say about whether that
78/// mem's writes are recorded anywhere, and what it cannot (04/04, criterion
79/// 6).
80#[derive(Serialize)]
81struct MemDurability {
82    mem: String,
83    backend: &'static str,
84    /// The engine's narrow answer: writes survive a process restart.
85    durable: bool,
86    /// Whether that answer was established from a real commit or read off the
87    /// mount kind.
88    basis: &'static str,
89    /// Present exactly when the engine cannot establish that the mem's writes
90    /// reached version control. Never a claim that they did not.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    unestablished: Option<&'static str>,
93}
94
95/// What the engine can and cannot say about each mem's durability.
96///
97/// `status` used to touch no backend at all, so a folder mem's writes could
98/// be sitting outside any version control and nothing said so. It still does
99/// not shell out to git: a folder mem's root may not be in a repository, and
100/// a missing repository is not a defect. The reportable fact is that the
101/// engine cannot ESTABLISH durability there, which is true either way
102/// (04/04, criterion 6). It never claims debt it did not observe.
103fn mem_durability(engine: &memstead_base::Engine) -> Vec<MemDurability> {
104    engine
105        .mounts()
106        .iter()
107        .map(|m| {
108            let head = engine.mem_head_sha(&m.mem).ok().flatten();
109            let basis = m.storage.durability_basis(head.as_deref());
110            MemDurability {
111                mem: m.mem.clone(),
112                backend: m.storage.backend_id(),
113                durable: m.storage.is_durable(),
114                basis: basis.as_wire(),
115                unestablished: match basis {
116                    memstead_base::workspace::DurabilityBasis::Established => None,
117                    memstead_base::workspace::DurabilityBasis::InferredFromMountKind => Some(
118                        "writes land on disk and survive a restart; whether they reached \
119                         version control is not something the engine can establish",
120                    ),
121                },
122            }
123        })
124        .collect()
125}
126
127pub fn run(ctx: &CliContext) -> anyhow::Result<()> {
128    // The workspace root (for the projection store / advance store reads). The
129    // engine build below fails before this matters when we are outside a
130    // workspace, so a `None` here only ever means "in a workspace that declares
131    // no projections" once we get past `cli_engine()?`.
132    let root = ctx.workspace_shape().map(|(_, r)| r);
133
134    let (status, total, real, schema_counts, projections, rollup, mems, quarantined) =
135        match ctx.cli_engine()? {
136            #[cfg(feature = "mem-repo")]
137            CliEngine::MemRepo(engine) => {
138                let status = engine.status();
139                let store: &Store = engine.store();
140                // One per-binding pass for both projections (A3 AC2).
141                let (projections, rollup) = root
142                    .as_deref()
143                    .map(|r| {
144                        let o = projection_overview(&engine, r);
145                        (o.bindings, o.rollup)
146                    })
147                    .unwrap_or_default();
148                let mems = mem_durability(&engine);
149                let quarantined = quarantine_roster(&engine);
150                (
151                    status,
152                    store.len(),
153                    store.all_entities().filter(|e| !e.stub).count(),
154                    count_by_type(store),
155                    projections,
156                    rollup,
157                    mems,
158                    quarantined,
159                )
160            }
161            CliEngine::Filesystem(engine) => {
162                let status = engine.status();
163                let store: &Store = engine.store();
164                // One per-binding pass for both projections (A3 AC2).
165                let (projections, rollup) = root
166                    .as_deref()
167                    .map(|r| {
168                        let o = projection_overview(&engine, r);
169                        (o.bindings, o.rollup)
170                    })
171                    .unwrap_or_default();
172                let mems = mem_durability(&engine);
173                let quarantined = quarantine_roster(&engine);
174                (
175                    status,
176                    store.len(),
177                    store.all_entities().filter(|e| !e.stub).count(),
178                    count_by_type(store),
179                    projections,
180                    rollup,
181                    mems,
182                    quarantined,
183                )
184            }
185        };
186    let stubs = total - real;
187
188    // Name order (A7 AC3): both lists are byte-stable across runs, and the
189    // health composer and the ui-api carry them in the same order.
190    let edge_pairs: Vec<(&String, &usize)> = status.edge_types.iter().collect();
191    let schema_pairs: Vec<(String, usize)> = schema_counts.into_iter().collect();
192
193    if ctx.json {
194        let payload = StatusPayload {
195            verdict_coverage: crate::coverage::STATUS
196                .axis_coverage()
197                .expect("status is a verdict surface")
198                .wire_line(),
199            rollup,
200            mems,
201            total_nodes: total,
202            real_nodes: real,
203            stub_nodes: stubs,
204            total_edges: status.edge_count,
205            edge_types: edge_pairs
206                .iter()
207                .map(|(t, c)| EdgeTypeCount {
208                    rel_type: t,
209                    count: **c,
210                })
211                .collect(),
212            type_distribution: schema_pairs
213                .iter()
214                .map(|(s, c)| TypeCount {
215                    entity_type: s,
216                    count: *c,
217                })
218                .collect(),
219            projections,
220            quarantined,
221        };
222        return print_json(&json!(payload));
223    }
224
225    let mut lines = Vec::new();
226
227    // Lead with the dashboard rollup: one verdict + the top-three concrete
228    // actions. The graph counts and per-binding projection detail below are the
229    // drill-down.
230    lines.push("# Status".to_string());
231    lines.push(String::new());
232    // The subject rides with the verdict, never apart from it: a bare
233    // "clean" is read as a claim about the workspace, and this one answers
234    // for projection bindings only (04/04, criterion 5).
235    lines.push(format!(
236        "**Verdict:** {} — for {}",
237        rollup.verdict.as_wire(),
238        rollup.subject,
239    ));
240    // The coverage rule: the axes the verdict answers for, in the
241    // output itself (memstead_base::ops::coverage).
242    if let Some(cov) = crate::coverage::STATUS.axis_coverage() {
243        lines.push(format!("**Verdict coverage:** {}", cov.wire_line()));
244    }
245
246    // What the engine could not establish, named rather than left to the
247    // reader's assumption. A mem whose durability IS established says so and
248    // adds no caveat (04/04, criterion 6 and its complement).
249    let unestablished: Vec<&MemDurability> =
250        mems.iter().filter(|m| m.unestablished.is_some()).collect();
251    if !unestablished.is_empty() {
252        lines.push(String::new());
253        lines.push("**Durability not established** for:".to_string());
254        for m in &unestablished {
255            lines.push(format!(
256                "- `{}` ({}) — {}",
257                m.mem,
258                m.backend,
259                m.unestablished.unwrap_or_default(),
260            ));
261        }
262    }
263    // The quarantine roster, present whenever non-empty — same rule as
264    // `health`. Without it a quarantined mem on a filesystem workspace
265    // vanished from the very surface the `mem list` refusal points at.
266    if !quarantined.is_empty() {
267        lines.push(String::new());
268        lines.push(format!(
269            "**Quarantined mems ({})** — attached but refused at load; each line carries \
270             the engine's reason and repair:",
271            quarantined.len()
272        ));
273        for q in &quarantined {
274            lines.push(format!("- `{}` [{}] {}", q.mem, q.reason_code, q.reason));
275        }
276    }
277    lines.push(String::new());
278    lines.push(rollup.headline.clone());
279    if !rollup.actions.is_empty() {
280        lines.push(String::new());
281        lines.push("## Do next".to_string());
282        lines.push(String::new());
283        for action in &rollup.actions {
284            lines.push(format!("- {action}"));
285        }
286    }
287    lines.push(String::new());
288
289    lines.push("# Graph status".to_string());
290    lines.push(String::new());
291    lines.push(format!("- Nodes: {total} ({real} real, {stubs} stubs)"));
292    lines.push(format!("- Edges: {}", status.edge_count));
293    if !edge_pairs.is_empty() {
294        let edges: Vec<String> = edge_pairs
295            .iter()
296            .map(|(t, c)| format!("{t} ({c})"))
297            .collect();
298        lines.push(format!("- Edge types: {}", edges.join(", ")));
299    }
300    if !schema_pairs.is_empty() {
301        let schemas: Vec<String> = schema_pairs
302            .iter()
303            .map(|(s, c)| format!("{s} ({c})"))
304            .collect();
305        lines.push(format!("- Types: {}", schemas.join(", ")));
306    }
307    if !projections.is_empty() {
308        lines.push(String::new());
309        lines.push("## Projections".to_string());
310        lines.push(String::new());
311        for p in &projections {
312            lines.push(format!(
313                "- `{}` → `{}` — operations: {}; advance: {} pending, {} disposed",
314                p.binding,
315                p.destination_mem,
316                p.operations.join(", "),
317                p.advance.pending,
318                p.advance.disposed,
319            ));
320            for (facet, state) in &p.state {
321                lines.push(format!(
322                    "  - {facet}: signal {}, synced {}, verified {}",
323                    state.signal,
324                    state.synced.as_deref().unwrap_or("none"),
325                    state.verified.as_deref().unwrap_or("none"),
326                ));
327            }
328        }
329    }
330    print_markdown(&lines.join("\n"));
331    Ok(())
332}
333
334/// Count real (non-stub) entities by `entity_type`. Both engine
335/// flavours expose a `&Store`, so this helper is engine-agnostic.
336fn count_by_type(store: &Store) -> BTreeMap<String, usize> {
337    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
338    for e in store.all_entities().filter(|e| !e.stub) {
339        *counts.entry(e.entity_type.clone()).or_default() += 1;
340    }
341    counts
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    /// A7 AC3: the JSON lists are rendered from name-ordered maps, so the
349    /// order is pinned at the type (a `BTreeMap`, never a hash map) and
350    /// checked at the render seam.
351    #[test]
352    fn edge_and_type_lists_render_in_name_order() {
353        fn name_ordered(_: &BTreeMap<String, usize>) {}
354        let mut edge_types = BTreeMap::new();
355        for (k, c) in [("USES", 3), ("DEPENDS_ON", 9), ("MENTIONS", 1)] {
356            edge_types.insert(k.to_string(), c);
357        }
358        let status = memstead_base::ops::Status {
359            entity_count: 0,
360            edge_count: 13,
361            edge_types,
362            community_count: 0,
363            mem_count: 0,
364            types_in_use: vec![],
365        };
366        name_ordered(&status.edge_types);
367        let rendered: Vec<EdgeTypeCount> = status
368            .edge_types
369            .iter()
370            .map(|(t, c)| EdgeTypeCount {
371                rel_type: t,
372                count: *c,
373            })
374            .collect();
375        let names: Vec<&str> = rendered.iter().map(|e| e.rel_type).collect();
376        assert_eq!(names, vec!["DEPENDS_ON", "MENTIONS", "USES"]);
377        // `count_by_type` renders from the same ordered map type.
378        name_ordered(&count_by_type(&Store::new()));
379    }
380}