Skip to main content

wp_log/
conf.rs

1use getset::Getters;
2use getset::WithSetters;
3use log::LevelFilter;
4use log4rs::append::console::ConsoleAppender;
5use log4rs::append::rolling_file::RollingFileAppender;
6use log4rs::append::rolling_file::policy::compound::CompoundPolicy;
7use log4rs::append::rolling_file::policy::compound::roll::fixed_window::FixedWindowRoller;
8use log4rs::append::rolling_file::policy::compound::trigger::size::SizeTrigger;
9use log4rs::config::{Appender, Config, Logger, Root};
10use log4rs::encode::pattern::PatternEncoder;
11use orion_conf::ErrorWith;
12use orion_conf::ToStructError;
13use orion_conf::error::ConfIOReason;
14#[cfg(feature = "std")]
15use orion_conf::error::OrionConfResult;
16use orion_error::conversion::SourceErr;
17use serde::{Deserialize, Serialize};
18use std::collections::BTreeMap;
19use std::fmt::{Display, Formatter};
20use std::path::PathBuf;
21use std::str::FromStr;
22use strum_macros::Display;
23#[derive(PartialEq, Deserialize, Serialize, Clone, Debug)]
24#[serde(deny_unknown_fields)]
25pub struct FileLogConf {
26    pub path: String,
27}
28
29#[derive(PartialEq, Deserialize, Serialize, Clone, Debug, WithSetters, Getters)]
30#[serde(deny_unknown_fields)]
31#[get = "pub"]
32pub struct LogConf {
33    pub level: String,
34    #[serde(default)]
35    pub levels: Option<BTreeMap<String, String>>, // structured levels: { global="warn", ctrl="info", ... }
36    #[set_with = "pub"]
37    pub output: Output,
38    #[serde(default)]
39    pub file: Option<FileLogConf>, // required when output has File/Both
40}
41
42#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize, Display)]
43pub enum Output {
44    Console,
45    File,
46    Both,
47}
48
49impl Default for LogConf {
50    fn default() -> Self {
51        LogConf {
52            level: String::from("warn,ctrl=info,data=error,matrc=error,dfx=warn,kdb=warn"),
53            levels: None,
54            output: Output::File,
55            file: Some(FileLogConf {
56                path: "./data/logs/".to_string(),
57            }),
58        }
59    }
60}
61
62impl Display for LogConf {
63    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
64        if let Some(map) = &self.levels {
65            // print structured levels first if present
66            writeln!(f, "levels: {:?}", map)?;
67        } else {
68            writeln!(f, "level: {}", self.level)?;
69        }
70        writeln!(f, "output: {}", self.output)?;
71        writeln!(f, "path: {:?}", self.file.as_ref().map(|x| x.path.clone()))
72    }
73}
74// chk_default 已移除:不再区分校验日志与运行日志
75
76impl FromStr for LogConf {
77    type Err = anyhow::Error;
78    fn from_str(debug: &str) -> Result<Self, Self::Err> {
79        Ok(LogConf {
80            level: debug.to_string(),
81            levels: None,
82            output: Output::File,
83            file: Some(FileLogConf {
84                path: "./logs".to_string(),
85            }),
86        })
87    }
88}
89
90impl LogConf {
91    pub fn log_to_console(debug: &str) -> Self {
92        LogConf {
93            level: debug.to_string(),
94            levels: None,
95            output: Output::Console,
96            file: None,
97        }
98    }
99}
100
101pub const PRINT_STAT: &str = "PRINT_STAT";
102
103#[cfg(feature = "std")]
104pub fn log_init(conf: &LogConf) -> OrionConfResult<()> {
105    let (root_level, target_levels) = parse_level_spec(&conf.level)?;
106
107    // Encoder: timestamp + [LEVEL] + [target] + message; no module path/line
108    let enc = PatternEncoder::new("{d(%Y-%m-%d %H:%M:%S.%f)} [{l:5}] [{t:7}] {m}{n}");
109
110    let mut config = Config::builder();
111    let mut root = Root::builder();
112
113    // structured levels(levels)在显示时已覆盖;解析仍走 level 字符串
114    match conf.output {
115        Output::Console => {
116            let stdout = ConsoleAppender::builder().encoder(Box::new(enc)).build();
117            config = config.appender(Appender::builder().build("stdout", Box::new(stdout)));
118            root = root.appender("stdout");
119        }
120        Output::File => {
121            let file_path = resolve_log_file(conf)?;
122            // Ensure parent dir exists
123            if let Some(p) = std::path::Path::new(&file_path).parent() {
124                let _ = std::fs::create_dir_all(p);
125            }
126            // Rolling: 10MB, keep 10 files, gzip
127            let pattern = format!("{}.{{}}.gz", &file_path);
128            let roller = FixedWindowRoller::builder()
129                .base(0)
130                .build(&pattern, 10)
131                .source_err(ConfIOReason::logic_error(), pattern.as_str())?;
132            let trigger = SizeTrigger::new(10 * 1024 * 1024);
133            let policy = CompoundPolicy::new(Box::new(trigger), Box::new(roller));
134            let file = RollingFileAppender::builder()
135                .encoder(Box::new(enc))
136                .build(&file_path, Box::new(policy))
137                .source_err(ConfIOReason::resource_error(), file_path.as_str())?;
138            config = config.appender(Appender::builder().build("file", Box::new(file)));
139            root = root.appender("file");
140        }
141        Output::Both => {
142            let file_path = resolve_log_file(conf)?;
143            if let Some(p) = std::path::Path::new(&file_path).parent() {
144                let _ = std::fs::create_dir_all(p);
145            }
146            let stdout = ConsoleAppender::builder()
147                .encoder(Box::new(enc.clone()))
148                .build();
149            config = config.appender(Appender::builder().build("stdout", Box::new(stdout)));
150            let pattern = format!("{}.{{}}.gz", &file_path);
151            let roller = FixedWindowRoller::builder()
152                .base(0)
153                .build(&pattern, 10)
154                .source_err(ConfIOReason::logic_error(), pattern.as_str())?;
155            let trigger = SizeTrigger::new(10 * 1024 * 1024);
156            let policy = CompoundPolicy::new(Box::new(trigger), Box::new(roller));
157            let file = RollingFileAppender::builder()
158                .encoder(Box::new(enc))
159                .build(&file_path, Box::new(policy))
160                .source_err(ConfIOReason::resource_error(), file_path.as_str())?;
161            config = config.appender(Appender::builder().build("file", Box::new(file)));
162            root = root.appender("stdout").appender("file");
163        }
164    }
165
166    for (name, lv) in target_levels {
167        config = config.logger(Logger::builder().build(name.as_str(), lv));
168    }
169
170    let cfg = config
171        .build(root.build(root_level))
172        .map_err(|e| ConfIOReason::logic_error().to_err().with_source(e))
173        .doing("build log cfg")?;
174    log4rs::init_config(cfg)
175        .map_err(|e| ConfIOReason::logic_error().to_err().with_source(e))
176        .doing("init log config")?;
177    Ok(())
178}
179
180#[cfg(feature = "std")]
181pub fn log_for_test() -> OrionConfResult<()> {
182    let conf = LogConf {
183        level: "debug".into(),
184        levels: None,
185        output: Output::Console,
186        file: None,
187    };
188    log_init(&conf)
189}
190
191#[cfg(feature = "std")]
192pub fn log_for_test_level(level: &str) -> OrionConfResult<()> {
193    let conf = LogConf {
194        level: level.into(),
195        levels: None,
196        output: Output::Console,
197        file: None,
198    };
199    log_init(&conf)
200}
201
202fn parse_level_spec(spec: &str) -> OrionConfResult<(LevelFilter, Vec<(String, LevelFilter)>)> {
203    let mut root = LevelFilter::Info;
204    let mut targets = Vec::new();
205    for part in spec.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
206        if let Some((k, v)) = part.split_once('=') {
207            targets.push((k.trim().to_string(), parse_lv(v.trim())?));
208        } else {
209            root = parse_lv(part)?;
210        }
211    }
212    Ok((root, targets))
213}
214
215fn resolve_log_file(conf: &LogConf) -> OrionConfResult<String> {
216    let dir = conf
217        .file
218        .as_ref()
219        .map(|f| f.path.clone())
220        .unwrap_or_else(|| "./logs".to_string());
221    let arg0 = std::env::args().next().unwrap_or_else(|| "app".to_string());
222    let stem = std::path::Path::new(&arg0)
223        .file_stem()
224        .and_then(|s| s.to_str())
225        .unwrap_or("app");
226    let mut p = PathBuf::from(dir);
227    p.push(format!("{}.log", stem));
228    Ok(p.display().to_string())
229}
230
231fn parse_lv(s: &str) -> OrionConfResult<LevelFilter> {
232    match s.to_ascii_lowercase().as_str() {
233        "off" => Ok(LevelFilter::Off),
234        "error" => Ok(LevelFilter::Error),
235        "warn" => Ok(LevelFilter::Warn),
236        "info" => Ok(LevelFilter::Info),
237        "debug" => Ok(LevelFilter::Debug),
238        "trace" => Ok(LevelFilter::Trace),
239        _ => ConfIOReason::Other("unknow log level".into())
240            .err_result()
241            .doing(s),
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_output_display() {
251        assert_eq!(Output::Console.to_string(), "Console");
252        assert_eq!(Output::File.to_string(), "File");
253        assert_eq!(Output::Both.to_string(), "Both");
254    }
255
256    #[test]
257    fn test_output_serde() {
258        let console: Output = serde_json::from_str(r#""Console""#).unwrap();
259        assert_eq!(console, Output::Console);
260
261        let file: Output = serde_json::from_str(r#""File""#).unwrap();
262        assert_eq!(file, Output::File);
263
264        let both: Output = serde_json::from_str(r#""Both""#).unwrap();
265        assert_eq!(both, Output::Both);
266
267        assert_eq!(
268            serde_json::to_string(&Output::Console).unwrap(),
269            r#""Console""#
270        );
271        assert_eq!(serde_json::to_string(&Output::File).unwrap(), r#""File""#);
272        assert_eq!(serde_json::to_string(&Output::Both).unwrap(), r#""Both""#);
273    }
274
275    #[test]
276    fn test_log_conf_default() {
277        let conf = LogConf::default();
278        assert!(conf.level.contains("warn"));
279        assert!(conf.level.contains("ctrl=info"));
280        assert_eq!(conf.output, Output::File);
281        assert!(conf.file.is_some());
282        assert_eq!(conf.file.as_ref().unwrap().path, "./data/logs/");
283        assert!(conf.levels.is_none());
284    }
285
286    #[test]
287    fn test_log_conf_from_str() {
288        let conf: LogConf = "debug".parse().unwrap();
289        assert_eq!(conf.level, "debug");
290        assert_eq!(conf.output, Output::File);
291        assert!(conf.file.is_some());
292        assert_eq!(conf.file.as_ref().unwrap().path, "./logs");
293        assert!(conf.levels.is_none());
294    }
295
296    #[test]
297    fn test_log_conf_log_to_console() {
298        let conf = LogConf::log_to_console("info");
299        assert_eq!(conf.level, "info");
300        assert_eq!(conf.output, Output::Console);
301        assert!(conf.file.is_none());
302        assert!(conf.levels.is_none());
303    }
304
305    #[test]
306    fn test_log_conf_display_without_levels() {
307        let conf = LogConf {
308            level: "debug".into(),
309            levels: None,
310            output: Output::Console,
311            file: Some(FileLogConf {
312                path: "/tmp/logs".into(),
313            }),
314        };
315        let display = conf.to_string();
316        assert!(display.contains("level: debug"));
317        assert!(display.contains("output: Console"));
318        assert!(display.contains("/tmp/logs"));
319    }
320
321    #[test]
322    fn test_log_conf_display_with_levels() {
323        let mut levels = BTreeMap::new();
324        levels.insert("global".into(), "warn".into());
325        levels.insert("ctrl".into(), "info".into());
326
327        let conf = LogConf {
328            level: "warn".into(),
329            levels: Some(levels),
330            output: Output::File,
331            file: None,
332        };
333        let display = conf.to_string();
334        assert!(display.contains("levels:"));
335        assert!(display.contains("global"));
336        assert!(display.contains("ctrl"));
337        assert!(display.contains("output: File"));
338    }
339
340    #[test]
341    fn test_log_conf_serde_basic() {
342        let json = r#"{
343            "level": "info",
344            "output": "Console"
345        }"#;
346        let conf: LogConf = serde_json::from_str(json).unwrap();
347        assert_eq!(conf.level, "info");
348        assert_eq!(conf.output, Output::Console);
349        assert!(conf.file.is_none());
350    }
351
352    #[test]
353    fn test_log_conf_serde_with_file() {
354        let json = r#"{
355            "level": "debug",
356            "output": "File",
357            "file": { "path": "/var/log/app" }
358        }"#;
359        let conf: LogConf = serde_json::from_str(json).unwrap();
360        assert_eq!(conf.level, "debug");
361        assert_eq!(conf.output, Output::File);
362        assert!(conf.file.is_some());
363        assert_eq!(conf.file.as_ref().unwrap().path, "/var/log/app");
364    }
365
366    #[test]
367    fn test_log_conf_serde_with_levels() {
368        let json = r#"{
369            "level": "warn",
370            "levels": { "ctrl": "info", "source": "debug" },
371            "output": "Both"
372        }"#;
373        let conf: LogConf = serde_json::from_str(json).unwrap();
374        assert_eq!(conf.level, "warn");
375        assert!(conf.levels.is_some());
376        let levels = conf.levels.as_ref().unwrap();
377        assert_eq!(levels.get("ctrl"), Some(&"info".to_string()));
378        assert_eq!(levels.get("source"), Some(&"debug".to_string()));
379        assert_eq!(conf.output, Output::Both);
380    }
381
382    #[test]
383    fn test_log_conf_serde_roundtrip() {
384        let conf = LogConf::default();
385        let json = serde_json::to_string(&conf).unwrap();
386        let parsed: LogConf = serde_json::from_str(&json).unwrap();
387        assert_eq!(conf, parsed);
388    }
389
390    #[test]
391    fn test_log_conf_reject_deprecated_output_path() {
392        let json = r#"{
393            "level": "info",
394            "output": "Console",
395            "output_path": "/old/path"
396        }"#;
397        let result: Result<LogConf, _> = serde_json::from_str(json);
398        assert!(result.is_err());
399        let err = result.unwrap_err().to_string();
400        assert!(err.contains("output_path"));
401    }
402
403    #[test]
404    fn test_log_conf_deny_unknown_fields() {
405        let json = r#"{
406            "level": "info",
407            "output": "Console",
408            "unknown_field": "value"
409        }"#;
410        let result: Result<LogConf, _> = serde_json::from_str(json);
411        assert!(result.is_err());
412    }
413
414    #[test]
415    fn test_file_log_conf_serde() {
416        let json = r#"{ "path": "/var/log" }"#;
417        let conf: FileLogConf = serde_json::from_str(json).unwrap();
418        assert_eq!(conf.path, "/var/log");
419
420        let serialized = serde_json::to_string(&conf).unwrap();
421        assert!(serialized.contains("/var/log"));
422    }
423
424    #[test]
425    fn test_parse_lv_all_levels() {
426        assert_eq!(parse_lv("off").unwrap(), LevelFilter::Off);
427        assert_eq!(parse_lv("error").unwrap(), LevelFilter::Error);
428        assert_eq!(parse_lv("warn").unwrap(), LevelFilter::Warn);
429        assert_eq!(parse_lv("info").unwrap(), LevelFilter::Info);
430        assert_eq!(parse_lv("debug").unwrap(), LevelFilter::Debug);
431        assert_eq!(parse_lv("trace").unwrap(), LevelFilter::Trace);
432    }
433
434    #[test]
435    fn test_parse_lv_case_insensitive() {
436        assert_eq!(parse_lv("DEBUG").unwrap(), LevelFilter::Debug);
437        assert_eq!(parse_lv("Info").unwrap(), LevelFilter::Info);
438        assert_eq!(parse_lv("WARN").unwrap(), LevelFilter::Warn);
439        assert_eq!(parse_lv("ErRoR").unwrap(), LevelFilter::Error);
440    }
441
442    #[test]
443    fn test_parse_lv_invalid() {
444        assert!(parse_lv("invalid").is_err());
445        assert!(parse_lv("").is_err());
446        assert!(parse_lv("warning").is_err());
447    }
448
449    #[test]
450    fn test_parse_level_spec_single_level() {
451        let (root, targets) = parse_level_spec("info").unwrap();
452        assert_eq!(root, LevelFilter::Info);
453        assert!(targets.is_empty());
454    }
455
456    #[test]
457    fn test_parse_level_spec_with_targets() {
458        let (root, targets) = parse_level_spec("warn,ctrl=info,source=debug").unwrap();
459        assert_eq!(root, LevelFilter::Warn);
460        assert_eq!(targets.len(), 2);
461        assert!(
462            targets
463                .iter()
464                .any(|(k, v)| k == "ctrl" && *v == LevelFilter::Info)
465        );
466        assert!(
467            targets
468                .iter()
469                .any(|(k, v)| k == "source" && *v == LevelFilter::Debug)
470        );
471    }
472
473    #[test]
474    fn test_parse_level_spec_with_whitespace() {
475        let (root, targets) = parse_level_spec("warn , ctrl = info , source = debug").unwrap();
476        assert_eq!(root, LevelFilter::Warn);
477        assert_eq!(targets.len(), 2);
478    }
479
480    #[test]
481    fn test_parse_level_spec_empty_parts() {
482        let (root, targets) = parse_level_spec("warn,,ctrl=info,").unwrap();
483        assert_eq!(root, LevelFilter::Warn);
484        assert_eq!(targets.len(), 1);
485    }
486
487    #[test]
488    fn test_parse_level_spec_default_like() {
489        let spec = "warn,ctrl=info,launch=info,source=info,sink=info,stat=info,runtime=warn";
490        let (root, targets) = parse_level_spec(spec).unwrap();
491        assert_eq!(root, LevelFilter::Warn);
492        assert_eq!(targets.len(), 6);
493    }
494
495    #[test]
496    fn test_log_conf_with_setters() {
497        let conf = LogConf::default().with_output(Output::Console);
498        assert_eq!(conf.output, Output::Console);
499    }
500
501    #[test]
502    fn test_log_conf_getters() {
503        let conf = LogConf::default();
504        assert_eq!(conf.level(), &conf.level);
505        assert_eq!(conf.output(), &conf.output);
506        assert_eq!(conf.file(), &conf.file);
507        assert_eq!(conf.levels(), &conf.levels);
508    }
509}