Skip to main content

dm_database_sqllog2db/
logging.rs

1use 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
12// 使用 LazyLock 缓存日志级别映射表,避免每次查找时重新构建
13static 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/// Manual UTC timestamp formatter using `std::time::SystemTime`.
24///
25/// Replaces an external datetime dependency with a pure-std implementation.
26/// Uses Howard Hinnant's civil-from-days algorithm to convert Unix seconds
27/// to year/month/day without any external crates.
28#[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    // civil_from_days: days since Unix epoch (1970-01-01) → year/month/day
44    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(); // infallible: writing to a String never fails
61    buf
62}
63
64/// 初始化日志系统
65///
66/// `log_to_stdout`: 是否同时向 stdout 输出日志。进度条模式下应传 `false`,
67/// 避免日志输出干扰进度条渲染。
68pub fn init_logging(config: &LoggingConfig, log_to_stdout: bool) -> Result<()> {
69    // 解析日志级别
70    let level = parse_log_level(&config.level)?;
71    // 获取日志文件路径和目录
72    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    // 创建日志目录(如果不存在)
81    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    // 自定义简单 Logger,写入文件,可选同时输出到 stdout
102    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            // 写到文件
137            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    // 注册 logger
152    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
174/// 解析日志级别字符串
175fn 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        // May fail silently if logger already registered in this process; that is intentional
203        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        // log_to_stdout=true exercises the stdout write path in SimpleLogger::log
253        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        // parse_log_level lowercases, so uppercase should also work
267        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}