Skip to main content

openinfra_logger/
lib.rs

1/*
2 * OpenInfra Logger
3 * Critical infrastructure library for structured observability.
4 *
5 * @author Jonathas Cordeiro (@jonathascordeiro20)
6 * @license MIT
7 * @copyright (c) 2026 Jonathas Cordeiro
8 */
9use 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
34/// Escapes a string for safe inclusion as a JSON string value (RFC 8259).
35/// Without this, embedded quotes / backslashes / control chars produce invalid JSON.
36pub 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
56/// Pure function that builds the JSON line, exposed for unit testing.
57pub 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    // Minimal zero-dependency JSON parser sufficient for our test assertions.
137    // (Keeps Cargo.toml free of dependencies as the original design intended.)
138    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                // Accumulate UTF-8 bytes verbatim so multibyte sequences are preserved.
186                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(); // '{'
223                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        // Must be valid JSON despite control chars in input
319        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        // Reference PathBuf to keep import used
365        let _: PathBuf = tmp;
366    }
367}