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}