dm_database_sqllog2db/
logging.rs1use crate::config::{LOG_LEVELS, LoggingConfig};
2use crate::error::{Error, FileError, Result};
3use log::{Level, LevelFilter, Metadata, Record};
4use std::collections::HashMap;
5use std::fmt::Write as FmtWrite;
6use std::fs::OpenOptions;
7use std::io::Write as IoWrite;
8use std::path::Path;
9use std::sync::{LazyLock, Mutex};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12static LOG_LEVEL_MAP: LazyLock<HashMap<&'static str, LevelFilter>> = LazyLock::new(|| {
14 let mut map = HashMap::with_capacity(5);
15 map.insert("trace", LevelFilter::Trace);
16 map.insert("debug", LevelFilter::Debug);
17 map.insert("info", LevelFilter::Info);
18 map.insert("warn", LevelFilter::Warn);
19 map.insert("error", LevelFilter::Error);
20 map
21});
22
23#[allow(clippy::cast_possible_wrap)]
29fn format_utc_timestamp() -> String {
30 let secs = SystemTime::now()
31 .duration_since(UNIX_EPOCH)
32 .unwrap_or_default()
33 .as_secs();
34
35 let days = (secs / 86400) as i64;
36 let rem = (secs % 86400) as i64;
37
38 let hours = rem / 3600;
39 let rem = rem % 3600;
40 let mins = rem / 60;
41 let secs_part = rem % 60;
42
43 let z = days + 719_468;
45 let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
46 let doe = z - era * 146_097;
47 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
48 let y = yoe + era * 400;
49 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
50 let mp = (5 * doy + 2) / 153;
51 let d = doy - (153 * mp + 2) / 5 + 1;
52 let m = mp + if mp < 10 { 3 } else { -9 };
53 let y = y + i64::from(m <= 2);
54
55 let mut buf = String::with_capacity(19);
56 write!(
57 buf,
58 "{y:04}-{m:02}-{d:02} {hours:02}:{mins:02}:{secs_part:02}",
59 )
60 .unwrap(); buf
62}
63
64pub fn init_logging(config: &LoggingConfig, log_to_stdout: bool) -> Result<()> {
69 let level = parse_log_level(&config.level)?;
71 let log_path = Path::new(&config.file);
73 let parent_dir = log_path.parent().ok_or_else(|| {
74 Error::File(FileError::CreateDirectoryFailed {
75 path: log_path.to_path_buf(),
76 reason: "Failed to get parent directory".to_string(),
77 })
78 })?;
79
80 if !parent_dir.exists() {
82 std::fs::create_dir_all(parent_dir).map_err(|e| {
83 Error::File(FileError::CreateDirectoryFailed {
84 path: parent_dir.to_path_buf(),
85 reason: e.to_string(),
86 })
87 })?;
88 }
89
90 let file = OpenOptions::new()
91 .create(true)
92 .append(true)
93 .open(log_path)
94 .map_err(|e| {
95 Error::File(FileError::CreateDirectoryFailed {
96 path: log_path.to_path_buf(),
97 reason: e.to_string(),
98 })
99 })?;
100
101 struct SimpleLogger {
103 level: LevelFilter,
104 file: Mutex<std::fs::File>,
105 log_to_stdout: bool,
106 }
107
108 impl log::Log for SimpleLogger {
109 fn enabled(&self, metadata: &Metadata) -> bool {
110 match self.level {
111 LevelFilter::Off => false,
112 LevelFilter::Error => metadata.level() == Level::Error,
113 LevelFilter::Warn => metadata.level() <= Level::Warn,
114 LevelFilter::Info => metadata.level() <= Level::Info,
115 LevelFilter::Debug => metadata.level() <= Level::Debug,
116 LevelFilter::Trace => true,
117 }
118 }
119
120 fn log(&self, record: &Record) {
121 if !self.enabled(record.metadata()) {
122 return;
123 }
124 let now = format_utc_timestamp();
125 let msg = format!(
126 "[{}][{}] {} - {}\n",
127 now,
128 record.level(),
129 record.target(),
130 record.args()
131 );
132 if self.log_to_stdout {
133 let _ = std::io::stdout().write_all(msg.as_bytes());
134 }
135
136 if let Ok(mut f) = self.file.lock() {
138 let _ = f.write_all(msg.as_bytes());
139 }
140 }
141
142 fn flush(&self) {}
143 }
144
145 let logger = SimpleLogger {
146 level,
147 file: Mutex::new(file),
148 log_to_stdout,
149 };
150
151 match log::set_boxed_logger(Box::new(logger)) {
153 Ok(()) => {
154 log::set_max_level(level);
155 }
156 Err(_) => {
157 eprintln!(
158 "warning: logging already initialized; config {:?} ignored",
159 config.file
160 );
161 }
162 }
163
164 log::info!(
165 "Logging initialized - level: {:?}, file: {}, retention_days: {}",
166 level,
167 config.file,
168 config.retention_days
169 );
170
171 Ok(())
172}
173
174fn parse_log_level(level_str: &str) -> Result<LevelFilter> {
176 let lower = level_str.to_lowercase();
177 LOG_LEVEL_MAP.get(lower.as_str()).copied().ok_or_else(|| {
178 Error::Config(crate::error::ConfigError::InvalidLogLevel {
179 level: level_str.to_string(),
180 valid_levels: LOG_LEVELS.iter().map(|s| (*s).to_string()).collect(),
181 })
182 })
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::config::LoggingConfig;
189
190 fn make_logging_config(dir: &std::path::Path, level: &str) -> LoggingConfig {
191 LoggingConfig {
192 file: dir.join("app.log").to_str().unwrap().to_string(),
193 level: level.to_string(),
194 retention_days: 7,
195 }
196 }
197
198 #[test]
199 fn test_init_logging_valid_level_info() {
200 let dir = tempfile::TempDir::new().unwrap();
201 let cfg = make_logging_config(dir.path(), "info");
202 let result = init_logging(&cfg, false);
204 assert!(result.is_ok());
205 assert!(dir.path().join("app.log").exists());
206 }
207
208 #[test]
209 fn test_init_logging_invalid_level_returns_error() {
210 let dir = tempfile::TempDir::new().unwrap();
211 let cfg = make_logging_config(dir.path(), "nonsense_level");
212 let result = init_logging(&cfg, false);
213 assert!(result.is_err());
214 }
215
216 #[test]
217 fn test_init_logging_creates_parent_dir() {
218 let dir = tempfile::TempDir::new().unwrap();
219 let nested = dir.path().join("sub/nested");
220 let cfg = LoggingConfig {
221 file: nested.join("app.log").to_str().unwrap().to_string(),
222 level: "warn".to_string(),
223 retention_days: 7,
224 };
225 let result = init_logging(&cfg, false);
226 assert!(result.is_ok());
227 assert!(nested.exists());
228 }
229
230 #[test]
231 fn test_init_logging_all_valid_levels() {
232 let dir = tempfile::TempDir::new().unwrap();
233 for level in &["trace", "debug", "info", "warn", "error"] {
234 let cfg = LoggingConfig {
235 file: dir
236 .path()
237 .join(format!("{level}.log"))
238 .to_str()
239 .unwrap()
240 .to_string(),
241 level: (*level).to_string(),
242 retention_days: 7,
243 };
244 assert!(init_logging(&cfg, false).is_ok());
245 }
246 }
247
248 #[test]
249 fn test_init_logging_with_stdout() {
250 let dir = tempfile::TempDir::new().unwrap();
251 let cfg = make_logging_config(dir.path(), "warn");
252 let result = init_logging(&cfg, true);
254 assert!(result.is_ok());
255 }
256
257 #[test]
258 fn test_parse_log_level_all() {
259 for level in &["trace", "debug", "info", "warn", "error"] {
260 assert!(parse_log_level(level).is_ok());
261 }
262 }
263
264 #[test]
265 fn test_parse_log_level_uppercase() {
266 assert!(parse_log_level("INFO").is_ok());
268 assert!(parse_log_level("DEBUG").is_ok());
269 }
270
271 #[test]
272 fn test_parse_log_level_invalid() {
273 assert!(parse_log_level("verbose").is_err());
274 assert!(parse_log_level("").is_err());
275 }
276}