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: Vec<AnchorRow> = match ctx.cli_engine()? {
50        #[cfg(feature = "mem-repo")]
51        CliEngine::MemRepo(engine) => collect(&engine, &args),
52        CliEngine::Filesystem(engine) => collect(&engine, &args),
53    };
54
55    let now = memstead_base::engine::mutation::iso_now();
56    if ctx.json {
57        let anchors_json: Vec<serde_json::Value> = rows
58            .iter()
59            .map(|(id, a, state, observed_at)| {
60                let mut v = serde_json::to_value(a).unwrap_or(serde_json::Value::Null);
61                if let Some(obj) = v.as_object_mut() {
62                    obj.insert("entity_id".into(), serde_json::json!(id));
63                    if let Some(s) = state {
64                        obj.insert("state".into(), serde_json::json!(s.as_wire()));
65                    }
66                    if let Some(at) = observed_at {
67                        obj.insert("observed_at".into(), serde_json::json!(at));
68                        if let Some(days) = memstead_base::anchor::days_between(at, &now) {
69                            obj.insert("unobserved_for_days".into(), serde_json::json!(days));
70                        }
71                    }
72                }
73                v
74            })
75            .collect();
76        let anchors_only: Vec<memstead_base::anchor::Anchor> =
77            rows.iter().map(|(_, a, _, _)| a.clone()).collect();
78        let composition = memstead_base::anchor::compose_entity_anchors(&anchors_only);
79        print_json(&serde_json::json!({
80            "count": rows.len(),
81            "anchors": anchors_json,
82            "composition": composition,
83        }))?;
84    } else if rows.is_empty() {
85        let subject = args
86            .artifact
87            .as_deref()
88            .map(|p| format!("artifact `{p}`"))
89            .or_else(|| args.id.as_deref().map(|i| format!("entity `{i}`")))
90            .unwrap_or_default();
91        print_markdown(&format!("# Anchors\n\nNo anchors for {subject}."));
92    } else {
93        let mut body = format!("# Anchors ({})\n", rows.len());
94        for (id, a, state, observed_at) in &rows {
95            let hash = a.hash.as_deref().unwrap_or("-");
96            let state_str = state
97                .map(|s| format!(", state: {}", s.as_wire()))
98                .unwrap_or_default();
99            // A recorded observation's age travels with its state: a url
100            // row's state is exactly as current as the observation it rests on.
101            let age_str = observed_at
102                .as_deref()
103                .map(|at| {
104                    let days = memstead_base::anchor::days_between(at, &now).unwrap_or(0);
105                    format!(", observed {at}, unobserved for {days} day(s)")
106                })
107                .unwrap_or_default();
108            body.push_str(&format!(
109                "\n- `{id}` — {} {} `{}` (hash: {hash}{state_str}{age_str})",
110                a.class.as_wire(),
111                a.grain.as_wire(),
112                a.artifact,
113            ));
114        }
115        print_markdown(&body);
116    }
117    Ok(())
118}
119
120/// One anchor row: `(entity_id, anchor, live_state, observed_at)` — the
121/// last element is present for a row whose state rests on a recorded
122/// observation (a `url` row) rather than a live one.
123type AnchorRow = (
124    String,
125    memstead_base::anchor::Anchor,
126    Option<memstead_base::anchor::AnchorState>,
127    Option<String>,
128);
129
130/// Gather anchor rows from an engine per the requested mode. The by-entity
131/// lookup carries the live resolution state; the reverse `--artifact` lookup
132/// spans mems and carries none.
133fn collect(engine: &memstead_base::Engine, args: &Args) -> Vec<AnchorRow> {
134    if let Some(path) = args.artifact.as_deref() {
135        engine
136            .anchors_referencing_artifact(path)
137            .into_iter()
138            .map(|(id, a)| (id.to_string(), a, None, None))
139            .collect()
140    } else if let Some(id) = args.id.as_deref() {
141        let eid = EntityId::canonical(id);
142        engine
143            .entity_anchors_resolved(&eid)
144            .into_iter()
145            .map(|r| (eid.to_string(), r.anchor, r.state, r.observed_at))
146            .collect()
147    } else {
148        Vec::new()
149    }
150}