Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ appenders:
path: "log/requests.log"
encoder:
pattern: "{d} - {m}{n}"
header: "=== Log started at $TIME{%Y-%m-%d %H:%M:%S} on $ENV{USER} ==="
root:
level: warn
appenders:
Expand Down
42 changes: 42 additions & 0 deletions examples/header_demo.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//! Demonstrates the log file header feature.
//!
//! Run with: cargo run --example header_demo --features file_appender

use log4rs::{
append::file::FileAppender,
config::{Appender, Config, Root},
encode::pattern::PatternEncoder,
};
use log::{info, LevelFilter};

fn main() {
// Create a file appender with a header
let header_text = "=== Log File Started at $TIME{%Y-%m-%d %H:%M:%S} ===\n\
=== User: $ENV{USER} ===";

let file_appender = FileAppender::builder()
.header(header_text)
.encoder(Box::new(PatternEncoder::new("{d} [{l}] {m}{n}")))
.build("/tmp/log4rs_header_demo.log")
.expect("Failed to create file appender");

let config = Config::builder()
.appender(Appender::builder().build("file", Box::new(file_appender)))
.build(Root::builder().appender("file").build(LevelFilter::Info))
.expect("Failed to build config");

log4rs::init_config(config).expect("Failed to initialize log4rs");

// Write some log messages
info!("This is the first log message");
info!("This is the second log message");
info!("This is the third log message");

println!("\n✓ Logs written to /tmp/log4rs_header_demo.log");
println!("\nFile contents:");
println!("─────────────────────────────────────");
let contents =
std::fs::read_to_string("/tmp/log4rs_header_demo.log").expect("Failed to read log file");
println!("{}", contents);
println!("─────────────────────────────────────");
}
150 changes: 149 additions & 1 deletion src/append/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ pub struct FileAppenderConfig {
path: String,
encoder: Option<EncoderConfig>,
append: Option<bool>,
header: Option<String>,
}

/// An appender which logs to a file.
Expand All @@ -46,6 +47,8 @@ pub struct FileAppender {
#[debug(skip)]
file: Mutex<SimpleWriter<BufWriter<File>>>,
encoder: Box<dyn Encode>,
#[allow(dead_code)] // reason = "debug purposes only"
header: Option<String>,
}

impl Append for FileAppender {
Expand All @@ -65,6 +68,7 @@ impl FileAppender {
FileAppenderBuilder {
encoder: None,
append: true,
header: None,
}
}
}
Expand All @@ -73,6 +77,7 @@ impl FileAppender {
pub struct FileAppenderBuilder {
encoder: Option<Box<dyn Encode>>,
append: bool,
header: Option<String>,
}

impl FileAppenderBuilder {
Expand All @@ -90,6 +95,19 @@ impl FileAppenderBuilder {
self
}

/// Sets the header text to write at the beginning of new log files.
///
/// The header supports templating with `$ENV{var}` for environment variables
/// and `$TIME{format}` for timestamps using chrono formatting.
/// A newline is automatically appended after the header.
///
/// The header is only written when a new empty file is created or when
/// truncating an existing file.
pub fn header(mut self, header: impl Into<String>) -> FileAppenderBuilder {
self.header = Some(header.into());
self
}

/// Consumes the `FileAppenderBuilder`, producing a `FileAppender`.
/// The path argument can contain special patterns that will be resolved:
///
Expand All @@ -111,19 +129,37 @@ impl FileAppenderBuilder {
if let Some(parent) = final_path.parent() {
fs::create_dir_all(parent)?;
}

// Check if file exists and is empty before opening
let file_was_empty = if final_path.exists() {
fs::metadata(&final_path)?.len() == 0
} else {
true
};

let file = OpenOptions::new()
.write(true)
.append(self.append)
.truncate(!self.append)
.create(true)
.open(&final_path)?;

let mut writer = SimpleWriter(BufWriter::with_capacity(1024, file));

// Write header if file was empty or being truncated
if file_was_empty || !self.append {
if let Some(ref header) = self.header {
super::header_util::write_header(&mut writer, header)?;
}
}

Ok(FileAppender {
path: final_path,
file: Mutex::new(SimpleWriter(BufWriter::with_capacity(1024, file))),
file: Mutex::new(writer),
encoder: self
.encoder
.unwrap_or_else(|| Box::<PatternEncoder>::default()),
header: self.header,
})
}

Expand Down Expand Up @@ -179,6 +215,12 @@ impl FileAppenderBuilder {
/// # The encoder to use to format output. Defaults to `kind: pattern`.
/// encoder:
/// kind: pattern
///
/// # Optional header text to write at the beginning of new log files.
/// # The header supports $ENV{var} and $TIME{format} templating.
/// # A newline is automatically appended. Only written when a new empty
/// # file is created or when truncating.
/// header: "=== Log started at $TIME{%Y-%m-%d %H:%M:%S} ==="
/// ```
#[cfg(feature = "config_parsing")]
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Default)]
Expand All @@ -202,6 +244,9 @@ impl Deserialize for FileAppenderDeserializer {
if let Some(encoder) = config.encoder {
appender = appender.encoder(deserializers.deserialize(&encoder.kind, encoder.config)?);
}
if let Some(header) = config.header {
appender = appender.header(header);
}
Ok(Box::new(appender.build(&config.path)?))
}
}
Expand Down Expand Up @@ -370,4 +415,107 @@ mod test {
));
assert_eq!(builder.path, expected_path);
}

#[test]
fn test_header_on_new_file() {
let tempdir = tempfile::tempdir().unwrap();
let path = tempdir.path().join("header_test.log");

FileAppender::builder()
.header("=== TEST HEADER ===")
.build(&path)
.unwrap();

let contents = fs::read_to_string(&path).unwrap();
assert!(contents.starts_with("=== TEST HEADER ===\n"));
}

#[test]
fn test_header_with_env_templating() {
use std::env;

let tempdir = tempfile::tempdir().unwrap();
let path = tempdir.path().join("template_test.log");

env::set_var("LOG4RS_TEST_VAR", "test_value");

FileAppender::builder()
.header("Header: $ENV{LOG4RS_TEST_VAR}")
.build(&path)
.unwrap();

let contents = fs::read_to_string(&path).unwrap();
assert!(contents.starts_with("Header: test_value\n"));
}

#[test]
fn test_header_with_time_templating() {
let tempdir = tempfile::tempdir().unwrap();
let path = tempdir.path().join("time_test.log");
let current_year = Local::now().format("%Y").to_string();

FileAppender::builder()
.header("Log started in $TIME{%Y}")
.build(&path)
.unwrap();

let contents = fs::read_to_string(&path).unwrap();
assert!(contents.starts_with(&format!("Log started in {}\n", current_year)));
}

#[test]
fn test_no_header_on_existing_nonempty_file_append() {
let tempdir = tempfile::tempdir().unwrap();
let path = tempdir.path().join("existing.log");

// Create file with content
fs::write(&path, "existing content\n").unwrap();

FileAppender::builder()
.header("=== HEADER ===")
.append(true)
.build(&path)
.unwrap();

let contents = fs::read_to_string(&path).unwrap();
assert!(!contents.contains("=== HEADER ==="));
assert_eq!(contents, "existing content\n");
}

#[test]
fn test_header_on_existing_empty_file_append() {
let tempdir = tempfile::tempdir().unwrap();
let path = tempdir.path().join("empty.log");

// Create empty file
File::create(&path).unwrap();

FileAppender::builder()
.header("=== HEADER ===")
.append(true)
.build(&path)
.unwrap();

let contents = fs::read_to_string(&path).unwrap();
assert!(contents.starts_with("=== HEADER ===\n"));
}

#[test]
fn test_header_with_truncate() {
let tempdir = tempfile::tempdir().unwrap();
let path = tempdir.path().join("truncate.log");

// Create file with content
fs::write(&path, "old content\n").unwrap();

FileAppender::builder()
.header("=== NEW HEADER ===")
.append(false)
.build(&path)
.unwrap();

let contents = fs::read_to_string(&path).unwrap();
assert!(contents.starts_with("=== NEW HEADER ===\n"));
assert!(!contents.contains("old content"));
}
}
49 changes: 49 additions & 0 deletions src/append/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,55 @@ mod env_util {
}
}

#[cfg(any(feature = "file_appender", feature = "rolling_file_appender"))]
mod header_util {
use chrono::Local;
use std::io::{self, Write};

const TIME_PREFIX: &str = "$TIME{";
const TIME_PREFIX_LEN: usize = TIME_PREFIX.len();
const TIME_SUFFIX: char = '}';
const TIME_SUFFIX_LEN: usize = 1;
const MAX_REPLACEMENTS: usize = 5;

/// Expands $TIME{format} placeholders in text using chrono formatting
pub fn expand_time_vars(text: &str) -> String {
let mut result = text.to_string();
let mut replacements = 0;

while let Some(start) = result.find(TIME_PREFIX) {
if replacements >= MAX_REPLACEMENTS {
break;
}
if let Some(end_offset) = result[start..].find(TIME_SUFFIX) {
let end = start + end_offset;
let format = &result[start + TIME_PREFIX_LEN..end];
let now = Local::now();
let formatted = now.format(format).to_string();
result.replace_range(start..end + TIME_SUFFIX_LEN, &formatted);
replacements += 1;
} else {
break;
}
}
result
}

/// Writes a header to the writer with templating support
pub fn write_header(writer: &mut impl Write, header_text: &str) -> io::Result<()> {
// Apply $ENV{} templating
let expanded = super::env_util::expand_env_vars(header_text);
// Apply $TIME{} templating
let templated = expand_time_vars(expanded.as_ref());

// Write header text + automatic newline
writer.write_all(templated.as_bytes())?;
writer.write_all(crate::encode::NEWLINE.as_bytes())?;
writer.flush()?;
Ok(())
}
}

/// A trait implemented by log4rs appenders.
///
/// Appenders take a log record and processes them, for example, by writing it
Expand Down
Loading