1use std::fs::{self, OpenOptions};
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::sync::Mutex;
13use std::time::{SystemTime, UNIX_EPOCH};
14
15const MAX_LOG_BYTES: u64 = 10 * 1024 * 1024; const MAX_ROTATIONS: u32 = 3;
17
18pub struct NodeLog {
20 text_path: PathBuf,
21 jsonl_path: PathBuf,
22 lock: Mutex<()>,
24}
25
26impl NodeLog {
27 pub fn open(log_dir: &Path, node_id: &str) -> std::io::Result<Self> {
30 fs::create_dir_all(log_dir)?;
31 Ok(Self {
32 text_path: log_dir.join(format!("{node_id}.log")),
33 jsonl_path: log_dir.join("events.jsonl"),
34 lock: Mutex::new(()),
35 })
36 }
37
38 pub fn write(&self, event: &str, node_id: &str, details: &str) {
44 let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
45 let ts = iso_now();
46 let text = format!("{ts} [{event}] node={node_id} {details}\n");
47 let jsonl = format!(
48 "{{\"ts\":\"{ts}\",\"event\":\"{event}\",\"node_id\":\"{node_id}\",\"details\":\"{}\"}}\n",
49 details.replace('"', "'")
50 );
51 let _ = self.append_rotating(&self.text_path, text.as_bytes());
52 let _ = self.append_rotating(&self.jsonl_path, jsonl.as_bytes());
53 }
54
55 fn append_rotating(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
56 if let Ok(m) = fs::metadata(path) {
57 if m.len() >= MAX_LOG_BYTES {
58 rotate(path);
59 }
60 }
61 OpenOptions::new()
62 .create(true)
63 .append(true)
64 .open(path)?
65 .write_all(data)
66 }
67}
68
69fn rotate(path: &Path) {
70 for i in (1..MAX_ROTATIONS).rev() {
71 let from = suffixed(path, i);
72 let to = suffixed(path, i + 1);
73 let _ = fs::rename(from, to);
74 }
75 let _ = fs::rename(path, suffixed(path, 1));
76}
77
78fn suffixed(path: &Path, n: u32) -> PathBuf {
79 let mut s = path.to_path_buf().into_os_string();
80 s.push(format!(".{n}"));
81 PathBuf::from(s)
82}
83
84fn iso_now() -> String {
85 let d = SystemTime::now()
86 .duration_since(UNIX_EPOCH)
87 .unwrap_or_default();
88 let secs = d.as_secs();
89 let (y, mon, day, h, min, sec) = epoch_to_datetime(secs);
90 format!("{y:04}-{mon:02}-{day:02}T{h:02}:{min:02}:{sec:02}Z")
91}
92
93fn epoch_to_datetime(secs: u64) -> (u32, u32, u32, u32, u32, u32) {
94 let sec = (secs % 60) as u32;
96 let min = ((secs / 60) % 60) as u32;
97 let h = ((secs / 3600) % 24) as u32;
98 let days = (secs / 86400) as u32;
99 let mut year = 1970u32;
101 let mut rem = days;
102 loop {
103 let dy = if is_leap(year) { 366 } else { 365 };
104 if rem < dy {
105 break;
106 }
107 rem -= dy;
108 year += 1;
109 }
110 let leap = is_leap(year);
111 let months = [
112 31u32,
113 if leap { 29 } else { 28 },
114 31,
115 30,
116 31,
117 30,
118 31,
119 31,
120 30,
121 31,
122 30,
123 31,
124 ];
125 let mut mon = 1u32;
126 for &m in &months {
127 if rem < m {
128 break;
129 }
130 rem -= m;
131 mon += 1;
132 }
133 (year, mon, rem + 1, h, min, sec)
134}
135
136fn is_leap(y: u32) -> bool {
137 (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use std::fs;
144
145 static TEST_CTR: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
146
147 fn tmp_dir() -> PathBuf {
148 let id = TEST_CTR.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
150 let d = std::env::temp_dir().join(format!("iicp_log_test_{id}_{}", std::process::id()));
151 let _ = fs::remove_dir_all(&d);
152 d
153 }
154
155 #[test]
156 fn creates_log_files() {
157 let dir = tmp_dir();
158 let log = NodeLog::open(&dir, "test-node").unwrap();
159 log.write("register_ok", "test-node", "endpoint=http://localhost:9484");
160 assert!(dir.join("test-node.log").exists());
161 assert!(dir.join("events.jsonl").exists());
162 let _ = fs::remove_dir_all(&dir);
163 }
164
165 #[test]
166 fn text_log_contains_event() {
167 let dir = tmp_dir();
168 let log = NodeLog::open(&dir, "abc").unwrap();
169 log.write("heartbeat_ok", "abc", "seq=1");
170 let content = fs::read_to_string(dir.join("abc.log")).unwrap();
171 assert!(content.contains("heartbeat_ok"));
172 assert!(content.contains("abc"));
173 let _ = fs::remove_dir_all(&dir);
174 }
175
176 #[test]
177 fn jsonl_is_valid_json() {
178 let dir = tmp_dir();
179 let log = NodeLog::open(&dir, "n1").unwrap();
180 log.write("register_fail", "n1", "error=timeout");
181 let line = fs::read_to_string(dir.join("events.jsonl")).unwrap();
182 let v: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
183 assert_eq!(v["event"], "register_fail");
184 assert_eq!(v["node_id"], "n1");
185 let _ = fs::remove_dir_all(&dir);
186 }
187
188 #[test]
189 fn rotation_on_size_limit() {
190 let dir = tmp_dir();
191 let log = NodeLog::open(&dir, "r").unwrap();
192 let padding: Vec<u8> = vec![b'X'; MAX_LOG_BYTES as usize + 1];
194 fs::write(dir.join("r.log"), &padding).unwrap();
195 let pre_size = fs::metadata(dir.join("r.log")).unwrap().len();
196 assert!(
197 pre_size > MAX_LOG_BYTES,
198 "padding not written correctly: {pre_size}"
199 );
200 log.write("serve_start", "r", "port=9484");
201 assert!(
203 dir.join("r.log.1").exists(),
204 "rotation did not create r.log.1; r.log size was {pre_size}"
205 );
206 let new = fs::read_to_string(dir.join("r.log")).unwrap();
207 assert!(new.contains("serve_start"));
208 let _ = fs::remove_dir_all(&dir);
209 }
210
211 #[test]
212 fn iso_now_format() {
213 let ts = iso_now();
214 assert_eq!(ts.len(), 20);
216 assert!(ts.ends_with('Z'));
217 }
218}