Skip to main content

ssg/
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 std::fs::File;
7use std::io::Write;
8
9use anyhow::{Context, Result};
10use log::{info, LevelFilter};
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        if self.enabled(record.metadata()) {
43            eprintln!(
44                "[{} {}] {}",
45                record.level(),
46                record.module_path().unwrap_or(""),
47                record.args()
48            );
49        }
50    }
51
52    fn flush(&self) {}
53}
54
55/// Initializes the logging system based on environment variables.
56pub(crate) fn initialize_logging() -> Result<()> {
57    let log_level = std::env::var(ENV_LOG_LEVEL)
58        .unwrap_or_else(|_| DEFAULT_LOG_LEVEL.to_string());
59
60    let level = parse_log_level(&log_level);
61
62    let _ = log::set_logger(&SimpleLogger).map(|()| log::set_max_level(level));
63
64    info!("Logging initialized at level: {log_level}");
65    Ok(())
66}
67
68/// Creates and initialises a log file for the static site generator.
69///
70/// Establishes a new log file at the specified path with appropriate permissions
71/// and write capabilities. The log file is used to track the generation process
72/// and any errors that occur.
73///
74/// # Arguments
75///
76/// * `file_path` - The desired location for the log file
77///
78/// # Returns
79///
80/// * `Ok(File)` - A file handle for the created log file
81/// * `Err` - If the file cannot be created or permissions are insufficient
82///
83/// # Examples
84///
85/// ```rust
86/// use ssg::create_log_file;
87///
88/// fn main() -> anyhow::Result<()> {
89///     let log_file = create_log_file("./site_generation.log")?;
90///     println!("Log file created successfully");
91///     Ok(())
92/// }
93/// ```
94///
95/// # Errors
96///
97/// Returns an error if:
98/// * The specified path is invalid
99/// * File creation permissions are insufficient
100/// * The parent directory is not writable
101pub fn create_log_file(file_path: &str) -> Result<File> {
102    File::create(file_path).context("Failed to create log file")
103}
104
105/// Records system initialisation in the logging system.
106///
107/// Creates a detailed log entry capturing the system's startup state,
108/// including configuration and initial conditions. Uses the Common Log Format (CLF)
109/// for consistent logging.
110///
111/// # Arguments
112///
113/// * `log_file` - Active file handle for writing log entries
114/// * `date` - Current date and time for log timestamps
115///
116/// # Returns
117///
118/// * `Ok(())` - If the log entry is written successfully
119/// * `Err` - If writing fails or translation errors occur
120///
121/// # Examples
122///
123/// ```rust
124/// use ssg::{create_log_file, log_initialization};
125///
126/// fn main() -> anyhow::Result<()> {
127///     let mut log_file = create_log_file("./site.log")?;
128///     let date = ssg::now_iso();
129///
130///     log_initialization(&mut log_file, &date)?;
131///     println!("System initialisation logged");
132///     Ok(())
133/// }
134/// ```
135pub fn log_initialization(log_file: &mut File, date: &str) -> Result<()> {
136    writeln!(
137        log_file,
138        "[{date}] INFO process: System initialization complete"
139    )
140    .context("Failed to write banner log")
141}
142
143/// Logs processed command-line arguments for debugging and auditing.
144///
145/// Records all provided command-line arguments and their values in the log file,
146/// providing a traceable record of site generation parameters.
147///
148/// # Arguments
149///
150/// * `log_file` - Active file handle for writing log entries
151/// * `date` - Current date and time for log timestamps
152///
153/// # Returns
154///
155/// * `Ok(())` - If arguments are logged successfully
156/// * `Err` - If writing fails or translation errors occur
157///
158/// # Examples
159///
160/// ```rust
161/// use ssg::{create_log_file, log_arguments};
162///
163/// fn main() -> anyhow::Result<()> {
164///     let mut log_file = create_log_file("./site.log")?;
165///     let date = ssg::now_iso();
166///
167///     log_arguments(&mut log_file, &date)?;
168///     println!("Arguments logged successfully");
169///     Ok(())
170/// }
171/// ```
172pub fn log_arguments(log_file: &mut File, date: &str) -> Result<()> {
173    writeln!(log_file, "[{date}] INFO process: Arguments processed")
174        .context("Failed to write arguments log")
175}
176
177#[cfg(test)]
178#[allow(clippy::unwrap_used, clippy::expect_used)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn parse_log_level_info() {
184        assert_eq!(parse_log_level("info"), LevelFilter::Info);
185    }
186
187    #[test]
188    fn parse_log_level_debug() {
189        assert_eq!(parse_log_level("debug"), LevelFilter::Debug);
190    }
191
192    #[test]
193    fn parse_log_level_warn() {
194        assert_eq!(parse_log_level("warn"), LevelFilter::Warn);
195    }
196
197    #[test]
198    fn parse_log_level_error() {
199        assert_eq!(parse_log_level("error"), LevelFilter::Error);
200    }
201
202    #[test]
203    fn parse_log_level_trace() {
204        assert_eq!(parse_log_level("trace"), LevelFilter::Trace);
205    }
206
207    #[test]
208    fn parse_log_level_case_insensitive() {
209        assert_eq!(parse_log_level("DEBUG"), LevelFilter::Debug);
210        assert_eq!(parse_log_level("Warn"), LevelFilter::Warn);
211    }
212
213    #[test]
214    fn parse_log_level_invalid_defaults_to_info() {
215        assert_eq!(parse_log_level("garbage"), LevelFilter::Info);
216        assert_eq!(parse_log_level(""), LevelFilter::Info);
217    }
218
219    #[test]
220    fn create_log_file_in_tempdir() {
221        let tmp = tempfile::tempdir().unwrap();
222        let path = tmp.path().join("test.log");
223        let file = create_log_file(path.to_str().unwrap());
224        assert!(file.is_ok());
225        assert!(path.exists());
226    }
227
228    #[test]
229    fn log_initialization_writes_entry() {
230        let tmp = tempfile::tempdir().unwrap();
231        let path = tmp.path().join("init.log");
232        let mut file = create_log_file(path.to_str().unwrap()).unwrap();
233
234        log_initialization(&mut file, "2025-01-01T00:00:00Z").unwrap();
235
236        let contents = std::fs::read_to_string(&path).unwrap();
237        assert!(contents.contains("System initialization complete"));
238        assert!(contents.contains("2025-01-01"));
239    }
240
241    #[test]
242    fn log_arguments_writes_entry() {
243        let tmp = tempfile::tempdir().unwrap();
244        let path = tmp.path().join("args.log");
245        let mut file = create_log_file(path.to_str().unwrap()).unwrap();
246
247        log_arguments(&mut file, "2025-06-15T12:00:00Z").unwrap();
248
249        let contents = std::fs::read_to_string(&path).unwrap();
250        assert!(contents.contains("Arguments processed"));
251    }
252}