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