Skip to main content

cumulus3d_cli/
util.rs

1//! 기록·시간 측정·작은 직렬화 도우미.
2
3use std::collections::BTreeMap;
4use std::fs::File;
5use std::io::Write;
6use std::path::Path;
7use std::sync::Mutex;
8use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
9
10/// run.log(+ 표준 출력) 기록기. 스크립트의 `exec > run.log` 에 해당.
11pub struct Logger {
12    file: Option<Mutex<File>>,
13    echo: bool,
14    tz_offset_s: i64,
15}
16
17impl Logger {
18    /// `path` 에 기록 파일을 만든다(`None` 이면 파일 없음; `echo` 면 표준 출력에도).
19    pub fn new(path: Option<&Path>, echo: bool) -> std::io::Result<Self> {
20        let file = match path {
21            Some(p) => Some(Mutex::new(File::create(p)?)),
22            None => None,
23        };
24        Ok(Self { file, echo, tz_offset_s: local_tz_offset() })
25    }
26
27    /// 한 줄 기록.
28    pub fn line(&self, s: &str) {
29        if let Some(f) = &self.file {
30            if let Ok(mut f) = f.lock() {
31                let _ = writeln!(f, "{s}");
32                let _ = f.flush();
33            }
34        }
35        if self.echo {
36            println!("{s}");
37        }
38    }
39
40    /// `[HH:MM:SS] msg` (현지 시각).
41    pub fn stamped(&self, s: &str) {
42        self.line(&format!("[{}] {s}", self.clock()));
43    }
44
45    /// `[HH:MM:SS] msg` (주어진 시각, 현지 시각).
46    pub fn stamped_at(&self, at: SystemTime, s: &str) {
47        self.line(&format!("[{}] {s}", self.clock_at(at)));
48    }
49
50    /// 현재 현지 시각 `HH:MM:SS`.
51    pub fn clock(&self) -> String {
52        self.clock_at(SystemTime::now())
53    }
54
55    /// 주어진 시각의 `HH:MM:SS`(현지 시각).
56    pub fn clock_at(&self, at: SystemTime) -> String {
57        let t = at.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64 + self.tz_offset_s;
58        let d = t.rem_euclid(86400);
59        format!("{:02}:{:02}:{:02}", d / 3600, (d / 60) % 60, d % 60)
60    }
61}
62
63/// 현지 시간대 오프셋(초). 외부 의존성 없이 `date +%z` 로 한 번 읽고, 실패하면 UTC.
64fn local_tz_offset() -> i64 {
65    let out = std::process::Command::new("date").arg("+%z").output();
66    let Ok(out) = out else { return 0 };
67    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
68    if s.len() != 5 {
69        return 0;
70    }
71    let sign = if s.starts_with('-') { -1 } else { 1 };
72    let h: i64 = s[1..3].parse().unwrap_or(0);
73    let m: i64 = s[3..5].parse().unwrap_or(0);
74    sign * (h * 3600 + m * 60)
75}
76
77/// 사건 기록(timeline.txt): `epoch초.나노 사건` 줄. 메모리에도 (상대 시각, 문자열) 로 보관.
78pub struct Timeline {
79    file: Mutex<File>,
80    events: Mutex<Vec<(f64, String)>>,
81}
82
83impl Timeline {
84    /// `path` 에 timeline 파일을 만든다.
85    pub fn new(path: &Path) -> std::io::Result<Self> {
86        Ok(Self { file: Mutex::new(File::create(path)?), events: Mutex::new(Vec::new()) })
87    }
88
89    /// 사건 기록 + run.log 에 `[시각] 사건`.
90    pub fn ev(&self, log: &Logger, s: &str) {
91        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
92        let line = format!("{}.{:09} {s}", now.as_secs(), now.subsec_nanos());
93        if let Ok(mut f) = self.file.lock() {
94            let _ = writeln!(f, "{line}");
95            let _ = f.flush();
96        }
97        if let Ok(mut e) = self.events.lock() {
98            e.push((now.as_secs_f64(), s.to_string()));
99        }
100        log.stamped(s);
101    }
102
103    /// (첫 사건 기준 초, 문자열).
104    pub fn relative(&self) -> Vec<(f64, String)> {
105        let e = self.events.lock().map(|e| e.clone()).unwrap_or_default();
106        let t0 = e.first().map(|x| x.0).unwrap_or(0.0);
107        e.into_iter().map(|(t, s)| (t - t0, s)).collect()
108    }
109}
110
111/// 단계별 누적 시간.
112#[derive(Default)]
113pub struct StageTimes {
114    m: Mutex<BTreeMap<String, (Duration, usize)>>,
115}
116
117impl StageTimes {
118    /// 단계 시간 누적.
119    pub fn add(&self, stage: &str, d: Duration) {
120        if let Ok(mut m) = self.m.lock() {
121            let e = m.entry(stage.to_string()).or_default();
122            e.0 += d;
123            e.1 += 1;
124        }
125    }
126
127    /// (단계 → 누적 시간, 횟수) 사본.
128    pub fn snapshot(&self) -> BTreeMap<String, (Duration, usize)> {
129        self.m.lock().map(|m| m.clone()).unwrap_or_default()
130    }
131}
132
133/// 단계 하나를 재고 run.log 에 남긴다.
134pub fn timed<T>(log: &Logger, times: &StageTimes, stage: &str, what: &str, f: impl FnOnce() -> T) -> T {
135    let t = Instant::now();
136    let r = f();
137    let d = t.elapsed();
138    times.add(stage, d);
139    log.stamped(&format!("[time] {stage} {what}: {:.3}s", d.as_secs_f64()));
140    r
141}
142
143/// 파이썬 `round(x, n)` 뒤 `repr` 과 같은 모양(정수면 `.0`).
144pub fn py_float(x: f64, digits: i32) -> String {
145    let p = 10f64.powi(digits);
146    let r = (x * p).round() / p;
147    let r = if r == 0.0 { 0.0 } else { r };
148    if !r.is_finite() {
149        return if r.is_nan() { "NaN".into() } else if r > 0.0 { "Infinity".into() } else { "-Infinity".into() };
150    }
151    let s = format!("{r}");
152    if s.contains('.') || s.contains('e') {
153        s
154    } else {
155        format!("{s}.0")
156    }
157}
158
159/// 작은 JSON 값(매니페스트용).
160#[derive(Clone, Debug)]
161pub enum Json {
162    /// `null`.
163    Null,
164    /// 불린.
165    Bool(bool),
166    /// 정수.
167    Int(i64),
168    /// 이미 서식화한 수(파이썬 round 결과).
169    Num(String),
170    /// 문자열.
171    Str(String),
172    /// 배열.
173    Arr(Vec<Json>),
174    /// 객체(키 순서 유지).
175    Obj(Vec<(String, Json)>),
176}
177
178impl Json {
179    /// (키, 값) 목록으로 객체.
180    pub fn obj(items: Vec<(&str, Json)>) -> Json {
181        Json::Obj(items.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
182    }
183    /// 소수 `digits` 자리 반올림 수.
184    pub fn f(x: f64, digits: i32) -> Json {
185        Json::Num(py_float(x, digits))
186    }
187    /// 수 또는 null.
188    pub fn of(x: Option<f64>, digits: i32) -> Json {
189        x.map_or(Json::Null, |v| Json::f(v, digits))
190    }
191    /// 정수 배열.
192    pub fn ints(v: &[usize]) -> Json {
193        Json::Arr(v.iter().map(|x| Json::Int(*x as i64)).collect())
194    }
195
196    /// `json.dump(..., indent=1, ensure_ascii=False)` 모양.
197    pub fn dump(&self) -> String {
198        let mut s = String::new();
199        self.write(&mut s, 0);
200        s
201    }
202
203    fn write(&self, s: &mut String, ind: usize) {
204        match self {
205            Json::Null => s.push_str("null"),
206            Json::Bool(b) => s.push_str(if *b { "true" } else { "false" }),
207            Json::Int(i) => s.push_str(&i.to_string()),
208            Json::Num(n) => s.push_str(n),
209            Json::Str(t) => push_str_lit(s, t),
210            Json::Arr(v) => {
211                if v.is_empty() {
212                    s.push_str("[]");
213                    return;
214                }
215                s.push_str("[\n");
216                for (i, x) in v.iter().enumerate() {
217                    s.push_str(&" ".repeat(ind + 1));
218                    x.write(s, ind + 1);
219                    if i + 1 < v.len() {
220                        s.push(',');
221                    }
222                    s.push('\n');
223                }
224                s.push_str(&" ".repeat(ind));
225                s.push(']');
226            }
227            Json::Obj(v) => {
228                if v.is_empty() {
229                    s.push_str("{}");
230                    return;
231                }
232                s.push_str("{\n");
233                for (i, (k, x)) in v.iter().enumerate() {
234                    s.push_str(&" ".repeat(ind + 1));
235                    push_str_lit(s, k);
236                    s.push_str(": ");
237                    x.write(s, ind + 1);
238                    if i + 1 < v.len() {
239                        s.push(',');
240                    }
241                    s.push('\n');
242                }
243                s.push_str(&" ".repeat(ind));
244                s.push('}');
245            }
246        }
247    }
248
249    /// 파이썬 dict/list `repr` 모양(요약 출력용).
250    pub fn py_repr(&self) -> String {
251        match self {
252            Json::Null => "None".into(),
253            Json::Bool(b) => (if *b { "True" } else { "False" }).into(),
254            Json::Int(i) => i.to_string(),
255            Json::Num(n) => n.clone(),
256            Json::Str(t) => format!("'{}'", t.replace('\\', "\\\\").replace('\'', "\\'")),
257            Json::Arr(v) => format!("[{}]", v.iter().map(|x| x.py_repr()).collect::<Vec<_>>().join(", ")),
258            Json::Obj(v) => {
259                format!("{{{}}}", v.iter().map(|(k, x)| format!("'{k}': {}", x.py_repr())).collect::<Vec<_>>().join(", "))
260            }
261        }
262    }
263
264    /// 객체의 키 조회.
265    pub fn get(&self, key: &str) -> Option<&Json> {
266        match self {
267            Json::Obj(v) => v.iter().find(|(k, _)| k == key).map(|(_, x)| x),
268            _ => None,
269        }
270    }
271}
272
273fn push_str_lit(s: &mut String, t: &str) {
274    s.push('"');
275    for c in t.chars() {
276        match c {
277            '"' => s.push_str("\\\""),
278            '\\' => s.push_str("\\\\"),
279            '\n' => s.push_str("\\n"),
280            '\t' => s.push_str("\\t"),
281            c if (c as u32) < 0x20 => s.push_str(&format!("\\u{:04x}", c as u32)),
282            c => s.push(c),
283        }
284    }
285    s.push('"');
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn py_float_matches_python_repr() {
294        assert_eq!(py_float(1.0, 3), "1.0");
295        assert_eq!(py_float(12.345, 1), "12.3");
296        assert_eq!(py_float(-0.0001, 2), "0.0");
297        assert_eq!(py_float(0.987654, 3), "0.988");
298    }
299
300    #[test]
301    fn json_dump_indent1() {
302        let j = Json::obj(vec![("a", Json::Int(1)), ("b", Json::Arr(vec![])), ("c", Json::Arr(vec![Json::Null]))]);
303        assert_eq!(j.dump(), "{\n \"a\": 1,\n \"b\": [],\n \"c\": [\n  null\n ]\n}");
304        assert_eq!(j.py_repr(), "{'a': 1, 'b': [], 'c': [None]}");
305    }
306}