Skip to main content

zenith_cli/commands/
tx.rs

1//! Pure logic for `zenith tx`.
2//!
3//! The public entry point [`run`] operates entirely on in-memory source text;
4//! the caller is responsible for all filesystem I/O and for deciding whether to
5//! persist `source_after` (the `--apply` flag lives in `lib.rs`, not here).
6
7use zenith_core::{KdlAdapter, KdlSource};
8use zenith_tx::{Transaction, TxResult, TxStatus, run_transaction};
9
10use crate::commands::serialize_pretty;
11use crate::json_types::{self, DiagnosticJson, TxOutputJson};
12
13// ── Error type ────────────────────────────────────────────────────────────────
14
15/// An error that prevents a [`TxOutcome`] from being produced.
16///
17/// Returned for doc-parse failures or transaction-JSON-parse failures.
18/// A *rejected* transaction still produces a `TxOutcome` (not a `TxCmdErr`).
19#[derive(Debug)]
20pub struct TxCmdErr {
21    /// Human-readable message.
22    pub message: String,
23    /// Recommended exit code (always 2 for parse errors).
24    pub exit_code: u8,
25}
26
27// ── Outcome type ──────────────────────────────────────────────────────────────
28
29/// The computed outcome of a successful transaction run (even a rejected one).
30#[derive(Debug)]
31pub struct TxOutcome {
32    /// The structured result from the engine.
33    pub result: TxResult,
34    /// Human-readable summary string (ready to print).
35    pub human: String,
36    /// JSON summary string (ready to print).
37    pub json_str: String,
38    /// Status-derived exit code: 0 for Accepted/AcceptedWithWarnings, 1 for Rejected.
39    pub exit_code: u8,
40}
41
42// ── Public entry point ────────────────────────────────────────────────────────
43
44/// Parse the document source and transaction JSON, run the transaction engine,
45/// and return a [`TxOutcome`].
46///
47/// Returns `Err(TxCmdErr { exit_code: 2 })` if either the document or the
48/// transaction JSON fails to parse.  A *rejected* transaction is **not** an
49/// error at this level — it returns `Ok(TxOutcome { exit_code: 1 })`.
50///
51/// This function never touches the filesystem.
52pub fn run(doc_src: &str, tx_json: &str) -> Result<TxOutcome, TxCmdErr> {
53    // Parse document ─────────────────────────────────────────────────────────
54    let doc = KdlAdapter.parse(doc_src.as_bytes()).map_err(|e| TxCmdErr {
55        message: format!("error[parse.error]: {}", e.message),
56        exit_code: 2,
57    })?;
58
59    // Parse transaction ──────────────────────────────────────────────────────
60    let tx = Transaction::from_json(tx_json).map_err(|e| TxCmdErr {
61        message: format!("error[tx.parse]: {}", e.message),
62        exit_code: 2,
63    })?;
64
65    // Run engine ─────────────────────────────────────────────────────────────
66    let result = run_transaction(&doc, &tx).map_err(|e| TxCmdErr {
67        message: format!("error[tx.engine]: {}", e.message),
68        exit_code: 2,
69    })?;
70
71    let exit_code = status_exit_code(&result.status);
72    let human = render_human(&result);
73    let json_str = render_json(&result);
74
75    Ok(TxOutcome {
76        result,
77        human,
78        json_str,
79        exit_code,
80    })
81}
82
83// ── Output renderers ──────────────────────────────────────────────────────────
84
85/// Render a human-readable summary of the transaction result.
86pub fn render_human(result: &TxResult) -> String {
87    let status_label = match result.status {
88        TxStatus::Accepted => "accepted",
89        TxStatus::AcceptedWithWarnings => "accepted (with warnings)",
90        TxStatus::Rejected => "rejected",
91    };
92
93    let changed = result.source_before != result.source_after;
94
95    let mut out = String::new();
96    out.push_str(&format!("status: {}\n", status_label));
97    out.push_str(&format!("changed: {}\n", changed));
98
99    if result.affected_node_ids.is_empty() {
100        out.push_str("affected: (none)\n");
101    } else {
102        out.push_str(&format!(
103            "affected: {}\n",
104            result.affected_node_ids.join(", ")
105        ));
106    }
107
108    if result.diagnostics.is_empty() {
109        out.push_str("diagnostics: (none)");
110    } else {
111        out.push_str("diagnostics:");
112        for d in &result.diagnostics {
113            let sev = json_types::severity_str(&d.severity);
114            let subject = d
115                .subject_id
116                .as_deref()
117                .map(|s| format!(" ({})", s))
118                .unwrap_or_default();
119            out.push_str(&format!(
120                "\n  {}[{}]{}: {}",
121                sev, d.code, subject, d.message
122            ));
123        }
124    }
125
126    out
127}
128
129/// Render a JSON summary of the transaction result.
130fn render_json(result: &TxResult) -> String {
131    let changed = result.source_before != result.source_after;
132    let status = match result.status {
133        TxStatus::Accepted => "accepted",
134        TxStatus::AcceptedWithWarnings => "accepted_with_warnings",
135        TxStatus::Rejected => "rejected",
136    };
137    let out = TxOutputJson {
138        schema: "zenith-tx-v1",
139        status: status.to_owned(),
140        affected: result.affected_node_ids.clone(),
141        diagnostics: result
142            .diagnostics
143            .iter()
144            .map(DiagnosticJson::from)
145            .collect(),
146        changed,
147    };
148    serialize_pretty(&out)
149}
150
151// ── Exit-code helper ──────────────────────────────────────────────────────────
152
153/// Map a `TxStatus` to an exit code.
154///
155/// `Accepted` and `AcceptedWithWarnings` → 0.  `Rejected` → 1.
156fn status_exit_code(status: &TxStatus) -> u8 {
157    match status {
158        TxStatus::Accepted | TxStatus::AcceptedWithWarnings => 0,
159        TxStatus::Rejected => 1,
160    }
161}
162
163// ── Tests ─────────────────────────────────────────────────────────────────────
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    /// Minimal document with a text node and a rect node.
170    const SMALL_DOC: &str = r##"zenith version=1 {
171  project id="proj.tx" name="Tx Test"
172  tokens format="zenith-token-v1" { }
173  styles { }
174  document id="doc.tx" title="Tx" {
175    page id="pg.tx" w=(px)400 h=(px)300 {
176      rect id="box.tx" x=(px)0 y=(px)0 w=(px)400 h=(px)300
177      text id="lbl.tx" x=(px)10 y=(px)10 w=(px)200 h=(px)40 {
178        span "hello"
179      }
180    }
181  }
182}"##;
183
184    // ── 1. Valid set_text_align → Accepted, changed, exit 0 ──────────────────
185
186    #[test]
187    fn valid_set_text_align_accepted() {
188        let tx_json = r#"{"ops":[{"op":"set_text_align","node":"lbl.tx","align":"center"}]}"#;
189        let outcome = run(SMALL_DOC, tx_json).expect("should not be a parse error");
190
191        assert_eq!(outcome.exit_code, 0, "Accepted must yield exit code 0");
192        assert_eq!(outcome.result.status, TxStatus::Accepted);
193
194        let changed = outcome.result.source_before != outcome.result.source_after;
195        assert!(changed, "source must differ after set_text_align");
196
197        assert!(
198            outcome
199                .result
200                .affected_node_ids
201                .contains(&"lbl.tx".to_owned()),
202            "affected_node_ids must contain lbl.tx"
203        );
204
205        assert!(
206            outcome.result.source_after.contains("center"),
207            "source_after must contain align=\"center\""
208        );
209    }
210
211    // ── 2. Unknown node → Rejected, unchanged, exit 1 ────────────────────────
212
213    #[test]
214    fn unknown_node_rejected_exit_1() {
215        let tx_json = r#"{"ops":[{"op":"set_text_align","node":"no.such.node","align":"center"}]}"#;
216        let outcome = run(SMALL_DOC, tx_json).expect("should not be a parse error");
217
218        assert_eq!(outcome.exit_code, 1, "Rejected must yield exit code 1");
219        assert_eq!(outcome.result.status, TxStatus::Rejected);
220
221        let changed = outcome.result.source_before != outcome.result.source_after;
222        assert!(!changed, "source must not change on rejection");
223
224        assert!(
225            outcome.result.affected_node_ids.is_empty(),
226            "no nodes should be affected on rejection"
227        );
228    }
229
230    // ── 3. Malformed tx JSON → Err(exit_code 2) ───────────────────────────────
231
232    #[test]
233    fn malformed_tx_json_returns_err_exit_2() {
234        let tx_json = r#"{"ops": [THIS IS NOT JSON]}"#;
235        let err = run(SMALL_DOC, tx_json).expect_err("malformed JSON must be Err");
236        assert_eq!(err.exit_code, 2, "parse error must yield exit code 2");
237        assert!(!err.message.is_empty(), "error message must not be empty");
238    }
239
240    // ── 4. Malformed doc → Err(exit_code 2) ──────────────────────────────────
241
242    #[test]
243    fn malformed_doc_returns_err_exit_2() {
244        let tx_json = r#"{"ops":[{"op":"set_text_align","node":"x","align":"center"}]}"#;
245        let err = run("not kdl at all {{{", tx_json).expect_err("malformed doc must be Err");
246        assert_eq!(err.exit_code, 2, "doc parse error must yield exit code 2");
247    }
248
249    // ── 5. JSON output contains schema ───────────────────────────────────────
250
251    #[test]
252    fn json_output_contains_schema() {
253        let tx_json = r#"{"ops":[{"op":"set_text_align","node":"lbl.tx","align":"center"}]}"#;
254        let outcome = run(SMALL_DOC, tx_json).expect("should succeed");
255        assert!(
256            outcome.json_str.contains("zenith-tx-v1"),
257            "JSON output must contain schema field; got: {}",
258            outcome.json_str
259        );
260    }
261
262    // ── 6. Human output contains status line ─────────────────────────────────
263
264    #[test]
265    fn human_output_contains_status() {
266        let tx_json = r#"{"ops":[{"op":"set_text_align","node":"lbl.tx","align":"center"}]}"#;
267        let outcome = run(SMALL_DOC, tx_json).expect("should succeed");
268        assert!(
269            outcome.human.contains("status:"),
270            "human output must contain 'status:'; got: {}",
271            outcome.human
272        );
273    }
274}