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#[cfg(test)]
167mod tests {
168    use super::*;
169    use mkit_core::hash;
170
171    #[test]
172    fn hex_hash_is_64_chars() {
173        let h = hash::hash(b"hello");
174        assert_eq!(hex_hash(&h).len(), 64);
175        assert!(hex_hash(&h).chars().all(|c| c.is_ascii_hexdigit()));
176    }
177
178    #[test]
179    fn short_hash_clamps() {
180        let h = hash::hash(b"x");
181        assert_eq!(short_hash(&h, 0).len(), 4);
182        assert_eq!(short_hash(&h, 8).len(), 8);
183        assert_eq!(short_hash(&h, 999).len(), 64);
184    }
185
186    #[test]
187    fn json_escape_basic() {
188        assert_eq!(json_escape("hello"), "hello");
189        assert_eq!(json_escape("a\"b"), "a\\\"b");
190        assert_eq!(json_escape("a\\b"), "a\\\\b");
191        assert_eq!(json_escape("a\nb"), "a\\nb");
192        assert_eq!(json_escape("a\tb"), "a\\tb");
193    }
194
195    #[test]
196    fn json_escape_control_chars() {
197        // \x01 escapes as .
198        assert_eq!(json_escape("\x01"), "\\u0001");
199        // \x7f stays unescaped (only chars < 0x20 are special).
200        assert_eq!(json_escape("\x7f"), "\x7f");
201    }
202
203    #[test]
204    fn human_date_utc_epoch() {
205        assert_eq!(human_date_utc(0), "1970-01-01 00:00:00 +0000");
206    }
207
208    #[test]
209    fn human_date_utc_known_instant() {
210        // 1700000000 = 2023-11-14 22:13:20 UTC.
211        assert_eq!(human_date_utc(1_700_000_000), "2023-11-14 22:13:20 +0000");
212    }
213
214    #[test]
215    fn human_date_utc_leap_day() {
216        // 1582934400 = 2020-02-29 00:00:00 UTC (leap day).
217        assert_eq!(human_date_utc(1_582_934_400), "2020-02-29 00:00:00 +0000");
218    }
219
220    #[test]
221    fn full_identity_mid() {
222        let id = mkit_core::Identity {
223            kind: mkit_core::IdentityKind::Opaque,
224            bytes: 42u64.to_le_bytes().to_vec(),
225        };
226        assert_eq!(full_identity(&id), "mid:42");
227    }
228
229    #[test]
230    fn full_identity_ed25519() {
231        let id = mkit_core::Identity {
232            kind: mkit_core::IdentityKind::Ed25519,
233            bytes: vec![0xab; 32],
234        };
235        let s = full_identity(&id);
236        assert!(s.starts_with("ed25519:"));
237        assert_eq!(s.len(), "ed25519:".len() + 64);
238    }
239}