Skip to main content

shape_runtime/stdlib/
log.rs

1//! Native `log` module for structured logging.
2//!
3//! Exports: log.debug, log.info, log.warn, log.error, log.set_level
4
5use crate::module_exports::{ModuleContext, ModuleExports, ModuleFunction, ModuleParam};
6use shape_value::ValueWord;
7use std::sync::atomic::{AtomicU8, Ordering};
8
9/// Log level constants, ordered by severity.
10const LEVEL_DEBUG: u8 = 0;
11const LEVEL_INFO: u8 = 1;
12const LEVEL_WARN: u8 = 2;
13const LEVEL_ERROR: u8 = 3;
14
15/// Global minimum log level. Messages below this level are silently dropped.
16static MIN_LEVEL: AtomicU8 = AtomicU8::new(LEVEL_DEBUG);
17
18/// Format optional fields object into a string suffix for the log message.
19fn format_fields(args: &[ValueWord]) -> String {
20    let fields_arg = args.get(1);
21    match fields_arg {
22        Some(f) if !f.is_none() && !f.is_unit() => {
23            let json = f.to_json_value();
24            if let serde_json::Value::Object(map) = json {
25                if map.is_empty() {
26                    return String::new();
27                }
28                let pairs: Vec<String> = map.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
29                format!(" {}", pairs.join(" "))
30            } else {
31                format!(" fields={}", json)
32            }
33        }
34        _ => String::new(),
35    }
36}
37
38fn parse_level(name: &str) -> Result<u8, String> {
39    match name.to_lowercase().as_str() {
40        "debug" => Ok(LEVEL_DEBUG),
41        "info" => Ok(LEVEL_INFO),
42        "warn" | "warning" => Ok(LEVEL_WARN),
43        "error" => Ok(LEVEL_ERROR),
44        _ => Err(format!(
45            "log.set_level() unknown level '{}'. Use: debug, info, warn, error",
46            name
47        )),
48    }
49}
50
51/// Create the `log` module with structured logging functions.
52pub fn create_log_module() -> ModuleExports {
53    let mut module = ModuleExports::new("log");
54    module.description = "Structured logging utilities".to_string();
55
56    let msg_param = ModuleParam {
57        name: "message".to_string(),
58        type_name: "string".to_string(),
59        required: true,
60        description: "Log message".to_string(),
61        ..Default::default()
62    };
63
64    let fields_param = ModuleParam {
65        name: "fields".to_string(),
66        type_name: "object".to_string(),
67        required: false,
68        description: "Optional structured fields to attach to the log entry".to_string(),
69        ..Default::default()
70    };
71
72    // log.debug(message: string, fields?: object) -> unit
73    module.add_function_with_schema(
74        "debug",
75        |args: &[ValueWord], _ctx: &ModuleContext| {
76            if MIN_LEVEL.load(Ordering::Relaxed) > LEVEL_DEBUG {
77                return Ok(ValueWord::unit());
78            }
79            let msg = args
80                .first()
81                .and_then(|a| a.as_str())
82                .ok_or_else(|| "log.debug() requires a message string".to_string())?;
83            let fields = format_fields(args);
84            tracing::debug!("[shape] {}{}", msg, fields);
85            Ok(ValueWord::unit())
86        },
87        ModuleFunction {
88            description: "Log a debug-level message".to_string(),
89            params: vec![msg_param.clone(), fields_param.clone()],
90            return_type: Some("unit".to_string()),
91        },
92    );
93
94    // log.info(message: string, fields?: object) -> unit
95    module.add_function_with_schema(
96        "info",
97        |args: &[ValueWord], _ctx: &ModuleContext| {
98            if MIN_LEVEL.load(Ordering::Relaxed) > LEVEL_INFO {
99                return Ok(ValueWord::unit());
100            }
101            let msg = args
102                .first()
103                .and_then(|a| a.as_str())
104                .ok_or_else(|| "log.info() requires a message string".to_string())?;
105            let fields = format_fields(args);
106            tracing::info!("[shape] {}{}", msg, fields);
107            Ok(ValueWord::unit())
108        },
109        ModuleFunction {
110            description: "Log an info-level message".to_string(),
111            params: vec![msg_param.clone(), fields_param.clone()],
112            return_type: Some("unit".to_string()),
113        },
114    );
115
116    // log.warn(message: string, fields?: object) -> unit
117    module.add_function_with_schema(
118        "warn",
119        |args: &[ValueWord], _ctx: &ModuleContext| {
120            if MIN_LEVEL.load(Ordering::Relaxed) > LEVEL_WARN {
121                return Ok(ValueWord::unit());
122            }
123            let msg = args
124                .first()
125                .and_then(|a| a.as_str())
126                .ok_or_else(|| "log.warn() requires a message string".to_string())?;
127            let fields = format_fields(args);
128            tracing::warn!("[shape] {}{}", msg, fields);
129            Ok(ValueWord::unit())
130        },
131        ModuleFunction {
132            description: "Log a warning-level message".to_string(),
133            params: vec![msg_param.clone(), fields_param.clone()],
134            return_type: Some("unit".to_string()),
135        },
136    );
137
138    // log.error(message: string, fields?: object) -> unit
139    module.add_function_with_schema(
140        "error",
141        |args: &[ValueWord], _ctx: &ModuleContext| {
142            if MIN_LEVEL.load(Ordering::Relaxed) > LEVEL_ERROR {
143                return Ok(ValueWord::unit());
144            }
145            let msg = args
146                .first()
147                .and_then(|a| a.as_str())
148                .ok_or_else(|| "log.error() requires a message string".to_string())?;
149            let fields = format_fields(args);
150            tracing::error!("[shape] {}{}", msg, fields);
151            Ok(ValueWord::unit())
152        },
153        ModuleFunction {
154            description: "Log an error-level message".to_string(),
155            params: vec![msg_param, fields_param],
156            return_type: Some("unit".to_string()),
157        },
158    );
159
160    // log.set_level(level: string) -> unit
161    module.add_function_with_schema(
162        "set_level",
163        |args: &[ValueWord], _ctx: &ModuleContext| {
164            let level_str = args
165                .first()
166                .and_then(|a| a.as_str())
167                .ok_or_else(|| "log.set_level() requires a level string argument".to_string())?;
168
169            let level = parse_level(level_str)?;
170            MIN_LEVEL.store(level, Ordering::Relaxed);
171            Ok(ValueWord::unit())
172        },
173        ModuleFunction {
174            description: "Set the minimum log level (debug, info, warn, error)".to_string(),
175            params: vec![ModuleParam {
176                name: "level".to_string(),
177                type_name: "string".to_string(),
178                required: true,
179                description: "Minimum log level: debug, info, warn, error".to_string(),
180                allowed_values: Some(vec![
181                    "debug".to_string(),
182                    "info".to_string(),
183                    "warn".to_string(),
184                    "error".to_string(),
185                ]),
186                ..Default::default()
187            }],
188            return_type: Some("unit".to_string()),
189        },
190    );
191
192    module
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn s(val: &str) -> ValueWord {
200        ValueWord::from_string(std::sync::Arc::new(val.to_string()))
201    }
202
203    fn test_ctx() -> crate::module_exports::ModuleContext<'static> {
204        let registry = Box::leak(Box::new(crate::type_schema::TypeSchemaRegistry::new()));
205        crate::module_exports::ModuleContext {
206            schemas: registry,
207            invoke_callable: None,
208            raw_invoker: None,
209            function_hashes: None,
210            vm_state: None,
211            granted_permissions: None,
212            scope_constraints: None,
213            set_pending_resume: None,
214            set_pending_frame_resume: None,
215        }
216    }
217
218    #[test]
219    fn test_log_module_creation() {
220        let module = create_log_module();
221        assert_eq!(module.name, "log");
222        assert!(module.has_export("debug"));
223        assert!(module.has_export("info"));
224        assert!(module.has_export("warn"));
225        assert!(module.has_export("error"));
226        assert!(module.has_export("set_level"));
227    }
228
229    #[test]
230    fn test_log_debug() {
231        let module = create_log_module();
232        let ctx = test_ctx();
233        // Reset level to debug for this test
234        MIN_LEVEL.store(LEVEL_DEBUG, Ordering::Relaxed);
235        let f = module.get_export("debug").unwrap();
236        let result = f(&[s("test message")], &ctx);
237        assert!(result.is_ok());
238        assert!(result.unwrap().is_unit());
239    }
240
241    #[test]
242    fn test_log_info() {
243        let module = create_log_module();
244        let ctx = test_ctx();
245        let f = module.get_export("info").unwrap();
246        let result = f(&[s("info message")], &ctx);
247        assert!(result.is_ok());
248    }
249
250    #[test]
251    fn test_log_warn() {
252        let module = create_log_module();
253        let ctx = test_ctx();
254        let f = module.get_export("warn").unwrap();
255        let result = f(&[s("warning message")], &ctx);
256        assert!(result.is_ok());
257    }
258
259    #[test]
260    fn test_log_error() {
261        let module = create_log_module();
262        let ctx = test_ctx();
263        let f = module.get_export("error").unwrap();
264        let result = f(&[s("error message")], &ctx);
265        assert!(result.is_ok());
266    }
267
268    #[test]
269    fn test_log_requires_string() {
270        let module = create_log_module();
271        let ctx = test_ctx();
272        let f = module.get_export("info").unwrap();
273        assert!(f(&[ValueWord::from_f64(42.0)], &ctx).is_err());
274        assert!(f(&[], &ctx).is_err());
275    }
276
277    #[test]
278    fn test_set_level_valid() {
279        let module = create_log_module();
280        let ctx = test_ctx();
281        let f = module.get_export("set_level").unwrap();
282        assert!(f(&[s("info")], &ctx).is_ok());
283        assert_eq!(MIN_LEVEL.load(Ordering::Relaxed), LEVEL_INFO);
284        // Reset
285        assert!(f(&[s("debug")], &ctx).is_ok());
286        assert_eq!(MIN_LEVEL.load(Ordering::Relaxed), LEVEL_DEBUG);
287    }
288
289    #[test]
290    fn test_set_level_invalid() {
291        let module = create_log_module();
292        let ctx = test_ctx();
293        let f = module.get_export("set_level").unwrap();
294        assert!(f(&[s("critical")], &ctx).is_err());
295    }
296
297    #[test]
298    fn test_set_level_case_insensitive() {
299        let module = create_log_module();
300        let ctx = test_ctx();
301        let f = module.get_export("set_level").unwrap();
302        assert!(f(&[s("WARN")], &ctx).is_ok());
303        assert_eq!(MIN_LEVEL.load(Ordering::Relaxed), LEVEL_WARN);
304        assert!(f(&[s("Warning")], &ctx).is_ok());
305        assert_eq!(MIN_LEVEL.load(Ordering::Relaxed), LEVEL_WARN);
306        // Reset
307        let _ = f(&[s("debug")], &ctx);
308    }
309
310    #[test]
311    fn test_log_level_filtering() {
312        let module = create_log_module();
313        let ctx = test_ctx();
314        let set_level = module.get_export("set_level").unwrap();
315        let debug_fn = module.get_export("debug").unwrap();
316        let error_fn = module.get_export("error").unwrap();
317
318        // Set level to error - debug should be silently dropped
319        set_level(&[s("error")], &ctx).unwrap();
320        let result = debug_fn(&[s("should be dropped")], &ctx);
321        assert!(result.is_ok());
322        assert!(result.unwrap().is_unit());
323
324        // Error should still work
325        let result = error_fn(&[s("error still works")], &ctx);
326        assert!(result.is_ok());
327
328        // Reset
329        let _ = set_level(&[s("debug")], &ctx);
330    }
331
332    #[test]
333    fn test_log_schemas() {
334        let module = create_log_module();
335
336        let info_schema = module.get_schema("info").unwrap();
337        assert_eq!(info_schema.params.len(), 2);
338        assert!(info_schema.params[0].required);
339        assert!(!info_schema.params[1].required);
340        assert_eq!(info_schema.return_type.as_deref(), Some("unit"));
341
342        let level_schema = module.get_schema("set_level").unwrap();
343        assert_eq!(level_schema.params.len(), 1);
344        assert!(level_schema.params[0].allowed_values.is_some());
345    }
346}