spotify_cli/
logging.rs

1//! Structured logging configuration.
2//!
3//! Supports both human-readable and JSON output formats with configurable verbosity.
4
5use tracing_subscriber::{EnvFilter, fmt};
6
7/// Log output format.
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
9pub enum LogFormat {
10    /// Human-readable colored output (default)
11    #[default]
12    Pretty,
13    /// JSON structured output for machine parsing
14    Json,
15}
16
17impl std::str::FromStr for LogFormat {
18    type Err = String;
19
20    fn from_str(s: &str) -> Result<Self, Self::Err> {
21        match s.to_lowercase().as_str() {
22            "pretty" | "text" | "human" => Ok(LogFormat::Pretty),
23            "json" => Ok(LogFormat::Json),
24            _ => Err(format!("Invalid log format: {}. Use 'pretty' or 'json'", s)),
25        }
26    }
27}
28
29impl std::fmt::Display for LogFormat {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            LogFormat::Pretty => write!(f, "pretty"),
33            LogFormat::Json => write!(f, "json"),
34        }
35    }
36}
37
38/// Logging configuration.
39#[derive(Debug, Clone)]
40pub struct LogConfig {
41    /// Verbosity level (0=warn, 1=info, 2=debug, 3+=trace)
42    pub verbosity: u8,
43    /// Output format
44    pub format: LogFormat,
45}
46
47impl Default for LogConfig {
48    fn default() -> Self {
49        Self {
50            verbosity: 0,
51            format: LogFormat::Pretty,
52        }
53    }
54}
55
56impl LogConfig {
57    /// Create a new log config with given verbosity.
58    pub fn new(verbosity: u8) -> Self {
59        Self {
60            verbosity,
61            format: LogFormat::Pretty,
62        }
63    }
64
65    /// Set the output format.
66    pub fn format(mut self, format: LogFormat) -> Self {
67        self.format = format;
68        self
69    }
70
71    /// Get the tracing filter level based on verbosity.
72    fn filter(&self) -> EnvFilter {
73        let level = match self.verbosity {
74            0 => "warn",
75            1 => "info",
76            2 => "debug",
77            _ => "trace",
78        };
79        EnvFilter::new(level)
80    }
81
82    /// Initialize the global tracing subscriber.
83    ///
84    /// Should be called once at application startup.
85    pub fn init(self) {
86        let filter = self.filter();
87
88        match self.format {
89            LogFormat::Pretty => {
90                fmt()
91                    .with_env_filter(filter)
92                    .with_target(false)
93                    .without_time()
94                    .init();
95            }
96            LogFormat::Json => {
97                fmt()
98                    .with_env_filter(filter)
99                    .json()
100                    .with_current_span(true)
101                    .init();
102            }
103        }
104    }
105}
106
107/// Helper macro for structured command logging.
108///
109/// Use at the start of command handlers to log command execution.
110#[macro_export]
111macro_rules! log_command {
112    ($cmd:expr) => {
113        tracing::info!(command = $cmd, "Executing command");
114    };
115    ($cmd:expr, $($field:tt)*) => {
116        tracing::info!(command = $cmd, $($field)*, "Executing command");
117    };
118}
119
120/// Helper macro for logging command completion with timing.
121#[macro_export]
122macro_rules! log_command_complete {
123    ($cmd:expr, $duration_ms:expr) => {
124        tracing::info!(command = $cmd, duration_ms = $duration_ms, "Command completed");
125    };
126    ($cmd:expr, $duration_ms:expr, $($field:tt)*) => {
127        tracing::info!(command = $cmd, duration_ms = $duration_ms, $($field)*, "Command completed");
128    };
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn log_format_from_str() {
137        assert_eq!("pretty".parse::<LogFormat>().unwrap(), LogFormat::Pretty);
138        assert_eq!("text".parse::<LogFormat>().unwrap(), LogFormat::Pretty);
139        assert_eq!("human".parse::<LogFormat>().unwrap(), LogFormat::Pretty);
140        assert_eq!("json".parse::<LogFormat>().unwrap(), LogFormat::Json);
141        assert_eq!("JSON".parse::<LogFormat>().unwrap(), LogFormat::Json);
142        assert!("invalid".parse::<LogFormat>().is_err());
143    }
144
145    #[test]
146    fn log_format_display() {
147        assert_eq!(LogFormat::Pretty.to_string(), "pretty");
148        assert_eq!(LogFormat::Json.to_string(), "json");
149    }
150
151    #[test]
152    fn log_config_builder() {
153        let config = LogConfig::new(2).format(LogFormat::Json);
154        assert_eq!(config.verbosity, 2);
155        assert_eq!(config.format, LogFormat::Json);
156    }
157
158    #[test]
159    fn log_config_default() {
160        let config = LogConfig::default();
161        assert_eq!(config.verbosity, 0);
162        assert_eq!(config.format, LogFormat::Pretty);
163    }
164
165    #[test]
166    fn log_format_default_is_pretty() {
167        assert_eq!(LogFormat::default(), LogFormat::Pretty);
168    }
169
170    #[test]
171    fn log_format_from_str_error_message() {
172        let result = "invalid".parse::<LogFormat>();
173        assert!(result.is_err());
174        let err = result.unwrap_err();
175        assert!(err.contains("invalid"));
176        assert!(err.contains("pretty"));
177        assert!(err.contains("json"));
178    }
179
180    #[test]
181    fn log_config_new_sets_verbosity() {
182        assert_eq!(LogConfig::new(0).verbosity, 0);
183        assert_eq!(LogConfig::new(1).verbosity, 1);
184        assert_eq!(LogConfig::new(3).verbosity, 3);
185    }
186
187    #[test]
188    fn log_config_format_method_is_chainable() {
189        let config = LogConfig::new(1).format(LogFormat::Json);
190        assert_eq!(config.format, LogFormat::Json);
191    }
192
193    #[test]
194    fn log_config_clone() {
195        let config = LogConfig::new(2).format(LogFormat::Json);
196        let cloned = config.clone();
197        assert_eq!(cloned.verbosity, 2);
198        assert_eq!(cloned.format, LogFormat::Json);
199    }
200
201    #[test]
202    fn log_format_copy() {
203        let format = LogFormat::Json;
204        let copied = format;
205        assert_eq!(copied, LogFormat::Json);
206    }
207
208    #[test]
209    fn log_format_eq() {
210        assert_eq!(LogFormat::Pretty, LogFormat::Pretty);
211        assert_eq!(LogFormat::Json, LogFormat::Json);
212        assert_ne!(LogFormat::Pretty, LogFormat::Json);
213    }
214
215    #[test]
216    fn log_format_debug() {
217        assert!(!format!("{:?}", LogFormat::Pretty).is_empty());
218        assert!(!format!("{:?}", LogFormat::Json).is_empty());
219    }
220
221    #[test]
222    fn log_config_debug() {
223        let config = LogConfig::default();
224        let debug = format!("{:?}", config);
225        assert!(debug.contains("LogConfig"));
226    }
227}