aquarium_control/utilities/
logger.rs

1/* Copyright 2024 Uwe Martin
2
3Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
5The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
7THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8*/
9use crate::utilities::logger_config::LoggerConfig;
10use log::error;
11use std::error::Error;
12use std::path::Path;
13use thiserror::Error;
14
15#[derive(Debug, Error)]
16/// Contains the error definition for the logger setup.
17pub enum LoggerSetupError {
18    /// The configured log file name is empty.
19    #[error("Configured log file name is empty.")]
20    EmptyLogFileName,
21
22    /// Log file configuration has no file name component.
23    #[error("Log file configuration {0} has no file name component.")]
24    MissingFileName(String),
25
26    /// The configured log file path is an existing directory.
27    #[error("The configured log file path {0} is an existing directory.")]
28    PathIsADirectory(String),
29
30    /// Log file configuration contains a non-existing folder.
31    #[error("Log file configuration contains a non-existing folder ({0}).")]
32    ParentDirDoesNotExist(String),
33
34    /// Log file configuration contains a folder, which is not a directory.
35    #[error("Log file configuration contains a folder, which is not a directory ({0}).")]
36    ParentIsNotADirectory(String),
37
38    /// Could not open the output file for logging.
39    #[error("Could not open output file for logging.")]
40    CouldNotOpenOutputFile {
41        #[source]
42        source: std::io::Error,
43    },
44}
45
46/// Sets up and configures the application's global logger using the `fern` crate.
47///
48/// This function initializes a global logger that dispatches messages to both
49/// standard output (the console) and a specified log file. It performs several
50/// validation checks on the provided log file path before attempting to create
51/// the file and apply the logging configuration.
52///
53/// Log messages are formatted to include a high-precision timestamp, log level,
54/// and the source module (`target`). The default logging level is set to `Debug`.
55///
56/// # Arguments
57/// * `logger_config` - A `LoggerConfig` struct containing the path to the log file.
58///
59/// # Returns
60/// An empty `Result` (`Ok(())`) on successful initialization of the logger.
61///
62/// # Errors
63/// Returns a `LoggerSetupError` variant if any validation or setup step fails:
64/// - `LoggerSetupError::EmptyLogFileName`: If the configured log file name is empty or contains only whitespace.
65/// - `LoggerSetupError::PathIsADirectory`: If the provided log file path points to an existing directory.
66/// - `LoggerSetupError::MissingFileName`: If the log file path does not have a valid file name component (e.g., it ends in `..`).
67/// - `LoggerSetupError::ParentDirDoesNotExist`: If the parent directory of the specified log file does not exist.
68/// - `LoggerSetupError::ParentIsNotADirectory`: If a component of the log file's parent path is a file, not a directory.
69/// - `LoggerSetupError::CouldNotOpenOutputFile`: If the log file cannot be opened or created, which typically indicates a file system permissions issue.
70pub fn setup_logger(logger_config: LoggerConfig) -> Result<(), LoggerSetupError> {
71    // check if the configured file name is empty
72    if logger_config.logfile.trim().is_empty() {
73        return Err(LoggerSetupError::EmptyLogFileName);
74    }
75
76    let path = Path::new(logger_config.logfile.as_str());
77
78    // Explicitly check if the path points to an existing directory.
79    // This is an error because we cannot create a log file with the same name.
80    if path.is_dir() {
81        return Err(LoggerSetupError::PathIsADirectory(logger_config.logfile));
82    }
83
84    if path.file_name().is_none() {
85        return Err(LoggerSetupError::MissingFileName(logger_config.logfile));
86    }
87
88    // If `parent()` is None, it's a relative path in the current directory (e.g., "logfile.log").
89    // We represent the current directory as `Path::new(".")`.
90    let parent_dir = path.parent().unwrap_or_else(|| Path::new("."));
91
92    // 3. Verify the parent directory exists and is, in fact, a directory.
93    if !parent_dir.exists() {
94        return Err(LoggerSetupError::ParentDirDoesNotExist(
95            parent_dir.display().to_string(),
96        ));
97    }
98    if !parent_dir.is_dir() {
99        return Err(LoggerSetupError::ParentIsNotADirectory(
100            parent_dir.display().to_string(),
101        ));
102    }
103
104    // open the file for logging
105    let log_file = match fern::log_file(logger_config.logfile) {
106        Ok(c) => c,
107        Err(e) => return Err(LoggerSetupError::CouldNotOpenOutputFile { source: e }),
108    };
109
110    // configure logger
111    let _ = fern::Dispatch::new()
112        .format(|out, message, record| {
113            let local_time = chrono::Local::now(); // Get current local time
114            out.finish(format_args!(
115                "[{} {} {}] {}",
116                local_time.format("%Y-%m-%d %H:%M:%S%.3f %:z"),
117                record.level(),
118                record.target(),
119                message
120            ))
121        })
122        .level(log::LevelFilter::Debug)
123        .chain(std::io::stdout()) // output to stdout
124        .chain(log_file) // output to file
125        .apply();
126
127    Ok(())
128}
129
130/// Logs an error and its entire chain of sources with a given context message.
131///
132/// This function provides a standardized way to log errors. It logs a high-level
133/// context message, followed by the primary error, and then traverses and logs
134/// every underlying `source` error.
135///
136/// # Arguments
137///
138/// * `module` - module name where the error logging was triggered.
139/// * `context` - additional description of error
140/// * `error` - Top level error
141///
142pub fn log_error_chain<E: Error>(module: &str, context: &str, error: E) {
143    error!(target: module,"{context} ({error})");
144    let mut source = error.source();
145    while let Some(cause) = source {
146        error!("  Caused by: {cause}");
147        source = cause.source();
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use std::fs::{self, File};
155    use tempfile::tempdir;
156
157    /// Tests that the function returns an error when the log file name is empty.
158    #[test]
159    fn test_empty_log_file_name() {
160        let config = LoggerConfig {
161            logfile: "  ".to_string(), // Whitespace only
162        };
163        let result = setup_logger(config);
164        assert!(matches!(result, Err(LoggerSetupError::EmptyLogFileName)));
165    }
166
167    /// Tests that the function returns an error if the path has no file name component,
168    /// for example, if it ends in `..`.
169    #[test]
170    fn test_path_missing_file_name() {
171        // A path ending in `..` has no file name component according to `std::path::Path`.
172        // We use a non-existent path to ensure the preceding `is_dir()` check is passed,
173        // which allows us to specifically isolate and test the `file_name().is_none()` check.
174        let path_str = "/some/non/existent/path/..".to_string();
175
176        let config = LoggerConfig { logfile: path_str };
177        let result = setup_logger(config);
178        assert!(matches!(result, Err(LoggerSetupError::MissingFileName(_))));
179    }
180
181    /// Tests that the function returns an error if the log file name is a directory.
182    #[test]
183    fn test_path_is_a_directory() {
184        let dir = tempdir().unwrap();
185        // Get the path of the temporary directory itself.
186        let path_str = dir.path().to_str().unwrap().to_string();
187
188        let config = LoggerConfig { logfile: path_str };
189        let result = setup_logger(config);
190        assert!(matches!(result, Err(LoggerSetupError::PathIsADirectory(_))));
191    }
192
193    /// Tests that the function returns an error if the parent directory does not exist.
194    #[test]
195    fn test_parent_directory_does_not_exist() {
196        let dir = tempdir().unwrap();
197        let file_path = dir.path().join("nonexistent_subdir/bad.log");
198
199        let config = LoggerConfig {
200            logfile: file_path.to_str().unwrap().to_string(),
201        };
202        let result = setup_logger(config);
203        assert!(matches!(
204            result,
205            Err(LoggerSetupError::ParentDirDoesNotExist(_))
206        ));
207    }
208
209    /// Tests that the function returns an error if the parent path is a file, not a directory.
210    #[test]
211    fn test_parent_is_a_file() {
212        let dir = tempdir().unwrap();
213        let file_as_parent = dir.path().join("file.txt");
214        File::create(&file_as_parent).unwrap(); // Create the file
215
216        let invalid_path = file_as_parent.join("log.log");
217
218        let config = LoggerConfig {
219            logfile: invalid_path.to_str().unwrap().to_string(),
220        };
221        let result = setup_logger(config);
222        assert!(matches!(
223            result,
224            Err(LoggerSetupError::ParentIsNotADirectory(_))
225        ));
226    }
227
228    /// Tests that the function returns an error if it cannot create the log file,
229    /// for example, due to permissions. This test is specific to Unix-like systems.
230    #[test]
231    #[cfg(unix)]
232    fn test_cannot_open_file_in_readonly_directory() {
233        use std::os::unix::fs::PermissionsExt;
234
235        // 1. Create a temporary directory
236        let dir = tempdir().unwrap();
237
238        // 2. Set its permissions to read-only (mode 555: r-xr-xr-x)
239        let mut perms = fs::metadata(dir.path()).unwrap().permissions();
240        perms.set_mode(0o555);
241        fs::set_permissions(dir.path(), perms).unwrap();
242
243        // 3. Attempt to set up a logger in the read-only directory
244        let file_path = dir.path().join("unwritable.log");
245        let config = LoggerConfig {
246            logfile: file_path.to_str().unwrap().to_string(),
247        };
248        let result = setup_logger(config);
249
250        // 4. IMPORTANT: Reset permissions so the tempdir can be cleaned up
251        let mut perms = fs::metadata(dir.path()).unwrap().permissions();
252        perms.set_mode(0o755); // rwxr-xr-x
253        fs::set_permissions(dir.path(), perms).unwrap();
254
255        // 5. Assert that the correct error was returned
256        assert!(matches!(
257            result,
258            Err(LoggerSetupError::CouldNotOpenOutputFile { .. })
259        ));
260    }
261}