Skip to main content

sonda_core/encoder/
json.rs

1//! JSON Lines encoder.
2//!
3//! Encodes metric and log events as newline-delimited JSON (NDJSON). Each line is a
4//! self-contained JSON object, making the output compatible with Elasticsearch, Loki,
5//! and generic HTTP ingest endpoints.
6//!
7//! Metric output format:
8//! ```text
9//! {"name":"metric","value":1.0,"labels":{"k":"v"},"timestamp":"2026-03-20T12:00:00.000Z"}
10//! ```
11//!
12//! Log output format:
13//! ```text
14//! {"timestamp":"2026-03-20T12:00:00.000Z","severity":"info","message":"Request from 10.0.0.1","labels":{"device":"wlan0"},"fields":{"ip":"10.0.0.1","endpoint":"/api"}}
15//! ```
16//!
17//! Timestamp uses RFC 3339 / ISO 8601 format with millisecond precision. Formatted without
18//! pulling in `chrono` — derived directly from [`std::time::SystemTime`] arithmetic.
19
20use std::collections::BTreeMap;
21
22use serde::ser::SerializeMap;
23use serde::Serialize;
24
25use crate::model::log::LogEvent;
26use crate::model::metric::{Labels, MetricEvent};
27use crate::{EncoderError, SondaError};
28
29use super::Encoder;
30
31/// Encodes [`MetricEvent`]s as newline-delimited JSON (JSON Lines format).
32///
33/// Each call to [`encode_metric`](Self::encode_metric) appends one complete JSON object
34/// followed by a newline character to the caller-provided buffer.
35///
36/// No per-event heap allocations beyond what `serde_json::to_writer` needs internally.
37/// All invariant content is pre-built at construction time.
38///
39/// When `precision` is set, metric values are pre-rounded before JSON serialization.
40/// Note that JSON has no trailing-zero concept, so `precision: 2` with value `100.0`
41/// produces `100.0` in JSON (not `100.00`), which is semantically correct.
42pub struct JsonLines {
43    /// Optional decimal precision for metric values.
44    precision: Option<u8>,
45}
46
47impl JsonLines {
48    /// Create a new `JsonLines` encoder.
49    ///
50    /// `precision` optionally rounds metric values to the specified number of decimal
51    /// places before JSON serialization. `None` preserves full `f64` precision.
52    pub fn new(precision: Option<u8>) -> Self {
53        Self { precision }
54    }
55}
56
57impl Default for JsonLines {
58    fn default() -> Self {
59        Self::new(None)
60    }
61}
62
63/// Zero-allocation serde wrapper that serializes a [`Labels`] reference as a JSON map.
64///
65/// `Labels` stores its data in a `BTreeMap<String, String>`, which already iterates
66/// in sorted key order. This wrapper serializes directly from the iterator without
67/// collecting into an intermediate `BTreeMap<&str, &str>`, eliminating per-event
68/// heap allocations for BTreeMap nodes.
69struct LabelsRef<'a>(&'a Labels);
70
71impl<'a> Serialize for LabelsRef<'a> {
72    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
73        let mut map = serializer.serialize_map(Some(self.0.len()))?;
74        for (k, v) in self.0.iter() {
75            map.serialize_entry(k, v)?;
76        }
77        map.end()
78    }
79}
80
81/// Zero-allocation serde wrapper that serializes a `&BTreeMap<String, String>` as a JSON map.
82///
83/// Iterates directly over the borrowed map entries in sorted key order without
84/// collecting into an intermediate `BTreeMap<&str, &str>`.
85struct StringMapRef<'a>(&'a BTreeMap<String, String>);
86
87impl<'a> Serialize for StringMapRef<'a> {
88    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
89        let mut map = serializer.serialize_map(Some(self.0.len()))?;
90        for (k, v) in self.0.iter() {
91            map.serialize_entry(k.as_str(), v.as_str())?;
92        }
93        map.end()
94    }
95}
96
97/// Intermediate serde-serializable representation of a metric event.
98///
99/// Labels are serialized directly from the source [`Labels`] iterator via [`LabelsRef`],
100/// avoiding the intermediate `BTreeMap<&str, &str>` collection. The source `Labels` type
101/// already stores data in sorted order, so JSON field order is consistent and deterministic.
102///
103/// The `timestamp` field borrows from a stack-allocated byte array to avoid heap allocation.
104#[derive(Serialize)]
105struct JsonMetric<'a> {
106    name: &'a str,
107    value: f64,
108    labels: LabelsRef<'a>,
109    timestamp: &'a str,
110}
111
112/// Intermediate serde-serializable representation of a log event.
113///
114/// Field order matches the spec: timestamp, severity, message, labels, fields.
115/// Labels and fields are serialized directly from their source iterators via
116/// [`LabelsRef`] and [`StringMapRef`], avoiding intermediate `BTreeMap<&str, &str>`
117/// collections. Both source types already iterate in sorted key order.
118///
119/// The `timestamp` field borrows from a stack-allocated byte array to avoid heap allocation.
120#[derive(Serialize)]
121struct JsonLog<'a> {
122    timestamp: &'a str,
123    severity: &'a str,
124    message: &'a str,
125    labels: LabelsRef<'a>,
126    fields: StringMapRef<'a>,
127}
128
129impl Encoder for JsonLines {
130    /// Encode a metric event as a JSON object and append it to `buf`, followed by `\n`.
131    ///
132    /// Uses `serde_json::to_writer` to write directly into the caller-provided buffer,
133    /// avoiding an intermediate `String` allocation.
134    fn encode_metric(&self, event: &MetricEvent, buf: &mut Vec<u8>) -> Result<(), SondaError> {
135        let ts_bytes = super::format_rfc3339_millis_array(event.timestamp)?;
136        // Invariant: the array contains only ASCII digits, hyphens, colons, 'T', '.', 'Z'.
137        let timestamp =
138            std::str::from_utf8(&ts_bytes).expect("RFC 3339 timestamp is always valid UTF-8");
139
140        let value = match self.precision {
141            None => event.value,
142            Some(n) => {
143                let factor = 10f64.powi(n as i32);
144                (event.value * factor).round() / factor
145            }
146        };
147
148        let record = JsonMetric {
149            name: &event.name,
150            value,
151            labels: LabelsRef(&event.labels),
152            timestamp,
153        };
154
155        serde_json::to_writer(&mut *buf, &record)
156            .map_err(|e| SondaError::Encoder(EncoderError::SerializationFailed(e)))?;
157
158        buf.push(b'\n');
159
160        Ok(())
161    }
162
163    /// Encode a log event as a JSON object and append it to `buf`, followed by `\n`.
164    ///
165    /// Output format: `{"timestamp":"...","severity":"info","message":"...","fields":{...}}`
166    ///
167    /// Uses `serde_json::to_writer` to write directly into the caller-provided buffer.
168    fn encode_log(&self, event: &LogEvent, buf: &mut Vec<u8>) -> Result<(), SondaError> {
169        let ts_bytes = super::format_rfc3339_millis_array(event.timestamp)?;
170        let timestamp =
171            std::str::from_utf8(&ts_bytes).expect("RFC 3339 timestamp is always valid UTF-8");
172
173        // Serialize severity to its lowercase string representation using serde.
174        let severity_str = match event.severity {
175            crate::model::log::Severity::Trace => "trace",
176            crate::model::log::Severity::Debug => "debug",
177            crate::model::log::Severity::Info => "info",
178            crate::model::log::Severity::Warn => "warn",
179            crate::model::log::Severity::Error => "error",
180            crate::model::log::Severity::Fatal => "fatal",
181        };
182
183        let record = JsonLog {
184            timestamp,
185            severity: severity_str,
186            message: &event.message,
187            labels: LabelsRef(&event.labels),
188            fields: StringMapRef(&event.fields),
189        };
190
191        serde_json::to_writer(&mut *buf, &record)
192            .map_err(|e| SondaError::Encoder(EncoderError::SerializationFailed(e)))?;
193
194        buf.push(b'\n');
195
196        Ok(())
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::model::metric::{Labels, MetricEvent};
204    use std::time::{Duration, UNIX_EPOCH};
205
206    /// Build a MetricEvent with a fixed timestamp for deterministic tests.
207    fn make_event(
208        name: &str,
209        value: f64,
210        labels: &[(&str, &str)],
211        timestamp: std::time::SystemTime,
212    ) -> MetricEvent {
213        let labels = Labels::from_pairs(labels).unwrap();
214        MetricEvent::with_timestamp(name.to_string(), value, labels, timestamp).unwrap()
215    }
216
217    // --- Happy path: valid JSON output ---
218
219    #[test]
220    fn output_is_valid_json_parseable_by_serde_json() {
221        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
222        let event = make_event("cpu_usage", 0.75, &[("host", "srv1")], ts);
223        let encoder = JsonLines::new(None);
224        let mut buf = Vec::new();
225        encoder.encode_metric(&event, &mut buf).unwrap();
226
227        let line = String::from_utf8(buf).unwrap();
228        let line = line.trim_end_matches('\n');
229        let parsed: serde_json::Value = serde_json::from_str(line).expect("must be valid JSON");
230        assert!(parsed.is_object(), "output must be a JSON object");
231    }
232
233    // --- Roundtrip: all fields survive encode → parse ---
234
235    #[test]
236    fn roundtrip_name_matches_original_event() {
237        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
238        let event = make_event("http_requests", 42.0, &[], ts);
239        let encoder = JsonLines::new(None);
240        let mut buf = Vec::new();
241        encoder.encode_metric(&event, &mut buf).unwrap();
242
243        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
244        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
245        assert_eq!(parsed["name"], "http_requests");
246    }
247
248    #[test]
249    fn roundtrip_value_matches_original_event() {
250        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
251        let event = make_event("latency", 3.14, &[], ts);
252        let encoder = JsonLines::new(None);
253        let mut buf = Vec::new();
254        encoder.encode_metric(&event, &mut buf).unwrap();
255
256        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
257        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
258        assert!((parsed["value"].as_f64().unwrap() - 3.14).abs() < f64::EPSILON);
259    }
260
261    #[test]
262    fn roundtrip_labels_match_original_event() {
263        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
264        let event = make_event("metric", 1.0, &[("env", "prod"), ("host", "srv1")], ts);
265        let encoder = JsonLines::new(None);
266        let mut buf = Vec::new();
267        encoder.encode_metric(&event, &mut buf).unwrap();
268
269        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
270        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
271        assert_eq!(parsed["labels"]["env"], "prod");
272        assert_eq!(parsed["labels"]["host"], "srv1");
273    }
274
275    #[test]
276    fn roundtrip_timestamp_matches_original_event() {
277        // Unix epoch 1700000000.000 = 2023-11-14T22:13:20.000Z
278        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
279        let event = make_event("up", 1.0, &[], ts);
280        let encoder = JsonLines::new(None);
281        let mut buf = Vec::new();
282        encoder.encode_metric(&event, &mut buf).unwrap();
283
284        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
285        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
286        assert_eq!(
287            parsed["timestamp"], "2023-11-14T22:13:20.000Z",
288            "timestamp must be RFC 3339 with millisecond precision"
289        );
290    }
291
292    // --- Empty labels ---
293
294    #[test]
295    fn empty_labels_produces_empty_json_object() {
296        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
297        let event = make_event("up", 1.0, &[], ts);
298        let encoder = JsonLines::new(None);
299        let mut buf = Vec::new();
300        encoder.encode_metric(&event, &mut buf).unwrap();
301
302        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
303        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
304        assert_eq!(
305            parsed["labels"],
306            serde_json::json!({}),
307            "empty labels must be an empty JSON object"
308        );
309    }
310
311    // --- Newline termination ---
312
313    #[test]
314    fn each_encoded_line_ends_with_newline() {
315        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
316        let event = make_event("up", 1.0, &[], ts);
317        let encoder = JsonLines::new(None);
318        let mut buf = Vec::new();
319        encoder.encode_metric(&event, &mut buf).unwrap();
320
321        assert_eq!(
322            *buf.last().unwrap(),
323            b'\n',
324            "line must terminate with newline"
325        );
326    }
327
328    #[test]
329    fn multiple_events_each_end_with_newline() {
330        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
331        let encoder = JsonLines::new(None);
332        let mut buf = Vec::new();
333        for i in 0..3u64 {
334            let event = make_event("up", i as f64, &[], ts + Duration::from_millis(i));
335            encoder.encode_metric(&event, &mut buf).unwrap();
336        }
337
338        let text = String::from_utf8(buf).unwrap();
339        let lines: Vec<&str> = text.lines().collect();
340        assert_eq!(lines.len(), 3, "must produce exactly 3 lines");
341        // Verify each line is valid JSON
342        for line in &lines {
343            serde_json::from_str::<serde_json::Value>(line).expect("each line must be valid JSON");
344        }
345    }
346
347    // --- Buffer accumulation ---
348
349    #[test]
350    fn multiple_encodes_accumulate_in_same_buffer() {
351        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
352        let encoder = JsonLines::new(None);
353        let mut buf = Vec::new();
354
355        let event1 = make_event("metric_a", 1.0, &[], ts);
356        let event2 = make_event("metric_b", 2.0, &[], ts + Duration::from_millis(1));
357        encoder.encode_metric(&event1, &mut buf).unwrap();
358        encoder.encode_metric(&event2, &mut buf).unwrap();
359
360        let text = String::from_utf8(buf).unwrap();
361        assert!(
362            text.contains("metric_a"),
363            "buffer must contain first metric name"
364        );
365        assert!(
366            text.contains("metric_b"),
367            "buffer must contain second metric name"
368        );
369    }
370
371    // --- Timestamp format ---
372
373    #[test]
374    fn timestamp_uses_rfc3339_format_with_millisecond_precision() {
375        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_123);
376        let event = make_event("up", 1.0, &[], ts);
377        let encoder = JsonLines::new(None);
378        let mut buf = Vec::new();
379        encoder.encode_metric(&event, &mut buf).unwrap();
380
381        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
382        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
383        let ts_str = parsed["timestamp"].as_str().unwrap();
384
385        // Must end with Z (UTC), have T separator, and contain milliseconds
386        assert!(ts_str.ends_with('Z'), "timestamp must end with Z: {ts_str}");
387        assert!(
388            ts_str.contains('T'),
389            "timestamp must contain T separator: {ts_str}"
390        );
391        // Must match pattern YYYY-MM-DDTHH:MM:SS.mmmZ
392        assert_eq!(
393            ts_str.len(),
394            24,
395            "timestamp must be exactly 24 chars: {ts_str}"
396        );
397        assert!(
398            ts_str.contains(".123"),
399            "milliseconds must be .123: {ts_str}"
400        );
401    }
402
403    #[test]
404    fn timestamp_at_unix_epoch_formats_correctly() {
405        let ts = UNIX_EPOCH;
406        let event = make_event("up", 1.0, &[], ts);
407        let encoder = JsonLines::new(None);
408        let mut buf = Vec::new();
409        encoder.encode_metric(&event, &mut buf).unwrap();
410
411        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
412        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
413        assert_eq!(parsed["timestamp"], "1970-01-01T00:00:00.000Z");
414    }
415
416    #[test]
417    fn timestamp_with_zero_milliseconds_shows_dot_zero_zero_zero() {
418        // Exactly 1 second past epoch, no sub-second component
419        let ts = UNIX_EPOCH + Duration::from_secs(1);
420        let event = make_event("up", 1.0, &[], ts);
421        let encoder = JsonLines::new(None);
422        let mut buf = Vec::new();
423        encoder.encode_metric(&event, &mut buf).unwrap();
424
425        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
426        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
427        assert_eq!(parsed["timestamp"], "1970-01-01T00:00:01.000Z");
428    }
429
430    // --- Regression anchor: hardcoded expected byte string ---
431
432    #[test]
433    fn regression_anchor_single_label_exact_output() {
434        // Timestamp: 2026-03-20T12:00:00.000Z = 1774008000 Unix seconds
435        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
436        let event = make_event("http_requests", 100.0, &[("endpoint", "api")], ts);
437        let encoder = JsonLines::new(None);
438        let mut buf = Vec::new();
439        encoder.encode_metric(&event, &mut buf).unwrap();
440
441        let output = String::from_utf8(buf).unwrap();
442        // Verify the exact JSON structure (field order must be deterministic)
443        assert_eq!(
444            output,
445            "{\"name\":\"http_requests\",\"value\":100.0,\"labels\":{\"endpoint\":\"api\"},\"timestamp\":\"2026-03-20T12:00:00.000Z\"}\n"
446        );
447    }
448
449    #[test]
450    fn regression_anchor_no_labels_exact_output() {
451        // 2023-11-14T22:13:20.000Z = 1700000000 seconds
452        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
453        let event = make_event("up", 1.0, &[], ts);
454        let encoder = JsonLines::new(None);
455        let mut buf = Vec::new();
456        encoder.encode_metric(&event, &mut buf).unwrap();
457
458        let output = String::from_utf8(buf).unwrap();
459        assert_eq!(
460            output,
461            "{\"name\":\"up\",\"value\":1.0,\"labels\":{},\"timestamp\":\"2023-11-14T22:13:20.000Z\"}\n"
462        );
463    }
464
465    #[test]
466    fn regression_anchor_multiple_labels_sorted_in_output() {
467        // Labels must appear sorted by key in the output
468        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
469        let event = make_event(
470            "cpu",
471            0.5,
472            &[("zone", "eu1"), ("host", "srv1"), ("env", "prod")],
473            ts,
474        );
475        let encoder = JsonLines::new(None);
476        let mut buf = Vec::new();
477        encoder.encode_metric(&event, &mut buf).unwrap();
478
479        let output = String::from_utf8(buf).unwrap();
480        assert_eq!(
481            output,
482            "{\"name\":\"cpu\",\"value\":0.5,\"labels\":{\"env\":\"prod\",\"host\":\"srv1\",\"zone\":\"eu1\"},\"timestamp\":\"2023-11-14T22:13:20.000Z\"}\n"
483        );
484    }
485
486    // --- JSON field order consistency ---
487
488    #[test]
489    fn json_fields_appear_in_consistent_order() {
490        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
491        let event = make_event("metric", 1.0, &[("k", "v")], ts);
492        let encoder = JsonLines::new(None);
493        let mut buf = Vec::new();
494        encoder.encode_metric(&event, &mut buf).unwrap();
495
496        let output = String::from_utf8(buf).unwrap();
497        let line = output.trim_end_matches('\n');
498
499        // Verify field order: name, value, labels, timestamp
500        let name_pos = line.find("\"name\"").unwrap();
501        let value_pos = line.find("\"value\"").unwrap();
502        let labels_pos = line.find("\"labels\"").unwrap();
503        let timestamp_pos = line.find("\"timestamp\"").unwrap();
504
505        assert!(name_pos < value_pos, "name must come before value");
506        assert!(value_pos < labels_pos, "value must come before labels");
507        assert!(
508            labels_pos < timestamp_pos,
509            "labels must come before timestamp"
510        );
511    }
512
513    // --- Send + Sync contract ---
514
515    #[test]
516    fn json_lines_encoder_is_send_and_sync() {
517        fn assert_send_sync<T: Send + Sync>() {}
518        assert_send_sync::<JsonLines>();
519    }
520
521    // --- EncoderConfig factory wiring ---
522
523    #[test]
524    fn encoder_config_json_lines_creates_encoder_via_factory() {
525        use crate::encoder::{create_encoder, EncoderConfig};
526
527        let config = EncoderConfig::JsonLines { precision: None };
528        let encoder = create_encoder(&config).unwrap();
529
530        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
531        let event = make_event("up", 1.0, &[], ts);
532        let mut buf = Vec::new();
533        encoder.encode_metric(&event, &mut buf).unwrap();
534
535        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
536        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
537        assert_eq!(parsed["name"], "up");
538    }
539
540    // --- format_rfc3339_millis direct tests ---
541
542    /// Helper: format a timestamp via the buffer-based API and return as String.
543    fn fmt_ts(ts: std::time::SystemTime) -> String {
544        let mut buf = Vec::new();
545        super::super::format_rfc3339_millis(ts, &mut buf).unwrap();
546        String::from_utf8(buf).unwrap()
547    }
548
549    /// Helper: format a timestamp via the array-based API and return as String.
550    fn fmt_ts_array(ts: std::time::SystemTime) -> String {
551        let arr = super::super::format_rfc3339_millis_array(ts).unwrap();
552        std::str::from_utf8(&arr).unwrap().to_string()
553    }
554
555    #[test]
556    fn format_rfc3339_millis_epoch_returns_correct_string() {
557        assert_eq!(fmt_ts(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
558    }
559
560    #[test]
561    fn format_rfc3339_millis_known_timestamp_2026_03_20_returns_correct_string() {
562        // 2026-03-20T12:00:00.000Z = 1774008000 Unix seconds
563        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
564        assert_eq!(fmt_ts(ts), "2026-03-20T12:00:00.000Z");
565    }
566
567    #[test]
568    fn format_rfc3339_millis_preserves_milliseconds() {
569        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_456);
570        let result = fmt_ts(ts);
571        assert!(
572            result.ends_with(".456Z"),
573            "must end with .456Z but got: {result}"
574        );
575    }
576
577    #[test]
578    fn format_rfc3339_millis_midnight_boundary() {
579        // End of day: 23:59:59.999
580        let ts = UNIX_EPOCH + Duration::from_millis(86_399_999);
581        assert_eq!(fmt_ts(ts), "1970-01-01T23:59:59.999Z");
582    }
583
584    #[test]
585    fn format_rfc3339_millis_start_of_day_plus_one_second() {
586        let ts = UNIX_EPOCH + Duration::from_secs(86400); // 1970-01-02T00:00:00.000Z
587        assert_eq!(fmt_ts(ts), "1970-01-02T00:00:00.000Z");
588    }
589
590    #[test]
591    fn format_rfc3339_millis_leap_year_feb_29() {
592        // 2024 is a leap year. 2024-02-29T00:00:00.000Z
593        // Days from epoch to 2024-02-29: calculate via known timestamp
594        // 2024-02-29T00:00:00Z = 1709164800 seconds
595        let ts = UNIX_EPOCH + Duration::from_secs(1_709_164_800);
596        assert_eq!(fmt_ts(ts), "2024-02-29T00:00:00.000Z");
597    }
598
599    #[test]
600    fn format_rfc3339_millis_end_of_year_dec_31() {
601        // 2023-12-31T23:59:59.999Z = 1704067199.999
602        let ts = UNIX_EPOCH + Duration::from_millis(1_704_067_199_999);
603        assert_eq!(fmt_ts(ts), "2023-12-31T23:59:59.999Z");
604    }
605
606    #[test]
607    fn format_rfc3339_millis_array_matches_buffer_output() {
608        // Both APIs must produce identical bytes for the same timestamp.
609        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_456);
610        assert_eq!(fmt_ts(ts), fmt_ts_array(ts));
611    }
612
613    #[test]
614    fn format_rfc3339_millis_array_is_valid_utf8() {
615        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
616        let arr = super::super::format_rfc3339_millis_array(ts).unwrap();
617        assert!(
618            std::str::from_utf8(&arr).is_ok(),
619            "array output must be valid UTF-8"
620        );
621    }
622
623    #[test]
624    fn format_rfc3339_millis_array_length_is_24() {
625        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
626        let arr = super::super::format_rfc3339_millis_array(ts).unwrap();
627        assert_eq!(
628            arr.len(),
629            24,
630            "RFC 3339 millis timestamp must be exactly 24 bytes"
631        );
632    }
633
634    // =========================================================================
635    // Slice 2.3: encode_log() tests
636    // =========================================================================
637
638    /// Build a LogEvent with a fixed timestamp for deterministic tests.
639    fn make_log_event(
640        severity: crate::model::log::Severity,
641        message: &str,
642        fields: &[(&str, &str)],
643        ts: std::time::SystemTime,
644    ) -> crate::model::log::LogEvent {
645        let mut map = std::collections::BTreeMap::new();
646        for (k, v) in fields {
647            map.insert(k.to_string(), v.to_string());
648        }
649        crate::model::log::LogEvent::with_timestamp(
650            ts,
651            severity,
652            message.to_string(),
653            crate::model::metric::Labels::default(),
654            map,
655        )
656    }
657
658    // --- encode_log: output is valid JSON ---
659
660    #[test]
661    fn encode_log_produces_valid_json() {
662        use crate::model::log::Severity;
663        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
664        let event = make_log_event(Severity::Info, "hello world", &[], ts);
665        let encoder = JsonLines::new(None);
666        let mut buf = Vec::new();
667        encoder.encode_log(&event, &mut buf).unwrap();
668        let line = String::from_utf8(buf).unwrap();
669        let line = line.trim_end_matches('\n');
670        let parsed: serde_json::Value =
671            serde_json::from_str(line).expect("encode_log output must be valid JSON");
672        assert!(parsed.is_object(), "output must be a JSON object");
673    }
674
675    // --- encode_log: all required fields are present ---
676
677    #[test]
678    fn encode_log_includes_timestamp_field() {
679        use crate::model::log::Severity;
680        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
681        let event = make_log_event(Severity::Info, "msg", &[], ts);
682        let encoder = JsonLines::new(None);
683        let mut buf = Vec::new();
684        encoder.encode_log(&event, &mut buf).unwrap();
685        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
686        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
687        assert!(
688            parsed.get("timestamp").is_some(),
689            "encode_log output must include 'timestamp' field"
690        );
691    }
692
693    #[test]
694    fn encode_log_includes_severity_field() {
695        use crate::model::log::Severity;
696        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
697        let event = make_log_event(Severity::Warn, "msg", &[], ts);
698        let encoder = JsonLines::new(None);
699        let mut buf = Vec::new();
700        encoder.encode_log(&event, &mut buf).unwrap();
701        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
702        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
703        assert!(
704            parsed.get("severity").is_some(),
705            "encode_log output must include 'severity' field"
706        );
707    }
708
709    #[test]
710    fn encode_log_includes_message_field() {
711        use crate::model::log::Severity;
712        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
713        let event = make_log_event(Severity::Info, "test message here", &[], ts);
714        let encoder = JsonLines::new(None);
715        let mut buf = Vec::new();
716        encoder.encode_log(&event, &mut buf).unwrap();
717        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
718        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
719        assert!(
720            parsed.get("message").is_some(),
721            "encode_log output must include 'message' field"
722        );
723    }
724
725    #[test]
726    fn encode_log_includes_fields_field() {
727        use crate::model::log::Severity;
728        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
729        let event = make_log_event(Severity::Info, "msg", &[("ip", "10.0.0.1")], ts);
730        let encoder = JsonLines::new(None);
731        let mut buf = Vec::new();
732        encoder.encode_log(&event, &mut buf).unwrap();
733        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
734        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
735        assert!(
736            parsed.get("fields").is_some(),
737            "encode_log output must include 'fields' field"
738        );
739    }
740
741    // --- encode_log: severity is lowercase ---
742
743    #[test]
744    fn encode_log_severity_info_is_lowercase() {
745        use crate::model::log::Severity;
746        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
747        let event = make_log_event(Severity::Info, "msg", &[], ts);
748        let encoder = JsonLines::new(None);
749        let mut buf = Vec::new();
750        encoder.encode_log(&event, &mut buf).unwrap();
751        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
752        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
753        assert_eq!(
754            parsed["severity"], "info",
755            "severity must be lowercase 'info'"
756        );
757    }
758
759    #[test]
760    fn encode_log_severity_error_is_lowercase() {
761        use crate::model::log::Severity;
762        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
763        let event = make_log_event(Severity::Error, "msg", &[], ts);
764        let encoder = JsonLines::new(None);
765        let mut buf = Vec::new();
766        encoder.encode_log(&event, &mut buf).unwrap();
767        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
768        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
769        assert_eq!(parsed["severity"], "error");
770    }
771
772    #[test]
773    fn encode_log_severity_warn_is_lowercase() {
774        use crate::model::log::Severity;
775        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
776        let event = make_log_event(Severity::Warn, "msg", &[], ts);
777        let encoder = JsonLines::new(None);
778        let mut buf = Vec::new();
779        encoder.encode_log(&event, &mut buf).unwrap();
780        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
781        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
782        assert_eq!(parsed["severity"], "warn");
783    }
784
785    #[test]
786    fn encode_log_severity_trace_is_lowercase() {
787        use crate::model::log::Severity;
788        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
789        let event = make_log_event(Severity::Trace, "msg", &[], ts);
790        let encoder = JsonLines::new(None);
791        let mut buf = Vec::new();
792        encoder.encode_log(&event, &mut buf).unwrap();
793        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
794        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
795        assert_eq!(parsed["severity"], "trace");
796    }
797
798    #[test]
799    fn encode_log_severity_debug_is_lowercase() {
800        use crate::model::log::Severity;
801        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
802        let event = make_log_event(Severity::Debug, "msg", &[], ts);
803        let encoder = JsonLines::new(None);
804        let mut buf = Vec::new();
805        encoder.encode_log(&event, &mut buf).unwrap();
806        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
807        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
808        assert_eq!(parsed["severity"], "debug");
809    }
810
811    #[test]
812    fn encode_log_severity_fatal_is_lowercase() {
813        use crate::model::log::Severity;
814        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
815        let event = make_log_event(Severity::Fatal, "msg", &[], ts);
816        let encoder = JsonLines::new(None);
817        let mut buf = Vec::new();
818        encoder.encode_log(&event, &mut buf).unwrap();
819        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
820        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
821        assert_eq!(parsed["severity"], "fatal");
822    }
823
824    // --- encode_log: roundtrip — all fields survive encode → parse ---
825
826    #[test]
827    fn encode_log_roundtrip_message_matches_original() {
828        use crate::model::log::Severity;
829        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
830        let event = make_log_event(Severity::Info, "Request from 10.0.0.1", &[], ts);
831        let encoder = JsonLines::new(None);
832        let mut buf = Vec::new();
833        encoder.encode_log(&event, &mut buf).unwrap();
834        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
835        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
836        assert_eq!(parsed["message"], "Request from 10.0.0.1");
837    }
838
839    #[test]
840    fn encode_log_roundtrip_fields_match_original() {
841        use crate::model::log::Severity;
842        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
843        let event = make_log_event(
844            Severity::Info,
845            "req",
846            &[("ip", "10.0.0.1"), ("endpoint", "/api")],
847            ts,
848        );
849        let encoder = JsonLines::new(None);
850        let mut buf = Vec::new();
851        encoder.encode_log(&event, &mut buf).unwrap();
852        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
853        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
854        assert_eq!(parsed["fields"]["ip"], "10.0.0.1");
855        assert_eq!(parsed["fields"]["endpoint"], "/api");
856    }
857
858    #[test]
859    fn encode_log_roundtrip_timestamp_matches_original() {
860        use crate::model::log::Severity;
861        // 2026-03-20T12:00:00.000Z = 1774008000 Unix seconds
862        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
863        let event = make_log_event(Severity::Info, "msg", &[], ts);
864        let encoder = JsonLines::new(None);
865        let mut buf = Vec::new();
866        encoder.encode_log(&event, &mut buf).unwrap();
867        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
868        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
869        assert_eq!(
870            parsed["timestamp"], "2026-03-20T12:00:00.000Z",
871            "roundtrip timestamp must match"
872        );
873    }
874
875    // --- encode_log: empty fields produces empty JSON object ---
876
877    #[test]
878    fn encode_log_empty_fields_produces_empty_json_object() {
879        use crate::model::log::Severity;
880        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
881        let event = make_log_event(Severity::Info, "msg", &[], ts);
882        let encoder = JsonLines::new(None);
883        let mut buf = Vec::new();
884        encoder.encode_log(&event, &mut buf).unwrap();
885        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
886        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
887        assert_eq!(
888            parsed["fields"],
889            serde_json::json!({}),
890            "empty fields must serialize as empty JSON object"
891        );
892    }
893
894    // --- encode_log: line ends with newline ---
895
896    #[test]
897    fn encode_log_line_ends_with_newline() {
898        use crate::model::log::Severity;
899        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
900        let event = make_log_event(Severity::Info, "msg", &[], ts);
901        let encoder = JsonLines::new(None);
902        let mut buf = Vec::new();
903        encoder.encode_log(&event, &mut buf).unwrap();
904        assert_eq!(
905            *buf.last().unwrap(),
906            b'\n',
907            "encode_log line must end with newline"
908        );
909    }
910
911    // --- encode_log: field order — timestamp, severity, message, fields ---
912
913    #[test]
914    fn encode_log_fields_appear_in_spec_order() {
915        // Spec: timestamp, severity, message, labels, fields
916        use crate::model::log::Severity;
917        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
918        let event = make_log_event(Severity::Info, "msg", &[("k", "v")], ts);
919        let encoder = JsonLines::new(None);
920        let mut buf = Vec::new();
921        encoder.encode_log(&event, &mut buf).unwrap();
922        let output = String::from_utf8(buf).unwrap();
923        let line = output.trim_end_matches('\n');
924        let ts_pos = line.find("\"timestamp\"").unwrap();
925        let sev_pos = line.find("\"severity\"").unwrap();
926        let msg_pos = line.find("\"message\"").unwrap();
927        let labels_pos = line.find("\"labels\"").unwrap();
928        let fields_pos = line.find("\"fields\"").unwrap();
929        assert!(ts_pos < sev_pos, "timestamp must come before severity");
930        assert!(sev_pos < msg_pos, "severity must come before message");
931        assert!(msg_pos < labels_pos, "message must come before labels");
932        assert!(labels_pos < fields_pos, "labels must come before fields");
933    }
934
935    // --- encode_log: regression anchor — exact byte output ---
936
937    #[test]
938    fn encode_log_regression_anchor_simple_info_event() {
939        use crate::model::log::Severity;
940        // 2026-03-20T12:00:00.000Z
941        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
942        let event = make_log_event(Severity::Info, "Request from 10.0.0.1", &[], ts);
943        let encoder = JsonLines::new(None);
944        let mut buf = Vec::new();
945        encoder.encode_log(&event, &mut buf).unwrap();
946        let output = String::from_utf8(buf).unwrap();
947        assert_eq!(
948            output,
949            "{\"timestamp\":\"2026-03-20T12:00:00.000Z\",\"severity\":\"info\",\"message\":\"Request from 10.0.0.1\",\"labels\":{},\"fields\":{}}\n"
950        );
951    }
952
953    #[test]
954    fn encode_log_regression_anchor_with_fields() {
955        use crate::model::log::Severity;
956        // Fields must be sorted by key (BTreeMap)
957        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
958        let event = make_log_event(
959            Severity::Error,
960            "db timeout",
961            &[("endpoint", "/api"), ("ip", "10.0.0.1")],
962            ts,
963        );
964        let encoder = JsonLines::new(None);
965        let mut buf = Vec::new();
966        encoder.encode_log(&event, &mut buf).unwrap();
967        let output = String::from_utf8(buf).unwrap();
968        assert_eq!(
969            output,
970            "{\"timestamp\":\"2026-03-20T12:00:00.000Z\",\"severity\":\"error\",\"message\":\"db timeout\",\"labels\":{},\"fields\":{\"endpoint\":\"/api\",\"ip\":\"10.0.0.1\"}}\n"
971        );
972    }
973
974    // --- encode_log: prometheus encoder still returns "not supported" error ---
975
976    // --- encode_log: labels in JSON output ---
977
978    /// Build a LogEvent with labels and a fixed timestamp for deterministic tests.
979    fn make_log_event_with_labels(
980        severity: crate::model::log::Severity,
981        message: &str,
982        labels: &[(&str, &str)],
983        fields: &[(&str, &str)],
984        ts: std::time::SystemTime,
985    ) -> crate::model::log::LogEvent {
986        let mut field_map = std::collections::BTreeMap::new();
987        for (k, v) in fields {
988            field_map.insert(k.to_string(), v.to_string());
989        }
990        let label_set = crate::model::metric::Labels::from_pairs(labels).unwrap();
991        crate::model::log::LogEvent::with_timestamp(
992            ts,
993            severity,
994            message.to_string(),
995            label_set,
996            field_map,
997        )
998    }
999
1000    #[test]
1001    fn encode_log_with_labels_includes_labels_in_json() {
1002        use crate::model::log::Severity;
1003        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
1004        let event = make_log_event_with_labels(
1005            Severity::Info,
1006            "labeled event",
1007            &[("device", "wlan0"), ("hostname", "router_01")],
1008            &[],
1009            ts,
1010        );
1011        let encoder = JsonLines::new(None);
1012        let mut buf = Vec::new();
1013        encoder.encode_log(&event, &mut buf).unwrap();
1014        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
1015        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
1016        assert_eq!(parsed["labels"]["device"], "wlan0");
1017        assert_eq!(parsed["labels"]["hostname"], "router_01");
1018    }
1019
1020    #[test]
1021    fn encode_log_labels_are_sorted_by_key() {
1022        use crate::model::log::Severity;
1023        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
1024        // Labels inserted in reverse alphabetical order; BTreeMap must sort them.
1025        let event = make_log_event_with_labels(
1026            Severity::Info,
1027            "sorted labels",
1028            &[("zone", "eu1"), ("env", "prod"), ("app", "sonda")],
1029            &[],
1030            ts,
1031        );
1032        let encoder = JsonLines::new(None);
1033        let mut buf = Vec::new();
1034        encoder.encode_log(&event, &mut buf).unwrap();
1035        let output = String::from_utf8(buf).unwrap();
1036        let line = output.trim_end_matches('\n');
1037
1038        // Verify key order in the raw JSON string (BTreeMap guarantees sort)
1039        let app_pos = line.find("\"app\"").unwrap();
1040        let env_pos = line.find("\"env\"").unwrap();
1041        let zone_pos = line.find("\"zone\"").unwrap();
1042        assert!(
1043            app_pos < env_pos,
1044            "app must come before env in sorted labels"
1045        );
1046        assert!(
1047            env_pos < zone_pos,
1048            "env must come before zone in sorted labels"
1049        );
1050    }
1051
1052    #[test]
1053    fn encode_log_with_empty_labels_produces_empty_labels_object() {
1054        use crate::model::log::Severity;
1055        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
1056        let event = make_log_event(Severity::Info, "no labels", &[], ts);
1057        let encoder = JsonLines::new(None);
1058        let mut buf = Vec::new();
1059        encoder.encode_log(&event, &mut buf).unwrap();
1060        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
1061        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
1062        assert_eq!(
1063            parsed["labels"],
1064            serde_json::json!({}),
1065            "empty labels must serialize as empty JSON object"
1066        );
1067    }
1068
1069    #[test]
1070    fn encode_log_regression_anchor_with_labels_exact_output() {
1071        use crate::model::log::Severity;
1072        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
1073        let event = make_log_event_with_labels(
1074            Severity::Info,
1075            "Request from 10.0.0.1",
1076            &[("device", "wlan0")],
1077            &[("ip", "10.0.0.1")],
1078            ts,
1079        );
1080        let encoder = JsonLines::new(None);
1081        let mut buf = Vec::new();
1082        encoder.encode_log(&event, &mut buf).unwrap();
1083        let output = String::from_utf8(buf).unwrap();
1084        assert_eq!(
1085            output,
1086            "{\"timestamp\":\"2026-03-20T12:00:00.000Z\",\"severity\":\"info\",\"message\":\"Request from 10.0.0.1\",\"labels\":{\"device\":\"wlan0\"},\"fields\":{\"ip\":\"10.0.0.1\"}}\n"
1087        );
1088    }
1089
1090    #[test]
1091    fn encode_log_with_labels_and_fields_both_present() {
1092        use crate::model::log::Severity;
1093        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
1094        let event = make_log_event_with_labels(
1095            Severity::Error,
1096            "timeout",
1097            &[("env", "prod")],
1098            &[("endpoint", "/api")],
1099            ts,
1100        );
1101        let encoder = JsonLines::new(None);
1102        let mut buf = Vec::new();
1103        encoder.encode_log(&event, &mut buf).unwrap();
1104        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
1105        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
1106        // Both labels and fields must be present and correct
1107        assert_eq!(parsed["labels"]["env"], "prod");
1108        assert_eq!(parsed["fields"]["endpoint"], "/api");
1109    }
1110
1111    // --- encode_log: prometheus encoder still returns "not supported" error ---
1112
1113    #[test]
1114    fn prometheus_encoder_encode_log_still_returns_not_supported_after_slice_2_3() {
1115        use crate::encoder::{create_encoder, EncoderConfig};
1116        let encoder = create_encoder(&EncoderConfig::PrometheusText { precision: None }).unwrap();
1117        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
1118        let event = make_log_event(crate::model::log::Severity::Info, "should fail", &[], ts);
1119        let mut buf = Vec::new();
1120        let result = encoder.encode_log(&event, &mut buf);
1121        assert!(
1122            result.is_err(),
1123            "prometheus encoder must still return error for encode_log"
1124        );
1125        let msg = result.unwrap_err().to_string();
1126        assert!(
1127            msg.contains("not supported"),
1128            "error must mention 'not supported', got: {msg}"
1129        );
1130        assert!(buf.is_empty(), "buffer must remain empty on error");
1131    }
1132
1133    // --- Precision: pre-rounding before JSON serialization ---
1134
1135    #[test]
1136    fn precision_two_rounds_json_value() {
1137        let encoder = JsonLines::new(Some(2));
1138        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
1139        let event = make_event("cpu", 99.60573, &[], ts);
1140        let mut buf = Vec::new();
1141        encoder.encode_metric(&event, &mut buf).unwrap();
1142        let line = String::from_utf8(buf).unwrap();
1143        let line = line.trim_end_matches('\n');
1144        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
1145        let value = parsed["value"].as_f64().unwrap();
1146        // JSON number should be the pre-rounded value 99.61
1147        assert!((value - 99.61).abs() < 1e-10, "expected 99.61, got {value}");
1148    }
1149
1150    #[test]
1151    fn precision_none_preserves_full_value_in_json() {
1152        let encoder = JsonLines::new(None);
1153        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
1154        let event = make_event("cpu", 99.60573506572389, &[], ts);
1155        let mut buf = Vec::new();
1156        encoder.encode_metric(&event, &mut buf).unwrap();
1157        let line = String::from_utf8(buf).unwrap();
1158        let line = line.trim_end_matches('\n');
1159        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
1160        let value = parsed["value"].as_f64().unwrap();
1161        // f64 round-trips through serde_json may lose the very last ULP.
1162        // We verify at least 12 significant digits are preserved.
1163        assert!(
1164            (value - 99.60573506572389).abs() < 1e-11,
1165            "full precision must be preserved: {value}"
1166        );
1167    }
1168
1169    #[test]
1170    fn precision_zero_rounds_to_whole_number_in_json() {
1171        let encoder = JsonLines::new(Some(0));
1172        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
1173        let event = make_event("up", 42.9, &[], ts);
1174        let mut buf = Vec::new();
1175        encoder.encode_metric(&event, &mut buf).unwrap();
1176        let line = String::from_utf8(buf).unwrap();
1177        let line = line.trim_end_matches('\n');
1178        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
1179        let value = parsed["value"].as_f64().unwrap();
1180        assert!((value - 43.0).abs() < 1e-10, "expected 43.0, got {value}");
1181    }
1182
1183    // =========================================================================
1184    // Slice 9B.4: LabelsRef and StringMapRef — zero-intermediate-collection
1185    // =========================================================================
1186
1187    // --- LabelsRef: serializes Labels directly without intermediate BTreeMap ---
1188
1189    #[test]
1190    fn labels_ref_serializes_empty_labels_as_empty_object() {
1191        let labels = Labels::from_pairs(&[]).unwrap();
1192        let wrapper = super::LabelsRef(&labels);
1193        let json = serde_json::to_string(&wrapper).unwrap();
1194        assert_eq!(json, "{}");
1195    }
1196
1197    #[test]
1198    fn labels_ref_serializes_single_label() {
1199        let labels = Labels::from_pairs(&[("host", "srv1")]).unwrap();
1200        let wrapper = super::LabelsRef(&labels);
1201        let json = serde_json::to_string(&wrapper).unwrap();
1202        assert_eq!(json, r#"{"host":"srv1"}"#);
1203    }
1204
1205    #[test]
1206    fn labels_ref_serializes_multiple_labels_in_sorted_order() {
1207        let labels =
1208            Labels::from_pairs(&[("zone", "eu1"), ("env", "prod"), ("host", "srv1")]).unwrap();
1209        let wrapper = super::LabelsRef(&labels);
1210        let json = serde_json::to_string(&wrapper).unwrap();
1211        assert_eq!(json, r#"{"env":"prod","host":"srv1","zone":"eu1"}"#);
1212    }
1213
1214    #[test]
1215    fn labels_ref_handles_values_with_special_json_characters() {
1216        let labels = Labels::from_pairs(&[("msg", "hello \"world\"")]).unwrap();
1217        let wrapper = super::LabelsRef(&labels);
1218        let json = serde_json::to_string(&wrapper).unwrap();
1219        // serde_json escapes double quotes inside values
1220        assert_eq!(json, r#"{"msg":"hello \"world\""}"#);
1221    }
1222
1223    // --- StringMapRef: serializes BTreeMap<String, String> directly ---
1224
1225    #[test]
1226    fn string_map_ref_serializes_empty_map_as_empty_object() {
1227        let map = BTreeMap::new();
1228        let wrapper = super::StringMapRef(&map);
1229        let json = serde_json::to_string(&wrapper).unwrap();
1230        assert_eq!(json, "{}");
1231    }
1232
1233    #[test]
1234    fn string_map_ref_serializes_entries_in_sorted_order() {
1235        let mut map = BTreeMap::new();
1236        map.insert("z_key".to_string(), "last".to_string());
1237        map.insert("a_key".to_string(), "first".to_string());
1238        map.insert("m_key".to_string(), "middle".to_string());
1239        let wrapper = super::StringMapRef(&map);
1240        let json = serde_json::to_string(&wrapper).unwrap();
1241        assert_eq!(json, r#"{"a_key":"first","m_key":"middle","z_key":"last"}"#);
1242    }
1243
1244    // --- End-to-end: metric encode output is identical with direct serialization ---
1245
1246    #[test]
1247    fn encode_metric_many_labels_produces_sorted_json_object() {
1248        // 10 labels inserted in reverse order to stress sort guarantee
1249        let pairs: Vec<(&str, &str)> = vec![
1250            ("j", "10"),
1251            ("i", "9"),
1252            ("h", "8"),
1253            ("g", "7"),
1254            ("f", "6"),
1255            ("e", "5"),
1256            ("d", "4"),
1257            ("c", "3"),
1258            ("b", "2"),
1259            ("a", "1"),
1260        ];
1261        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
1262        let event = make_event("multi", 1.0, &pairs, ts);
1263        let encoder = JsonLines::new(None);
1264        let mut buf = Vec::new();
1265        encoder.encode_metric(&event, &mut buf).unwrap();
1266        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
1267        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
1268        let labels = parsed["labels"].as_object().unwrap();
1269        let keys: Vec<&String> = labels.keys().collect();
1270        assert_eq!(
1271            keys,
1272            &["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"],
1273            "labels must be sorted alphabetically"
1274        );
1275    }
1276
1277    #[test]
1278    fn encode_log_many_fields_produces_sorted_json_object() {
1279        use crate::model::log::Severity;
1280        let ts = UNIX_EPOCH + Duration::from_millis(1_700_000_000_000);
1281        let event = make_log_event(
1282            Severity::Info,
1283            "multi-field",
1284            &[
1285                ("z_field", "last"),
1286                ("a_field", "first"),
1287                ("m_field", "mid"),
1288            ],
1289            ts,
1290        );
1291        let encoder = JsonLines::new(None);
1292        let mut buf = Vec::new();
1293        encoder.encode_log(&event, &mut buf).unwrap();
1294        let line = std::str::from_utf8(&buf).unwrap().trim_end_matches('\n');
1295        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
1296        let fields = parsed["fields"].as_object().unwrap();
1297        let keys: Vec<&String> = fields.keys().collect();
1298        assert_eq!(
1299            keys,
1300            &["a_field", "m_field", "z_field"],
1301            "fields must be sorted alphabetically"
1302        );
1303    }
1304}