netget/protocol/log_template.rs
1//! Log template system for standardized event and action logging
2//!
3//! Provides a template-based logging system where protocols define log formats
4//! at INFO/DEBUG/TRACE levels. Templates use `{field}` interpolation syntax
5//! to extract values from event/action data.
6
7use regex::Regex;
8use serde_json::Value;
9use std::sync::LazyLock;
10
11/// Log level for template rendering
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum LogLevel {
14 Info,
15 Debug,
16 Trace,
17}
18
19impl LogLevel {
20 /// Get the log level prefix for TUI display
21 pub fn prefix(&self) -> &'static str {
22 match self {
23 LogLevel::Info => "[INFO]",
24 LogLevel::Debug => "[DEBUG]",
25 LogLevel::Trace => "[TRACE]",
26 }
27 }
28}
29
30/// Log template for generating log messages from event/action data
31///
32/// Templates use `{field}` syntax to interpolate values from JSON data.
33/// Each level (info/debug/trace) can have its own template format.
34///
35/// # Example
36/// ```rust,ignore
37/// let template = LogTemplate::new()
38/// .with_info("{client_ip} {method} {path} -> {status}")
39/// .with_debug("HTTP {method} {path} from {client_ip}:{client_port}")
40/// .with_trace("HTTP request: {json_pretty(.)}");
41/// ```
42#[derive(Clone, Debug, Default)]
43pub struct LogTemplate {
44 /// INFO level: One-liner access log style (protocol-specific)
45 /// Example: "{client_ip} {method} {path} -> {status} ({response_bytes}B, {duration_ms}ms)"
46 pub info: Option<String>,
47
48 /// DEBUG level: Extra details + timing
49 /// Example: "HTTP {method} {path} from {client_ip}:{client_port}, headers: {headers_len}"
50 pub debug: Option<String>,
51
52 /// TRACE level: Full request/response details
53 /// Example: "Full HTTP request: {json_pretty(.)}"
54 pub trace: Option<String>,
55}
56
57impl LogTemplate {
58 /// Create a new empty log template
59 pub fn new() -> Self {
60 Self::default()
61 }
62
63 /// Set INFO level template
64 ///
65 /// Used for one-liner access log style messages.
66 /// Example: "{client_ip} {method} {path} -> {status}"
67 pub fn with_info(mut self, template: impl Into<String>) -> Self {
68 self.info = Some(template.into());
69 self
70 }
71
72 /// Set DEBUG level template
73 ///
74 /// Used for extra details including timing.
75 /// Example: "HTTP {method} {path} from {client_ip}:{client_port}"
76 pub fn with_debug(mut self, template: impl Into<String>) -> Self {
77 self.debug = Some(template.into());
78 self
79 }
80
81 /// Set TRACE level template
82 ///
83 /// Used for full payload details.
84 /// Example: "Full request: {json_pretty(.)}"
85 pub fn with_trace(mut self, template: impl Into<String>) -> Self {
86 self.trace = Some(template.into());
87 self
88 }
89
90 /// Get the template string for a given log level
91 pub fn get_template(&self, level: LogLevel) -> Option<&str> {
92 match level {
93 LogLevel::Info => self.info.as_deref(),
94 LogLevel::Debug => self.debug.as_deref(),
95 LogLevel::Trace => self.trace.as_deref(),
96 }
97 }
98
99 /// Render the template for a given log level with event/action data
100 ///
101 /// Returns None if no template is defined for the level.
102 ///
103 /// # Arguments
104 /// * `level` - The log level to render
105 /// * `data` - JSON data to interpolate into the template
106 ///
107 /// # Template Syntax
108 /// - `{field}` - Simple field access
109 /// - `{field.subfield}` - Nested field access (dot notation)
110 /// - `{json(field)}` - Compact JSON of field
111 /// - `{json_pretty(.)}` - Pretty-print entire data object
112 /// - `{hex(field)}` - Hex-encode string field
113 /// - `{field_len}` - Length of array/string/object field
114 /// - `{preview(field)}` - First 100 chars with ellipsis
115 /// - `{preview(field,50)}` - First N chars with ellipsis
116 pub fn render(&self, level: LogLevel, data: &Value) -> Option<String> {
117 self.get_template(level).map(|t| render_template(t, data))
118 }
119
120 /// Check if any template is defined
121 pub fn has_any(&self) -> bool {
122 self.info.is_some() || self.debug.is_some() || self.trace.is_some()
123 }
124}
125
126/// Regex for matching template placeholders
127static PLACEHOLDER_REGEX: LazyLock<Regex> =
128 LazyLock::new(|| Regex::new(r"\{([^}]+)\}").expect("Invalid regex"));
129
130/// Render a template string with JSON data
131///
132/// Supports:
133/// - `{field}` - Simple field access
134/// - `{field.subfield}` - Nested field access
135/// - `{json(field)}` - Compact JSON
136/// - `{json_pretty(field)}` or `{json_pretty(.)}` - Pretty JSON
137/// - `{hex(field)}` - Hex-encode bytes
138/// - `{field_len}` - Array/string length
139/// - `{preview(field)}` - First 100 chars
140/// - `{preview(field,N)}` - First N chars
141fn render_template(template: &str, data: &Value) -> String {
142 // Replace matches in the template once. Replacing on the already-rendered string
143 // would interpret placeholder-shaped text inside a peer's data as another field.
144 PLACEHOLDER_REGEX
145 .replace_all(template, |cap: ®ex::Captures<'_>| {
146 let placeholder = &cap[1];
147
148 // The template is written by the protocol and is trusted. The *value* substituted into
149 // it is not: it comes from the model's action or from the wire, and this renderer is
150 // what puts it on a log line.
151 //
152 // A control character in it forges a log entry. `\r\nERROR forged` turns one line into
153 // two, and the second is indistinguishable from something NetGet wrote — which is the
154 // whole point of impersonating a device. LLDP, CDP and HSRP each fixed this locally in
155 // their own fields; this is the one place that covers all 140 servers, including the
156 // ones nobody has looked at yet.
157 //
158 // `line_field` substitutes a space rather than deleting, for the reason
159 // `utils::sanitize` documents: deleting joins the two sides into one word, which reads
160 // as a single legitimate value and is its own small lie. The JSON placeholder forms
161 // escape control characters already, so this is a no-op for them.
162 crate::utils::sanitize::line_field(&render_placeholder(placeholder, data))
163 })
164 .into_owned()
165}
166
167/// Render a single placeholder
168fn render_placeholder(placeholder: &str, data: &Value) -> String {
169 // Handle special functions
170 if placeholder == "." {
171 // Entire data object as compact JSON
172 return serde_json::to_string(data).unwrap_or_else(|_| "{}".to_string());
173 }
174
175 if placeholder.starts_with("json_pretty(") && placeholder.ends_with(')') {
176 // Pretty-print JSON
177 let field = &placeholder[12..placeholder.len() - 1];
178 let value = if field == "." {
179 data.clone()
180 } else {
181 get_nested_value(data, field)
182 };
183 return serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string());
184 }
185
186 if placeholder.starts_with("json(") && placeholder.ends_with(')') {
187 // Compact JSON
188 let field = &placeholder[5..placeholder.len() - 1];
189 let value = if field == "." {
190 data.clone()
191 } else {
192 get_nested_value(data, field)
193 };
194 return serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string());
195 }
196
197 if placeholder.starts_with("hex(") && placeholder.ends_with(')') {
198 // Hex encode
199 let field = &placeholder[4..placeholder.len() - 1];
200 let value = get_nested_value(data, field);
201 if let Some(s) = value.as_str() {
202 return hex::encode(s.as_bytes());
203 }
204 return String::new();
205 }
206
207 if placeholder.starts_with("preview(") && placeholder.ends_with(')') {
208 // Preview with optional length
209 let inner = &placeholder[8..placeholder.len() - 1];
210 let (field, max_len) = if let Some(comma_pos) = inner.find(',') {
211 let field = inner[..comma_pos].trim();
212 let len_str = inner[comma_pos + 1..].trim();
213 let len = len_str.parse::<usize>().unwrap_or(100);
214 (field, len)
215 } else {
216 (inner, 100)
217 };
218
219 let value = get_nested_value(data, field);
220 let s = value_to_string(&value);
221 // Must cut on a character boundary: `s` is network text or model output, and
222 // `s.len()` is a byte count, so `&s[..max_len]` panics whenever the cut lands
223 // inside a multi-byte character. This is shared infrastructure — `preview(...)`
224 // is used by irc, pop3, xmpp and others, so the panic defeated those protocols'
225 // own local fixes for the same class.
226 return crate::utils::truncate_for_log(&s, max_len);
227 }
228
229 if placeholder.ends_with("_len") {
230 // Length of field
231 let field = &placeholder[..placeholder.len() - 4];
232 let value = get_nested_value(data, field);
233 return match &value {
234 Value::String(s) => s.len().to_string(),
235 Value::Array(a) => a.len().to_string(),
236 Value::Object(o) => o.len().to_string(),
237 _ => "0".to_string(),
238 };
239 }
240
241 // Simple field access (supports dot notation)
242 let value = get_nested_value(data, placeholder);
243 value_to_string(&value)
244}
245
246/// Get a nested value from JSON using dot notation
247///
248/// Example: `get_nested_value(data, "headers.content_type")`
249fn get_nested_value(data: &Value, path: &str) -> Value {
250 let parts: Vec<&str> = path.split('.').collect();
251 let mut current = data.clone();
252
253 for part in parts {
254 // Handle array index notation like "items[0]"
255 if let Some(bracket_pos) = part.find('[') {
256 let field = &part[..bracket_pos];
257 let index_str = &part[bracket_pos + 1..part.len() - 1];
258 if let Ok(index) = index_str.parse::<usize>() {
259 current = current
260 .get(field)
261 .and_then(|v| v.get(index))
262 .cloned()
263 .unwrap_or(Value::Null);
264 continue;
265 }
266 }
267
268 current = current.get(part).cloned().unwrap_or(Value::Null);
269 }
270
271 current
272}
273
274/// Convert a JSON value to a display string
275fn value_to_string(value: &Value) -> String {
276 match value {
277 Value::String(s) => s.clone(),
278 Value::Number(n) => n.to_string(),
279 Value::Bool(b) => b.to_string(),
280 Value::Null => String::new(),
281 Value::Array(_) | Value::Object(_) => {
282 serde_json::to_string(value).unwrap_or_else(|_| String::new())
283 }
284 }
285}