Skip to main content

agentstatedeveloper_cli/commands/
invariant.rs

1//! `asd invariant` — first-class shortcut for ledger invariant operations.
2//!
3//! Three subcommands:
4//!   add <qname> "<summary>"   — record an invariant that must hold at qname
5//!   list [<qname>]            — list all recorded invariants (or for a single symbol)
6//!   rm <entry-id>             — remove (withdraw) an invariant entry
7
8use anyhow::Result;
9use clap::{Args, Subcommand};
10use serde_json::json;
11
12use agentstatedeveloper_core::{
13    AsgIndexStore, AsgLedgerStore, Author, AuthorKind, Engine, IndexStore, LedgerEntry, LedgerKind,
14    LedgerStore,
15};
16
17use crate::commands::{graph::build_id_map, ledger::open_engine_public};
18use crate::config::Config;
19
20#[derive(Debug, Subcommand)]
21pub enum InvariantCmd {
22    /// Record an invariant that must hold at the given symbol.
23    Add(AddArgs),
24
25    /// List invariants — for one symbol or all symbols.
26    List(ListArgs),
27
28    /// Remove (withdraw) an invariant entry by its entry-id.
29    Rm(RmArgs),
30}
31
32#[derive(Debug, Args)]
33pub struct AddArgs {
34    /// Fully-qualified symbol name.
35    pub qname: String,
36
37    /// One-line invariant summary.
38    pub summary: String,
39
40    /// Author identifier recorded in the ledger entry.
41    #[arg(long, default_value = "asd-cli-user")]
42    pub author_id: String,
43}
44
45#[derive(Debug, Args)]
46pub struct ListArgs {
47    /// Filter to a single symbol's invariants (omit to show all).
48    pub qname: Option<String>,
49}
50
51#[derive(Debug, Args)]
52pub struct RmArgs {
53    /// Entry id of the invariant to remove (e.g. `led_abc…`).
54    pub entry_id: String,
55
56    /// Author id — must match the original entry's author.
57    #[arg(long, default_value = "asd-cli-user")]
58    pub author_id: String,
59}
60
61pub fn run(cfg: &Config, cmd: InvariantCmd) -> Result<()> {
62    match cmd {
63        InvariantCmd::Add(args) => add(cfg, args),
64        InvariantCmd::List(args) => list(cfg, args),
65        InvariantCmd::Rm(args) => rm(cfg, args),
66    }
67}
68
69fn add(cfg: &Config, args: AddArgs) -> Result<()> {
70    let engine = open_engine_public(cfg)?;
71    let index_store = AsgIndexStore::from_engine(&engine);
72    let symbol = index_store
73        .get_symbol_by_qname(&engine.ref_name, &args.qname)?
74        .ok_or_else(|| anyhow::anyhow!("symbol not found: {}", args.qname))?;
75
76    let author = Author {
77        kind: AuthorKind::Agent,
78        id: args.author_id.clone(),
79    };
80    let entry = LedgerEntry::new(
81        &symbol.symbol_id,
82        LedgerKind::Invariant,
83        args.summary.clone(),
84        author,
85    );
86
87    let ledger_store = AsgLedgerStore::from_engine(&engine);
88    ledger_store.append_entry(&engine.ref_name, &entry, &cfg.agent_id)?;
89
90    println!(
91        "{}",
92        serde_json::to_string_pretty(&json!({
93            "status": "added",
94            "entry_id": entry.entry_id,
95            "symbol_id": entry.symbol_id,
96            "qname": args.qname,
97            "summary": args.summary,
98        }))?
99    );
100    Ok(())
101}
102
103fn list(cfg: &Config, args: ListArgs) -> Result<()> {
104    let engine = Engine::open_sqlite(&cfg.db_path)?;
105    let id_map = build_id_map(&engine);
106
107    // Build a reverse map: symbol_id → qname.
108    let sym_to_qname: std::collections::HashMap<&str, &str> = id_map
109        .iter()
110        .map(|(id, sym)| (id.as_str(), sym.qname.as_str()))
111        .collect();
112
113    let rows: Vec<serde_json::Value> = if let Some(ref qname) = args.qname {
114        let index_store = AsgIndexStore::from_engine(&engine);
115        let ledger_store = AsgLedgerStore::from_engine(&engine);
116        let symbol = index_store
117            .get_symbol_by_qname(&engine.ref_name, qname)?
118            .ok_or_else(|| anyhow::anyhow!("symbol not found: {qname}"))?;
119        ledger_store
120            .list_entries(&engine.ref_name, &symbol.symbol_id)
121            .unwrap_or_default()
122            .into_iter()
123            .filter(|e| e.kind == LedgerKind::Invariant)
124            .map(|e| entry_to_json(e, qname))
125            .collect()
126    } else {
127        // Walk all ledger entries via raw tree (same approach as `asd list ledger`).
128        let tree = match engine.repo.get_tree(&engine.ref_name, "/asd/v1/ledger") {
129            Ok(v) => v,
130            _ => serde_json::Value::Null,
131        };
132        let mut rows = Vec::new();
133        if let Some(sym_map) = tree.as_object() {
134            for per_symbol in sym_map.values() {
135                if let Some(entry_map) = per_symbol.as_object() {
136                    for entry_val in entry_map.values() {
137                        if let Ok(e) = serde_json::from_value::<LedgerEntry>(entry_val.clone()) {
138                            if e.kind == LedgerKind::Invariant {
139                                let qname = sym_to_qname
140                                    .get(e.symbol_id.as_str())
141                                    .copied()
142                                    .unwrap_or("");
143                                rows.push(entry_to_json(e, qname));
144                            }
145                        }
146                    }
147                }
148            }
149        }
150        rows.sort_by(|a, b| {
151            a.get("qname")
152                .and_then(serde_json::Value::as_str)
153                .cmp(&b.get("qname").and_then(serde_json::Value::as_str))
154        });
155        rows
156    };
157
158    println!(
159        "{}",
160        serde_json::to_string_pretty(&json!({ "invariants": rows }))?
161    );
162    Ok(())
163}
164
165fn rm(cfg: &Config, args: RmArgs) -> Result<()> {
166    let engine = open_engine_public(cfg)?;
167    let ledger_store = AsgLedgerStore::from_engine(&engine);
168    let outcome = ledger_store.withdraw_entry(
169        &engine.ref_name,
170        &args.entry_id,
171        &args.author_id,
172        &cfg.agent_id,
173    )?;
174    let status = if outcome.already_resolved {
175        "already-withdrawn"
176    } else {
177        "withdrawn"
178    };
179    println!(
180        "{}",
181        serde_json::to_string_pretty(&json!({
182            "status": status,
183            "entry_id": outcome.entry.entry_id,
184            "symbol_id": outcome.entry.symbol_id,
185        }))?
186    );
187    Ok(())
188}
189
190fn entry_to_json(entry: agentstatedeveloper_core::LedgerEntry, qname: &str) -> serde_json::Value {
191    json!({
192        "entry_id": entry.entry_id,
193        "qname": qname,
194        "symbol_id": entry.symbol_id,
195        "summary": entry.summary,
196        "author": { "kind": format!("{:?}", entry.author.kind), "id": entry.author.id },
197        "created_at": entry.created_at,
198        "tags": entry.tags,
199    })
200}