Skip to main content

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}