memstead_cli/commands/
anchors.rs1use 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#[derive(Parser, Debug)]
24pub struct Args {
25 pub id: Option<String>,
28
29 #[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 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 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 "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 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
160struct SidecarCondition {
162 mem: String,
163 reason: String,
164}
165
166fn 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
183type AnchorRow = (
187 String,
188 memstead_base::anchor::Anchor,
189 Option<memstead_base::anchor::AnchorState>,
190 Option<String>,
191);
192
193fn 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}