clap_logflag/
fern.rs

1use std::io::IsTerminal as _;
2
3use anyhow::Result;
4use fern::{
5    colors::{Color, ColoredLevelConfig},
6    Dispatch, FormatCallback,
7};
8
9use super::config::{LogDestination, LogDestinationConfig, LoggingConfig};
10
11/// Initialize logging with the given configuration and default level.
12///
13/// # Arguments
14/// * `config` - The logging configuration to use.
15/// * `default_level` - The default log level to use if a destination was specified without a log level filter.
16///
17/// # Example
18/// ```rust
19#[doc = include_str!("../examples/simple_cli.rs")]
20/// ```
21#[macro_export]
22macro_rules! init_logging {
23    ($config:expr, $default_level:expr $(,)?) => {{
24        $crate::_init_logging(
25            $config,
26            $default_level,
27            option_env!("CARGO_BIN_NAME"),
28            env!("CARGO_CRATE_NAME"),
29        )
30        .expect("Failed to initialize logging");
31    }};
32}
33
34/// Don't use this function directly, use the [init_logging!] macro instead.
35pub fn _init_logging(
36    config: LoggingConfig,
37    default_level: log::LevelFilter,
38    cargo_bin_name: Option<&str>,
39    cargo_crate_name: &str,
40) -> Result<()> {
41    if let Some(main_logger) =
42        build_main_logger(config, default_level, cargo_bin_name, cargo_crate_name)
43    {
44        main_logger.apply()?;
45    }
46    Ok(())
47}
48
49fn build_main_logger(
50    config: LoggingConfig,
51    default_level: log::LevelFilter,
52    cargo_bin_name: Option<&str>,
53    cargo_crate_name: &str,
54) -> Option<Dispatch> {
55    if config.destinations().is_empty() {
56        // Logging is disabled
57        return None;
58    }
59
60    let process_name = process_name(cargo_bin_name, cargo_crate_name);
61
62    let mut main_logger = Dispatch::new();
63    for destination in config.destinations() {
64        if let Ok(logger) = build_logger(destination, default_level, process_name.clone()) {
65            main_logger = main_logger.chain(logger);
66        }
67    }
68    Some(main_logger)
69}
70
71fn build_logger(
72    config: &LogDestinationConfig,
73    default_level: log::LevelFilter,
74    process_name: String,
75) -> Result<Dispatch> {
76    let logger = Dispatch::new().level(config.level.unwrap_or(default_level));
77    let logger = match &config.destination {
78        LogDestination::Stderr => {
79            if std::io::stderr().is_terminal() {
80                logger.format(log_formatter_tty()).chain(std::io::stderr())
81            } else {
82                logger.format(log_formatter_file()).chain(std::io::stderr())
83            }
84        }
85        LogDestination::File(path) => logger
86            .format(log_formatter_file())
87            .chain(fern::log_file(path)?),
88        LogDestination::Syslog => {
89            let syslog_formatter = syslog::Formatter3164 {
90                facility: syslog::Facility::LOG_USER,
91                hostname: None,
92                process: process_name,
93                pid: std::process::id(),
94            };
95            logger.chain(syslog::unix(syslog_formatter)?)
96        }
97    };
98    Ok(logger)
99}
100
101fn log_formatter_tty() -> impl Fn(FormatCallback, &std::fmt::Arguments, &log::Record) {
102    let colors = ColoredLevelConfig::new()
103        .trace(Color::Magenta)
104        .debug(Color::Cyan)
105        .info(Color::Green)
106        .warn(Color::Yellow)
107        .error(Color::Red);
108    move |out: FormatCallback, message: &std::fmt::Arguments, record: &log::Record| {
109        out.finish(format_args!(
110            "[{} {} {}] {}",
111            humantime::format_rfc3339_seconds(std::time::SystemTime::now()),
112            colors.color(record.level()),
113            record.target(),
114            message
115        ))
116    }
117}
118
119fn log_formatter_file() -> impl Fn(FormatCallback, &std::fmt::Arguments, &log::Record) {
120    move |out: FormatCallback, message: &std::fmt::Arguments, record: &log::Record| {
121        out.finish(format_args!(
122            "[{} {} {}] {}",
123            humantime::format_rfc3339_seconds(std::time::SystemTime::now()),
124            record.level(),
125            record.target(),
126            message
127        ))
128    }
129}
130
131/// Get a process name. Try in the following order:
132/// 1. Try getting it from argv, i.e. the name of the currently running executable
133/// 2. Try getting it from the `CARGO_BIN_NAME` environment variable
134/// 3. Get it from the `CARGO_CRATE_NAME` environment variable
135fn process_name(cargo_bin_name: Option<&str>, cargo_crate_name: &str) -> String {
136    exe_name()
137        .unwrap_or_else(|| {
138            cargo_bin_name
139                .map(str::to_string)
140                .unwrap_or_else(|| cargo_crate_name.to_string())
141        })
142        .to_string()
143}
144
145/// Get the currently running executable name from argv.
146fn exe_name() -> Option<String> {
147    std::env::current_exe()
148        .map(|exe_path| {
149            exe_path
150                .file_name()
151                .and_then(std::ffi::OsStr::to_str)
152                .map(str::to_string)
153        })
154        .unwrap_or(None)
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use log::LevelFilter;
161    use predicates::Predicate;
162    use rstest::rstest;
163
164    #[test]
165    fn test_exe_name() {
166        let actual_exe_name = exe_name().unwrap();
167        assert!(
168            actual_exe_name.starts_with("clap_logflag"),
169            "exe_name should start with clap_logflag but was {actual_exe_name}"
170        );
171    }
172
173    #[test]
174    fn test_process_name() {
175        let actual_process_name = process_name(None, "cargo_crate_name");
176        assert!(
177            actual_process_name.starts_with("clap_logflag"),
178            "process_name should start with clap_logflag but was {actual_process_name}"
179        );
180    }
181
182    #[rstest]
183    fn test_build_stderr_logger(
184        #[values(
185            LevelFilter::Error,
186            LevelFilter::Warn,
187            LevelFilter::Info,
188            LevelFilter::Debug,
189            LevelFilter::Trace
190        )]
191        level: LevelFilter,
192    ) {
193        let config = LogDestinationConfig {
194            destination: LogDestination::Stderr,
195            level: None,
196        };
197        let logger = build_logger(&config, level, "process_name".to_string())
198            .unwrap()
199            .into_log();
200        assert_eq!(logger.0, level);
201    }
202
203    #[rstest]
204    fn test_build_file_logger(
205        #[values(
206            LevelFilter::Error,
207            LevelFilter::Warn,
208            LevelFilter::Info,
209            LevelFilter::Debug,
210            LevelFilter::Trace
211        )]
212        level: LevelFilter,
213    ) {
214        let tempdir = assert_fs::TempDir::new().unwrap();
215        let file = tempdir.path().join("logfile");
216        let config = LogDestinationConfig {
217            destination: LogDestination::File(file),
218            level: None,
219        };
220        let logger = build_logger(&config, level, "process_name".to_string())
221            .unwrap()
222            .into_log();
223        assert_eq!(logger.0, level);
224    }
225
226    #[rstest]
227    fn test_log_formatter_file(
228        #[values(
229            LevelFilter::Error,
230            LevelFilter::Warn,
231            LevelFilter::Info,
232            LevelFilter::Debug,
233            LevelFilter::Trace
234        )]
235        level: LevelFilter,
236    ) {
237        let tempdir = assert_fs::TempDir::new().unwrap();
238        let file = tempdir.path().join("logfile");
239        let config = LogDestinationConfig {
240            destination: LogDestination::File(file.clone()),
241            level: None,
242        };
243        let (actual_level, logger) = build_logger(&config, level, "process_name".to_string())
244            .unwrap()
245            .into_log();
246        assert_eq!(level, actual_level);
247        logger.log(
248            &log::Record::builder()
249                .args(format_args!("test log message"))
250                .level(level.to_level().unwrap())
251                .target("my-test")
252                .build(),
253        );
254        logger.flush();
255
256        let expected_log_regex = format!(
257            r"\[{} {level} my-test\] test log message\n",
258            timestamp_regex()
259        );
260        let actually_logged = std::fs::read_to_string(&file).unwrap();
261        // Assert it matches
262        assert!(
263            predicates::str::is_match(expected_log_regex)
264                .unwrap()
265                .eval(&actually_logged),
266            "actually_logged: \"{actually_logged}\""
267        );
268    }
269
270    const fn timestamp_regex() -> &'static str {
271        r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)"
272    }
273
274    #[rstest]
275    fn test_build_main_logger_disabled(
276        #[values(
277            LevelFilter::Error,
278            LevelFilter::Warn,
279            LevelFilter::Info,
280            LevelFilter::Debug,
281            LevelFilter::Trace
282        )]
283        default_level: LevelFilter,
284    ) {
285        let config = LoggingConfig::disabled();
286        let built = build_main_logger(config, default_level, None, "process_name");
287        assert!(built.is_none());
288    }
289
290    #[rstest]
291    fn test_build_main_logger_stderr_and_file(
292        #[values(
293            LevelFilter::Error,
294            LevelFilter::Warn,
295            LevelFilter::Info,
296            LevelFilter::Debug,
297            LevelFilter::Trace
298        )]
299        default_level: LevelFilter,
300    ) {
301        let tempdir = assert_fs::TempDir::new().unwrap();
302        let file = tempdir.path().join("logfile");
303        let config = LoggingConfig::new(vec![
304            LogDestinationConfig {
305                destination: LogDestination::Stderr,
306                level: None,
307            },
308            LogDestinationConfig {
309                destination: LogDestination::File(file.clone()),
310                level: None,
311            },
312        ]);
313        let (actual_level, logger) = build_main_logger(config, default_level, None, "process_name")
314            .unwrap()
315            .into_log();
316        assert_eq!(actual_level, default_level);
317
318        // And test actual logging
319        logger.log(
320            &log::Record::builder()
321                .args(format_args!("test log message"))
322                .level(default_level.to_level().unwrap())
323                .target("my-test")
324                .build(),
325        );
326        logger.flush();
327
328        let expected_log_regex = format!(
329            r"\[{} {default_level} my-test\] test log message\n",
330            timestamp_regex()
331        );
332        let actually_logged = std::fs::read_to_string(&file).unwrap();
333        // Assert it matches
334        assert!(
335            predicates::str::is_match(expected_log_regex)
336                .unwrap()
337                .eval(&actually_logged),
338            "actually_logged: \"{actually_logged}\""
339        );
340    }
341}