Skip to main content

cumulus3d_cli/
util.rs

1/*
2 * util.rs
3 *
4 * Copyright (c) 2026 ParkSangWoo
5 *
6 * Author(s):
7 *
8 *      ParkSangWoo <dev.parksangwoo@gmail.com>
9 *
10 *
11 * This file is part of cumulus3d.
12 *
13 * Licensed under the Apache License, Version 2.0 (the "License");
14 * you may not use this file except in compliance with the License.
15 * You may obtain a copy of the License at
16 *
17 *      http://www.apache.org/licenses/LICENSE-2.0
18 *
19 * Unless required by applicable law or agreed to in writing, software
20 * distributed under the License is distributed on an "AS IS" BASIS,
21 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
22 * See the License for the specific language governing permissions and
23 * limitations under the License.
24 *
25 * SPDX-License-Identifier: Apache-2.0
26 */
27
28//! Logging, time measurement and small serialization helpers.
29
30use std::collections::BTreeMap;
31use std::fs::File;
32use std::io::Write;
33use std::path::Path;
34use std::sync::Mutex;
35use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
36
37/// run.log (+ standard output) recorder. Corresponds to `exec > run.log` in the script.
38pub struct Logger {
39    file: Option<Mutex<File>>,
40    echo: bool,
41    tz_offset_s: i64,
42}
43
44impl Logger {
45    /// Create the log file at `path` (if `None`, no file; if `echo`, also standard output).
46    pub fn new(path: Option<&Path>, echo: bool) -> std::io::Result<Self> {
47        let file = match path {
48            Some(p) => Some(Mutex::new(File::create(p)?)),
49            None => None,
50        };
51        Ok(Self { file, echo, tz_offset_s: local_tz_offset() })
52    }
53
54    /// Record one line.
55    pub fn line(&self, s: &str) {
56        if let Some(f) = &self.file {
57            if let Ok(mut f) = f.lock() {
58                let _ = writeln!(f, "{s}");
59                let _ = f.flush();
60            }
61        }
62        if self.echo {
63            println!("{s}");
64        }
65    }
66
67    /// `[HH:MM:SS] msg` (local time).
68    pub fn stamped(&self, s: &str) {
69        self.line(&format!("[{}] {s}", self.clock()));
70    }
71
72    /// `[HH:MM:SS] msg` (given time, local time).
73    pub fn stamped_at(&self, at: SystemTime, s: &str) {
74        self.line(&format!("[{}] {s}", self.clock_at(at)));
75    }
76
77    /// Current local time `HH:MM:SS`.
78    pub fn clock(&self) -> String {
79        self.clock_at(SystemTime::now())
80    }
81
82    /// `HH:MM:SS` of the given time (local time).
83    pub fn clock_at(&self, at: SystemTime) -> String {
84        let t = at.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64 + self.tz_offset_s;
85        let d = t.rem_euclid(86400);
86        format!("{:02}:{:02}:{:02}", d / 3600, (d / 60) % 60, d % 60)
87    }
88}
89
90/// Local timezone offset (seconds). Read once via `date +%z` without external dependencies; UTC on failure.
91fn local_tz_offset() -> i64 {
92    let out = std::process::Command::new("date").arg("+%z").output();
93    let Ok(out) = out else { return 0 };
94    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
95    if s.len() != 5 {
96        return 0;
97    }
98    let sign = if s.starts_with('-') { -1 } else { 1 };
99    let h: i64 = s[1..3].parse().unwrap_or(0);
100    let m: i64 = s[3..5].parse().unwrap_or(0);
101    sign * (h * 3600 + m * 60)
102}
103
104/// Event record (timeline.txt): `epoch seconds.nanoseconds event` lines. Also kept in memory as (relative time, string).
105pub struct Timeline {
106    file: Mutex<File>,
107    events: Mutex<Vec<(f64, String)>>,
108}
109
110impl Timeline {
111    /// Create the timeline file at `path`.
112    pub fn new(path: &Path) -> std::io::Result<Self> {
113        Ok(Self { file: Mutex::new(File::create(path)?), events: Mutex::new(Vec::new()) })
114    }
115
116    /// Event record + `[time] event` in run.log.
117    pub fn ev(&self, log: &Logger, s: &str) {
118        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
119        let line = format!("{}.{:09} {s}", now.as_secs(), now.subsec_nanos());
120        if let Ok(mut f) = self.file.lock() {
121            let _ = writeln!(f, "{line}");
122            let _ = f.flush();
123        }
124        if let Ok(mut e) = self.events.lock() {
125            e.push((now.as_secs_f64(), s.to_string()));
126        }
127        log.stamped(s);
128    }
129
130    /// (seconds from the first event, string).
131    pub fn relative(&self) -> Vec<(f64, String)> {
132        let e = self.events.lock().map(|e| e.clone()).unwrap_or_default();
133        let t0 = e.first().map(|x| x.0).unwrap_or(0.0);
134        e.into_iter().map(|(t, s)| (t - t0, s)).collect()
135    }
136}
137
138/// Per-stage accumulated time.
139#[derive(Default)]
140pub struct StageTimes {
141    m: Mutex<BTreeMap<String, (Duration, usize)>>,
142}
143
144impl StageTimes {
145    /// Accumulate stage time.
146    pub fn add(&self, stage: &str, d: Duration) {
147        if let Ok(mut m) = self.m.lock() {
148            let e = m.entry(stage.to_string()).or_default();
149            e.0 += d;
150            e.1 += 1;
151        }
152    }
153
154    /// Copy of (stage -> accumulated time, count).
155    pub fn snapshot(&self) -> BTreeMap<String, (Duration, usize)> {
156        self.m.lock().map(|m| m.clone()).unwrap_or_default()
157    }
158}
159
160/// Time one stage and record it in run.log.
161pub fn timed<T>(log: &Logger, times: &StageTimes, stage: &str, what: &str, f: impl FnOnce() -> T) -> T {
162    let t = Instant::now();
163    let r = f();
164    let d = t.elapsed();
165    times.add(stage, d);
166    log.stamped(&format!("[time] {stage} {what}: {:.3}s", d.as_secs_f64()));
167    r
168}
169
170/// Same shape as Python `round(x, n)` followed by `repr` (`.0` for integers).
171pub fn py_float(x: f64, digits: i32) -> String {
172    let p = 10f64.powi(digits);
173    let r = (x * p).round() / p;
174    let r = if r == 0.0 { 0.0 } else { r };
175    if !r.is_finite() {
176        return if r.is_nan() {
177            "NaN".into()
178        } else if r > 0.0 {
179            "Infinity".into()
180        } else {
181            "-Infinity".into()
182        };
183    }
184    let s = format!("{r}");
185    if s.contains('.') || s.contains('e') {
186        s
187    } else {
188        format!("{s}.0")
189    }
190}
191
192/// Small JSON value (for the manifest).
193#[derive(Clone, Debug)]
194pub enum Json {
195    /// `null`.
196    Null,
197    /// Boolean.
198    Bool(bool),
199    /// Integer.
200    Int(i64),
201    /// An already formatted number (Python round result).
202    Num(String),
203    /// String.
204    Str(String),
205    /// Array.
206    Arr(Vec<Json>),
207    /// Object (key order preserved).
208    Obj(Vec<(String, Json)>),
209}
210
211impl Json {
212    /// Object from a (key, value) list.
213    pub fn obj(items: Vec<(&str, Json)>) -> Json {
214        Json::Obj(items.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
215    }
216    /// Number rounded to `digits` decimal places.
217    pub fn f(x: f64, digits: i32) -> Json {
218        Json::Num(py_float(x, digits))
219    }
220    /// Number or null.
221    pub fn of(x: Option<f64>, digits: i32) -> Json {
222        x.map_or(Json::Null, |v| Json::f(v, digits))
223    }
224    /// Integer array.
225    pub fn ints(v: &[usize]) -> Json {
226        Json::Arr(v.iter().map(|x| Json::Int(*x as i64)).collect())
227    }
228
229    /// Shape of `json.dump(..., indent=1, ensure_ascii=False)`.
230    pub fn dump(&self) -> String {
231        let mut s = String::new();
232        self.write(&mut s, 0);
233        s
234    }
235
236    fn write(&self, s: &mut String, ind: usize) {
237        match self {
238            Json::Null => s.push_str("null"),
239            Json::Bool(b) => s.push_str(if *b { "true" } else { "false" }),
240            Json::Int(i) => s.push_str(&i.to_string()),
241            Json::Num(n) => s.push_str(n),
242            Json::Str(t) => push_str_lit(s, t),
243            Json::Arr(v) => {
244                if v.is_empty() {
245                    s.push_str("[]");
246                    return;
247                }
248                s.push_str("[\n");
249                for (i, x) in v.iter().enumerate() {
250                    s.push_str(&" ".repeat(ind + 1));
251                    x.write(s, ind + 1);
252                    if i + 1 < v.len() {
253                        s.push(',');
254                    }
255                    s.push('\n');
256                }
257                s.push_str(&" ".repeat(ind));
258                s.push(']');
259            }
260            Json::Obj(v) => {
261                if v.is_empty() {
262                    s.push_str("{}");
263                    return;
264                }
265                s.push_str("{\n");
266                for (i, (k, x)) in v.iter().enumerate() {
267                    s.push_str(&" ".repeat(ind + 1));
268                    push_str_lit(s, k);
269                    s.push_str(": ");
270                    x.write(s, ind + 1);
271                    if i + 1 < v.len() {
272                        s.push(',');
273                    }
274                    s.push('\n');
275                }
276                s.push_str(&" ".repeat(ind));
277                s.push('}');
278            }
279        }
280    }
281
282    /// Shape of Python dict/list `repr` (for summary output).
283    pub fn py_repr(&self) -> String {
284        match self {
285            Json::Null => "None".into(),
286            Json::Bool(b) => (if *b { "True" } else { "False" }).into(),
287            Json::Int(i) => i.to_string(),
288            Json::Num(n) => n.clone(),
289            Json::Str(t) => format!("'{}'", t.replace('\\', "\\\\").replace('\'', "\\'")),
290            Json::Arr(v) => format!("[{}]", v.iter().map(|x| x.py_repr()).collect::<Vec<_>>().join(", ")),
291            Json::Obj(v) => {
292                format!("{{{}}}", v.iter().map(|(k, x)| format!("'{k}': {}", x.py_repr())).collect::<Vec<_>>().join(", "))
293            }
294        }
295    }
296
297    /// Look up an object's key.
298    pub fn get(&self, key: &str) -> Option<&Json> {
299        match self {
300            Json::Obj(v) => v.iter().find(|(k, _)| k == key).map(|(_, x)| x),
301            _ => None,
302        }
303    }
304
305    /// f64 if a number (integer or float).
306    pub fn as_f64(&self) -> Option<f64> {
307        match self {
308            Json::Int(i) => Some(*i as f64),
309            Json::Num(n) => n.parse().ok(),
310            _ => None,
311        }
312    }
313
314    /// Read JSON text (standard syntax; numbers keep their original text in [`Json::Num`], or [`Json::Int`] if integer-shaped).
315    pub fn parse(text: &str) -> Result<Json, String> {
316        let mut p = JsonParser { s: text.as_bytes(), i: 0 };
317        let v = p.value()?;
318        p.ws();
319        if p.i != p.s.len() {
320            return Err(format!("JSON: {} 바이트에 뒤따르는 내용", p.i));
321        }
322        Ok(v)
323    }
324}
325
326struct JsonParser<'a> {
327    s: &'a [u8],
328    i: usize,
329}
330
331impl JsonParser<'_> {
332    fn ws(&mut self) {
333        while self.i < self.s.len() && self.s[self.i].is_ascii_whitespace() {
334            self.i += 1;
335        }
336    }
337
338    fn eat(&mut self, c: u8) -> Result<(), String> {
339        self.ws();
340        if self.s.get(self.i) == Some(&c) {
341            self.i += 1;
342            Ok(())
343        } else {
344            Err(format!("JSON: {} 바이트에서 '{}' 필요", self.i, c as char))
345        }
346    }
347
348    fn lit(&mut self, w: &str, v: Json) -> Result<Json, String> {
349        if self.s[self.i..].starts_with(w.as_bytes()) {
350            self.i += w.len();
351            Ok(v)
352        } else {
353            Err(format!("JSON: {} 바이트의 값을 읽을 수 없음", self.i))
354        }
355    }
356
357    fn value(&mut self) -> Result<Json, String> {
358        self.ws();
359        match self.s.get(self.i) {
360            None => Err("JSON: 내용이 끝남".into()),
361            Some(b'{') => {
362                self.i += 1;
363                let mut items = Vec::new();
364                self.ws();
365                if self.s.get(self.i) == Some(&b'}') {
366                    self.i += 1;
367                    return Ok(Json::Obj(items));
368                }
369                loop {
370                    self.ws();
371                    let k = self.string()?;
372                    self.eat(b':')?;
373                    items.push((k, self.value()?));
374                    self.ws();
375                    match self.s.get(self.i) {
376                        Some(b',') => self.i += 1,
377                        Some(b'}') => {
378                            self.i += 1;
379                            return Ok(Json::Obj(items));
380                        }
381                        _ => return Err(format!("JSON: {} 바이트에서 ',' 또는 '}}' 필요", self.i)),
382                    }
383                }
384            }
385            Some(b'[') => {
386                self.i += 1;
387                let mut items = Vec::new();
388                self.ws();
389                if self.s.get(self.i) == Some(&b']') {
390                    self.i += 1;
391                    return Ok(Json::Arr(items));
392                }
393                loop {
394                    items.push(self.value()?);
395                    self.ws();
396                    match self.s.get(self.i) {
397                        Some(b',') => self.i += 1,
398                        Some(b']') => {
399                            self.i += 1;
400                            return Ok(Json::Arr(items));
401                        }
402                        _ => return Err(format!("JSON: {} 바이트에서 ',' 또는 ']' 필요", self.i)),
403                    }
404                }
405            }
406            Some(b'"') => self.string().map(Json::Str),
407            Some(b't') => self.lit("true", Json::Bool(true)),
408            Some(b'f') => self.lit("false", Json::Bool(false)),
409            Some(b'n') => self.lit("null", Json::Null),
410            Some(_) => {
411                let st = self.i;
412                while self.i < self.s.len() && matches!(self.s[self.i], b'0'..=b'9' | b'-' | b'+' | b'.' | b'e' | b'E') {
413                    self.i += 1;
414                }
415                let t = std::str::from_utf8(&self.s[st..self.i]).unwrap_or("");
416                if t.is_empty() || t.parse::<f64>().is_err() {
417                    return Err(format!("JSON: {st} 바이트의 값을 읽을 수 없음"));
418                }
419                Ok(t.parse::<i64>().map_or_else(|_| Json::Num(t.to_string()), Json::Int))
420            }
421        }
422    }
423
424    fn string(&mut self) -> Result<String, String> {
425        self.eat(b'"')?;
426        let mut out: Vec<u8> = Vec::new();
427        loop {
428            let Some(&c) = self.s.get(self.i) else { return Err("JSON: 문자열이 끝나지 않음".into()) };
429            self.i += 1;
430            match c {
431                b'"' => return String::from_utf8(out).map_err(|e| e.to_string()),
432                b'\\' => {
433                    let Some(&e) = self.s.get(self.i) else { return Err("JSON: 문자열이 끝나지 않음".into()) };
434                    self.i += 1;
435                    match e {
436                        b'n' => out.push(b'\n'),
437                        b't' => out.push(b'\t'),
438                        b'r' => out.push(b'\r'),
439                        b'b' => out.push(8),
440                        b'f' => out.push(12),
441                        b'u' => {
442                            let h = self.s.get(self.i..self.i + 4).and_then(|h| std::str::from_utf8(h).ok());
443                            let c = h.and_then(|h| u32::from_str_radix(h, 16).ok()).and_then(char::from_u32).unwrap_or('\u{fffd}');
444                            self.i += 4;
445                            let mut b = [0u8; 4];
446                            out.extend_from_slice(c.encode_utf8(&mut b).as_bytes());
447                        }
448                        other => out.push(other),
449                    }
450                }
451                c => out.push(c),
452            }
453        }
454    }
455}
456
457fn push_str_lit(s: &mut String, t: &str) {
458    s.push('"');
459    for c in t.chars() {
460        match c {
461            '"' => s.push_str("\\\""),
462            '\\' => s.push_str("\\\\"),
463            '\n' => s.push_str("\\n"),
464            '\t' => s.push_str("\\t"),
465            c if (c as u32) < 0x20 => s.push_str(&format!("\\u{:04x}", c as u32)),
466            c => s.push(c),
467        }
468    }
469    s.push('"');
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn py_float_matches_python_repr() {
478        assert_eq!(py_float(1.0, 3), "1.0");
479        assert_eq!(py_float(12.345, 1), "12.3");
480        assert_eq!(py_float(-0.0001, 2), "0.0");
481        assert_eq!(py_float(0.987654, 3), "0.988");
482    }
483
484    #[test]
485    fn json_parse_reads_nested_values() {
486        let j = Json::parse(r#" {"note": "a\"b\u00e9", "cameras": {"1": [1607.5, 1611, 1e2, -0.08], "2": []}, "ok": true, "n": null} "#)
487            .unwrap();
488        assert!(matches!(j.get("note"), Some(Json::Str(s)) if s == "a\"bé"));
489        let Some(Json::Arr(v)) = j.get("cameras").and_then(|c| c.get("1")) else { panic!() };
490        let f: Vec<f64> = v.iter().filter_map(Json::as_f64).collect();
491        assert_eq!(f, vec![1607.5, 1611.0, 100.0, -0.08]);
492        assert!(matches!(j.get("ok"), Some(Json::Bool(true))) && matches!(j.get("n"), Some(Json::Null)));
493        assert!(Json::parse("{\"a\": }").is_err() && Json::parse("[1, 2").is_err() && Json::parse("1 2").is_err());
494        // Round trip through the writer.
495        let d = Json::obj(vec![("a", Json::Int(1)), ("b", Json::Arr(vec![Json::f(0.5, 2), Json::Null]))]);
496        assert_eq!(Json::parse(&d.dump()).unwrap().dump(), d.dump());
497    }
498
499    #[test]
500    fn json_dump_indent1() {
501        let j = Json::obj(vec![("a", Json::Int(1)), ("b", Json::Arr(vec![])), ("c", Json::Arr(vec![Json::Null]))]);
502        assert_eq!(j.dump(), "{\n \"a\": 1,\n \"b\": [],\n \"c\": [\n  null\n ]\n}");
503        assert_eq!(j.py_repr(), "{'a': 1, 'b': [], 'c': [None]}");
504    }
505}