Skip to main content

nika_core/ast/
logging.rs

1//! Logging configuration
2//!
3//! Provides level-filtered logging for workflows and tasks.
4//!
5//! # YAML Syntax
6//!
7//! ```yaml
8//! # Workflow-level defaults
9//! log:
10//!   level: info
11//!   console: true
12//!   file: ./logs/{{context.meta.workflow}}.log
13//!
14//! tasks:
15//!   # Task-specific override
16//!   - id: verbose_task
17//!     log:
18//!       level: debug
19//!       console: false
20//!       file: ./logs/verbose.log
21//! ```
22
23use serde::{Deserialize, Serialize};
24
25// ═══════════════════════════════════════════════════════════════════════════
26// LOG FORMAT
27// ═══════════════════════════════════════════════════════════════════════════
28
29/// Log output format
30#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq, Hash)]
31#[serde(rename_all = "lowercase")]
32pub enum LogFormat {
33    /// Plain text output (default)
34    #[default]
35    Text,
36
37    /// JSON structured output
38    Json,
39}
40
41impl std::fmt::Display for LogFormat {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            Self::Text => write!(f, "text"),
45            Self::Json => write!(f, "json"),
46        }
47    }
48}
49
50// ═══════════════════════════════════════════════════════════════════════════
51// LOG CONFIG
52// ═══════════════════════════════════════════════════════════════════════════
53
54/// Logging configuration for workflow or task
55#[derive(Debug, Clone, Deserialize, Serialize)]
56pub struct LogConfig {
57    /// Minimum log level
58    #[serde(default)]
59    pub level: LogLevel,
60
61    /// Log output format (text or json)
62    #[serde(default)]
63    pub format: LogFormat,
64
65    /// Show in console output
66    #[serde(default = "default_true")]
67    pub console: bool,
68
69    /// Optional log file path (supports context.meta.* templates)
70    #[serde(default)]
71    pub file: Option<String>,
72}
73
74impl Default for LogConfig {
75    fn default() -> Self {
76        Self {
77            level: LogLevel::default(),
78            format: LogFormat::default(),
79            console: true,
80            file: None,
81        }
82    }
83}
84
85fn default_true() -> bool {
86    true
87}
88
89// ═══════════════════════════════════════════════════════════════════════════
90// LOG LEVEL
91// ═══════════════════════════════════════════════════════════════════════════
92
93/// Log levels
94///
95/// Ordered from most verbose to least:
96/// Trace < Debug < Info < Warn < Error
97#[derive(
98    Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq, PartialOrd, Ord, Hash,
99)]
100#[serde(rename_all = "lowercase")]
101pub enum LogLevel {
102    /// Trace messages (most verbose)
103    Trace,
104
105    /// Debug messages
106    Debug,
107
108    /// Informational messages (default)
109    #[default]
110    Info,
111
112    /// Warning messages
113    Warn,
114
115    /// Error messages (least verbose)
116    Error,
117}
118
119impl std::fmt::Display for LogLevel {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        match self {
122            Self::Trace => write!(f, "trace"),
123            Self::Debug => write!(f, "debug"),
124            Self::Info => write!(f, "info"),
125            Self::Warn => write!(f, "warn"),
126            Self::Error => write!(f, "error"),
127        }
128    }
129}
130
131impl LogLevel {
132    /// Check if this level should be logged given a minimum level
133    ///
134    /// # Example
135    ///
136    /// ```ignore
137    /// let min = LogLevel::Warn;
138    /// assert!(!LogLevel::Debug.should_log(min)); // Debug < Warn
139    /// assert!(!LogLevel::Info.should_log(min));  // Info < Warn
140    /// assert!(LogLevel::Warn.should_log(min));   // Warn >= Warn
141    /// assert!(LogLevel::Error.should_log(min));  // Error > Warn
142    /// ```
143    pub fn should_log(&self, min_level: LogLevel) -> bool {
144        *self >= min_level
145    }
146}
147
148// ═══════════════════════════════════════════════════════════════════════════
149// TESTS
150// ═══════════════════════════════════════════════════════════════════════════
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::serde_yaml;
156
157    #[test]
158    fn test_parse_log_config_full() {
159        let yaml = r#"
160level: debug
161console: false
162file: ./logs/workflow.log
163"#;
164        let config: LogConfig = serde_yaml::from_str(yaml).unwrap();
165        assert_eq!(config.level, LogLevel::Debug);
166        assert!(!config.console);
167        assert_eq!(config.file, Some("./logs/workflow.log".to_string()));
168    }
169
170    #[test]
171    fn test_parse_log_config_minimal() {
172        let yaml = "level: warn";
173        let config: LogConfig = serde_yaml::from_str(yaml).unwrap();
174        assert_eq!(config.level, LogLevel::Warn);
175        assert!(config.console); // default true
176        assert_eq!(config.file, None);
177    }
178
179    #[test]
180    fn test_log_config_defaults() {
181        let config = LogConfig::default();
182        assert_eq!(config.level, LogLevel::Info);
183        assert_eq!(config.format, LogFormat::Text);
184        assert!(config.console);
185        assert_eq!(config.file, None);
186    }
187
188    #[test]
189    fn test_log_level_ordering() {
190        assert!(LogLevel::Debug < LogLevel::Info);
191        assert!(LogLevel::Info < LogLevel::Warn);
192        assert!(LogLevel::Warn < LogLevel::Error);
193    }
194
195    #[test]
196    fn test_log_level_should_log() {
197        let min = LogLevel::Warn;
198
199        assert!(!LogLevel::Debug.should_log(min));
200        assert!(!LogLevel::Info.should_log(min));
201        assert!(LogLevel::Warn.should_log(min));
202        assert!(LogLevel::Error.should_log(min));
203    }
204
205    #[test]
206    fn test_log_level_display() {
207        assert_eq!(LogLevel::Trace.to_string(), "trace");
208        assert_eq!(LogLevel::Debug.to_string(), "debug");
209        assert_eq!(LogLevel::Info.to_string(), "info");
210        assert_eq!(LogLevel::Warn.to_string(), "warn");
211        assert_eq!(LogLevel::Error.to_string(), "error");
212    }
213
214    #[test]
215    fn test_log_format_display() {
216        assert_eq!(LogFormat::Text.to_string(), "text");
217        assert_eq!(LogFormat::Json.to_string(), "json");
218    }
219
220    #[test]
221    fn test_parse_log_format_json() {
222        let yaml = r#"
223level: debug
224format: json
225console: true
226"#;
227        let config: LogConfig = serde_yaml::from_str(yaml).unwrap();
228        assert_eq!(config.format, LogFormat::Json);
229    }
230
231    #[test]
232    fn test_parse_log_level_trace() {
233        let yaml = "level: trace";
234        let config: LogConfig = serde_yaml::from_str(yaml).unwrap();
235        assert_eq!(config.level, LogLevel::Trace);
236    }
237
238    #[test]
239    fn test_trace_should_log() {
240        // Trace is most verbose, should log at all levels
241        assert!(LogLevel::Trace.should_log(LogLevel::Trace));
242        assert!(!LogLevel::Trace.should_log(LogLevel::Debug));
243        assert!(!LogLevel::Trace.should_log(LogLevel::Info));
244    }
245
246    #[test]
247    fn test_log_config_with_template() {
248        let yaml = r#"
249level: info
250file: ./logs/{{context.meta.workflow}}-{{context.meta.date}}.log
251"#;
252        let config: LogConfig = serde_yaml::from_str(yaml).unwrap();
253        assert!(config
254            .file
255            .as_ref()
256            .unwrap()
257            .contains("context.meta.workflow"));
258        assert!(config.file.as_ref().unwrap().contains("context.meta.date"));
259    }
260}