Skip to main content

mkit_cli/
format.rs

1//! Human-oriented output formatters — the CLI's thin presentation
2//! layer. Anything that emits canonical on-disk or wire bytes belongs
3//! in `mkit-core` (`serialize.rs`, `pack.rs`, etc.), not here.
4
5use mkit_core::hash::Hash;
6
7/// Render a [`Hash`](tyalias@mkit_core::Hash) as 64 lowercase hex chars. Wrapper over
8/// `mkit_core`'s byte-level API that keeps a stable name at this layer.
9#[must_use]
10pub fn hex_hash(h: &Hash) -> String {
11    mkit_core::hash::to_hex(h)
12}
13
14/// Render the first `n` hex chars of a hash (min 4, max 64).
15#[must_use]
16pub fn short_hash(h: &Hash, n: usize) -> String {
17    let full = hex_hash(h);
18    let take = n.clamp(4, 64);
19    full[..take].to_owned()
20}
21
22static HEX_ALPHABET: &[u8; 16] = b"0123456789abcdef";
23
24/// Render a short [`mkit_core::Identity`]: for 8-byte opaque keys we
25/// show the LE u64 decimal; otherwise `<kind>:<8-hex>`.
26#[must_use]
27pub fn short_identity(id: &mkit_core::Identity) -> String {
28    match id.kind {
29        mkit_core::IdentityKind::Opaque if id.bytes.len() == 8 => {
30            let mut arr = [0u8; 8];
31            arr.copy_from_slice(&id.bytes);
32            u64::from_le_bytes(arr).to_string()
33        }
34        // A DidKey payload is a printable-ASCII multibase string, so show a
35        // readable prefix of it (e.g. `did:key:z6MkExam`) rather than hex.
36        mkit_core::IdentityKind::DidKey => {
37            let s = String::from_utf8_lossy(&id.bytes);
38            let prefix: String = s.chars().take(8).collect();
39            format!("did:key:{prefix}")
40        }
41        // Printable opaque identities (e.g. an imported git
42        // `Name <email>` carried verbatim) render as their text — the
43        // hex fallback below is for genuinely binary payloads.
44        mkit_core::IdentityKind::Opaque if printable_text(&id.bytes).is_some() => {
45            printable_text(&id.bytes).unwrap_or_default().to_owned()
46        }
47        kind => {
48            let kind_name = match kind {
49                mkit_core::IdentityKind::Ed25519 => "ed25519",
50                mkit_core::IdentityKind::DidKey => "did:key",
51                mkit_core::IdentityKind::Opaque => "opaque",
52            };
53            let take = id.bytes.len().min(4);
54            let mut hex = String::with_capacity(take * 2);
55            for b in &id.bytes[..take] {
56                hex.push(HEX_ALPHABET[(b >> 4) as usize] as char);
57                hex.push(HEX_ALPHABET[(b & 0x0F) as usize] as char);
58            }
59            format!("{kind_name}:{hex}")
60        }
61    }
62}
63
64/// The payload as text iff it is valid UTF-8 with no control
65/// characters (terminal-safe to print verbatim).
66fn printable_text(bytes: &[u8]) -> Option<&str> {
67    let s = std::str::from_utf8(bytes).ok()?;
68    (!s.is_empty() && !s.chars().any(char::is_control)).then_some(s)
69}
70
71/// Full-detail rendering of an [`mkit_core::Identity`] suitable for
72/// machine-readable output (e.g. JSONL from `mkit log --format=json`).
73///
74/// Format mirrors the parser shorthands accepted by `mkit config
75/// user.identity` / `--author` so a value emitted here round-trips:
76/// `ed25519:<full-hex>`, `did:key:<multibase>` (the payload verbatim,
77/// matching `--author did:key:…`), `mid:<decimal-u64>` for 8-byte opaque
78/// keys, and `opaque:<full-hex>` for other opaque lengths.
79#[must_use]
80pub fn full_identity(id: &mkit_core::Identity) -> String {
81    match id.kind {
82        mkit_core::IdentityKind::Opaque if id.bytes.len() == 8 => {
83            let mut arr = [0u8; 8];
84            arr.copy_from_slice(&id.bytes);
85            format!("mid:{}", u64::from_le_bytes(arr))
86        }
87        mkit_core::IdentityKind::Ed25519 => format!("ed25519:{}", to_hex(&id.bytes)),
88        // DidKey bytes are the multibase payload (printable ASCII); emit it
89        // verbatim so it round-trips through `--author did:key:<multibase>`.
90        mkit_core::IdentityKind::DidKey => {
91            format!("did:key:{}", String::from_utf8_lossy(&id.bytes))
92        }
93        mkit_core::IdentityKind::Opaque => format!("opaque:{}", to_hex(&id.bytes)),
94    }
95}
96
97/// Escape a Rust string for inclusion in a JSON string literal.
98/// Sufficient for the small, known fields emitted by `--format=json`
99/// callers (commit messages, hashes, identity strings). Does NOT
100/// handle surrogate pairs — UTF-8 round-trips as itself since JSON
101/// strings are UTF-8.
102#[must_use]
103pub fn json_escape(s: &str) -> String {
104    let mut out = String::with_capacity(s.len() + 2);
105    for c in s.chars() {
106        match c {
107            '"' => out.push_str("\\\""),
108            '\\' => out.push_str("\\\\"),
109            '\n' => out.push_str("\\n"),
110            '\r' => out.push_str("\\r"),
111            '\t' => out.push_str("\\t"),
112            '\x08' => out.push_str("\\b"),
113            '\x0c' => out.push_str("\\f"),
114            c if (c as u32) < 0x20 => {
115                use std::fmt::Write as _;
116                let _ = write!(out, "\\u{:04x}", c as u32);
117            }
118            c => out.push(c),
119        }
120    }
121    out
122}
123
124/// Render a Unix timestamp (seconds since the epoch, UTC) as a stable,
125/// human-readable string: `YYYY-MM-DD HH:MM:SS +0000`.
126///
127/// The format is fixed UTC (`+0000`) and intentionally locale- and
128/// timezone-independent so log output is reproducible across machines.
129/// Machine-readable callers (e.g. `mkit log --format=json`) keep the
130/// raw integer instead — only the default human log uses this.
131///
132/// Implemented with Howard Hinnant's civil-from-days algorithm to avoid
133/// pulling in a date/time crate. Valid for the entire `u64` range.
134#[must_use]
135pub fn human_date_utc(secs: u64) -> String {
136    let days = i64::try_from(secs / 86_400).unwrap_or(i64::MAX);
137    let rem = secs % 86_400;
138    let hour = rem / 3_600;
139    let minute = (rem % 3_600) / 60;
140    let second = rem % 60;
141
142    // Civil date from a day count relative to 1970-01-01 (Hinnant).
143    let z = days + 719_468;
144    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
145    let doe = z - era * 146_097; // [0, 146096]
146    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
147    let y = yoe + era * 400;
148    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
149    let mp = (5 * doy + 2) / 153; // [0, 11]
150    let day = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
151    let month = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
152    let year = if month <= 2 { y + 1 } else { y };
153
154    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02} +0000")
155}
156
157fn to_hex(bytes: &[u8]) -> String {
158    let mut out = String::with_capacity(bytes.len() * 2);
159    for b in bytes {
160        out.push(HEX_ALPHABET[(b >> 4) as usize] as char);
161        out.push(HEX_ALPHABET[(b & 0x0F) as usize] as char);
162    }
163    out
164}
165
166/// Default abbreviation length for the git-style ref-update summary
167/// lines (`<old7>..<new7>`). Matches `log --oneline`'s default; mkit ids
168/// stay BLAKE3 prefixes (the documented hash-length divergence).
169pub const SUMMARY_ABBREV: usize = 7;
170
171/// A single git-style ref-update summary line, as printed under the
172/// `To <url>` / `From <url>` header of a push or fetch. `old` is the
173/// previous value of the destination ref (None = the ref did not exist),
174/// `new` the value just written; `src -> dst` is the refspec mapping.
175///
176/// Shapes match git's `transport.c` for the single-ref case:
177/// - new ref:   ` * [new branch]      <src> -> <dst>`
178/// - forced:    ` + <old>...<new> <src> -> <dst> (forced update)`
179/// - fast-fwd:  `   <old>..<new>  <src> -> <dst>`
180///
181/// Object ids are mkit BLAKE3 prefixes rather than git SHA-1 (documented
182/// divergence); everything else is byte-shaped like git.
183#[must_use]
184pub fn ref_update_line(
185    old: Option<&Hash>,
186    new: &Hash,
187    src: &str,
188    dst: &str,
189    forced: bool,
190) -> String {
191    let n = short_hash(new, SUMMARY_ABBREV);
192    match old {
193        None => format!(" * [new branch]      {src} -> {dst}"),
194        Some(o) => {
195            let o = short_hash(o, SUMMARY_ABBREV);
196            if forced {
197                format!(" + {o}...{n} {src} -> {dst} (forced update)")
198            } else {
199                format!("   {o}..{n}  {src} -> {dst}")
200            }
201        }
202    }
203}
204
205/// The git-style rejected-ref summary line (non-fast-forward), printed
206/// alongside the actionable hint when a push is refused.
207#[must_use]
208pub fn ref_rejected_line(src: &str, dst: &str) -> String {
209    format!(" ! [rejected]        {src} -> {dst} (non-fast-forward)")
210}
211
212/// A minimal single-object JSON builder for `--format=json` on the
213/// mutating commands (`commit`, `push`, `pull`, `fetch`, `merge`,
214/// `cherry-pick`, `revert`, `rebase`, `stash`, `tag`, `verify-attest`):
215/// each invocation emits exactly one JSON object to stdout describing
216/// the outcome, unlike `log`/`branch`'s per-record JSONL streaming.
217///
218/// Keeps the same hand-rolled-escaping approach as the rest of this
219/// module (`json_escape`) rather than pulling `serde_json` into the
220/// CLI's presentation layer — see `branch.rs`/`log.rs` for the
221/// precedent this mirrors. Fields are written in insertion order, so
222/// callers should add them in a fixed, documented order to keep output
223/// deterministic and snapshot-friendly.
224#[derive(Debug, Default)]
225pub struct JsonObject {
226    buf: String,
227    first: bool,
228}
229
230impl JsonObject {
231    #[must_use]
232    pub fn new() -> Self {
233        Self {
234            buf: String::from("{"),
235            first: true,
236        }
237    }
238
239    fn comma(&mut self) {
240        if !self.first {
241            self.buf.push(',');
242        }
243        self.first = false;
244    }
245
246    /// Append `"<key>":"<escaped value>"`.
247    pub fn field_str(&mut self, key: &str, value: &str) -> &mut Self {
248        self.comma();
249        self.buf.push('"');
250        self.buf.push_str(key);
251        self.buf.push_str("\":\"");
252        self.buf.push_str(&json_escape(value));
253        self.buf.push('"');
254        self
255    }
256
257    /// Append `"<key>":<hash-as-64-hex-string>"`.
258    pub fn field_hash(&mut self, key: &str, h: &Hash) -> &mut Self {
259        self.field_str(key, &hex_hash(h))
260    }
261
262    /// Append `"<key>":null` when `h` is `None`, else the hex hash.
263    pub fn field_opt_hash(&mut self, key: &str, h: Option<&Hash>) -> &mut Self {
264        match h {
265            Some(h) => self.field_hash(key, h),
266            None => self.field_raw(key, "null"),
267        }
268    }
269
270    /// Append `"<key>":null` when `s` is `None`, else the escaped string.
271    pub fn field_opt_str(&mut self, key: &str, s: Option<&str>) -> &mut Self {
272        match s {
273            Some(s) => self.field_str(key, s),
274            None => self.field_raw(key, "null"),
275        }
276    }
277
278    /// Append `"<key>":true`/`"<key>":false`.
279    pub fn field_bool(&mut self, key: &str, v: bool) -> &mut Self {
280        self.field_raw(key, if v { "true" } else { "false" })
281    }
282
283    /// Append `"<key>":<integer>`.
284    pub fn field_u64(&mut self, key: &str, v: u64) -> &mut Self {
285        use std::fmt::Write as _;
286        self.comma();
287        let _ = write!(self.buf, "\"{key}\":{v}");
288        self
289    }
290
291    /// Append `"<key>":<raw>` verbatim — `raw` must already be valid
292    /// JSON (a literal, number, array, or nested object built via a
293    /// nested `JsonObject`/`json_string_array`).
294    pub fn field_raw(&mut self, key: &str, raw: &str) -> &mut Self {
295        self.comma();
296        self.buf.push('"');
297        self.buf.push_str(key);
298        self.buf.push_str("\":");
299        self.buf.push_str(raw);
300        self
301    }
302
303    /// Consume the builder and return the closed `{...}` JSON text (no
304    /// trailing newline — callers `writeln!` it).
305    #[must_use]
306    pub fn finish(mut self) -> String {
307        self.buf.push('}');
308        self.buf
309    }
310}
311
312/// Render a slice of strings as a JSON array of escaped string
313/// literals, e.g. for a `field_raw` value: `["a","b"]`.
314#[must_use]
315pub fn json_string_array<S: AsRef<str>>(items: &[S]) -> String {
316    let mut out = String::from("[");
317    for (i, s) in items.iter().enumerate() {
318        if i > 0 {
319            out.push(',');
320        }
321        out.push('"');
322        out.push_str(&json_escape(s.as_ref()));
323        out.push('"');
324    }
325    out.push(']');
326    out
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use mkit_core::hash;
333
334    #[test]
335    fn hex_hash_is_64_chars() {
336        let h = hash::hash(b"hello");
337        assert_eq!(hex_hash(&h).len(), 64);
338        assert!(hex_hash(&h).chars().all(|c| c.is_ascii_hexdigit()));
339    }
340
341    #[test]
342    fn short_hash_clamps() {
343        let h = hash::hash(b"x");
344        assert_eq!(short_hash(&h, 0).len(), 4);
345        assert_eq!(short_hash(&h, 8).len(), 8);
346        assert_eq!(short_hash(&h, 999).len(), 64);
347    }
348
349    #[test]
350    fn ref_update_line_shapes_match_git() {
351        let old = hash::hash(b"old");
352        let new = hash::hash(b"new");
353        let o7 = short_hash(&old, SUMMARY_ABBREV);
354        let n7 = short_hash(&new, SUMMARY_ABBREV);
355        // new branch (no old)
356        assert_eq!(
357            ref_update_line(None, &new, "main", "main", false),
358            " * [new branch]      main -> main"
359        );
360        // fast-forward: `   <old>..<new>  src -> dst`
361        assert_eq!(
362            ref_update_line(Some(&old), &new, "main", "main", false),
363            format!("   {o7}..{n7}  main -> main")
364        );
365        // forced: `+ <old>...<new> src -> dst (forced update)`
366        assert_eq!(
367            ref_update_line(Some(&old), &new, "main", "main", true),
368            format!(" + {o7}...{n7} main -> main (forced update)")
369        );
370        // rejected
371        assert_eq!(
372            ref_rejected_line("main", "main"),
373            " ! [rejected]        main -> main (non-fast-forward)"
374        );
375    }
376
377    #[test]
378    fn json_escape_basic() {
379        assert_eq!(json_escape("hello"), "hello");
380        assert_eq!(json_escape("a\"b"), "a\\\"b");
381        assert_eq!(json_escape("a\\b"), "a\\\\b");
382        assert_eq!(json_escape("a\nb"), "a\\nb");
383        assert_eq!(json_escape("a\tb"), "a\\tb");
384    }
385
386    #[test]
387    fn json_escape_control_chars() {
388        // \x01 escapes as .
389        assert_eq!(json_escape("\x01"), "\\u0001");
390        // \x7f stays unescaped (only chars < 0x20 are special).
391        assert_eq!(json_escape("\x7f"), "\x7f");
392    }
393
394    #[test]
395    fn human_date_utc_epoch() {
396        assert_eq!(human_date_utc(0), "1970-01-01 00:00:00 +0000");
397    }
398
399    #[test]
400    fn human_date_utc_known_instant() {
401        // 1700000000 = 2023-11-14 22:13:20 UTC.
402        assert_eq!(human_date_utc(1_700_000_000), "2023-11-14 22:13:20 +0000");
403    }
404
405    #[test]
406    fn human_date_utc_leap_day() {
407        // 1582934400 = 2020-02-29 00:00:00 UTC (leap day).
408        assert_eq!(human_date_utc(1_582_934_400), "2020-02-29 00:00:00 +0000");
409    }
410
411    #[test]
412    fn full_identity_mid() {
413        let id = mkit_core::Identity {
414            kind: mkit_core::IdentityKind::Opaque,
415            bytes: 42u64.to_le_bytes().to_vec(),
416        };
417        assert_eq!(full_identity(&id), "mid:42");
418    }
419
420    #[test]
421    fn full_identity_ed25519() {
422        let id = mkit_core::Identity {
423            kind: mkit_core::IdentityKind::Ed25519,
424            bytes: vec![0xab; 32],
425        };
426        let s = full_identity(&id);
427        assert!(s.starts_with("ed25519:"));
428        assert_eq!(s.len(), "ed25519:".len() + 64);
429    }
430
431    #[test]
432    fn json_object_empty() {
433        assert_eq!(JsonObject::new().finish(), "{}");
434    }
435
436    #[test]
437    fn json_object_fields_in_insertion_order() {
438        let h = hash::hash(b"x");
439        let mut obj = JsonObject::new();
440        obj.field_bool("ok", true)
441            .field_str("branch", "main")
442            .field_hash("hash", &h)
443            .field_opt_hash("parent", None)
444            .field_opt_str("note", None)
445            .field_u64("count", 3)
446            .field_raw("items", &json_string_array(&["a", "b"]));
447        let out = obj.finish();
448        assert_eq!(
449            out,
450            format!(
451                "{{\"ok\":true,\"branch\":\"main\",\"hash\":\"{}\",\"parent\":null,\"note\":null,\"count\":3,\"items\":[\"a\",\"b\"]}}",
452                hex_hash(&h)
453            )
454        );
455    }
456
457    #[test]
458    fn json_object_escapes_string_fields() {
459        let mut obj = JsonObject::new();
460        obj.field_str("message", "line one\nline \"two\"");
461        assert_eq!(
462            obj.finish(),
463            "{\"message\":\"line one\\nline \\\"two\\\"\"}"
464        );
465    }
466
467    #[test]
468    fn json_string_array_empty_and_populated() {
469        let empty: &[&str] = &[];
470        assert_eq!(json_string_array(empty), "[]");
471        assert_eq!(
472            json_string_array(&["a.txt", "b.txt"]),
473            "[\"a.txt\",\"b.txt\"]"
474        );
475    }
476}