Skip to main content

nika_engine/runtime/builtin/
log.rs

1//! nika:log - Emit log event at level.
2//!
3//! # Parameters
4//!
5//! ```json
6//! {
7//!   "level": "info",      // trace, debug, info, warn, error
8//!   "message": "Hello"    // Log message
9//! }
10//! ```
11//!
12//! # Returns
13//!
14//! ```json
15//! {
16//!   "logged": true,
17//!   "level": "info",
18//!   "message": "Hello"
19//! }
20//! ```
21
22use super::BuiltinTool;
23use crate::error::NikaError;
24use serde::{Deserialize, Serialize};
25use std::future::Future;
26use std::pin::Pin;
27use std::str::FromStr;
28use tracing::{debug, error, info, trace, warn};
29
30/// Valid log levels for nika:log tool.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
32#[serde(rename_all = "lowercase")]
33pub enum LogLevel {
34    Trace,
35    Debug,
36    Info,
37    Warn,
38    Error,
39}
40
41impl LogLevel {
42    /// Get the level name as a string.
43    pub fn as_str(&self) -> &'static str {
44        match self {
45            Self::Trace => "trace",
46            Self::Debug => "debug",
47            Self::Info => "info",
48            Self::Warn => "warn",
49            Self::Error => "error",
50        }
51    }
52}
53
54impl std::str::FromStr for LogLevel {
55    type Err = ();
56
57    /// Parse log level from string (case-insensitive).
58    fn from_str(s: &str) -> Result<Self, Self::Err> {
59        match s.to_lowercase().as_str() {
60            "trace" => Ok(Self::Trace),
61            "debug" => Ok(Self::Debug),
62            "info" => Ok(Self::Info),
63            "warn" | "warning" => Ok(Self::Warn),
64            "error" => Ok(Self::Error),
65            _ => Err(()),
66        }
67    }
68}
69
70/// Parameters for nika:log tool.
71#[derive(Debug, Clone, Deserialize)]
72struct LogParams {
73    /// Log level: trace, debug, info, warn, error.
74    level: String,
75    /// Log message.
76    message: String,
77}
78
79/// Response from nika:log tool.
80#[derive(Debug, Clone, Serialize)]
81struct LogResponse {
82    /// Whether the log was emitted.
83    logged: bool,
84    /// The level that was logged.
85    level: String,
86    /// The message that was logged.
87    message: String,
88}
89
90/// nika:log builtin tool.
91///
92/// Emits log events via tracing at the specified level.
93pub struct LogTool;
94
95impl BuiltinTool for LogTool {
96    fn name(&self) -> &'static str {
97        "log"
98    }
99
100    fn description(&self) -> &'static str {
101        "Emit log event at specified level (trace, debug, info, warn, error)"
102    }
103
104    fn parameters_schema(&self) -> serde_json::Value {
105        // OpenAI-compatible schema with additionalProperties: false
106        serde_json::json!({
107            "type": "object",
108            "properties": {
109                "level": {
110                    "type": "string",
111                    "description": "Log level: trace, debug, info, warn, error",
112                    "enum": ["trace", "debug", "info", "warn", "error"]
113                },
114                "message": {
115                    "type": "string",
116                    "description": "Log message to emit"
117                }
118            },
119            "required": ["level", "message"],
120            "additionalProperties": false
121        })
122    }
123
124    fn call<'a>(
125        &'a self,
126        args: String,
127    ) -> Pin<Box<dyn Future<Output = Result<String, NikaError>> + Send + 'a>> {
128        Box::pin(async move {
129            // Parse parameters
130            let params: LogParams =
131                serde_json::from_str(&args).map_err(|e| NikaError::BuiltinInvalidParams {
132                    tool: "nika:log".into(),
133                    reason: format!("Invalid JSON parameters: {}", e),
134                })?;
135
136            // Parse log level
137            let level =
138                LogLevel::from_str(&params.level).map_err(|_| NikaError::BuiltinInvalidParams {
139                    tool: "nika:log".into(),
140                    reason: format!(
141                        "Invalid log level '{}'. Valid levels: trace, debug, info, warn, error",
142                        params.level
143                    ),
144                })?;
145
146            // Emit log via tracing
147            match level {
148                LogLevel::Trace => trace!(target: "nika:log", "{}", params.message),
149                LogLevel::Debug => debug!(target: "nika:log", "{}", params.message),
150                LogLevel::Info => info!(target: "nika:log", "{}", params.message),
151                LogLevel::Warn => warn!(target: "nika:log", "{}", params.message),
152                LogLevel::Error => error!(target: "nika:log", "{}", params.message),
153            }
154
155            // Return response
156            let response = LogResponse {
157                logged: true,
158                level: level.as_str().to_string(),
159                message: params.message,
160            };
161
162            serde_json::to_string(&response).map_err(|e| NikaError::BuiltinToolError {
163                tool: "nika:log".into(),
164                reason: format!("Failed to serialize response: {}", e),
165            })
166        })
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn test_log_tool_name() {
176        let tool = LogTool;
177        assert_eq!(tool.name(), "log");
178    }
179
180    #[test]
181    fn test_log_tool_description() {
182        let tool = LogTool;
183        assert!(tool.description().contains("log"));
184    }
185
186    #[test]
187    fn test_log_tool_schema() {
188        let tool = LogTool;
189        let schema = tool.parameters_schema();
190        assert_eq!(schema["type"], "object");
191        assert!(schema["properties"]["level"].is_object());
192        assert!(schema["properties"]["message"].is_object());
193        assert!(schema["required"]
194            .as_array()
195            .unwrap()
196            .contains(&serde_json::json!("level")));
197        assert!(schema["required"]
198            .as_array()
199            .unwrap()
200            .contains(&serde_json::json!("message")));
201    }
202
203    #[test]
204    fn test_log_level_from_str() {
205        assert_eq!(LogLevel::from_str("trace"), Ok(LogLevel::Trace));
206        assert_eq!(LogLevel::from_str("DEBUG"), Ok(LogLevel::Debug));
207        assert_eq!(LogLevel::from_str("Info"), Ok(LogLevel::Info));
208        assert_eq!(LogLevel::from_str("warn"), Ok(LogLevel::Warn));
209        assert_eq!(LogLevel::from_str("WARNING"), Ok(LogLevel::Warn));
210        assert_eq!(LogLevel::from_str("error"), Ok(LogLevel::Error));
211        assert_eq!(LogLevel::from_str("invalid"), Err(()));
212    }
213
214    #[test]
215    fn test_log_level_as_str() {
216        assert_eq!(LogLevel::Trace.as_str(), "trace");
217        assert_eq!(LogLevel::Debug.as_str(), "debug");
218        assert_eq!(LogLevel::Info.as_str(), "info");
219        assert_eq!(LogLevel::Warn.as_str(), "warn");
220        assert_eq!(LogLevel::Error.as_str(), "error");
221    }
222
223    #[tokio::test]
224    async fn test_log_info() {
225        let tool = LogTool;
226        let result = tool
227            .call(r#"{"level": "info", "message": "Test info message"}"#.to_string())
228            .await;
229
230        assert!(result.is_ok());
231        let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
232        assert_eq!(response["logged"], true);
233        assert_eq!(response["level"], "info");
234        assert_eq!(response["message"], "Test info message");
235    }
236
237    #[tokio::test]
238    async fn test_log_error() {
239        let tool = LogTool;
240        let result = tool
241            .call(r#"{"level": "error", "message": "Test error message"}"#.to_string())
242            .await;
243
244        assert!(result.is_ok());
245        let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
246        assert_eq!(response["logged"], true);
247        assert_eq!(response["level"], "error");
248    }
249
250    #[tokio::test]
251    async fn test_log_all_levels() {
252        let tool = LogTool;
253
254        for level in ["trace", "debug", "info", "warn", "error"] {
255            let result = tool
256                .call(format!(r#"{{"level": "{}", "message": "Test"}}"#, level))
257                .await;
258            assert!(result.is_ok(), "Failed for level: {}", level);
259        }
260    }
261
262    #[tokio::test]
263    async fn test_log_invalid_level() {
264        let tool = LogTool;
265        let result = tool
266            .call(r#"{"level": "critical", "message": "Test"}"#.to_string())
267            .await;
268
269        assert!(result.is_err());
270        let err = result.unwrap_err();
271        assert!(err.to_string().contains("Invalid log level"));
272    }
273
274    #[tokio::test]
275    async fn test_log_invalid_json() {
276        let tool = LogTool;
277        let result = tool.call("not json".to_string()).await;
278
279        assert!(result.is_err());
280        let err = result.unwrap_err();
281        assert!(err.to_string().contains("Invalid JSON parameters"));
282    }
283
284    #[tokio::test]
285    async fn test_log_missing_level() {
286        let tool = LogTool;
287        let result = tool.call(r#"{"message": "Test"}"#.to_string()).await;
288
289        assert!(result.is_err());
290        let err = result.unwrap_err();
291        assert!(err.to_string().contains("Invalid JSON parameters"));
292    }
293
294    #[tokio::test]
295    async fn test_log_missing_message() {
296        let tool = LogTool;
297        let result = tool.call(r#"{"level": "info"}"#.to_string()).await;
298
299        assert!(result.is_err());
300        let err = result.unwrap_err();
301        assert!(err.to_string().contains("Invalid JSON parameters"));
302    }
303
304    #[tokio::test]
305    async fn test_log_case_insensitive_level() {
306        let tool = LogTool;
307
308        // Test uppercase
309        let result = tool
310            .call(r#"{"level": "INFO", "message": "Test"}"#.to_string())
311            .await;
312        assert!(result.is_ok());
313
314        // Test mixed case
315        let result = tool
316            .call(r#"{"level": "WaRn", "message": "Test"}"#.to_string())
317            .await;
318        assert!(result.is_ok());
319    }
320
321    #[tokio::test]
322    async fn test_log_warning_alias() {
323        let tool = LogTool;
324        let result = tool
325            .call(r#"{"level": "warning", "message": "Test warning"}"#.to_string())
326            .await;
327
328        assert!(result.is_ok());
329        let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
330        assert_eq!(response["level"], "warn"); // Normalized to "warn"
331    }
332}