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