zenith_cli/commands/
fmt.rs1use sha2::{Digest, Sha256};
8use zenith_core::{KdlAdapter, KdlSource};
9
10use crate::commands::serialize_pretty;
11use crate::json_types::FmtOutput;
12
13#[derive(Debug)]
17pub struct FmtErr {
18 pub message: String,
20 pub exit_code: u8,
22}
23
24#[derive(Debug)]
26pub struct FmtResult {
27 pub formatted: Vec<u8>,
29 pub changed: bool,
31 pub hash: String,
33}
34
35pub fn run(src: &str) -> Result<FmtResult, FmtErr> {
42 let doc = KdlAdapter.parse(src.as_bytes()).map_err(|e| FmtErr {
44 message: format!("parse error: {}", e.message),
45 exit_code: 2,
46 })?;
47
48 let formatted = KdlAdapter.format(&doc).map_err(|e| FmtErr {
50 message: format!("format error: {}", e.message),
51 exit_code: 2,
52 })?;
53
54 let changed = formatted != src.as_bytes();
55 let hash = hex_hash(&formatted);
56
57 Ok(FmtResult {
58 formatted,
59 changed,
60 hash,
61 })
62}
63
64pub fn render_stdout(result: &FmtResult, json: bool) -> String {
68 if json {
69 let out = FmtOutput {
70 schema: "zenith-fmt-v1",
71 changed: result.changed,
72 hash: result.hash.clone(),
73 };
74 serialize_pretty(&out)
75 } else if result.changed {
76 format!("formatted (hash: {})", result.hash)
77 } else {
78 format!("already canonical (hash: {})", result.hash)
79 }
80}
81
82fn hex_hash(bytes: &[u8]) -> String {
90 let digest = Sha256::digest(bytes);
91 let mut out = String::with_capacity(digest.len() * 2);
92 for byte in digest {
93 use std::fmt::Write as _;
94 let _ = write!(out, "{byte:02x}");
95 }
96 out
97}
98
99#[cfg(test)]
102mod tests {
103 use super::*;
104
105 const FMT_INPUT: &str = r##"zenith version=1 {
110 project id="proj.f" name="Fmt Test"
111 tokens format="zenith-token-v1" {
112 token id="color.bg" type="color" value="#f8fafc"
113 }
114 styles {
115 }
116 document id="doc.f" title="Fmt Test" {
117 page id="page.f" w=(px)320 h=(px)200 {
118 rect id="rect.f" x=(px)0 y=(px)0 w=(px)320 h=(px)200 fill=(token)"color.bg"
119 }
120 }
121}
122"##;
123
124 #[test]
125 fn already_formatted_doc_reports_not_changed() {
126 let first = run(FMT_INPUT).expect("must succeed");
128 let canonical = std::str::from_utf8(&first.formatted).expect("utf8");
130 let second = run(canonical).expect("second run");
131 assert!(
132 !second.changed,
133 "fmt on already-canonical doc must report changed=false"
134 );
135 }
136
137 #[test]
138 fn fmt_is_idempotent() {
139 let first = run(FMT_INPUT).expect("first fmt");
140 let second = run(std::str::from_utf8(&first.formatted).expect("utf8")).expect("second fmt");
141 assert_eq!(
142 first.formatted, second.formatted,
143 "fmt must be idempotent: fmt(fmt(x)) == fmt(x)"
144 );
145 }
146
147 #[test]
148 fn parse_error_returns_err() {
149 let result = run("not valid kdl {{{");
150 assert!(result.is_err(), "parse error must return Err");
151 assert_eq!(result.unwrap_err().exit_code, 2);
152 }
153
154 #[test]
155 fn hash_is_stable() {
156 let r1 = run(FMT_INPUT).expect("r1");
157 let r2 = run(FMT_INPUT).expect("r2");
158 assert_eq!(r1.hash, r2.hash, "hash must be stable across runs");
159 }
160}