use std::fs::{File, OpenOptions};
use std::io::{self, Write};
use std::path::Path;
use chrono::Local;
use env_logger::{Builder, Target};
pub fn init(log_file_override: Option<&Path>) {
let default_filter = if cfg!(debug_assertions) {
"debug"
} else {
"info"
};
let env = env_logger::Env::default().default_filter_or(default_filter);
let (log_path, append) = match log_file_override {
Some(path) => {
(std::path::PathBuf::from(path), false)
}
None => {
let date = today_local_date();
let config_dir = crate::config::dirs::resolve_config_dir(None);
let path = crate::config::dirs::log_file_path_in(&config_dir, &date);
(path, true)
}
};
match open_log_file(&log_path, append) {
Ok(file) => {
let mut builder = Builder::from_env(env);
builder.target(Target::Pipe(Box::new(TeeWriter::new(file))));
builder.init();
log::info!("flowd: logging to {}", log_path.display());
}
Err(e) => {
Builder::from_env(env).init();
log::warn!(
"flowd: could not open log file {}: {e}; falling back to stderr-only",
log_path.display()
);
}
}
}
fn today_local_date() -> String {
Local::now().format("%Y-%m-%d").to_string()
}
fn open_log_file(path: &Path, append: bool) -> io::Result<File> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut opts = OpenOptions::new();
opts.create(true).write(true);
if append {
opts.append(true);
} else {
opts.truncate(true);
}
opts.open(path)
}
struct TeeWriter {
file: File,
}
impl TeeWriter {
fn new(file: File) -> Self {
Self { file }
}
}
impl Write for TeeWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.file.write_all(buf)?;
let _ = io::stderr().write_all(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
self.file.flush()?;
let _ = io::stderr().flush();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn today_local_date_is_iso_format() {
let date = today_local_date();
let parts: Vec<&str> = date.split('-').collect();
assert_eq!(parts.len(), 3, "expected YYYY-MM-DD, got: {date}");
assert_eq!(parts[0].len(), 4, "year should be 4 digits: {date}");
assert_eq!(parts[1].len(), 2, "month should be 2 digits: {date}");
assert_eq!(parts[2].len(), 2, "day should be 2 digits: {date}");
let year: u32 = parts[0]
.parse()
.unwrap_or_else(|_| panic!("year should be numeric: {date}"));
assert!(
(2024..=2100).contains(&year),
"year should be plausible: {date}"
);
}
#[test]
fn tee_writer_writes_payload_to_file() {
let temp = tempfile::tempdir().expect("tempdir creation failed");
let path = temp.path().join("tee-test.log");
let file = File::create(&path).expect("file creation failed");
let mut tee = TeeWriter::new(file);
let payload = b"hello log line\nsecond line\n";
tee.write_all(payload).expect("write_all failed");
tee.flush().expect("flush failed");
drop(tee);
let contents = std::fs::read_to_string(&path).expect("read back failed");
assert_eq!(contents.as_bytes(), payload);
}
#[test]
fn tee_writer_reports_byte_count() {
let temp = tempfile::tempdir().expect("tempdir creation failed");
let path = temp.path().join("tee-count.log");
let file = File::create(&path).expect("file creation failed");
let mut tee = TeeWriter::new(file);
let payload = b"exactly 13 bytes"; let written = tee.write(payload).expect("write failed");
assert_eq!(written, payload.len(), "should report all bytes written");
}
#[test]
fn open_log_file_creates_parent_dirs() {
let temp = tempfile::tempdir().expect("tempdir creation failed");
let nested = temp.path().join("a").join("b").join("logs");
let file_path = nested.join("flowd-test.log");
let _file = open_log_file(&file_path, true).expect("open_log_file failed");
assert!(
file_path.exists(),
"log file should exist after open_log_file"
);
assert!(
nested.exists(),
"parent directory tree should have been created"
);
}
#[test]
fn open_log_file_appends_without_truncating() {
let temp = tempfile::tempdir().expect("tempdir creation failed");
let path = temp.path().join("append-test.log");
{
let mut f = open_log_file(&path, true).expect("first open failed");
f.write_all(b"first line\n").expect("first write failed");
f.flush().expect("first flush failed");
}
{
let mut f = open_log_file(&path, true).expect("second open failed");
f.write_all(b"second line\n").expect("second write failed");
f.flush().expect("second flush failed");
}
let contents = std::fs::read_to_string(&path).expect("read back failed");
assert_eq!(contents, "first line\nsecond line\n");
}
#[test]
fn open_log_file_truncates_when_override() {
let temp = tempfile::tempdir().expect("tempdir creation failed");
let path = temp.path().join("override-test.log");
std::fs::write(&path, "leftover from a previous debug run\n").expect("pre-write failed");
assert_eq!(
std::fs::read_to_string(&path).expect("read back failed"),
"leftover from a previous debug run\n"
);
let _file = open_log_file(&path, false).expect("open in override mode failed");
let contents = std::fs::read_to_string(&path).expect("read back failed");
assert!(
contents.is_empty(),
"override mode should truncate the file to empty, got: {contents}"
);
}
}