Skip to main content

zenith_cli/commands/
fmt.rs

1//! Pure logic for `zenith fmt`.
2//!
3//! The public entry point [`run`] operates entirely on in-memory source text;
4//! the caller is responsible for reading the original file and writing the
5//! formatted result back to disk.
6
7use sha2::{Digest, Sha256};
8use zenith_core::{KdlAdapter, KdlSource};
9
10use crate::commands::serialize_pretty;
11use crate::json_types::FmtOutput;
12
13// ── Result type ───────────────────────────────────────────────────────────────
14
15/// Error type for `fmt`.
16#[derive(Debug)]
17pub struct FmtErr {
18    /// Human-readable message.
19    pub message: String,
20    /// Exit code: 2 for parse or format errors.
21    pub exit_code: u8,
22}
23
24/// The outcome of a successful fmt run.
25#[derive(Debug)]
26pub struct FmtResult {
27    /// The canonical formatted bytes to write back to disk.
28    pub formatted: Vec<u8>,
29    /// Whether the formatted bytes differ from the original source.
30    pub changed: bool,
31    /// Hex-encoded hash of the formatted content (stable, deterministic).
32    pub hash: String,
33}
34
35// ── Public entry point ────────────────────────────────────────────────────────
36
37/// Parse `src`, format it canonically, and return the result.
38///
39/// Returns `Err(FmtErr)` on parse or format failure.  On success returns
40/// [`FmtResult`] with the formatted bytes, a `changed` flag, and a content hash.
41pub fn run(src: &str) -> Result<FmtResult, FmtErr> {
42    // Parse ─────────────────────────────────────────────────────────────────
43    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    // Format ─────────────────────────────────────────────────────────────────
49    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
64/// Render the fmt result for stdout.
65///
66/// If `json` is true emits a JSON object; otherwise a one-line human message.
67pub 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
82// ── Helpers ───────────────────────────────────────────────────────────────────
83
84/// Compute the lowercase hex-encoded SHA-256 of `bytes`.
85///
86/// SHA-256 is stable across toolchain versions and platforms, so the reported
87/// content hash is reproducible (unlike `DefaultHasher`) and consistent with the
88/// content-addressing model used elsewhere in the project.
89fn 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// ── Tests ─────────────────────────────────────────────────────────────────────
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    /// A valid `.zen` source used as input to the formatter tests.
106    ///
107    /// This does NOT need to be byte-for-byte canonical — idempotency is the
108    /// critical property verified by `fmt_is_idempotent`.
109    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        // First fmt produces the canonical form.
127        let first = run(FMT_INPUT).expect("must succeed");
128        // Second fmt on the canonical form must report changed=false.
129        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}