Skip to main content

woody/
lib.rs

1///! A (really) very simple logger that can be used globally in any project.
2///!
3///! Logs the current time, the log level, the thread name, the file and line number, and the message.
4///! Log messages are written to a file (`woody.log` by default).
5use lazy_static::lazy_static;
6use std::{
7    env,
8    fs::{File, OpenOptions},
9    io::Write,
10    sync::{Arc, Mutex},
11};
12
13#[cfg(test)]
14use std::hash::{Hash, Hasher};
15
16const DEFAULT_LOG_FILE: &str = "woody.log";
17
18lazy_static! {
19    static ref INSTANCE: Arc<Mutex<Option<Logger>>> = Arc::new(Mutex::new(None));
20    static ref FILENAME: Arc<Mutex<String>> = Arc::new(Mutex::new(DEFAULT_LOG_FILE.to_string()));
21}
22
23/// Determines the log level of a message.
24#[allow(dead_code)]
25#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
26pub enum LogLevel {
27    /// Error level.
28    Error = 5,
29    /// Warning level.
30    Warning = 4,
31    /// Debug level.
32    Debug = 3,
33    /// Info level.
34    Info = 2,
35    /// Trace level.
36    Trace = 1,
37    /// Off level.
38    Off = 0,
39    /// ! Internal use only. Do not use.
40    ALL = -1,
41}
42
43impl std::fmt::Display for LogLevel {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            LogLevel::Debug => write!(f, "DEBUG"),
47            LogLevel::Info => write!(f, "INFO"),
48            LogLevel::Warning => write!(f, "WARNING"),
49            LogLevel::Error => write!(f, "ERROR"),
50            LogLevel::Trace => write!(f, "TRACE"),
51            LogLevel::Off => write!(f, "OFF"),
52            LogLevel::ALL => write!(f, ""),
53        }
54    }
55}
56
57/// The logger struct. A singleton that can only be created once.
58#[derive(Clone, Debug)]
59#[allow(dead_code)]
60pub struct Logger {
61    file: Arc<Mutex<File>>,
62    level: LogLevel,
63    filename: String,
64}
65
66/// Generates a temp file name
67///
68/// Returns a string that looks like this:
69/// `temp-8444741687653642537.log`
70#[cfg(test)]
71fn generate_temp_file_name() -> String {
72    let mut hasher = std::collections::hash_map::DefaultHasher::new();
73    let now = chrono::Local::now();
74    let now_string = now.format("%Y-%m-%d %H:%M:%S%.3f %Z").to_string();
75    now_string.hash(&mut hasher);
76    let hash = hasher.finish();
77    let prefix = "temp-";
78    let suffix = ".log";
79    // make sure it's exactly 32 characters long
80    let len = 32 - prefix.len() - suffix.len();
81    let hash = format!("{hash:0>len$}");
82
83    format!("temp-{hash}.log")
84}
85
86#[cfg(not(test))]
87fn get_file_and_filename() -> (Arc<Mutex<File>>, String) {
88    let mut filename: String;
89    let file: Arc<Mutex<File>>;
90    filename = FILENAME.lock().unwrap().clone();
91    let env_filename = env::var("WOODY_FILE");
92    if let Ok(env_filename) = env_filename {
93        filename = env_filename;
94    }
95    let f = OpenOptions::new().create(true).append(true).open(&filename);
96    file = Arc::new(Mutex::new(f.unwrap()));
97    return (file, filename);
98}
99
100/// Gets the file and filename to use for logging.
101#[cfg(test)]
102fn get_file_and_filename() -> (Arc<Mutex<File>>, String) {
103    let filename: String;
104    let file: Arc<Mutex<File>>;
105    let temp_dir_base = env::temp_dir();
106    // append "logger" to the temp dir so it's like this:
107    // /tmp/logger/temp-af44fa0-1f2c-4b5a-9c1f-7f8e9d0a1b2c.log
108    let temp_dir = temp_dir_base.join("logger");
109    // remove the temp dir if it already exists
110    if temp_dir.exists() {
111        std::fs::remove_dir_all(&temp_dir).unwrap();
112    }
113    std::fs::create_dir(&temp_dir).unwrap();
114    let temp_file_name = generate_temp_file_name();
115    let temp_file_path = temp_dir.join(temp_file_name);
116    filename = temp_file_path.to_str().unwrap().to_string();
117
118    let f = OpenOptions::new()
119        .create(true)
120        .append(true)
121        .open(temp_file_path);
122    file = Arc::new(Mutex::new(f.unwrap()));
123
124    (file, filename)
125}
126
127impl Logger {
128    /// Create a new logger. This is a singleton, so it can only be called once.
129    fn new() -> Self {
130        let env_level = env::var("WOODY_LEVEL");
131        let level = match env_level {
132            Ok(x) => match x.to_lowercase().as_str() {
133                "error" | "5" => LogLevel::Error,
134                "warning" | "warn" | "4" => LogLevel::Warning,
135                "debug" | "3" => LogLevel::Debug,
136                "info" | "2" => LogLevel::Info,
137                "trace" | "1" => LogLevel::Trace,
138                "off" | "0" => LogLevel::Off,
139                _ => LogLevel::ALL,
140            },
141            Err(_) => LogLevel::ALL,
142        };
143
144        let (file, filename) = get_file_and_filename();
145
146        Self {
147            file,
148            level,
149            filename,
150        }
151    }
152
153    /// Set the log level. This will only log messages that are equal to or above the log level.
154    pub fn set_level(&mut self, level: LogLevel) {
155        self.level = level;
156    }
157
158    /// Log a message at the given level.
159    pub fn log<W: Write>(&self, info: &LogInfo, writer: Option<&mut W>) {
160        if self.level > info.level || self.level == LogLevel::Off {
161            // println!(
162            //     "not logging because self.level ({} {}) > info.level ({} {})",
163            //     self.level, self.level as u8, info.level, info.level as u8
164            // );
165            return;
166        }
167
168        let now = chrono::Local::now();
169        let thread = info.thread.clone().unwrap_or_else(|| {
170            let thread = std::thread::current();
171            let name = thread.name().unwrap_or("unnamed");
172            name.to_string()
173        });
174        let location = format!("{}:{}", info.filepath, info.line_number);
175        let level = info.level;
176        let message = info.message.clone();
177        let now_string = now.format("%Y-%m-%d %H:%M:%S%.3f %Z");
178        let output = format!("[{now_string}] [{level}] [{thread}] [{location}] {message}\n");
179
180        if let Some(writer) = writer {
181            writer.write_all(output.as_bytes()).unwrap();
182            return;
183        }
184
185        let mut file = self.file.lock().unwrap();
186        file.write_all(output.as_bytes()).unwrap();
187    }
188
189    /// Gets the instance of the logger. If the logger is not created, it will create it.
190    pub fn get_instance() -> Logger {
191        // Check if the instance is already created.
192        let current_global_instance = INSTANCE.clone();
193        let mut current_global_instance_lock = current_global_instance.lock().unwrap();
194        if current_global_instance_lock.is_none() {
195            // If the instance is not created, create it.
196            let logger = Logger::new();
197            *current_global_instance_lock = Some(logger.clone());
198            logger
199        } else {
200            // If the instance is already created, return it.
201            current_global_instance_lock.clone().unwrap()
202        }
203    }
204}
205
206/// The log info struct. This is used to log a message.
207#[derive(Clone)]
208pub struct LogInfo {
209    /// The log level.
210    pub level: LogLevel,
211    /// The message to log.
212    pub message: String,
213    /// The filepath of the file that called the log macro.
214    pub filepath: &'static str,
215    /// The line number of the file that called the log macro.
216    pub line_number: u32,
217    /// The thread that called the log macro.
218    pub thread: Option<String>,
219}
220
221/// The log macro. Used in other macros.
222///
223/// # Examples
224/// ```
225/// use woody::log;
226/// use woody::LogLevel;
227/// log!(LogLevel::Info, "Hello, world!");
228/// log!("Hello, world!");
229/// ```
230#[macro_export]
231macro_rules! log {
232    ($message:expr) => {
233        let message = $message.to_string();
234        let logger = $crate::Logger::get_instance();
235        let info = $crate::LogInfo {
236            level: $crate::LogLevel::Info,
237            message,
238            filepath: file!(),
239            line_number: line!(),
240            thread: None,
241        };
242        let writer: Option<&mut Vec<u8>> = None;
243        logger.log(&info, writer);
244    };
245    ($level:expr, $message:expr) => {
246        let message = $message.to_string();
247        let logger = $crate::Logger::get_instance();
248        let info = $crate::LogInfo {
249            level: $level,
250            message,
251            filepath: file!(),
252            line_number: line!(),
253            thread: None,
254        };
255        let writer: Option<&mut Vec<u8>> = None;
256        logger.log(&info, writer);
257    };
258}
259
260/// Logs a debug message.
261///
262/// # Examples
263/// ```
264/// use woody::log_debug;
265/// log_debug!("Hello, world!");
266/// ```
267#[macro_export]
268macro_rules! log_debug {
269    ($message:expr) => {
270        $crate::log!($crate::LogLevel::Debug, $message);
271    };
272
273    ($message:expr, $($arg:tt)*) => {
274        let message = format!($message, $($arg)*).to_string();
275        $crate::log!($crate::LogLevel::Debug, message);
276    };
277}
278
279/// Logs an info message.
280/// # Examples
281/// ```
282/// use woody::log_info;
283/// log_info!("Hello, world!");
284/// ```
285#[macro_export]
286macro_rules! log_info {
287    ($message:expr) => {
288        $crate::log!($crate::LogLevel::Info, $message);
289    };
290
291    ($message:expr, $($arg:tt)*) => {
292        let message = format!($message, $($arg)*).to_string();
293        $crate::log!($crate::LogLevel::Info, message);
294    };
295}
296
297/// Logs a warning message.
298/// # Examples
299/// ```
300/// use woody::log_warning;
301/// log_warning!("Hello, world!");
302/// ```
303#[macro_export]
304macro_rules! log_warning {
305    ($message:expr) => {
306        $crate::log!($crate::LogLevel::Warning, $message);
307    };
308
309    ($message:expr, $($arg:tt)*) => {
310        let message = format!($message, $($arg)*).to_string();
311        $crate::log!($crate::LogLevel::Warning, message);
312    };
313}
314
315/// Logs an error message.
316/// # Examples
317/// ```
318/// use woody::log_error;
319/// log_error!("Hello, world!");
320/// ```
321#[macro_export]
322macro_rules! log_error {
323    ($message:expr) => {
324        $crate::log!($crate::LogLevel::Error, $message);
325    };
326
327    ($message:expr, $($arg:tt)*) => {
328        let message = format!($message, $($arg)*).to_string();
329        $crate::log!($crate::LogLevel::Error, message);
330    };
331}
332
333/// Logs a trace message.
334/// # Examples
335/// ```
336/// use woody::log_trace;
337/// log_trace!("Hello, world!");
338/// ```
339#[macro_export]
340macro_rules! log_trace {
341    ($message:expr) => {
342        $crate::log!($crate::LogLevel::Trace, $message);
343    };
344
345    ($message:expr, $($arg:tt)*) => {
346        let message = format!($message, $($arg)*).to_string();
347        $crate::log!($crate::LogLevel::Trace, message);
348    };
349}
350
351/// Logs a text message.
352/// # Examples
353/// ```
354/// use woody::log_text;
355/// log_text!("Hello, world!");
356/// ```
357#[macro_export]
358macro_rules! log_text {
359    ($message:expr) => {
360        $crate::log!($crate::LogLevel::Off, $message);
361    };
362
363    ($message:expr, $($arg:tt)*) => {
364        let message = format!($message, $($arg)*).to_string();
365        $crate::log!($crate::LogLevel::Off, message);
366    };
367}
368
369/// Gets the name of the current function.
370///
371/// *Note: Keeping this here so we can add as a feature later.
372#[allow(unused_macros)]
373macro_rules! function {
374    () => {{
375        fn f() {}
376        fn type_name_of<T>(_: T) -> &'static str {
377            std::any::type_name::<T>()
378        }
379        let name = type_name_of(f);
380        &name[..name.len() - 3]
381    }};
382}
383
384#[cfg(test)]
385mod tests {
386    use serial_test::serial;
387    use std::io::Read;
388    use tokio::runtime::Runtime;
389
390    use super::*;
391
392    async fn write_to_logger(id: Option<u8>) {
393        let logger = Logger::get_instance();
394        let thread = std::thread::current();
395        let thread = thread.name();
396        let thread = match id {
397            Some(id) => format!("{}-{}", thread.unwrap(), id),
398            None => thread.unwrap().to_string(),
399        };
400        let id = id.unwrap_or(0);
401        let message = format!("Hello, world! {id}");
402        let info = LogInfo {
403            level: LogLevel::Info,
404            message,
405            filepath: file!(),
406            line_number: line!(),
407            thread: Some(thread),
408        };
409
410        let writer: Option<&mut Vec<u8>> = None;
411        logger.log(&info, writer);
412    }
413
414    /// Get the global instance of the Logger (or None if it doesn't exist).
415    fn get_global_instance() -> Option<Logger> {
416        let current_global_instance = INSTANCE.clone();
417        let current_global_instance_lock = current_global_instance.lock().unwrap();
418        current_global_instance_lock.clone()
419    }
420
421    /// Check that the global instance is None before running `Logger::get_instance()`.
422    /// and that it is Some after running `Logger::get_instance()`.
423    #[test]
424    #[serial]
425    fn test_global_instance_value() {
426        let current_global_instance = get_global_instance();
427        assert!(current_global_instance.is_none() || current_global_instance.is_some());
428
429        let logger = Logger::get_instance();
430        let current_global_instance = get_global_instance();
431        assert!(current_global_instance.is_some());
432        assert_eq!(logger.level, LogLevel::ALL);
433    }
434
435    /// Check that writing to the logger works.
436    #[test]
437    fn test_writing_to_logger() {
438        let logger = Logger::get_instance();
439        let info = LogInfo {
440            level: LogLevel::Info,
441            message: "Hello, world!".to_string(),
442            filepath: file!(),
443            line_number: line!(),
444            thread: None,
445        };
446
447        let mut writer = Vec::new();
448        logger.log(&info, Some(&mut writer));
449
450        let mut contents = String::new();
451        contents.push_str(&String::from_utf8(writer).unwrap());
452
453        assert!(
454            contents.contains(info.message.as_str()),
455            "Contents of log does not contain 'Hello, world!'\nContents: {contents}"
456        );
457    }
458
459    fn check_log_file_contains(s: String) {
460        // open the file and check that it contains the message
461        let logger = Logger::get_instance();
462        let filename = &logger.filename;
463        let file = OpenOptions::new().read(true).open(filename);
464        if file.is_err() {
465            panic!("Could not open {}: {:?}", filename, file.unwrap_err());
466        }
467        let mut contents = String::new();
468        file.unwrap().read_to_string(&mut contents).unwrap();
469        assert!(
470            contents.contains(s.as_str()),
471            "Contents of log does not contain '{s}'\nContents: {contents}\nLogger: {logger:?}"
472        );
473    }
474
475    /// Check that writing to the logger across multiple threads works.
476    #[test]
477    fn test_writing_to_logger_across_threads() {
478        async fn spawn_logs() {
479            let mut handles = Vec::new();
480            for i in 0..10 {
481                let task = tokio::spawn(write_to_logger(Some(i)));
482                handles.push(task);
483            }
484
485            for handle in handles {
486                handle.await.unwrap();
487            }
488        }
489
490        let rt = Runtime::new().unwrap();
491        rt.block_on(spawn_logs());
492
493        let filename = Logger::get_instance().filename;
494        let mut file = OpenOptions::new().read(true).open(&filename).unwrap();
495        let mut contents = String::new();
496        file.read_to_string(&mut contents).unwrap();
497
498        for i in 0..10 {
499            let message = format!("Hello, world! {i}");
500            check_log_file_contains(message);
501        }
502    }
503
504    #[test]
505    fn test_log_info() {
506        let f = function!();
507        let s = format!("Hello, {f}!");
508        log_info!(s);
509        check_log_file_contains(s);
510    }
511
512    #[test]
513    fn test_log_debug() {
514        let f = function!();
515        let s = format!("Hello, {f}!");
516        log_debug!(s);
517        check_log_file_contains(s);
518    }
519
520    #[test]
521    fn test_log_warning() {
522        let f = function!();
523        let s = format!("Hello, {f}!");
524        log_warning!(s);
525        check_log_file_contains(s);
526    }
527
528    #[test]
529    fn test_log_error() {
530        let f = function!();
531        let s = format!("Hello, {f}!");
532        log_error!(s);
533        check_log_file_contains(s);
534    }
535
536    #[test]
537    fn test_log_trace() {
538        let f = function!();
539        let s = format!("Hello, {f}!");
540        log_trace!(s);
541        check_log_file_contains(s);
542    }
543
544    #[test]
545    fn test_log_text() {
546        let f = function!();
547        let s = format!("Hello, {f}!");
548        log_text!(s);
549        check_log_file_contains(s);
550    }
551
552    #[test]
553    fn test_random_file_name() {
554        let filename = generate_temp_file_name();
555
556        // make sure the filename is 32 characters long
557        assert_eq!(
558            filename.len(),
559            32,
560            "Filename is not 32 characters long: {}",
561            filename.len()
562        );
563
564        // make sure the filename starts with "temp-"
565        assert!(
566            filename.starts_with("temp-"),
567            "Filename does not start with 'temp-': {filename}"
568        );
569    }
570}