use zenith_core::{KdlAdapter, KdlSource};
use zenith_tx::{Transaction, TxResult, TxStatus, run_transaction};
use crate::commands::serialize_pretty;
use crate::json_types::{self, DiagnosticJson, TxOutputJson};
#[derive(Debug)]
pub struct TxCmdErr {
pub message: String,
pub exit_code: u8,
}
#[derive(Debug)]
pub struct TxOutcome {
pub result: TxResult,
pub human: String,
pub json_str: String,
pub exit_code: u8,
}
pub fn run(doc_src: &str, tx_json: &str) -> Result<TxOutcome, TxCmdErr> {
let doc = KdlAdapter.parse(doc_src.as_bytes()).map_err(|e| TxCmdErr {
message: format!("error[parse.error]: {}", e.message),
exit_code: 2,
})?;
let tx = Transaction::from_json(tx_json).map_err(|e| TxCmdErr {
message: format!("error[tx.parse]: {}", e.message),
exit_code: 2,
})?;
let result = run_transaction(&doc, &tx).map_err(|e| TxCmdErr {
message: format!("error[tx.engine]: {}", e.message),
exit_code: 2,
})?;
let exit_code = status_exit_code(&result.status);
let human = render_human(&result);
let json_str = render_json(&result);
Ok(TxOutcome {
result,
human,
json_str,
exit_code,
})
}
pub fn render_human(result: &TxResult) -> String {
let status_label = match result.status {
TxStatus::Accepted => "accepted",
TxStatus::AcceptedWithWarnings => "accepted (with warnings)",
TxStatus::Rejected => "rejected",
};
let changed = result.source_before != result.source_after;
let mut out = String::new();
out.push_str(&format!("status: {}\n", status_label));
out.push_str(&format!("changed: {}\n", changed));
if result.affected_node_ids.is_empty() {
out.push_str("affected: (none)\n");
} else {
out.push_str(&format!(
"affected: {}\n",
result.affected_node_ids.join(", ")
));
}
if result.diagnostics.is_empty() {
out.push_str("diagnostics: (none)");
} else {
out.push_str("diagnostics:");
for d in &result.diagnostics {
let sev = json_types::severity_str(&d.severity);
let subject = d
.subject_id
.as_deref()
.map(|s| format!(" ({})", s))
.unwrap_or_default();
out.push_str(&format!(
"\n {}[{}]{}: {}",
sev, d.code, subject, d.message
));
}
}
out
}
fn render_json(result: &TxResult) -> String {
let changed = result.source_before != result.source_after;
let status = match result.status {
TxStatus::Accepted => "accepted",
TxStatus::AcceptedWithWarnings => "accepted_with_warnings",
TxStatus::Rejected => "rejected",
};
let out = TxOutputJson {
schema: "zenith-tx-v1",
status: status.to_owned(),
affected: result.affected_node_ids.clone(),
diagnostics: result
.diagnostics
.iter()
.map(DiagnosticJson::from)
.collect(),
changed,
};
serialize_pretty(&out)
}
fn status_exit_code(status: &TxStatus) -> u8 {
match status {
TxStatus::Accepted | TxStatus::AcceptedWithWarnings => 0,
TxStatus::Rejected => 1,
}
}
#[cfg(test)]
mod tests {
use super::*;
const SMALL_DOC: &str = r##"zenith version=1 {
project id="proj.tx" name="Tx Test"
tokens format="zenith-token-v1" { }
styles { }
document id="doc.tx" title="Tx" {
page id="pg.tx" w=(px)400 h=(px)300 {
rect id="box.tx" x=(px)0 y=(px)0 w=(px)400 h=(px)300
text id="lbl.tx" x=(px)10 y=(px)10 w=(px)200 h=(px)40 {
span "hello"
}
}
}
}"##;
#[test]
fn valid_set_text_align_accepted() {
let tx_json = r#"{"ops":[{"op":"set_text_align","node":"lbl.tx","align":"center"}]}"#;
let outcome = run(SMALL_DOC, tx_json).expect("should not be a parse error");
assert_eq!(outcome.exit_code, 0, "Accepted must yield exit code 0");
assert_eq!(outcome.result.status, TxStatus::Accepted);
let changed = outcome.result.source_before != outcome.result.source_after;
assert!(changed, "source must differ after set_text_align");
assert!(
outcome
.result
.affected_node_ids
.contains(&"lbl.tx".to_owned()),
"affected_node_ids must contain lbl.tx"
);
assert!(
outcome.result.source_after.contains("center"),
"source_after must contain align=\"center\""
);
}
#[test]
fn unknown_node_rejected_exit_1() {
let tx_json = r#"{"ops":[{"op":"set_text_align","node":"no.such.node","align":"center"}]}"#;
let outcome = run(SMALL_DOC, tx_json).expect("should not be a parse error");
assert_eq!(outcome.exit_code, 1, "Rejected must yield exit code 1");
assert_eq!(outcome.result.status, TxStatus::Rejected);
let changed = outcome.result.source_before != outcome.result.source_after;
assert!(!changed, "source must not change on rejection");
assert!(
outcome.result.affected_node_ids.is_empty(),
"no nodes should be affected on rejection"
);
}
#[test]
fn malformed_tx_json_returns_err_exit_2() {
let tx_json = r#"{"ops": [THIS IS NOT JSON]}"#;
let err = run(SMALL_DOC, tx_json).expect_err("malformed JSON must be Err");
assert_eq!(err.exit_code, 2, "parse error must yield exit code 2");
assert!(!err.message.is_empty(), "error message must not be empty");
}
#[test]
fn malformed_doc_returns_err_exit_2() {
let tx_json = r#"{"ops":[{"op":"set_text_align","node":"x","align":"center"}]}"#;
let err = run("not kdl at all {{{", tx_json).expect_err("malformed doc must be Err");
assert_eq!(err.exit_code, 2, "doc parse error must yield exit code 2");
}
#[test]
fn json_output_contains_schema() {
let tx_json = r#"{"ops":[{"op":"set_text_align","node":"lbl.tx","align":"center"}]}"#;
let outcome = run(SMALL_DOC, tx_json).expect("should succeed");
assert!(
outcome.json_str.contains("zenith-tx-v1"),
"JSON output must contain schema field; got: {}",
outcome.json_str
);
}
#[test]
fn human_output_contains_status() {
let tx_json = r#"{"ops":[{"op":"set_text_align","node":"lbl.tx","align":"center"}]}"#;
let outcome = run(SMALL_DOC, tx_json).expect("should succeed");
assert!(
outcome.human.contains("status:"),
"human output must contain 'status:'; got: {}",
outcome.human
);
}
}