1use 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#[derive(Debug)]
20pub struct TxCmdErr {
21 pub message: String,
23 pub exit_code: u8,
25}
26
27#[derive(Debug)]
31pub struct TxOutcome {
32 pub result: TxResult,
34 pub human: String,
36 pub json_str: String,
38 pub exit_code: u8,
40}
41
42pub fn run(doc_src: &str, tx_json: &str) -> Result<TxOutcome, TxCmdErr> {
53 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 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 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
83pub 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
129fn 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
151fn status_exit_code(status: &TxStatus) -> u8 {
157 match status {
158 TxStatus::Accepted | TxStatus::AcceptedWithWarnings => 0,
159 TxStatus::Rejected => 1,
160 }
161}
162
163#[cfg(test)]
166mod tests {
167 use super::*;
168
169 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 #[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 #[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 #[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 #[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 #[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 #[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}