ssg/core/logging.rs
1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Logging infrastructure for the static site generator.
5
6use crate::error::SsgError;
7use log::{debug, LevelFilter};
8use std::fs::File;
9use std::io::Write;
10use std::path::PathBuf;
11
12// Constants for configuration
13pub(crate) const DEFAULT_LOG_LEVEL: &str = "info";
14pub(crate) const ENV_LOG_LEVEL: &str = "SSG_LOG_LEVEL";
15
16/// Maps a case-insensitive log level string to a `LevelFilter`.
17///
18/// Unrecognised values fall back to `LevelFilter::Info`. Extracted
19/// from `initialize_logging` so it can be unit-tested without
20/// installing a global logger (which is one-shot per process).
21pub(crate) fn parse_log_level(log_level: &str) -> LevelFilter {
22 match log_level.to_lowercase().as_str() {
23 "error" => LevelFilter::Error,
24 "warn" => LevelFilter::Warn,
25 "info" => LevelFilter::Info,
26 "debug" => LevelFilter::Debug,
27 "trace" => LevelFilter::Trace,
28 _ => LevelFilter::Info,
29 }
30}
31
32/// A minimal logger that writes to stderr.
33#[derive(Debug)]
34pub(crate) struct SimpleLogger;
35
36impl log::Log for SimpleLogger {
37 fn enabled(&self, metadata: &log::Metadata) -> bool {
38 metadata.level() <= log::max_level()
39 }
40
41 fn log(&self, record: &log::Record) {
42 log_record(self.enabled(record.metadata()), record);
43 }
44
45 fn flush(&self) {}
46}
47
48/// Writes `record` to stderr when `enabled` is true.
49///
50/// Extracted from `SimpleLogger::log` so both the enabled and the
51/// filtered branch are unit-testable without racing other tests over
52/// the process-global `log::max_level()`.
53fn log_record(enabled: bool, record: &log::Record) {
54 if enabled {
55 eprintln!(
56 "[{} {}] {}",
57 record.level(),
58 record.module_path().unwrap_or(""),
59 record.args()
60 );
61 }
62}
63
64/// Initializes the logging system based on environment variables.
65pub(crate) fn initialize_logging() -> Result<(), SsgError> {
66 let log_level = std::env::var(ENV_LOG_LEVEL)
67 .unwrap_or_else(|_| DEFAULT_LOG_LEVEL.to_string());
68
69 let level = parse_log_level(&log_level);
70
71 let installed = log::set_logger(&SimpleLogger).is_ok();
72 apply_log_level(installed, level);
73
74 // Diagnostic, not user-facing: a CLI should not announce its own
75 // logger on every run. Visible via SSG_LOG_LEVEL=debug.
76 debug!("Logging initialized at level: {log_level}");
77 Ok(())
78}
79
80/// Applies `level` as the global max level iff the logger install
81/// succeeded.
82///
83/// Extracted from `initialize_logging` so both branches are
84/// deterministically unit-testable: whether `log::set_logger` wins
85/// or loses depends on process-global state (another test may have
86/// installed a logger first).
87fn apply_log_level(installed: bool, level: LevelFilter) {
88 if installed {
89 log::set_max_level(level);
90 }
91}
92
93/// Creates and initialises a log file for the static site generator.
94///
95/// Establishes a new log file at the specified path with appropriate permissions
96/// and write capabilities. The log file is used to track the generation process
97/// and any errors that occur.
98///
99/// # Arguments
100///
101/// * `file_path` - The desired location for the log file
102///
103/// # Returns
104///
105/// * `Ok(File)` - A file handle for the created log file
106/// * `Err` - If the file cannot be created or permissions are insufficient
107///
108/// # Examples
109///
110/// ```rust
111/// use ssg::create_log_file;
112///
113/// fn main() -> anyhow::Result<()> {
114/// let log_file = create_log_file("./site_generation.log")?;
115/// println!("Log file created successfully");
116/// Ok(())
117/// }
118/// ```
119///
120/// # Errors
121///
122/// Returns an error if:
123/// * The specified path is invalid
124/// * File creation permissions are insufficient
125/// * The parent directory is not writable
126pub fn create_log_file(file_path: &str) -> Result<File, SsgError> {
127 File::create(file_path).map_err(|source| SsgError::Io {
128 path: PathBuf::from(file_path),
129 source,
130 })
131}
132
133/// Records system initialisation in the logging system.
134///
135/// Creates a detailed log entry capturing the system's startup state,
136/// including configuration and initial conditions. Uses the Common Log Format (CLF)
137/// for consistent logging.
138///
139/// # Arguments
140///
141/// * `log_file` - Active file handle for writing log entries
142/// * `date` - Current date and time for log timestamps
143///
144/// # Returns
145///
146/// * `Ok(())` - If the log entry is written successfully
147/// * `Err` - If writing fails or translation errors occur
148///
149/// # Examples
150///
151/// ```rust
152/// use ssg::{create_log_file, log_initialization};
153///
154/// fn main() -> anyhow::Result<()> {
155/// let mut log_file = create_log_file("./site.log")?;
156/// let date = ssg::now_iso();
157///
158/// log_initialization(&mut log_file, &date)?;
159/// println!("System initialisation logged");
160/// Ok(())
161/// }
162/// ```
163pub fn log_initialization(
164 log_file: &mut File,
165 date: &str,
166) -> Result<(), SsgError> {
167 writeln!(
168 log_file,
169 "[{date}] INFO process: System initialization complete"
170 )
171 .map_err(|source| SsgError::Io {
172 path: PathBuf::from("log"),
173 source,
174 })
175}
176
177/// Logs processed command-line arguments for debugging and auditing.
178///
179/// Records all provided command-line arguments and their values in the log file,
180/// providing a traceable record of site generation parameters.
181///
182/// # Arguments
183///
184/// * `log_file` - Active file handle for writing log entries
185/// * `date` - Current date and time for log timestamps
186///
187/// # Returns
188///
189/// * `Ok(())` - If arguments are logged successfully
190/// * `Err` - If writing fails or translation errors occur
191///
192/// # Examples
193///
194/// ```rust
195/// use ssg::{create_log_file, log_arguments};
196///
197/// fn main() -> anyhow::Result<()> {
198/// let mut log_file = create_log_file("./site.log")?;
199/// let date = ssg::now_iso();
200///
201/// log_arguments(&mut log_file, &date)?;
202/// println!("Arguments logged successfully");
203/// Ok(())
204/// }
205/// ```
206pub fn log_arguments(log_file: &mut File, date: &str) -> Result<(), SsgError> {
207 writeln!(log_file, "[{date}] INFO process: Arguments processed").map_err(
208 |source| SsgError::Io {
209 path: PathBuf::from("log"),
210 source,
211 },
212 )
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn parse_log_level_info() {
221 assert_eq!(parse_log_level("info"), LevelFilter::Info);
222 }
223
224 #[test]
225 fn parse_log_level_debug() {
226 assert_eq!(parse_log_level("debug"), LevelFilter::Debug);
227 }
228
229 #[test]
230 fn parse_log_level_warn() {
231 assert_eq!(parse_log_level("warn"), LevelFilter::Warn);
232 }
233
234 #[test]
235 fn parse_log_level_error() {
236 assert_eq!(parse_log_level("error"), LevelFilter::Error);
237 }
238
239 #[test]
240 fn parse_log_level_trace() {
241 assert_eq!(parse_log_level("trace"), LevelFilter::Trace);
242 }
243
244 #[test]
245 fn parse_log_level_case_insensitive() {
246 assert_eq!(parse_log_level("DEBUG"), LevelFilter::Debug);
247 assert_eq!(parse_log_level("Warn"), LevelFilter::Warn);
248 }
249
250 #[test]
251 fn parse_log_level_invalid_defaults_to_info() {
252 assert_eq!(parse_log_level("garbage"), LevelFilter::Info);
253 assert_eq!(parse_log_level(""), LevelFilter::Info);
254 }
255
256 #[test]
257 fn create_log_file_in_tempdir() {
258 let tmp = tempfile::tempdir().unwrap();
259 let path = tmp.path().join("test.log");
260 let file = create_log_file(path.to_str().unwrap());
261 assert!(file.is_ok());
262 assert!(path.exists());
263 }
264
265 #[test]
266 fn log_initialization_writes_entry() {
267 let tmp = tempfile::tempdir().unwrap();
268 let path = tmp.path().join("init.log");
269 let mut file = create_log_file(path.to_str().unwrap()).unwrap();
270
271 log_initialization(&mut file, "2025-01-01T00:00:00Z").unwrap();
272
273 let contents = std::fs::read_to_string(&path).unwrap();
274 assert!(contents.contains("System initialization complete"));
275 assert!(contents.contains("2025-01-01"));
276 }
277
278 #[test]
279 fn log_arguments_writes_entry() {
280 let tmp = tempfile::tempdir().unwrap();
281 let path = tmp.path().join("args.log");
282 let mut file = create_log_file(path.to_str().unwrap()).unwrap();
283
284 log_arguments(&mut file, "2025-06-15T12:00:00Z").unwrap();
285
286 let contents = std::fs::read_to_string(&path).unwrap();
287 assert!(contents.contains("Arguments processed"));
288 }
289
290 #[test]
291 fn create_log_file_returns_err_for_invalid_path() {
292 // Target a path whose parent doesn't exist so File::create
293 // returns Err, firing the map_err closure that wraps the IO
294 // error into SsgError::Io.
295 let res = create_log_file("/no/such/parent/dir/test.log");
296 assert!(res.is_err());
297 let msg = format!("{}", res.unwrap_err());
298 assert!(!msg.is_empty());
299 }
300
301 #[test]
302 fn initialize_logging_runs_to_completion() {
303 // Exercises the body of initialize_logging once. log::set_logger
304 // is process-global so this can race with other tests that
305 // touch the logger, but the call is idempotent (we use `let _`
306 // on the result) and just covers the parse + set sequence.
307 // Safe to call multiple times — subsequent set_logger calls
308 // return Err which we ignore.
309 let res = initialize_logging();
310 assert!(res.is_ok());
311 }
312
313 #[test]
314 fn apply_log_level_covers_both_branches() {
315 // installed=false must not touch the global level; we cannot
316 // compare before/after snapshots because other tests mutate
317 // the global level concurrently — the branch executing without
318 // side effects is the contract under test.
319 apply_log_level(false, LevelFilter::Error);
320
321 // installed=true sets the level. Trace matches the shared
322 // test fixture's level, so concurrent tests are never starved
323 // of log output. Another test may overwrite the level between
324 // the set and the read, but never to Off.
325 apply_log_level(true, LevelFilter::Trace);
326 assert!(log::max_level() > LevelFilter::Off);
327 }
328
329 #[test]
330 fn log_record_respects_enabled_flag() {
331 let record = log::Record::builder()
332 .level(log::Level::Info)
333 .args(format_args!("visible test record"))
334 .build();
335 // Both branches: filtered out, then printed to stderr.
336 log_record(false, &record);
337 log_record(true, &record);
338 }
339
340 #[test]
341 fn simple_logger_enabled_and_flush() {
342 use log::Log;
343
344 crate::test_support::init_logger();
345 let logger = SimpleLogger;
346 let metadata = log::Metadata::builder()
347 .level(log::Level::Error)
348 .target("ssg-test")
349 .build();
350 assert!(logger.enabled(&metadata));
351 logger.flush(); // no-op, but the region is exercised
352
353 let record = log::Record::builder()
354 .level(log::Level::Error)
355 .args(format_args!("via Log::log"))
356 .build();
357 logger.log(&record);
358 }
359
360 #[test]
361 #[cfg(unix)]
362 fn log_initialization_and_log_arguments_propagate_write_errors() {
363 // Open /dev/null read-only — writes to a read-only file
364 // descriptor return EBADF on Linux/macOS, firing the map_err
365 // closures in both log_initialization and log_arguments.
366 let mut file = std::fs::OpenOptions::new()
367 .read(true)
368 .open("/dev/null")
369 .unwrap();
370 let res_a = log_initialization(&mut file, "2025-01-01");
371 let res_b = log_arguments(&mut file, "2025-01-01");
372 // Don't assert is_err — some platforms accept writes to RO
373 // /dev/null without erroring. The closures were exercised
374 // either way; the bodies of both functions executed end-to-end.
375 let _ = res_a;
376 let _ = res_b;
377 }
378}