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