Skip to main content

memstead_cli/commands/
anchors.rs

1//! `memstead anchors` — read provenance anchors (E3a).
2//!
3//! Two read modes, no mutation:
4//!
5//! * **By entity.** `memstead anchors <id>` lists the entity's stored
6//!   anchors plus their class/grain composition.
7//! * **By artifact (reverse lookup).** `memstead anchors --artifact <path>`
8//!   lists every `(entity, anchor)` across all mems whose anchor
9//!   references that path. This is the query the rebuilt
10//!   check-realization plugin hook consumes: given the file an agent just
11//!   edited, which entities anchored to it. `tree`-grain anchors match the
12//!   path and anything beneath the tree.
13
14use clap::Parser;
15
16use memstead_base::EntityId;
17
18use crate::CliError;
19use crate::output::{ExitKind, print_json, print_markdown};
20use crate::setup::{CliContext, CliEngine};
21
22/// Read provenance anchors by entity or by referenced artifact path.
23#[derive(Parser, Debug)]
24pub struct Args {
25    /// Entity ID (e.g. `specs--my-entity`). Required unless `--artifact`
26    /// is given.
27    pub id: Option<String>,
28
29    /// Reverse lookup: list every entity whose anchor references this
30    /// artifact path. Mutually exclusive with a positional entity id.
31    #[arg(long = "artifact", value_name = "PATH", conflicts_with = "id")]
32    pub artifact: Option<String>,
33}
34
35pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
36    if args.id.is_none() && args.artifact.is_none() {
37        return Err(CliError::new(
38            ExitKind::Validation,
39            "INVALID_INPUT",
40            "pass an entity id or `--artifact <path>`",
41        )
42        .into());
43    }
44
45    // Collect the anchor rows off whichever engine backs the workspace.
46    // Both variants expose the same read surface. `state` carries the live
47    // resolution (present only for a by-entity lookup on a path-medium mem;
48    // `None` for the reverse `--artifact` lookup, which spans mems).
49    let (rows, unreadable): (Vec<AnchorRow>, Vec<SidecarCondition>) = match ctx.cli_engine()? {
50        #[cfg(feature = "mem-repo")]
51        CliEngine::MemRepo(engine) => {
52            (collect(&engine, &args), unreadable_sidecars(&engine, &args))
53        }
54        CliEngine::Filesystem(engine) => {
55            (collect(&engine, &args), unreadable_sidecars(&engine, &args))
56        }
57    };
58    // By entity: the one mem's sidecar is the whole answer, and an
59    // unreadable one is a refusal, not "no anchors". By artifact: the
60    // lookup spans mems, so the readable ones still answer and the
61    // unreadable ones ride along as conditions.
62    if args.id.is_some()
63        && let Some(c) = unreadable.first()
64    {
65        return Err(CliError::new(
66            ExitKind::Validation,
67            "ANCHORS_SIDECAR_UNREADABLE",
68            format!(
69                "mem `{}`: the anchors sidecar could not be read ({}); the entity's anchors \
70                 are unknown, not absent",
71                c.mem, c.reason
72            ),
73        )
74        .with_details(serde_json::json!({ "mem": c.mem, "reason": c.reason }))
75        .into());
76    }
77
78    let now = memstead_base::engine::mutation::iso_now();
79    if ctx.json {
80        let anchors_json: Vec<serde_json::Value> = rows
81            .iter()
82            .map(|(id, a, state, observed_at)| {
83                let mut v = serde_json::to_value(a).unwrap_or(serde_json::Value::Null);
84                if let Some(obj) = v.as_object_mut() {
85                    obj.insert("entity_id".into(), serde_json::json!(id));
86                    if let Some(s) = state {
87                        obj.insert("state".into(), serde_json::json!(s.as_wire()));
88                    }
89                    if let Some(at) = observed_at {
90                        obj.insert("observed_at".into(), serde_json::json!(at));
91                        if let Some(days) = memstead_base::anchor::days_between(at, &now) {
92                            obj.insert("unobserved_for_days".into(), serde_json::json!(days));
93                        }
94                    }
95                }
96                v
97            })
98            .collect();
99        let anchors_only: Vec<memstead_base::anchor::Anchor> =
100            rows.iter().map(|(_, a, _, _)| a.clone()).collect();
101        let composition = memstead_base::anchor::compose_entity_anchors(&anchors_only);
102        print_json(&serde_json::json!({
103            "count": rows.len(),
104            "anchors": anchors_json,
105            "composition": composition,
106            // Mems whose sidecar could not be read: their rows are unknown,
107            // not absent, and `count` does not cover them.
108            "sidecar_unreadable": unreadable
109                .iter()
110                .map(|c| serde_json::json!({
111                    "code": "ANCHORS_SIDECAR_UNREADABLE",
112                    "mem": c.mem,
113                    "reason": c.reason,
114                }))
115                .collect::<Vec<_>>(),
116        }))?;
117    } else if rows.is_empty() && unreadable.is_empty() {
118        let subject = args
119            .artifact
120            .as_deref()
121            .map(|p| format!("artifact `{p}`"))
122            .or_else(|| args.id.as_deref().map(|i| format!("entity `{i}`")))
123            .unwrap_or_default();
124        print_markdown(&format!("# Anchors\n\nNo anchors for {subject}."));
125    } else {
126        let mut body = format!("# Anchors ({})\n", rows.len());
127        for c in &unreadable {
128            body.push_str(&format!(
129                "\n> **ANCHORS_SIDECAR_UNREADABLE** — mem `{}`: {}. Its rows are unknown, not \
130                 absent; the count above does not cover it.\n",
131                c.mem, c.reason
132            ));
133        }
134        for (id, a, state, observed_at) in &rows {
135            let hash = a.hash.as_deref().unwrap_or("-");
136            let state_str = state
137                .map(|s| format!(", state: {}", s.as_wire()))
138                .unwrap_or_default();
139            // A recorded observation's age travels with its state: a url
140            // row's state is exactly as current as the observation it rests on.
141            let age_str = observed_at
142                .as_deref()
143                .map(|at| {
144                    let days = memstead_base::anchor::days_between(at, &now).unwrap_or(0);
145                    format!(", observed {at}, unobserved for {days} day(s)")
146                })
147                .unwrap_or_default();
148            body.push_str(&format!(
149                "\n- `{id}` — {} {} `{}` (hash: {hash}{state_str}{age_str})",
150                a.class.as_wire(),
151                a.grain.as_wire(),
152                a.artifact,
153            ));
154        }
155        print_markdown(&body);
156    }
157    Ok(())
158}
159
160/// A mem whose anchors sidecar could not be read this pass.
161struct SidecarCondition {
162    mem: String,
163    reason: String,
164}
165
166/// The unreadable sidecars the requested lookup touches: the one mem for a
167/// by-entity read, every mounted mem for the reverse lookup.
168fn unreadable_sidecars(engine: &memstead_base::Engine, args: &Args) -> Vec<SidecarCondition> {
169    let mems: Vec<String> = if let Some(id) = args.id.as_deref() {
170        vec![EntityId::canonical(id).mem().to_string()]
171    } else {
172        engine.mem_names().iter().map(|m| m.to_string()).collect()
173    };
174    mems.into_iter()
175        .filter_map(|mem| {
176            engine
177                .anchors_sidecar_error(&mem)
178                .map(|reason| SidecarCondition { mem, reason })
179        })
180        .collect()
181}
182
183/// One anchor row: `(entity_id, anchor, live_state, observed_at)` — the
184/// last element is present for a row whose state rests on a recorded
185/// observation (a `url` row) rather than a live one.
186type AnchorRow = (
187    String,
188    memstead_base::anchor::Anchor,
189    Option<memstead_base::anchor::AnchorState>,
190    Option<String>,
191);
192
193/// Gather anchor rows from an engine per the requested mode. The by-entity
194/// lookup carries the live resolution state; the reverse `--artifact` lookup
195/// spans mems and carries none.
196fn collect(engine: &memstead_base::Engine, args: &Args) -> Vec<AnchorRow> {
197    if let Some(path) = args.artifact.as_deref() {
198        engine
199            .anchors_referencing_artifact(path)
200            .into_iter()
201            .map(|(id, a)| (id.to_string(), a, None, None))
202            .collect()
203    } else if let Some(id) = args.id.as_deref() {
204        let eid = EntityId::canonical(id);
205        engine
206            .entity_anchors_resolved(&eid)
207            .into_iter()
208            .map(|r| (eid.to_string(), r.anchor, r.state, r.observed_at))
209            .collect()
210    } else {
211        Vec::new()
212    }
213}