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