1use std::collections::HashMap;
10use std::fs::OpenOptions;
11use std::io::Write;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14pub struct Config {
15 pub transports: Vec<String>,
16 pub file_path: String,
17 pub default_metadata: HashMap<String, String>,
18}
19
20impl Default for Config {
21 fn default() -> Self {
22 Config {
23 transports: vec!["console".to_string()],
24 file_path: "./app.log".to_string(),
25 default_metadata: HashMap::new(),
26 }
27 }
28}
29
30pub struct Logger {
31 pub config: Config,
32}
33
34pub fn escape_json_string(s: &str) -> String {
37 let mut out = String::with_capacity(s.len() + 2);
38 for c in s.chars() {
39 match c {
40 '"' => out.push_str("\\\""),
41 '\\' => out.push_str("\\\\"),
42 '\n' => out.push_str("\\n"),
43 '\r' => out.push_str("\\r"),
44 '\t' => out.push_str("\\t"),
45 '\x08' => out.push_str("\\b"),
46 '\x0c' => out.push_str("\\f"),
47 c if (c as u32) < 0x20 => {
48 out.push_str(&format!("\\u{:04x}", c as u32));
49 }
50 c => out.push(c),
51 }
52 }
53 out
54}
55
56pub fn build_json_line(
58 message: &str,
59 level: &str,
60 timestamp_secs: u64,
61 default_metadata: &HashMap<String, String>,
62 metadata: &HashMap<String, String>,
63) -> String {
64 let mut json = String::new();
65 json.push('{');
66 json.push_str(&format!(
67 r#""timestamp":"{}","level":"{}","message":"{}""#,
68 timestamp_secs,
69 escape_json_string(level),
70 escape_json_string(message),
71 ));
72 for (k, v) in default_metadata {
73 json.push_str(&format!(
74 r#","{}":"{}""#,
75 escape_json_string(k),
76 escape_json_string(v),
77 ));
78 }
79 for (k, v) in metadata {
80 json.push_str(&format!(
81 r#","{}":"{}""#,
82 escape_json_string(k),
83 escape_json_string(v),
84 ));
85 }
86 json.push('}');
87 json
88}
89
90impl Logger {
91 pub fn new(config: Config) -> Self {
92 Logger { config }
93 }
94
95 pub fn log(&self, message: &str, level: &str, metadata: HashMap<String, String>) {
96 let timestamp = SystemTime::now()
97 .duration_since(UNIX_EPOCH)
98 .map(|d| d.as_secs())
99 .unwrap_or(0);
100
101 let json = build_json_line(
102 message,
103 level,
104 timestamp,
105 &self.config.default_metadata,
106 &metadata,
107 );
108
109 if self.config.transports.contains(&"console".to_string()) {
110 println!("{}", json);
111 }
112
113 if self.config.transports.contains(&"file".to_string()) {
114 if let Ok(mut file) = OpenOptions::new()
115 .create(true)
116 .append(true)
117 .open(&self.config.file_path)
118 {
119 let _ = writeln!(file, "{}", json);
120 }
121 }
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use std::collections::HashMap;
129 use std::fs;
130 use std::path::PathBuf;
131
132 fn parse_json(s: &str) -> serde_json_lite::Value {
133 serde_json_lite::parse(s).expect("invalid JSON")
134 }
135
136 mod serde_json_lite {
139 use std::collections::HashMap;
140
141 #[derive(Debug, Clone)]
142 pub enum Value {
143 Str(String),
144 Num(f64),
145 Bool(bool),
146 Null,
147 Array(Vec<Value>),
148 Object(HashMap<String, Value>),
149 }
150
151 impl Value {
152 pub fn as_str(&self) -> Option<&str> {
153 if let Value::Str(s) = self { Some(s) } else { None }
154 }
155 pub fn get(&self, key: &str) -> Option<&Value> {
156 if let Value::Object(m) = self { m.get(key) } else { None }
157 }
158 }
159
160 struct Parser<'a> { src: &'a [u8], pos: usize }
161
162 impl<'a> Parser<'a> {
163 fn skip_ws(&mut self) {
164 while self.pos < self.src.len() && matches!(self.src[self.pos], b' '|b'\t'|b'\n'|b'\r') {
165 self.pos += 1;
166 }
167 }
168 fn peek(&self) -> Option<u8> { self.src.get(self.pos).copied() }
169 fn bump(&mut self) -> Option<u8> { let c = self.peek()?; self.pos += 1; Some(c) }
170
171 fn parse_value(&mut self) -> Result<Value, String> {
172 self.skip_ws();
173 match self.peek().ok_or("unexpected end")? {
174 b'"' => self.parse_string().map(Value::Str),
175 b'{' => self.parse_object(),
176 b'[' => self.parse_array(),
177 b't' | b'f' => self.parse_bool(),
178 b'n' => self.parse_null(),
179 _ => self.parse_number(),
180 }
181 }
182
183 fn parse_string(&mut self) -> Result<String, String> {
184 if self.bump() != Some(b'"') { return Err("expected '\"'".into()); }
185 let mut buf: Vec<u8> = Vec::new();
187 loop {
188 let c = self.bump().ok_or("unterminated string")?;
189 if c == b'"' {
190 return String::from_utf8(buf).map_err(|e| e.to_string());
191 }
192 if c == b'\\' {
193 let esc = self.bump().ok_or("bad escape")?;
194 match esc {
195 b'"' => buf.push(b'"'),
196 b'\\' => buf.push(b'\\'),
197 b'/' => buf.push(b'/'),
198 b'n' => buf.push(b'\n'),
199 b'r' => buf.push(b'\r'),
200 b't' => buf.push(b'\t'),
201 b'b' => buf.push(0x08),
202 b'f' => buf.push(0x0c),
203 b'u' => {
204 let mut hex = String::new();
205 for _ in 0..4 { hex.push(self.bump().ok_or("bad \\u")? as char); }
206 let cp = u32::from_str_radix(&hex, 16).map_err(|e| e.to_string())?;
207 if let Some(ch) = char::from_u32(cp) {
208 let mut tmp = [0u8; 4];
209 let s = ch.encode_utf8(&mut tmp);
210 buf.extend_from_slice(s.as_bytes());
211 }
212 }
213 _ => return Err(format!("unknown escape \\{}", esc as char)),
214 }
215 } else {
216 buf.push(c);
217 }
218 }
219 }
220
221 fn parse_object(&mut self) -> Result<Value, String> {
222 self.bump(); let mut m = HashMap::new();
224 self.skip_ws();
225 if self.peek() == Some(b'}') { self.bump(); return Ok(Value::Object(m)); }
226 loop {
227 self.skip_ws();
228 let key = self.parse_string()?;
229 self.skip_ws();
230 if self.bump() != Some(b':') { return Err("expected ':'".into()); }
231 let val = self.parse_value()?;
232 m.insert(key, val);
233 self.skip_ws();
234 match self.bump() {
235 Some(b',') => continue,
236 Some(b'}') => return Ok(Value::Object(m)),
237 _ => return Err("expected ',' or '}'".into()),
238 }
239 }
240 }
241
242 fn parse_array(&mut self) -> Result<Value, String> {
243 self.bump();
244 let mut v = Vec::new();
245 self.skip_ws();
246 if self.peek() == Some(b']') { self.bump(); return Ok(Value::Array(v)); }
247 loop {
248 v.push(self.parse_value()?);
249 self.skip_ws();
250 match self.bump() {
251 Some(b',') => continue,
252 Some(b']') => return Ok(Value::Array(v)),
253 _ => return Err("expected ',' or ']'".into()),
254 }
255 }
256 }
257
258 fn parse_bool(&mut self) -> Result<Value, String> {
259 if self.src[self.pos..].starts_with(b"true") { self.pos += 4; Ok(Value::Bool(true)) }
260 else if self.src[self.pos..].starts_with(b"false") { self.pos += 5; Ok(Value::Bool(false)) }
261 else { Err("bad bool".into()) }
262 }
263
264 fn parse_null(&mut self) -> Result<Value, String> {
265 if self.src[self.pos..].starts_with(b"null") { self.pos += 4; Ok(Value::Null) }
266 else { Err("bad null".into()) }
267 }
268
269 fn parse_number(&mut self) -> Result<Value, String> {
270 let start = self.pos;
271 while let Some(c) = self.peek() {
272 if c.is_ascii_digit() || c == b'-' || c == b'+' || c == b'.' || c == b'e' || c == b'E' {
273 self.pos += 1;
274 } else { break; }
275 }
276 let s = std::str::from_utf8(&self.src[start..self.pos]).map_err(|e| e.to_string())?;
277 s.parse::<f64>().map(Value::Num).map_err(|e| e.to_string())
278 }
279 }
280
281 pub fn parse(s: &str) -> Result<Value, String> {
282 let mut p = Parser { src: s.as_bytes(), pos: 0 };
283 p.parse_value()
284 }
285 }
286
287 #[test]
288 fn test_escape_quotes_and_backslash() {
289 let out = build_json_line(
290 r#"with "quotes" and \backslash"#,
291 "info",
292 1000,
293 &HashMap::new(),
294 &HashMap::new(),
295 );
296 let parsed = parse_json(&out);
297 assert_eq!(parsed.get("message").and_then(|v| v.as_str()).unwrap(),
298 r#"with "quotes" and \backslash"#);
299 }
300
301 #[test]
302 fn test_escape_newlines_and_tabs() {
303 let out = build_json_line("line1\nline2\there", "info", 1, &HashMap::new(), &HashMap::new());
304 let parsed = parse_json(&out);
305 assert_eq!(parsed.get("message").and_then(|v| v.as_str()).unwrap(), "line1\nline2\there");
306 }
307
308 #[test]
309 fn test_unicode_passthrough() {
310 let out = build_json_line("hello π δΈζ", "info", 1, &HashMap::new(), &HashMap::new());
311 let parsed = parse_json(&out);
312 assert_eq!(parsed.get("message").and_then(|v| v.as_str()).unwrap(), "hello π δΈζ");
313 }
314
315 #[test]
316 fn test_control_chars_escaped() {
317 let out = build_json_line("bell:\x07 null:\x00", "info", 1, &HashMap::new(), &HashMap::new());
318 let parsed = parse_json(&out);
320 assert!(parsed.get("message").is_some());
321 }
322
323 #[test]
324 fn test_basic_fields_present() {
325 let mut md = HashMap::new();
326 md.insert("user".to_string(), "alice".to_string());
327 let out = build_json_line("msg", "warn", 1234, &HashMap::new(), &md);
328 let parsed = parse_json(&out);
329 assert_eq!(parsed.get("level").and_then(|v| v.as_str()).unwrap(), "warn");
330 assert_eq!(parsed.get("user").and_then(|v| v.as_str()).unwrap(), "alice");
331 }
332
333 #[test]
334 fn test_default_metadata_included() {
335 let mut defaults = HashMap::new();
336 defaults.insert("service".to_string(), "billing".to_string());
337 let out = build_json_line("msg", "info", 1, &defaults, &HashMap::new());
338 let parsed = parse_json(&out);
339 assert_eq!(parsed.get("service").and_then(|v| v.as_str()).unwrap(), "billing");
340 }
341
342 #[test]
343 fn test_file_transport_writes_valid_json() {
344 let tmp = std::env::temp_dir().join("openinfra_rust_test.log");
345 let _ = fs::remove_file(&tmp);
346 let cfg = Config {
347 transports: vec!["file".to_string()],
348 file_path: tmp.to_string_lossy().to_string(),
349 default_metadata: HashMap::new(),
350 };
351 let logger = Logger::new(cfg);
352 let mut md = HashMap::new();
353 md.insert("k".to_string(), r#"val with "quotes""#.to_string());
354 logger.log(r#"crit "alert""#, "error", md);
355
356 let content = fs::read_to_string(&tmp).expect("read log");
357 let last_line = content.lines().last().expect("at least one line");
358 let parsed = parse_json(last_line);
359 assert_eq!(parsed.get("message").and_then(|v| v.as_str()).unwrap(), r#"crit "alert""#);
360 assert_eq!(parsed.get("k").and_then(|v| v.as_str()).unwrap(), r#"val with "quotes""#);
361 assert_eq!(parsed.get("level").and_then(|v| v.as_str()).unwrap(), "error");
362
363 let _ = fs::remove_file(&tmp);
364 let _: PathBuf = tmp;
366 }
367}