1use mkit_core::hash::Hash;
6
7#[must_use]
10pub fn hex_hash(h: &Hash) -> String {
11 mkit_core::hash::to_hex(h)
12}
13
14#[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#[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 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 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
64fn 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#[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 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#[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#[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 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; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
148 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let day = doy - (153 * mp + 2) / 5 + 1; let month = if mp < 10 { mp + 3 } else { mp - 9 }; 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
166pub const SUMMARY_ABBREV: usize = 7;
170
171#[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#[must_use]
208pub fn ref_rejected_line(src: &str, dst: &str) -> String {
209 format!(" ! [rejected] {src} -> {dst} (non-fast-forward)")
210}
211
212#[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 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 pub fn field_hash(&mut self, key: &str, h: &Hash) -> &mut Self {
259 self.field_str(key, &hex_hash(h))
260 }
261
262 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 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 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 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 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 #[must_use]
306 pub fn finish(mut self) -> String {
307 self.buf.push('}');
308 self.buf
309 }
310}
311
312#[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 assert_eq!(
357 ref_update_line(None, &new, "main", "main", false),
358 " * [new branch] main -> main"
359 );
360 assert_eq!(
362 ref_update_line(Some(&old), &new, "main", "main", false),
363 format!(" {o7}..{n7} main -> main")
364 );
365 assert_eq!(
367 ref_update_line(Some(&old), &new, "main", "main", true),
368 format!(" + {o7}...{n7} main -> main (forced update)")
369 );
370 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 assert_eq!(json_escape("\x01"), "\\u0001");
390 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 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 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}