Skip to main content

termwright_protocol/
logs.rs

1//! Application log records carried over the semantic channel.
2//!
3//! A TUI cannot print diagnostics to the screen without corrupting the render,
4//! so applications write them to a logger instead. The `logs` capability
5//! forwards those records to the driver, where they become assertable test
6//! state rather than invisible side effects.
7//!
8//! Records are bounded exactly like snapshots: measured against a byte ceiling
9//! and rejected wholesale on any violation, so a misbehaving logger degrades
10//! into dropped records rather than unbounded driver memory.
11
12use std::collections::BTreeMap;
13
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17use crate::error::ValidationError;
18use crate::framing::project_dto;
19use crate::limits::Limits;
20use crate::marker::MAX_SAFE_INTEGER;
21
22/// One rung of the severity ladder.
23///
24/// The set is closed: it is the intersection of the ladders used by Rust
25/// `tracing`, Python `logging`, Go `slog`, pino and winston, so every bridge
26/// maps onto it without inventing a level.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "lowercase")]
29pub enum LogLevel {
30    /// Finer than debug.
31    Trace,
32    /// Diagnostic detail.
33    Debug,
34    /// Ordinary progress.
35    Info,
36    /// Something recoverable went wrong.
37    Warn,
38    /// An operation failed.
39    Error,
40    /// The application cannot continue.
41    Fatal,
42}
43
44impl LogLevel {
45    /// Numeric severity; higher is more severe.
46    pub fn severity(self) -> u8 {
47        match self {
48            LogLevel::Trace => 10,
49            LogLevel::Debug => 20,
50            LogLevel::Info => 30,
51            LogLevel::Warn => 40,
52            LogLevel::Error => 50,
53            LogLevel::Fatal => 60,
54        }
55    }
56
57    /// The wire spelling.
58    pub fn as_str(self) -> &'static str {
59        match self {
60            LogLevel::Trace => "trace",
61            LogLevel::Debug => "debug",
62            LogLevel::Info => "info",
63            LogLevel::Warn => "warn",
64            LogLevel::Error => "error",
65            LogLevel::Fatal => "fatal",
66        }
67    }
68}
69
70/// The ladder in order, least to most severe.
71pub const LOG_LEVELS: [&str; 6] = ["trace", "debug", "info", "warn", "error", "fatal"];
72
73/// Maximum number of attribute keys on one record.
74pub const MAX_LOG_ATTRS: usize = 64;
75
76const RECORD_FIELDS: [&str; 7] = [
77    "ts", "level", "message", "attrs", "logger", "seq", "revision",
78];
79
80/// A structured attribute value. Scalars only: nested values make a record's
81/// size unbounded and depth-dependent, so bridges flatten before they send.
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83#[serde(untagged)]
84pub enum AttrValue {
85    /// Absent or explicitly empty.
86    Null,
87    /// A flag.
88    Bool(bool),
89    /// An integer.
90    Int(i64),
91    /// A finite float.
92    Float(f64),
93    /// Text.
94    Text(String),
95}
96
97impl From<bool> for AttrValue {
98    fn from(value: bool) -> Self {
99        AttrValue::Bool(value)
100    }
101}
102
103impl From<i64> for AttrValue {
104    fn from(value: i64) -> Self {
105        AttrValue::Int(value)
106    }
107}
108
109impl From<u64> for AttrValue {
110    fn from(value: u64) -> Self {
111        AttrValue::Int(value as i64)
112    }
113}
114
115impl From<f64> for AttrValue {
116    fn from(value: f64) -> Self {
117        if value.is_finite() {
118            AttrValue::Float(value)
119        } else {
120            AttrValue::Text(value.to_string())
121        }
122    }
123}
124
125impl From<&str> for AttrValue {
126    fn from(value: &str) -> Self {
127        AttrValue::Text(value.to_owned())
128    }
129}
130
131impl From<String> for AttrValue {
132    fn from(value: String) -> Self {
133        AttrValue::Text(value)
134    }
135}
136
137/// One application log record.
138///
139/// `ts` is Unix epoch milliseconds, not session-relative: an adapter has no
140/// reliable view of when the driver considers the session to have started, so
141/// the wall clock is the only clock both sides agree on without negotiating.
142/// The driver rebases it onto the session timeline.
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct LogRecord {
145    /// Unix epoch milliseconds when the record was produced.
146    pub ts: i64,
147    /// Severity.
148    pub level: LogLevel,
149    /// Human-readable message, already formatted by the source logger.
150    pub message: String,
151    /// Flat structured context; sorted so a record serialises the same way twice.
152    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
153    pub attrs: BTreeMap<String, AttrValue>,
154    /// Logger or channel name, e.g. `http` or `db.pool`.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub logger: Option<String>,
157    /// Per-session counter assigned by the adapter. A gap tells the driver
158    /// records were dropped upstream rather than lost in transit.
159    pub seq: i64,
160    /// Semantic revision current when the record was produced, when known.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub revision: Option<i64>,
163}
164
165impl LogRecord {
166    /// A record with only the required fields. `seq` is assigned by the client.
167    pub fn new(level: LogLevel, message: impl Into<String>) -> Self {
168        Self {
169            ts: 0,
170            level,
171            message: message.into(),
172            attrs: BTreeMap::new(),
173            logger: None,
174            seq: 0,
175            revision: None,
176        }
177    }
178
179    /// Add one flat attribute.
180    pub fn with_attr(mut self, key: impl Into<String>, value: impl Into<AttrValue>) -> Self {
181        self.attrs.insert(key.into(), value.into());
182        self
183    }
184
185    /// Name the logger this record came from.
186    pub fn with_logger(mut self, logger: impl Into<String>) -> Self {
187        self.logger = Some(logger.into());
188        self
189    }
190
191    /// Validate against `limits`, as the driver will.
192    ///
193    /// # Errors
194    /// Returns a [`ValidationError`] with the shared taxonomy.
195    pub fn validate(&self, limits: &Limits) -> Result<(), ValidationError> {
196        let value = serde_json::to_value(self)
197            .map_err(|_| ValidationError::new("schema", "log record is not JSON-serialisable"))?;
198        validate_log_record(&value, limits)
199    }
200}
201
202fn fail(code: &'static str, detail: impl Into<String>) -> ValidationError {
203    ValidationError::new(code, detail)
204}
205
206fn safe_non_negative(value: Option<&Value>) -> Option<i64> {
207    value
208        .and_then(Value::as_i64)
209        .filter(|number| *number >= 0 && *number <= MAX_SAFE_INTEGER)
210}
211
212/// Validate an untrusted log record against `limits`.
213///
214/// Mirrors [`crate::validate_snapshot`]: projected first, then measured
215/// against `max_log_record_bytes`, then checked field by field. Never panics.
216///
217/// # Errors
218/// Returns a [`ValidationError`] whose `code` matches the reference
219/// implementation's.
220pub fn validate_log_record(value: &Value, limits: &Limits) -> Result<(), ValidationError> {
221    if let Err(violation) = project_dto(value, limits.max_depth) {
222        let code = if violation.code == "dto-depth" {
223            "depth"
224        } else {
225            "schema"
226        };
227        return Err(fail(code, violation.to_string()));
228    }
229
230    let serialised = serde_json::to_vec(value)
231        .map_err(|_| fail("schema", "log record is not JSON-serialisable"))?;
232    if serialised.len() > limits.max_log_record_bytes {
233        return Err(fail(
234            "bytes",
235            format!(
236                "log record is {} bytes, ceiling is {}",
237                serialised.len(),
238                limits.max_log_record_bytes
239            ),
240        ));
241    }
242
243    let Some(record) = value.as_object() else {
244        return Err(fail("schema", "log record must be an object"));
245    };
246
247    for key in record.keys() {
248        if !RECORD_FIELDS.contains(&key.as_str()) {
249            return Err(fail(
250                "schema",
251                format!("unknown log record property \"{key}\""),
252            ));
253        }
254    }
255
256    match safe_non_negative(record.get("ts")) {
257        Some(ts) if ts > 0 => {}
258        _ => {
259            return Err(fail(
260                "schema",
261                "ts must be a positive safe integer (epoch milliseconds)",
262            ))
263        }
264    }
265    match record.get("level").and_then(Value::as_str) {
266        Some(level) if LOG_LEVELS.contains(&level) => {}
267        _ => {
268            return Err(fail(
269                "schema",
270                format!("level must be one of {}", LOG_LEVELS.join(", ")),
271            ))
272        }
273    }
274    let Some(message) = record.get("message").and_then(Value::as_str) else {
275        return Err(fail("schema", "message must be a string"));
276    };
277    if message.len() > limits.max_string_bytes {
278        return Err(fail(
279            "string-bytes",
280            format!("message exceeds {} UTF-8 bytes", limits.max_string_bytes),
281        ));
282    }
283    if safe_non_negative(record.get("seq")).is_none() {
284        return Err(fail("schema", "seq must be a non-negative safe integer"));
285    }
286
287    if let Some(logger) = record.get("logger") {
288        let Some(text) = logger.as_str() else {
289            return Err(fail("schema", "logger must be a string"));
290        };
291        if text.len() > limits.max_string_bytes {
292            return Err(fail(
293                "string-bytes",
294                format!("logger exceeds {} UTF-8 bytes", limits.max_string_bytes),
295            ));
296        }
297    }
298
299    if let Some(revision) = record.get("revision") {
300        match safe_non_negative(Some(revision)) {
301            Some(value) if value > 0 => {}
302            _ => return Err(fail("revision", "revision must be a positive safe integer")),
303        }
304    }
305
306    if let Some(attrs) = record.get("attrs") {
307        let Some(attrs) = attrs.as_object() else {
308            return Err(fail("schema", "attrs must be a flat object"));
309        };
310        if attrs.len() > MAX_LOG_ATTRS {
311            return Err(fail(
312                "count",
313                format!(
314                    "attrs carries {} keys, ceiling is {MAX_LOG_ATTRS}",
315                    attrs.len()
316                ),
317            ));
318        }
319        for (key, attr) in attrs {
320            if key.len() > limits.max_string_bytes {
321                return Err(fail(
322                    "string-bytes",
323                    format!("attribute key \"{key}\" exceeds the string ceiling"),
324                ));
325            }
326            match attr {
327                Value::Null | Value::Bool(_) => {}
328                Value::Number(number) => {
329                    // `map_or`, not `is_none_or`: the latter is stable only
330                    // since 1.82 and this crate's MSRV is 1.74.
331                    if number.as_f64().map_or(true, |value| !value.is_finite()) {
332                        return Err(fail(
333                            "schema",
334                            format!("attribute \"{key}\" must be a finite number"),
335                        ));
336                    }
337                }
338                Value::String(text) => {
339                    if text.len() > limits.max_string_bytes {
340                        return Err(fail(
341                            "string-bytes",
342                            format!("attribute \"{key}\" exceeds the string ceiling"),
343                        ));
344                    }
345                }
346                _ => {
347                    return Err(fail(
348                        "schema",
349                        format!("attribute \"{key}\" must be a string, number, boolean or null"),
350                    ))
351                }
352            }
353        }
354    }
355
356    Ok(())
357}