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#[doc = include_str!("../examples/simple_cli.rs")]
20#[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
34pub 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 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
131fn 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
145fn 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!(
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 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!(
335 predicates::str::is_match(expected_log_regex)
336 .unwrap()
337 .eval(&actually_logged),
338 "actually_logged: \"{actually_logged}\""
339 );
340 }
341}