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    if ctx.json {
56        let anchors_json: Vec<serde_json::Value> = rows
57            .iter()
58            .map(|(id, a, state)| {
59                let mut v = serde_json::to_value(a).unwrap_or(serde_json::Value::Null);
60                if let Some(obj) = v.as_object_mut() {
61                    obj.insert("entity_id".into(), serde_json::json!(id));
62                    if let Some(s) = state {
63                        obj.insert("state".into(), serde_json::json!(s.as_wire()));
64                    }
65                }
66                v
67            })
68            .collect();
69        let anchors_only: Vec<memstead_base::anchor::Anchor> =
70            rows.iter().map(|(_, a, _)| a.clone()).collect();
71        let composition = memstead_base::anchor::compose_entity_anchors(&anchors_only);
72        print_json(&serde_json::json!({
73            "count": rows.len(),
74            "anchors": anchors_json,
75            "composition": composition,
76        }))?;
77    } else if rows.is_empty() {
78        let subject = args
79            .artifact
80            .as_deref()
81            .map(|p| format!("artifact `{p}`"))
82            .or_else(|| args.id.as_deref().map(|i| format!("entity `{i}`")))
83            .unwrap_or_default();
84        print_markdown(&format!("# Anchors\n\nNo anchors for {subject}."));
85    } else {
86        let mut body = format!("# Anchors ({})\n", rows.len());
87        for (id, a, state) in &rows {
88            let hash = a.hash.as_deref().unwrap_or("-");
89            let state_str = state
90                .map(|s| format!(", state: {}", s.as_wire()))
91                .unwrap_or_default();
92            body.push_str(&format!(
93                "\n- `{id}` — {} {} `{}` (hash: {hash}{state_str})",
94                a.class.as_wire(),
95                a.grain.as_wire(),
96                a.artifact,
97            ));
98        }
99        print_markdown(&body);
100    }
101    Ok(())
102}
103
104/// One anchor row: `(entity_id, anchor, live_state)`.
105type AnchorRow = (
106    String,
107    memstead_base::anchor::Anchor,
108    Option<memstead_base::anchor::AnchorState>,
109);
110
111/// Gather anchor rows from an engine per the requested mode. The by-entity
112/// lookup carries the live resolution state; the reverse `--artifact` lookup
113/// spans mems and carries none.
114fn collect(engine: &memstead_base::Engine, args: &Args) -> Vec<AnchorRow> {
115    if let Some(path) = args.artifact.as_deref() {
116        engine
117            .anchors_referencing_artifact(path)
118            .into_iter()
119            .map(|(id, a)| (id.to_string(), a, None))
120            .collect()
121    } else if let Some(id) = args.id.as_deref() {
122        let eid = EntityId::canonical(id);
123        engine
124            .entity_anchors_resolved(&eid)
125            .into_iter()
126            .map(|r| (eid.to_string(), r.anchor, r.state))
127            .collect()
128    } else {
129        Vec::new()
130    }
131}