1use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5use tracing_appender::rolling::{RollingFileAppender, Rotation};
6use tracing_subscriber::{
7 EnvFilter,
8 fmt::{self, format::FmtSpan},
9 layer::SubscriberExt,
10 util::SubscriberInitExt,
11};
12
13use crate::error::{TelemetryError, TelemetryResult};
14
15fn init_err<E: std::fmt::Display>(e: E) -> TelemetryError {
17 TelemetryError::InitError(e.to_string())
18}
19
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum FileRotation {
24 #[default]
26 Daily,
27 Hourly,
29 Minutely,
31 Never,
33}
34
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum LogFormat {
39 #[default]
41 Pretty,
42 Compact,
44 Json,
46 Full,
48}
49
50#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum LogTarget {
54 Stdout,
56 #[default]
58 Stderr,
59 File(PathBuf),
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct FileLogConfig {
66 pub directory: PathBuf,
68 #[serde(default = "default_file_prefix")]
70 pub prefix: String,
71 #[serde(default)]
73 pub rotation: FileRotation,
74 #[serde(default)]
76 pub max_files: usize,
77}
78
79fn default_file_prefix() -> String {
80 "astrid".to_string()
81}
82
83impl Default for FileLogConfig {
84 fn default() -> Self {
85 Self {
86 directory: PathBuf::from("logs"),
87 prefix: default_file_prefix(),
88 rotation: FileRotation::default(),
89 max_files: 0,
90 }
91 }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96#[expect(clippy::struct_excessive_bools)]
97pub struct LogConfig {
98 #[serde(default = "default_level")]
100 pub level: String,
101 #[serde(default)]
103 pub format: LogFormat,
104 #[serde(default)]
106 pub target: LogTarget,
107 #[serde(default)]
109 pub file: FileLogConfig,
110 #[serde(default = "default_true")]
112 pub timestamps: bool,
113 #[serde(default)]
115 pub file_info: bool,
116 #[serde(default)]
118 pub thread_ids: bool,
119 #[serde(default)]
121 pub thread_names: bool,
122 #[serde(default)]
124 pub span_events: bool,
125 #[serde(default = "default_true")]
127 pub ansi: bool,
128 #[serde(default)]
130 pub directives: Vec<String>,
131}
132
133fn default_level() -> String {
134 "info".to_string()
135}
136
137fn default_true() -> bool {
138 true
139}
140
141impl Default for LogConfig {
142 fn default() -> Self {
143 Self {
144 level: default_level(),
145 format: LogFormat::default(),
146 target: LogTarget::default(),
147 file: FileLogConfig::default(),
148 timestamps: true,
149 file_info: false,
150 thread_ids: false,
151 thread_names: false,
152 span_events: false,
153 ansi: true,
154 directives: Vec::new(),
155 }
156 }
157}
158
159impl LogConfig {
160 #[must_use]
162 pub fn new(level: impl Into<String>) -> Self {
163 Self {
164 level: level.into(),
165 ..Default::default()
166 }
167 }
168
169 #[must_use]
171 pub fn with_format(mut self, format: LogFormat) -> Self {
172 self.format = format;
173 self
174 }
175
176 #[must_use]
178 pub fn with_target(mut self, target: LogTarget) -> Self {
179 self.target = target;
180 self
181 }
182
183 #[must_use]
185 pub fn with_file_logging(
186 mut self,
187 directory: impl Into<PathBuf>,
188 prefix: impl Into<String>,
189 ) -> Self {
190 self.target = LogTarget::File(directory.into());
191 self.file.prefix = prefix.into();
192 self.file.rotation = FileRotation::Daily;
193 self.ansi = false;
195 self
196 }
197
198 #[must_use]
200 pub fn with_file_logging_rotation(
201 mut self,
202 directory: impl Into<PathBuf>,
203 prefix: impl Into<String>,
204 rotation: FileRotation,
205 ) -> Self {
206 self.target = LogTarget::File(directory.into());
207 self.file.prefix = prefix.into();
208 self.file.rotation = rotation;
209 self.ansi = false;
211 self
212 }
213
214 #[must_use]
216 pub fn with_directive(mut self, directive: impl Into<String>) -> Self {
217 self.directives.push(directive.into());
218 self
219 }
220
221 #[must_use]
223 pub fn without_timestamps(mut self) -> Self {
224 self.timestamps = false;
225 self
226 }
227
228 #[must_use]
230 pub fn with_file_info(mut self) -> Self {
231 self.file_info = true;
232 self
233 }
234
235 #[must_use]
237 pub fn with_span_events(mut self) -> Self {
238 self.span_events = true;
239 self
240 }
241
242 #[must_use]
244 pub fn without_ansi(mut self) -> Self {
245 self.ansi = false;
246 self
247 }
248
249 fn build_filter(&self) -> TelemetryResult<EnvFilter> {
251 let mut filter = EnvFilter::try_new(&self.level)
252 .map_err(|e| TelemetryError::ConfigError(e.to_string()))?;
253
254 for directive in &self.directives {
255 filter = filter.add_directive(directive.parse().map_err(
256 |e: tracing_subscriber::filter::ParseError| {
257 TelemetryError::ConfigError(e.to_string())
258 },
259 )?);
260 }
261
262 Ok(filter)
263 }
264
265 fn span_events(&self) -> FmtSpan {
267 if self.span_events {
268 FmtSpan::NEW | FmtSpan::CLOSE
269 } else {
270 FmtSpan::NONE
271 }
272 }
273}
274
275pub fn setup_logging(config: &LogConfig) -> TelemetryResult<()> {
281 let filter = config.build_filter()?;
282
283 match (&config.target, config.format) {
284 (LogTarget::Stdout, LogFormat::Json) => {
285 setup_json_logging(filter, config, std::io::stdout)?;
286 },
287 (LogTarget::Stdout, LogFormat::Pretty) => {
288 setup_pretty_logging(filter, config, std::io::stdout)?;
289 },
290 (LogTarget::Stdout, LogFormat::Compact) => {
291 setup_compact_logging(filter, config, std::io::stdout)?;
292 },
293 (LogTarget::Stdout, LogFormat::Full) => {
294 setup_full_logging(filter, config, std::io::stdout)?;
295 },
296 (LogTarget::Stderr, LogFormat::Json) => {
297 setup_json_logging(filter, config, std::io::stderr)?;
298 },
299 (LogTarget::Stderr, LogFormat::Pretty) => {
300 setup_pretty_logging(filter, config, std::io::stderr)?;
301 },
302 (LogTarget::Stderr, LogFormat::Compact) => {
303 setup_compact_logging(filter, config, std::io::stderr)?;
304 },
305 (LogTarget::Stderr, LogFormat::Full) => {
306 setup_full_logging(filter, config, std::io::stderr)?;
307 },
308 (LogTarget::File(dir), format) => {
309 std::fs::create_dir_all(dir).map_err(|e| {
311 TelemetryError::ConfigError(format!("failed to create log directory: {e}"))
312 })?;
313
314 let rotation = match config.file.rotation {
315 FileRotation::Daily => Rotation::DAILY,
316 FileRotation::Hourly => Rotation::HOURLY,
317 FileRotation::Minutely => Rotation::MINUTELY,
318 FileRotation::Never => Rotation::NEVER,
319 };
320
321 let appender = RollingFileAppender::new(rotation, dir, &config.file.prefix);
322
323 match format {
324 LogFormat::Json => setup_json_logging(filter, config, appender)?,
325 LogFormat::Pretty => setup_pretty_logging(filter, config, appender)?,
326 LogFormat::Compact => setup_compact_logging(filter, config, appender)?,
327 LogFormat::Full => setup_full_logging(filter, config, appender)?,
328 }
329 },
330 }
331
332 Ok(())
333}
334
335fn setup_json_logging<W>(filter: EnvFilter, config: &LogConfig, writer: W) -> TelemetryResult<()>
336where
337 W: for<'a> tracing_subscriber::fmt::MakeWriter<'a> + Send + Sync + 'static,
338{
339 let layer = fmt::layer()
340 .json()
341 .with_writer(writer)
342 .with_file(config.file_info)
343 .with_line_number(config.file_info)
344 .with_thread_ids(config.thread_ids)
345 .with_thread_names(config.thread_names)
346 .with_span_events(config.span_events());
347
348 if config.timestamps {
349 tracing_subscriber::registry()
350 .with(filter)
351 .with(layer)
352 .try_init()
353 .map_err(init_err)
354 } else {
355 tracing_subscriber::registry()
356 .with(filter)
357 .with(layer.without_time())
358 .try_init()
359 .map_err(init_err)
360 }
361}
362
363fn setup_pretty_logging<W>(filter: EnvFilter, config: &LogConfig, writer: W) -> TelemetryResult<()>
364where
365 W: for<'a> tracing_subscriber::fmt::MakeWriter<'a> + Send + Sync + 'static,
366{
367 let layer = fmt::layer()
368 .pretty()
369 .with_writer(writer)
370 .with_ansi(config.ansi)
371 .with_file(config.file_info)
372 .with_line_number(config.file_info)
373 .with_thread_ids(config.thread_ids)
374 .with_thread_names(config.thread_names)
375 .with_span_events(config.span_events());
376
377 if config.timestamps {
378 tracing_subscriber::registry()
379 .with(filter)
380 .with(layer)
381 .try_init()
382 .map_err(init_err)
383 } else {
384 tracing_subscriber::registry()
385 .with(filter)
386 .with(layer.without_time())
387 .try_init()
388 .map_err(init_err)
389 }
390}
391
392fn setup_compact_logging<W>(filter: EnvFilter, config: &LogConfig, writer: W) -> TelemetryResult<()>
393where
394 W: for<'a> tracing_subscriber::fmt::MakeWriter<'a> + Send + Sync + 'static,
395{
396 let layer = fmt::layer()
397 .compact()
398 .with_writer(writer)
399 .with_ansi(config.ansi)
400 .with_file(config.file_info)
401 .with_line_number(config.file_info)
402 .with_thread_ids(config.thread_ids)
403 .with_thread_names(config.thread_names)
404 .with_span_events(config.span_events());
405
406 if config.timestamps {
407 tracing_subscriber::registry()
408 .with(filter)
409 .with(layer)
410 .try_init()
411 .map_err(init_err)
412 } else {
413 tracing_subscriber::registry()
414 .with(filter)
415 .with(layer.without_time())
416 .try_init()
417 .map_err(init_err)
418 }
419}
420
421fn setup_full_logging<W>(filter: EnvFilter, config: &LogConfig, writer: W) -> TelemetryResult<()>
422where
423 W: for<'a> tracing_subscriber::fmt::MakeWriter<'a> + Send + Sync + 'static,
424{
425 let layer = fmt::layer()
426 .with_writer(writer)
427 .with_ansi(config.ansi)
428 .with_file(config.file_info)
429 .with_line_number(config.file_info)
430 .with_thread_ids(config.thread_ids)
431 .with_thread_names(config.thread_names)
432 .with_span_events(config.span_events());
433
434 if config.timestamps {
435 tracing_subscriber::registry()
436 .with(filter)
437 .with(layer)
438 .try_init()
439 .map_err(init_err)
440 } else {
441 tracing_subscriber::registry()
442 .with(filter)
443 .with(layer.without_time())
444 .try_init()
445 .map_err(init_err)
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 #[test]
454 fn test_log_config_default() {
455 let config = LogConfig::default();
456 assert_eq!(config.level, "info");
457 assert_eq!(config.format, LogFormat::Pretty);
458 assert!(config.timestamps);
459 assert!(config.ansi);
460 }
461
462 #[test]
463 fn test_log_config_builder() {
464 let config = LogConfig::new("debug")
465 .with_format(LogFormat::Json)
466 .without_timestamps()
467 .with_file_info()
468 .with_directive("astrid_mcp=trace");
469
470 assert_eq!(config.level, "debug");
471 assert_eq!(config.format, LogFormat::Json);
472 assert!(!config.timestamps);
473 assert!(config.file_info);
474 assert_eq!(config.directives, vec!["astrid_mcp=trace"]);
475 }
476
477 #[test]
478 fn test_log_config_serialization() {
479 let config = LogConfig::new("warn").with_format(LogFormat::Compact);
480
481 let json = serde_json::to_string(&config).unwrap();
482 assert!(json.contains("\"level\":\"warn\""));
483 assert!(json.contains("\"format\":\"compact\""));
484
485 let parsed: LogConfig = serde_json::from_str(&json).unwrap();
486 assert_eq!(parsed.level, "warn");
487 assert_eq!(parsed.format, LogFormat::Compact);
488 }
489
490 #[test]
491 fn test_build_filter() {
492 let config = LogConfig::new("debug").with_directive("astrid=trace");
493
494 let filter = config.build_filter();
495 assert!(filter.is_ok());
496 }
497
498 #[test]
499 fn test_build_filter_invalid() {
500 let config = LogConfig::new("debug").with_directive("[invalid=syntax");
502
503 let filter = config.build_filter();
504 assert!(filter.is_err());
505 }
506}