Skip to main content

agentic_logger/
lib.rs

1//! # agentic-logger (Rust)
2//!
3//! Structured logging SDK for Coding Agents. Emits **byte-compatible** JSONL
4//! that the AgenticLogger Python query layer (`cli` / `mcp_server`) reads
5//! without any conversion.
6//!
7//! @contract: sdks/INTERCHANGE.md
8//!
9//! ```
10//! use agentic_logger::{AgentLogger, ErrorCode};
11//! use serde_json::json;
12//!
13//! let logger = AgentLogger::new("my_agent", Some("build"), "./logs", None).unwrap();
14//! logger.info("Processing started", Some("parser")).ok();
15//! logger.tool_call("bash", "npm install", 0, 1234, None, None, None, None, None).ok();
16//! logger.error("Build failed", Some("build"), ErrorCode::ExecNonZero, None).ok();
17//! logger.info_full("with ctx", Some("net"), None, None, None, Some(json!({"endpoint": "/x"}))).ok();
18//! ```
19
20mod error_codes;
21mod fmt;
22mod time;
23
24pub use error_codes::ErrorCode;
25
26use serde::Serialize;
27use serde_json::{Map, Value};
28use std::fs::{File, OpenOptions};
29use std::io::Write;
30use std::path::{Path, PathBuf};
31use std::sync::atomic::{AtomicU64, Ordering};
32use std::sync::Mutex;
33use uuid::Uuid;
34
35// Compact-key map (contract §4). Top-level entry keys only.
36const COMPACT_MAP: &[(&str, &str)] = &[
37    ("ts","t"),("level","l"),("module","n"),("msg","m"),("pid","p"),("rid","r"),("seq","q"),
38    ("error_code","e"),("dur","d"),("tool","o"),("cmd","c"),("exit","x"),("op","w"),("path","h"),
39    ("ctx","z"),("tid","i"),("lines","s"),("funcs","f"),("lang","g"),("choice","k"),("alts","a"),
40    ("reason","u"),("stdout","v"),("stderr","b"),("ok","y"),("size","j"),
41];
42
43fn compact_entry(entry: Map<String, Value>) -> Map<String, Value> {
44    entry.into_iter().map(|(k, v)| {
45        let k = COMPACT_MAP.iter().find(|(full, _)| *full == k).map(|(_, c)| (*c).to_string()).unwrap_or(k);
46        (k, v)
47    }).collect()
48}
49
50/// The structured logger. One instance == one run == one JSONL file.
51///
52/// Thread-safe: the underlying file handle is guarded by a `Mutex` and the
53/// sequence counter is atomic.
54pub struct AgentLogger {
55    program: String,
56    command: String,
57    file_path: PathBuf,
58    rid: String,
59    pid: String,
60    seq: AtomicU64,
61    file: Mutex<File>,
62    compact: bool,
63}
64
65impl AgentLogger {
66    /// Create a new logger (and its log file).
67    ///
68    /// * `program`   – program name (filename component, sanitised).
69    /// * `command`   – sub-command; `None` → `pid{PID}`.
70    /// * `log_dir`   – directory for log files (`./logs` is typical).
71    /// * `rid`       – run id override; `None` → random 8 hex chars.
72    pub fn new(
73        program: &str,
74        command: Option<&str>,
75        log_dir: impl AsRef<Path>,
76        rid: Option<&str>,
77    ) -> std::io::Result<Self> {
78        Self::new_compact(program, command, log_dir, rid, false)
79    }
80
81    /// Like [`new`], but enables compact-key mode (contract §4).
82    pub fn new_compact(
83        program: &str,
84        command: Option<&str>,
85        log_dir: impl AsRef<Path>,
86        rid: Option<&str>,
87        compact: bool,
88    ) -> std::io::Result<Self> {
89        let pid = std::process::id().to_string();
90        let rid = rid.map(|s| s.to_string()).unwrap_or_else(|| {
91            Uuid::new_v4().simple().to_string()[..8].to_string()
92        });
93
94        let safe_program = sanitize(program);
95        let command_str = command.map(|c| c.to_string()).unwrap_or_else(|| format!("pid{}", pid));
96        let safe_command = sanitize(&command_str);
97
98        let log_dir = log_dir.as_ref().to_path_buf();
99        std::fs::create_dir_all(&log_dir)?;
100
101        let stamp = time::filename_stamp();
102        let filename = format!("{}_{}_{}.jsonl", safe_program, safe_command, stamp);
103        let file_path = log_dir.join(&filename);
104
105        let mut file = OpenOptions::new()
106            .create(true)
107            .append(true)
108            .open(&file_path)?;
109
110        // Global-context header (contract §1.2).
111        let mut header = Map::new();
112        header.insert("ts".into(), Value::String(time::now_iso()));
113        header.insert("level".into(), Value::String("__GLOBAL_CTX__".into()));
114        header.insert("msg".into(), Value::String("Global context".into()));
115        header.insert("module".into(), Value::String("__system__".into()));
116        header.insert("rid".into(), Value::String(rid.clone()));
117        header.insert("pid".into(), Value::String(pid.clone()));
118        header.insert("seq".into(), Value::Number(0u64.into()));
119        header.insert("program".into(), Value::String(safe_program.clone()));
120        header.insert("command".into(), Value::String(safe_command.clone()));
121        let header = if compact { compact_entry(header) } else { header };
122        write_value(&mut file, &Value::Object(header))?;
123        file.write_all(b"\n")?;
124
125        Ok(Self {
126            program: safe_program,
127            command: safe_command,
128            file_path,
129            rid,
130            pid,
131            seq: AtomicU64::new(0),
132            file: Mutex::new(file),
133            compact,
134        })
135    }
136
137    /// Absolute path of the active log file.
138    pub fn file_path(&self) -> &Path {
139        &self.file_path
140    }
141    pub fn rid(&self) -> &str {
142        &self.rid
143    }
144    pub fn program(&self) -> &str {
145        &self.program
146    }
147    pub fn command(&self) -> &str {
148        &self.command
149    }
150
151    // ---- core write -------------------------------------------------------
152
153    fn write_entry(&self, mut entry: Map<String, Value>) -> std::io::Result<()> {
154        let seq = self.seq.fetch_add(1, Ordering::SeqCst) + 1;
155        entry.insert("ts".into(), Value::String(time::now_iso()));
156        entry.insert("pid".into(), Value::String(self.pid.clone()));
157        entry.insert("rid".into(), Value::String(self.rid.clone()));
158        entry.insert("seq".into(), Value::Number(seq.into()));
159        if self.compact {
160            entry = compact_entry(entry);
161        }
162        let mut f = self.file.lock().expect("poisoned lock");
163        write_value(&mut *f, &Value::Object(entry))?;
164        f.write_all(b"\n")?;
165        Ok(())
166    }
167
168    // ---- traceback sidecar (contract §5; sidecar always uses FULL keys) ---
169
170    /// Persist a traceback to the `.tracebacks` sidecar; returns the generated tid.
171    pub fn save_traceback(&self, exc_type: &str, exc_msg: &str, traceback: &str) -> std::io::Result<String> {
172        let tid = format!("tb_{}", &Uuid::new_v4().simple().to_string()[..8]);
173        self.save_traceback_text(&tid, exc_type, exc_msg, traceback)?;
174        Ok(tid)
175    }
176
177    pub fn save_traceback_text(&self, tid: &str, exc_type: &str, exc_msg: &str, traceback: &str) -> std::io::Result<()> {
178        let tb_path = self.file_path.with_extension("tracebacks");
179        let mut rec = Map::new();
180        rec.insert("tid".into(), Value::String(tid.into()));
181        rec.insert("exception_type".into(), Value::String(exc_type.into()));
182        rec.insert("exception_msg".into(), Value::String(exc_msg.into()));
183        rec.insert("traceback".into(), Value::String(traceback.into()));
184        let mut f = OpenOptions::new().create(true).append(true).open(tb_path)?;
185        write_value(&mut f, &Value::Object(rec))?;
186        f.write_all(b"\n")?;
187        Ok(())
188    }
189
190    // ---- basic levels -----------------------------------------------------
191
192    /// `logger.info(msg, module, dur?, error_code?, tid?, ctx?)`
193    pub fn info(&self, msg: &str, module: Option<&str>) -> std::io::Result<()> {
194        self.info_full(msg, module, None, None, None, None)
195    }
196    #[allow(clippy::too_many_arguments)]
197    pub fn info_full(
198        &self,
199        msg: &str,
200        module: Option<&str>,
201        dur: Option<u64>,
202        error_code: Option<&str>,
203        tid: Option<&str>,
204        ctx: Option<Value>,
205    ) -> std::io::Result<()> {
206        let mut e = Map::new();
207        e.insert("level".into(), Value::String("INFO".into()));
208        e.insert("msg".into(), Value::String(truncate_msg(msg)));
209        basic_fields(&mut e, module, dur, error_code, tid, ctx);
210        self.write_entry(e)
211    }
212
213    pub fn warn(&self, msg: &str, module: Option<&str>) -> std::io::Result<()> {
214        self.warn_full(msg, module, None, None, None, None)
215    }
216    #[allow(clippy::too_many_arguments)]
217    pub fn warn_full(
218        &self,
219        msg: &str,
220        module: Option<&str>,
221        dur: Option<u64>,
222        error_code: Option<&str>,
223        tid: Option<&str>,
224        ctx: Option<Value>,
225    ) -> std::io::Result<()> {
226        let mut e = Map::new();
227        e.insert("level".into(), Value::String("WARN".into()));
228        e.insert("msg".into(), Value::String(truncate_msg(msg)));
229        basic_fields(&mut e, module, dur, error_code, tid, ctx);
230        self.write_entry(e)
231    }
232
233    pub fn error(
234        &self,
235        msg: &str,
236        module: Option<&str>,
237        error_code: ErrorCode,
238        tid: Option<&str>,
239    ) -> std::io::Result<()> {
240        self.error_full(msg, module, error_code.as_str(), tid, None, None)
241    }
242    #[allow(clippy::too_many_arguments)]
243    pub fn error_full(
244        &self,
245        msg: &str,
246        module: Option<&str>,
247        error_code: &str,
248        tid: Option<&str>,
249        dur: Option<u64>,
250        ctx: Option<Value>,
251    ) -> std::io::Result<()> {
252        let mut e = Map::new();
253        e.insert("level".into(), Value::String("ERROR".into()));
254        e.insert("msg".into(), Value::String(truncate_msg(msg)));
255        if let Some(m) = module {
256            e.insert("module".into(), Value::String(m.into()));
257        }
258        e.insert("error_code".into(), Value::String(error_code.into()));
259        if let Some(t) = tid {
260            e.insert("tid".into(), Value::String(t.into()));
261        }
262        if let Some(d) = dur {
263            e.insert("dur".into(), Value::Number(d.into()));
264        }
265        if let Some(c) = ctx {
266            e.insert("ctx".into(), c);
267        }
268        self.write_entry(e)
269    }
270
271    // ---- specialised ------------------------------------------------------
272
273    /// `tool_call(tool, cmd, exit, dur, error_code?, tid?, stdout?, stderr?, ctx?)`.
274    /// Like Python, requires `error_code` semantics for failures are the caller's
275    /// responsibility (this SDK accepts it optionally).
276    #[allow(clippy::too_many_arguments)]
277    pub fn tool_call(
278        &self,
279        tool: &str,
280        cmd: &str,
281        exit: i64,
282        dur: u64,
283        error_code: Option<&str>,
284        tid: Option<&str>,
285        stdout: Option<&str>,
286        stderr: Option<&str>,
287        ctx: Option<Value>,
288    ) -> std::io::Result<()> {
289        let mut e = Map::new();
290        let ok = exit == 0;
291        e.insert(
292            "level".into(),
293            Value::String("TOOL".into()),
294        );
295        e.insert(
296            "msg".into(),
297            Value::String(format!("Tool {} {}", tool, if ok { "succeeded" } else { "failed" })),
298        );
299        e.insert("tool".into(), Value::String(tool.into()));
300        e.insert("cmd".into(), Value::String(cmd.into()));
301        e.insert("exit".into(), Value::Number(exit.into()));
302        e.insert("dur".into(), Value::Number(dur.into()));
303        if let Some(ec) = error_code {
304            e.insert("error_code".into(), Value::String(ec.into()));
305        }
306        if let Some(t) = tid {
307            e.insert("tid".into(), Value::String(t.into()));
308        }
309        if let Some(o) = stdout {
310            e.insert("stdout".into(), Value::String(truncate64k(o)));
311        }
312        if let Some(s) = stderr {
313            e.insert("stderr".into(), Value::String(truncate64k(s)));
314        }
315        if let Some(c) = ctx {
316            e.insert("ctx".into(), c);
317        }
318        e.insert(
319            "module".into(),
320            Value::String("unknown".into()),
321        );
322        self.write_entry(e)
323    }
324
325    #[allow(clippy::too_many_arguments)]
326    pub fn file_op(
327        &self,
328        op: &str,
329        path: &str,
330        ok: bool,
331        size: Option<u64>,
332        error_code: Option<&str>,
333        tid: Option<&str>,
334        dur: Option<u64>,
335        ctx: Option<Value>,
336    ) -> std::io::Result<()> {
337        let mut e = Map::new();
338        e.insert("level".into(), Value::String("FILE_OP".into()));
339        e.insert(
340            "msg".into(),
341            Value::String(format!("File {} {}: {}", op, if ok { "succeeded" } else { "failed" }, path)),
342        );
343        e.insert("op".into(), Value::String(op.into()));
344        e.insert("path".into(), Value::String(path.into()));
345        e.insert("ok".into(), Value::Bool(ok));
346        if let Some(s) = size {
347            e.insert("size".into(), Value::Number(s.into()));
348        }
349        if let Some(ec) = error_code {
350            e.insert("error_code".into(), Value::String(ec.into()));
351        }
352        if let Some(t) = tid {
353            e.insert("tid".into(), Value::String(t.into()));
354        }
355        if let Some(d) = dur {
356            e.insert("dur".into(), Value::Number(d.into()));
357        }
358        if let Some(c) = ctx {
359            e.insert("ctx".into(), c);
360        }
361        e.insert("module".into(), Value::String("unknown".into()));
362        self.write_entry(e)
363    }
364
365    #[allow(clippy::too_many_arguments)]
366    pub fn decision(
367        &self,
368        choice: &str,
369        alts: Option<&[&str]>,
370        reason: Option<&str>,
371        confidence: Option<f64>,
372        module: Option<&str>,
373        ctx: Option<Value>,
374    ) -> std::io::Result<()> {
375        let mut e = Map::new();
376        e.insert("level".into(), Value::String("DECISION".into()));
377        e.insert("msg".into(), Value::String(format!("Decision: {}", choice)));
378        e.insert("choice".into(), Value::String(choice.into()));
379        if let Some(a) = alts {
380            e.insert(
381                "alts".into(),
382                Value::Array(a.iter().map(|s| Value::String((*s).into())).collect()),
383            );
384        }
385        if let Some(r) = reason {
386            e.insert("reason".into(), Value::String(r.into()));
387        }
388        if let Some(c) = confidence {
389            e.insert("confidence".into(), serde_json::Number::from_f64(c).map(Value::Number).unwrap_or(Value::Null));
390        }
391        if let Some(m) = module {
392            e.insert("module".into(), Value::String(m.into()));
393        } else {
394            e.insert("module".into(), Value::String("unknown".into()));
395        }
396        if let Some(c) = ctx {
397            e.insert("ctx".into(), c);
398        }
399        self.write_entry(e)
400    }
401
402    #[allow(clippy::too_many_arguments)]
403    pub fn code_gen(
404        &self,
405        lang: &str,
406        path: &str,
407        lines: Option<u64>,
408        funcs: Option<&[&str]>,
409        imports: Option<&[&str]>,
410        module: Option<&str>,
411        ctx: Option<Value>,
412    ) -> std::io::Result<()> {
413        let mut e = Map::new();
414        e.insert("level".into(), Value::String("CODE_GEN".into()));
415        e.insert("msg".into(), Value::String(format!("Generated {} code: {}", lang, path)));
416        e.insert("lang".into(), Value::String(lang.into()));
417        e.insert("path".into(), Value::String(path.into()));
418        if let Some(l) = lines {
419            e.insert("lines".into(), Value::Number(l.into()));
420        }
421        if let Some(f) = funcs {
422            e.insert(
423                "funcs".into(),
424                Value::Array(f.iter().map(|s| Value::String((*s).into())).collect()),
425            );
426        }
427        if let Some(im) = imports {
428            e.insert(
429                "imports".into(),
430                Value::Array(im.iter().map(|s| Value::String((*s).into())).collect()),
431            );
432        }
433        e.insert("module".into(), Value::String(module.unwrap_or("unknown").into()));
434        if let Some(c) = ctx {
435            e.insert("ctx".into(), c);
436        }
437        self.write_entry(e)
438    }
439
440    pub fn context_switch(
441        &self,
442        to_task: &str,
443        from_task: Option<&str>,
444        reason: Option<&str>,
445        module: Option<&str>,
446        ctx: Option<Value>,
447    ) -> std::io::Result<()> {
448        let mut e = Map::new();
449        e.insert("level".into(), Value::String("CONTEXT".into()));
450        e.insert("msg".into(), Value::String(format!("Switching to: {}", to_task)));
451        e.insert("to_task".into(), Value::String(to_task.into()));
452        if let Some(f) = from_task {
453            e.insert("from_task".into(), Value::String(f.into()));
454        }
455        if let Some(r) = reason {
456            e.insert("reason".into(), Value::String(r.into()));
457        }
458        e.insert("module".into(), Value::String(module.unwrap_or("unknown").into()));
459        if let Some(c) = ctx {
460            e.insert("ctx".into(), c);
461        }
462        self.write_entry(e)
463    }
464}
465
466// ---- helpers --------------------------------------------------------------
467
468fn write_value(w: &mut impl Write, v: &Value) -> std::io::Result<()> {
469    use serde_json::ser::Serializer;
470    let mut ser = Serializer::with_formatter(w, fmt::PythonFormatter);
471    v.serialize(&mut ser)
472        .map_err(std::io::Error::other)?;
473    Ok(())
474}
475
476#[allow(clippy::too_many_arguments)]
477fn basic_fields(
478    e: &mut Map<String, Value>,
479    module: Option<&str>,
480    dur: Option<u64>,
481    error_code: Option<&str>,
482    tid: Option<&str>,
483    ctx: Option<Value>,
484) {
485    e.insert("module".into(), Value::String(module.unwrap_or("unknown").into()));
486    if let Some(d) = dur {
487        e.insert("dur".into(), Value::Number(d.into()));
488    }
489    if let Some(ec) = error_code {
490        e.insert("error_code".into(), Value::String(ec.into()));
491    }
492    if let Some(t) = tid {
493        e.insert("tid".into(), Value::String(t.into()));
494    }
495    if let Some(c) = ctx {
496        e.insert("ctx".into(), c);
497    }
498}
499
500fn truncate_msg(s: &str) -> String {
501    if s.chars().count() <= 4096 {
502        s.to_string()
503    } else {
504        s.chars().take(4096).collect()
505    }
506}
507fn truncate64k(s: &str) -> String {
508    if s.len() <= 65536 {
509        s.to_string()
510    } else {
511        s[..65536].to_string()
512    }
513}
514
515/// Sanitise a filename component: keep `[A-Za-z0-9_-]`, else `_`, cap 50 chars.
516fn sanitize(s: &str) -> String {
517    s.chars()
518        .map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' })
519        .take(50)
520        .collect()
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    fn tmpdir() -> PathBuf {
528        let d = std::env::temp_dir().join(format!("agentic_rust_test_{}", Uuid::new_v4().simple()));
529        std::fs::create_dir_all(&d).unwrap();
530        d
531    }
532
533    #[test]
534    fn writes_byte_compatible_jsonl() {
535        let d = tmpdir();
536        let lg = AgentLogger::new("rust_probe", Some("demo"), &d, Some("cafebabe")).unwrap();
537        lg.info_full(
538            "Processing started",
539            Some("parser"),
540            Some(12),
541            None,
542            None,
543            Some(serde_json::json!({"file": "data.json", "size": 1024})),
544        )
545        .unwrap();
546        lg.tool_call("bash", "npm install", 0, 1234, None, None, Some("added 50 pkgs"), None, None)
547            .unwrap();
548        lg.error("Build failed", Some("build"), ErrorCode::ExecNonZero, Some("tb_abcd1234"))
549            .unwrap();
550        lg.file_op("write", "/p/f.rs", true, Some(2048), None, None, Some(5), None).unwrap();
551        lg.decision("use_redis", Some(&["redis", "memcached"]), Some("perf"), Some(0.85), Some("arch"), None)
552            .unwrap();
553        lg.code_gen("rust", "src/main.rs", Some(50), Some(&["main", "helper"]), None, None, None)
554            .unwrap();
555        lg.context_switch("test", Some("build"), Some("done"), None, None).unwrap();
556        drop(lg);
557
558        let content = std::fs::read_to_string(lg_file(&d)).unwrap();
559        let lines: Vec<&str> = content.trim_end().split('\n').collect();
560        assert_eq!(lines.len(), 8); // 1 header + 7 entries
561
562        for (i, line) in lines.iter().enumerate() {
563            let v: Value = serde_json::from_str(line).unwrap_or_else(|e| panic!("line {i} not JSON: {e}\n{line}"));
564            let obj = v.as_object().unwrap();
565            assert!(obj.get("ts").unwrap().is_string(), "ts must be string");
566            assert!(obj.get("pid").unwrap().is_string(), "pid must be string");
567            assert!(obj.get("seq").unwrap().is_i64(), "seq must be number");
568            assert_eq!(obj.get("rid").unwrap(), &Value::String("cafebabe".into()));
569        }
570
571        // Header line.
572        let hdr: Value = serde_json::from_str(lines[0]).unwrap();
573        assert_eq!(hdr["level"], "__GLOBAL_CTX__");
574        assert_eq!(hdr["seq"], 0);
575
576        // Byte-level contract: separators contain ": " and ", ".
577        assert!(content.contains("\"level\": \"INFO\""), "missing python-style separator");
578        assert!(content.contains("\"exit\": 0"), "exit must be unquoted number");
579        assert!(!content.contains("\\u"), "must not emit \\uXXXX escapes (ensure_ascii=false)");
580    }
581
582    fn lg_file(d: &Path) -> PathBuf {
583        std::fs::read_dir(d)
584            .unwrap()
585            .filter_map(|e| e.ok())
586            .map(|e| e.path())
587            .find(|p| p.extension().and_then(|s| s.to_str()) == Some("jsonl"))
588            .unwrap()
589    }
590
591    #[test]
592    fn compact_mode_matches_python_keys() {
593        let d = tmpdir();
594        let lg = AgentLogger::new_compact("cp", Some("d"), &d, Some("cafe0000"), true).unwrap();
595        lg.info_full("hi", Some("parser"), Some(12), None, None, Some(serde_json::json!({"f":"d.json"}))).unwrap();
596        drop(lg);
597        let content = std::fs::read_to_string(lg_file(&d)).unwrap();
598        assert!(content.contains("\"l\": \"INFO\""), "level→l: {content}");
599        assert!(content.contains("\"n\": \"parser\""), "module→n");
600        assert!(content.contains("\"d\": 12"), "dur→d");
601        assert!(content.contains("\"q\": 1"), "seq→q");
602        // nested ctx keys NOT compacted
603        assert!(content.contains("\"z\""), "ctx→z");
604    }
605
606    #[test]
607    fn traceback_sidecar_full_keys() {
608        let d = tmpdir();
609        let lg = AgentLogger::new("tb", Some("d"), &d, None).unwrap();
610        let tid = lg.save_traceback("ValueError", "bad", "Traceback:\n  boom").unwrap();
611        assert!(tid.starts_with("tb_"));
612        drop(lg);
613        let tb = std::fs::read_dir(&d).unwrap()
614            .filter_map(|e| e.ok()).map(|e| e.path())
615            .find(|p| p.extension().and_then(|s| s.to_str()) == Some("tracebacks")).unwrap();
616        let line = std::fs::read_to_string(tb).unwrap();
617        assert!(line.contains("\"exception_type\": \"ValueError\""), "{line}");
618        assert!(line.contains("\"tid\": "), "{line}");
619    }
620
621    #[test]
622    fn sanitizes_filename() {
623        let d = tmpdir();
624        let lg = AgentLogger::new("weird/prog name!", Some("a b"), &d, None).unwrap();
625        let name = lg.file_path().file_name().unwrap().to_str().unwrap();
626        assert!(name.starts_with("weird_prog_name_"), "got {name}");
627    }
628
629    #[test]
630    fn msg_truncation() {
631        assert_eq!(truncate_msg("hi"), "hi");
632        let big: String = "a".repeat(5000);
633        assert_eq!(truncate_msg(&big).chars().count(), 4096);
634    }
635}