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