mik_sdk/
log.rs

1//! Structured JSON logging to stderr.
2//!
3//! Outputs logs in a standardized JSON format compatible with most log aggregation
4//! systems including ELK Stack, Datadog, `CloudWatch`, Splunk, and Grafana Loki.
5//!
6//! # Output Format
7//!
8//! Each log line is a JSON object with these fields:
9//! - `level`: Log level ("debug", "info", "warn", "error")
10//! - `msg`: The log message
11//! - `ts`: ISO 8601 UTC timestamp (e.g., "2025-01-16T10:30:00Z")
12//! - Additional key-value fields as specified
13//!
14//! ```json
15//! {"level":"info","msg":"user created","id":"123","email":"alice@example.com","ts":"2025-01-16T10:30:00Z"}
16//! {"level":"warn","msg":"rate limit approaching","remaining":"5","ts":"2025-01-16T10:30:01Z"}
17//! {"level":"error","msg":"failed to fetch","url":"https://api.example.com","status":"500","ts":"2025-01-16T10:30:02Z"}
18//! ```
19//!
20//! # Structured Logging Usage
21//!
22//! ```no_run
23//! # use mik_sdk::log;
24//! let user_id = "123";
25//! let email = "alice@example.com";
26//! let count = 5;
27//!
28//! // Structured logging with key-value pairs
29//! log!(info, "user created", id: user_id, email: &email);
30//! log!(warn, "rate limit approaching", remaining: count);
31//! ```
32//!
33//! # Simple Logging (format string style)
34//!
35//! ```no_run
36//! # use mik_sdk::log;
37//! let user_id = "u123";
38//! let key = "cache_key";
39//! let err = "connection refused";
40//!
41//! log::info!("User {} logged in", user_id);
42//! log::warn!("Cache miss for key: {}", key);
43//! log::error!("Database connection failed: {}", err);
44//! log::debug!("Debug message");  // Only in debug builds
45//! ```
46//!
47//! # Debug Logging
48//!
49//! `debug!` messages are only emitted in debug builds (when `#[cfg(debug_assertions)]` is true).
50//! In release builds, `debug!` is a no-op.
51//!
52//! # Compatibility
53//!
54//! This format is designed for maximum compatibility:
55//! - **ELK Stack**: Auto-parses JSON, maps fields automatically
56//! - **Datadog**: Auto-detects JSON logs, extracts standard fields
57//! - **`CloudWatch` Logs Insights**: Query with parsed JSON fields
58//! - **Splunk**: Auto-extracts JSON fields
59//! - **Grafana Loki**: Label extraction from JSON fields
60//!
61//! # Note on Macro Names
62//!
63//! These macros are prefixed with `log_` internally to avoid conflicts with other macros
64//! and builtin attributes, but are re-exported as `info`, `warn`, `error`, and `debug`
65//! for use as `log::info!()`, `log::warn!()`, etc.
66
67use std::time::{SystemTime, UNIX_EPOCH};
68
69/// Format timestamp as ISO 8601 with millisecond precision.
70/// Returns format: "YYYY-MM-DDTHH:MM:SS.sssZ"
71#[doc(hidden)]
72#[must_use]
73pub fn __format_timestamp() -> String {
74    let now = SystemTime::now()
75        .duration_since(UNIX_EPOCH)
76        .unwrap_or_default();
77
78    __format_timestamp_from_duration(now.as_secs(), now.subsec_millis())
79}
80
81/// Internal: format timestamp from seconds and milliseconds.
82/// Exposed for testing the date calculation algorithm.
83///
84/// # Algorithm
85///
86/// Uses Howard Hinnant's civil_from_days algorithm for converting days since
87/// Unix epoch (1970-01-01) to year/month/day. This is a well-tested, efficient
88/// algorithm used in many date libraries.
89///
90/// Reference: <https://howardhinnant.github.io/date_algorithms.html#civil_from_days>
91#[doc(hidden)]
92#[must_use]
93#[allow(clippy::similar_names)] // doe/doy are standard date algorithm abbreviations
94pub fn __format_timestamp_from_duration(secs: u64, millis: u32) -> String {
95    use crate::constants::{SECONDS_PER_DAY, SECONDS_PER_HOUR, SECONDS_PER_MINUTE};
96
97    // Split timestamp into days and time-of-day components
98    let days = secs / SECONDS_PER_DAY;
99    let remaining = secs % SECONDS_PER_DAY;
100
101    // Extract hours, minutes, seconds from time-of-day
102    let hours = remaining / SECONDS_PER_HOUR;
103    let remaining = remaining % SECONDS_PER_HOUR;
104    let minutes = remaining / SECONDS_PER_MINUTE;
105    let seconds = remaining % SECONDS_PER_MINUTE;
106
107    // ========================================================================
108    // Howard Hinnant's civil_from_days algorithm
109    // Converts days since Unix epoch to (year, month, day)
110    // ========================================================================
111
112    // Shift epoch from 1970-01-01 to 0000-03-01 (simplifies leap year math)
113    // 719468 = days from 0000-03-01 to 1970-01-01
114    let z = days + 719468;
115
116    // Calculate era (400-year cycle). Each era has exactly 146097 days.
117    // 146097 = 365*400 + 97 leap days (97 = 400/4 - 400/100 + 400/400)
118    let era = z / 146097;
119
120    // Day-of-era: 0 to 146096
121    let doe = z - era * 146097;
122
123    // Year-of-era: 0 to 399
124    // The formula accounts for leap years:
125    // - Every 4 years is a leap year (subtract doe/1460)
126    // - Except every 100 years (add doe/36524)
127    // - Except every 400 years (subtract doe/146096)
128    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
129
130    // Absolute year (from year 0)
131    let y = yoe + era * 400;
132
133    // Day-of-year: 0 to 365
134    // Subtracts the days in previous years of this era
135    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
136
137    // Month calculation using the "30.6 rule" (months avg ~30.6 days Mar-Feb)
138    // mp ranges 0-11, representing Mar(0) to Feb(11)
139    let mp = (5 * doy + 2) / 153;
140
141    // Day-of-month: 1 to 31
142    // 153 = sum of days in 5-month groups (Mar-Jul or Aug-Dec)
143    let d = doy - (153 * mp + 2) / 5 + 1;
144
145    // Convert month from Mar=0..Feb=11 to Jan=1..Dec=12
146    let m = if mp < 10 { mp + 3 } else { mp - 9 };
147
148    // Adjust year for Jan/Feb (they belong to the next calendar year in this algorithm)
149    let year = if m <= 2 { y + 1 } else { y };
150
151    format!("{year:04}-{m:02}-{d:02}T{hours:02}:{minutes:02}:{seconds:02}.{millis:03}Z")
152}
153
154/// Escape a string for JSON output per RFC 7159.
155///
156/// Handles quotes, backslashes, and control characters.
157///
158/// # Note: Intentional Duplication
159///
160/// This function is duplicated in `mik-bridge/src/lib.rs` as `escape_json_string()`.
161/// This is intentional because:
162/// - `mik-bridge` is a standalone WASM component that cannot depend on `mik-sdk`
163/// - `mik-sdk` cannot depend on `mik-bridge` (it's the other way around)
164/// - Creating a shared crate for ~20 lines of code adds unnecessary complexity
165///
166/// If you modify this function, please update the duplicate in `mik-bridge` too.
167#[doc(hidden)]
168#[must_use]
169pub fn __escape_json(s: &str) -> String {
170    // Estimate capacity: base length + 10% for potential escapes.
171    // Common escapes (", \, \n, \r, \t) expand 1 char to 2.
172    // Control chars expand to 6 (\uXXXX) but are rare.
173    let estimated_capacity = s.len() + (s.len() / 10).max(8);
174    let mut result = String::with_capacity(estimated_capacity);
175    for c in s.chars() {
176        match c {
177            '"' => result.push_str("\\\""),
178            '\\' => result.push_str("\\\\"),
179            '\n' => result.push_str("\\n"),
180            '\r' => result.push_str("\\r"),
181            '\t' => result.push_str("\\t"),
182            c if c.is_control() => {
183                // Use write! to avoid format! allocation for rare control chars
184                use std::fmt::Write;
185                let _ = write!(result, "\\u{:04x}", c as u32);
186            },
187            c => result.push(c),
188        }
189    }
190    result
191}
192
193/// Write a simple log line to stderr (internal helper).
194///
195/// This function contains the actual logging logic. The public macros
196/// (`log::info!`, etc.) are thin wrappers that call this function.
197#[doc(hidden)]
198pub fn __write_simple_log(level: &str, msg: &str) {
199    use std::io::Write;
200    let timestamp = __format_timestamp();
201    let escaped = __escape_json(msg);
202    let _ = writeln!(
203        std::io::stderr(),
204        r#"{{"level":"{level}","msg":"{escaped}","ts":"{timestamp}"}}"#,
205    );
206}
207
208/// Log an informational message to stderr.
209///
210/// # Examples
211///
212/// ```no_run
213/// # use mik_sdk::log;
214/// log::info!("Server started on port 8080");
215/// log::info!("User {} logged in", "u123");
216/// ```
217#[macro_export]
218macro_rules! log_info {
219    ($($arg:tt)*) => { $crate::log::__write_simple_log("info", &format!($($arg)*)) };
220}
221
222/// Log a warning message to stderr.
223///
224/// # Examples
225///
226/// ```no_run
227/// # use mik_sdk::log;
228/// log::warn!("Cache miss for key: {}", "user:123");
229/// log::warn!("Slow query detected: {}ms", 150);
230/// ```
231#[macro_export]
232macro_rules! log_warn {
233    ($($arg:tt)*) => { $crate::log::__write_simple_log("warn", &format!($($arg)*)) };
234}
235
236/// Log an error message to stderr.
237///
238/// # Examples
239///
240/// ```no_run
241/// # use mik_sdk::log;
242/// log::error!("Database connection failed: {}", "timeout");
243/// log::error!("Failed to parse request body");
244/// ```
245#[macro_export]
246macro_rules! log_error {
247    ($($arg:tt)*) => { $crate::log::__write_simple_log("error", &format!($($arg)*)) };
248}
249
250/// Log a debug message to stderr (only in debug builds).
251///
252/// In release builds, this macro is a no-op and produces no code.
253///
254/// # Examples
255///
256/// ```no_run
257/// # use mik_sdk::log;
258/// log::debug!("Request payload: {:?}", "test data");
259/// log::debug!("Cache size: {} entries", 42);
260/// ```
261#[macro_export]
262macro_rules! log_debug {
263    ($($arg:tt)*) => {{
264        #[cfg(debug_assertions)]
265        $crate::log::__write_simple_log("debug", &format!($($arg)*));
266    }};
267}
268
269// Re-export macros with clean names for use as `log::info!()`, etc.
270pub use log_debug as debug;
271pub use log_error as error;
272pub use log_info as info;
273pub use log_warn as warn;
274
275// ============================================================================
276// STRUCTURED LOGGING MACRO
277// ============================================================================
278
279/// Build a structured JSON log line with key-value pairs.
280///
281/// Internal helper that constructs the JSON string from level, message, and fields.
282#[doc(hidden)]
283#[must_use]
284pub fn __build_structured_log(level: &str, msg: &str, fields: &[(&str, &str)]) -> String {
285    use crate::time::now_iso;
286
287    // Estimate capacity: {"level":"info","msg":"...","ts":"...","key":"val",...}
288    // Base overhead ~50, plus message, plus ~20 per field
289    let estimated_capacity = 50 + msg.len() * 2 + fields.len() * 30;
290    let mut output = String::with_capacity(estimated_capacity);
291
292    output.push_str(r#"{"level":""#);
293    output.push_str(level);
294    output.push_str(r#"","msg":""#);
295    output.push_str(&__escape_json(msg));
296    output.push('"');
297
298    for (key, value) in fields {
299        output.push_str(r#",""#);
300        output.push_str(&__escape_json(key));
301        output.push_str(r#"":""#);
302        output.push_str(&__escape_json(value));
303        output.push('"');
304    }
305
306    output.push_str(r#","ts":""#);
307    output.push_str(&now_iso());
308    output.push_str(r#""}"#);
309
310    output
311}
312
313/// Structured logging macro with key-value pairs.
314///
315/// Outputs JSON logs to stderr with level, message, custom fields, and timestamp.
316///
317/// # Usage
318///
319/// ```no_run
320/// # use mik_sdk::log;
321/// let user_id = "123";
322/// let email = "alice@example.com";
323///
324/// // With key-value fields
325/// log!(info, "user created", id: user_id, email: &email);
326/// log!(warn, "rate limit approaching", remaining: 5);
327///
328/// // Without fields (just level and message)
329/// log!(info, "server started");
330/// ```
331///
332/// # Output Format
333///
334/// ```json
335/// {"level":"info","msg":"user created","id":"123","email":"alice@example.com","ts":"2025-01-16T10:30:00Z"}
336/// ```
337///
338/// # Note on debug level
339///
340/// Unlike `log::debug!`, the structured `log!(debug, ...)` is NOT compiled out in
341/// release builds. If you need debug logs that are removed in release, use `log::debug!`.
342#[macro_export]
343macro_rules! log {
344    // Pattern: log!(level, "message", key: value, ...)
345    ($level:ident, $msg:expr $(, $key:ident : $value:expr)* $(,)?) => {{
346        use std::io::Write;
347        let fields: &[(&str, &str)] = &[
348            $( (stringify!($key), &format!("{}", $value)) ),*
349        ];
350        let log_line = $crate::log::__build_structured_log(stringify!($level), $msg, fields);
351        let _ = writeln!(std::io::stderr(), "{}", log_line);
352    }};
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn test_escape_json_simple() {
361        assert_eq!(__escape_json("hello"), "hello");
362    }
363
364    #[test]
365    fn test_escape_json_quotes() {
366        assert_eq!(__escape_json(r#"say "hello""#), r#"say \"hello\""#);
367    }
368
369    #[test]
370    fn test_escape_json_backslash() {
371        assert_eq!(__escape_json(r"path\to\file"), r"path\\to\\file");
372    }
373
374    #[test]
375    fn test_escape_json_newlines() {
376        assert_eq!(__escape_json("line1\nline2"), "line1\\nline2");
377    }
378
379    #[test]
380    fn test_escape_json_tabs() {
381        assert_eq!(__escape_json("col1\tcol2"), "col1\\tcol2");
382    }
383
384    #[test]
385    fn test_escape_json_carriage_return() {
386        assert_eq!(__escape_json("line1\rline2"), "line1\\rline2");
387    }
388
389    #[test]
390    fn test_escape_json_control_chars() {
391        // Test control character escaping (ASCII 0-31 except common ones)
392        let input = "\x00\x01\x02\x1F";
393        let escaped = __escape_json(input);
394        assert!(escaped.contains("\\u0000"));
395        assert!(escaped.contains("\\u0001"));
396        assert!(escaped.contains("\\u0002"));
397        assert!(escaped.contains("\\u001f"));
398    }
399
400    #[test]
401    fn test_escape_json_mixed() {
402        let input = "Hello \"World\"\nLine2\t\x00end";
403        let escaped = __escape_json(input);
404        assert!(escaped.contains("\\\""));
405        assert!(escaped.contains("\\n"));
406        assert!(escaped.contains("\\t"));
407        assert!(escaped.contains("\\u0000"));
408    }
409
410    #[test]
411    fn test_escape_json_empty() {
412        assert_eq!(__escape_json(""), "");
413    }
414
415    #[test]
416    fn test_escape_json_unicode() {
417        // Unicode characters should pass through unchanged
418        assert_eq!(__escape_json("日本語"), "日本語");
419        assert_eq!(__escape_json("emoji: 🎉"), "emoji: 🎉");
420    }
421
422    #[test]
423    fn test_timestamp_format() {
424        let ts = __format_timestamp();
425        // Should be in ISO 8601 format: YYYY-MM-DDTHH:MM:SS.sssZ
426        assert_eq!(ts.len(), 24);
427        assert_eq!(ts.chars().nth(4), Some('-'));
428        assert_eq!(ts.chars().nth(7), Some('-'));
429        assert_eq!(ts.chars().nth(10), Some('T'));
430        assert_eq!(ts.chars().nth(13), Some(':'));
431        assert_eq!(ts.chars().nth(16), Some(':'));
432        assert_eq!(ts.chars().nth(19), Some('.'));
433        assert_eq!(ts.chars().last(), Some('Z'));
434    }
435
436    #[test]
437    fn test_timestamp_valid_date_parts() {
438        let ts = __format_timestamp();
439        // Parse year
440        let year: u32 = ts[0..4].parse().expect("valid year");
441        assert!((1970..=3000).contains(&year));
442
443        // Parse month (01-12)
444        let month: u32 = ts[5..7].parse().expect("valid month");
445        assert!((1..=12).contains(&month));
446
447        // Parse day (01-31)
448        let day: u32 = ts[8..10].parse().expect("valid day");
449        assert!((1..=31).contains(&day));
450
451        // Parse hour (00-23)
452        let hour: u32 = ts[11..13].parse().expect("valid hour");
453        assert!(hour <= 23);
454
455        // Parse minute (00-59)
456        let minute: u32 = ts[14..16].parse().expect("valid minute");
457        assert!(minute <= 59);
458
459        // Parse second (00-59)
460        let second: u32 = ts[17..19].parse().expect("valid second");
461        assert!(second <= 59);
462
463        // Parse millisecond (000-999)
464        let millis: u32 = ts[20..23].parse().expect("valid milliseconds");
465        assert!(millis <= 999);
466    }
467
468    #[test]
469    fn test_timestamp_changes_over_time() {
470        let ts1 = __format_timestamp();
471        // Small sleep to ensure time passes
472        std::thread::sleep(std::time::Duration::from_millis(2));
473        let ts2 = __format_timestamp();
474
475        // Timestamps should differ (at least in milliseconds)
476        // Note: could be same if very fast, so we just check they're valid
477        assert_eq!(ts1.len(), 24);
478        assert_eq!(ts2.len(), 24);
479    }
480
481    // Tests for timestamp arithmetic (catches mutation testing issues)
482    #[test]
483    fn test_timestamp_epoch() {
484        // Unix epoch: 1970-01-01 00:00:00.000
485        assert_eq!(
486            __format_timestamp_from_duration(0, 0),
487            "1970-01-01T00:00:00.000Z"
488        );
489    }
490
491    #[test]
492    fn test_timestamp_known_date() {
493        // 2025-01-16 10:50:00.000 UTC
494        assert_eq!(
495            __format_timestamp_from_duration(1737024600, 0),
496            "2025-01-16T10:50:00.000Z"
497        );
498    }
499
500    #[test]
501    fn test_timestamp_with_millis() {
502        // 2025-01-16 10:50:00.123 UTC
503        assert_eq!(
504            __format_timestamp_from_duration(1737024600, 123),
505            "2025-01-16T10:50:00.123Z"
506        );
507    }
508
509    #[test]
510    fn test_timestamp_leap_year() {
511        // 2024-02-29 12:00:00.000 UTC (leap year)
512        assert_eq!(
513            __format_timestamp_from_duration(1709208000, 0),
514            "2024-02-29T12:00:00.000Z"
515        );
516    }
517
518    #[test]
519    fn test_timestamp_end_of_year() {
520        // 2024-12-31 23:59:59.999 UTC
521        assert_eq!(
522            __format_timestamp_from_duration(1735689599, 999),
523            "2024-12-31T23:59:59.999Z"
524        );
525    }
526
527    #[test]
528    fn test_timestamp_start_of_year() {
529        // 2024-01-01 00:00:00.000 UTC
530        assert_eq!(
531            __format_timestamp_from_duration(1704067200, 0),
532            "2024-01-01T00:00:00.000Z"
533        );
534    }
535
536    #[test]
537    fn test_timestamp_y2k() {
538        // 2000-01-01 00:00:00.000 UTC
539        assert_eq!(
540            __format_timestamp_from_duration(946684800, 0),
541            "2000-01-01T00:00:00.000Z"
542        );
543    }
544
545    #[test]
546    fn test_timestamp_far_future() {
547        // 2100-12-31 23:59:59.000 UTC
548        assert_eq!(
549            __format_timestamp_from_duration(4133980799, 0),
550            "2100-12-31T23:59:59.000Z"
551        );
552    }
553
554    #[test]
555    fn test_timestamp_hour_minute_second_boundaries() {
556        // Test specific time: 23:59:59
557        // 1970-01-01 23:59:59.000 UTC = 86399 seconds
558        assert_eq!(
559            __format_timestamp_from_duration(86399, 0),
560            "1970-01-01T23:59:59.000Z"
561        );
562
563        // Test specific time: 12:30:45
564        // 1970-01-01 12:30:45.000 UTC = 12*3600 + 30*60 + 45 = 45045 seconds
565        assert_eq!(
566            __format_timestamp_from_duration(45045, 0),
567            "1970-01-01T12:30:45.000Z"
568        );
569    }
570
571    #[test]
572    fn test_timestamp_day_boundary() {
573        // First second of day 2 (1970-01-02 00:00:00)
574        assert_eq!(
575            __format_timestamp_from_duration(86400, 0),
576            "1970-01-02T00:00:00.000Z"
577        );
578    }
579
580    #[test]
581    fn test_timestamp_month_boundaries() {
582        // 1970-02-01 00:00:00 UTC = 31 days * 86400
583        assert_eq!(
584            __format_timestamp_from_duration(31 * 86400, 0),
585            "1970-02-01T00:00:00.000Z"
586        );
587
588        // 1970-03-01 00:00:00 UTC = (31 + 28) days * 86400 (1970 is not a leap year)
589        assert_eq!(
590            __format_timestamp_from_duration(59 * 86400, 0),
591            "1970-03-01T00:00:00.000Z"
592        );
593    }
594
595    #[test]
596    fn test_timestamp_century_boundary() {
597        // 2000 is a leap year (divisible by 400)
598        // 2000-02-29 00:00:00 UTC
599        assert_eq!(
600            __format_timestamp_from_duration(951782400, 0),
601            "2000-02-29T00:00:00.000Z"
602        );
603
604        // 1900 was NOT a leap year (divisible by 100 but not 400)
605        // But we can't test pre-epoch dates easily
606    }
607
608    // ========================================================================
609    // STRUCTURED LOG TESTS
610    // ========================================================================
611
612    /// Helper to build structured log without timestamp for testing
613    fn build_log_without_ts(level: &str, msg: &str, fields: &[(&str, &str)]) -> String {
614        let mut output = String::new();
615        output.push_str(r#"{"level":""#);
616        output.push_str(level);
617        output.push_str(r#"","msg":""#);
618        output.push_str(&__escape_json(msg));
619        output.push('"');
620
621        for (key, value) in fields {
622            output.push_str(r#",""#);
623            output.push_str(&__escape_json(key));
624            output.push_str(r#"":""#);
625            output.push_str(&__escape_json(value));
626            output.push('"');
627        }
628
629        output
630    }
631
632    #[test]
633    fn test_structured_log_basic() {
634        let output = build_log_without_ts("info", "user created", &[]);
635        assert_eq!(output, r#"{"level":"info","msg":"user created""#);
636    }
637
638    #[test]
639    fn test_structured_log_with_single_field() {
640        let output = build_log_without_ts("info", "user created", &[("id", "123")]);
641        assert_eq!(output, r#"{"level":"info","msg":"user created","id":"123""#);
642    }
643
644    #[test]
645    fn test_structured_log_with_multiple_fields() {
646        let output = build_log_without_ts(
647            "info",
648            "user created",
649            &[("id", "123"), ("email", "alice@example.com")],
650        );
651        assert_eq!(
652            output,
653            r#"{"level":"info","msg":"user created","id":"123","email":"alice@example.com""#
654        );
655    }
656
657    #[test]
658    fn test_structured_log_error_level() {
659        let output = build_log_without_ts(
660            "error",
661            "failed to fetch",
662            &[("url", "https://api.example.com"), ("status", "500")],
663        );
664        assert_eq!(
665            output,
666            r#"{"level":"error","msg":"failed to fetch","url":"https://api.example.com","status":"500""#
667        );
668    }
669
670    #[test]
671    fn test_structured_log_warn_level() {
672        let output = build_log_without_ts("warn", "rate limit approaching", &[("remaining", "5")]);
673        assert_eq!(
674            output,
675            r#"{"level":"warn","msg":"rate limit approaching","remaining":"5""#
676        );
677    }
678
679    #[test]
680    fn test_structured_log_debug_level() {
681        let output = build_log_without_ts(
682            "debug",
683            "request parsed",
684            &[("method", "GET"), ("path", "/users")],
685        );
686        assert_eq!(
687            output,
688            r#"{"level":"debug","msg":"request parsed","method":"GET","path":"/users""#
689        );
690    }
691
692    #[test]
693    fn test_structured_log_escapes_message() {
694        let output = build_log_without_ts("info", "message with \"quotes\"", &[]);
695        assert_eq!(output, r#"{"level":"info","msg":"message with \"quotes\"""#);
696    }
697
698    #[test]
699    fn test_structured_log_escapes_field_values() {
700        let output = build_log_without_ts("info", "test", &[("data", "line1\nline2")]);
701        assert_eq!(
702            output,
703            r#"{"level":"info","msg":"test","data":"line1\nline2""#
704        );
705    }
706
707    #[test]
708    fn test_structured_log_full_output_format() {
709        // Test the full __build_structured_log function including timestamp
710        let output = __build_structured_log("info", "test message", &[("key", "value")]);
711
712        // Should start with level
713        assert!(output.starts_with(r#"{"level":"info""#));
714
715        // Should contain msg
716        assert!(output.contains(r#""msg":"test message""#));
717
718        // Should contain the field
719        assert!(output.contains(r#""key":"value""#));
720
721        // Should end with timestamp and closing brace
722        assert!(output.contains(r#","ts":"20"#)); // Starts with 20xx year
723        assert!(output.ends_with(r#"Z"}"#));
724    }
725
726    #[test]
727    fn test_structured_log_timestamp_is_valid_iso() {
728        let output = __build_structured_log("info", "test", &[]);
729
730        // Extract timestamp from output
731        let ts_start = output.find(r#""ts":""#).expect("should have ts field") + 6;
732        let ts_end = output[ts_start..].find('"').expect("should close ts") + ts_start;
733        let ts = &output[ts_start..ts_end];
734
735        // Should be valid ISO format
736        assert!(ts.ends_with('Z'), "timestamp should end with Z");
737        assert!(ts.contains('T'), "timestamp should contain T separator");
738        assert!(
739            ts.len() == 20 || ts.len() == 24,
740            "timestamp should be 20 or 24 chars"
741        );
742    }
743}