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: 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 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
120type AnchorRow = (
124 String,
125 memstead_base::anchor::Anchor,
126 Option<memstead_base::anchor::AnchorState>,
127 Option<String>,
128);
129
130fn 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}