From 2ba48663e4538852305dd327ae249595f047e518 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Tue, 18 Nov 2025 15:49:53 +0100 Subject: [PATCH] feat: logging to log file --- soulseek-rs-lib/src/utils/logger.rs | 110 +++++++++++++++++++++++++++- soulseek-rs/src/cli.rs | 4 + soulseek-rs/src/main.rs | 5 ++ 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/soulseek-rs-lib/src/utils/logger.rs b/soulseek-rs-lib/src/utils/logger.rs index 7170ffb..24f745b 100644 --- a/soulseek-rs-lib/src/utils/logger.rs +++ b/soulseek-rs-lib/src/utils/logger.rs @@ -1,4 +1,6 @@ use std::env; +use std::fs::{File, OpenOptions}; +use std::io::Write; use std::sync::{ atomic::{AtomicBool, Ordering}, Mutex, Once, @@ -18,6 +20,7 @@ static mut LOG_LEVEL: LogLevel = LogLevel::Warn; static BUFFER: Mutex> = Mutex::new(Vec::new()); static BUFFERING: AtomicBool = AtomicBool::new(false); +static LOG_FILE: Mutex> = Mutex::new(None); pub fn init() { INIT.call_once(|| { @@ -37,6 +40,24 @@ pub fn init() { _ => LogLevel::Warn, // Default to WARN }; } + + // Initialize log file if LOG_FILE env var is set + if let Ok(log_file_path) = env::var("LOG_FILE") { + match OpenOptions::new() + .create(true) + .append(true) + .open(&log_file_path) + { + Ok(file) => { + if let Ok(mut log_file) = LOG_FILE.lock() { + *log_file = Some(file); + } + } + Err(e) => { + eprintln!("Failed to open log file '{}': {}", log_file_path, e); + } + } + } }); } @@ -103,6 +124,14 @@ pub fn log(level: LogLevel, message: &str) { let minutes = (seconds_in_day % 3600) / 60; let seconds = seconds_in_day % 60; + let level_str_plain = match level { + LogLevel::Error => "ERROR", + LogLevel::Warn => "WARN", + LogLevel::Info => "INFO", + LogLevel::Debug => "DEBUG", + LogLevel::Trace => "TRACE", + }; + let formatted_message = format!( "[{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:03}] [{}] {}", year, @@ -116,12 +145,44 @@ pub fn log(level: LogLevel, message: &str) { message ); + let formatted_message_plain = format!( + "[{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:03}] [{}] {}", + year, + month, + day, + hours, + minutes, + seconds, + subsec_millis, + level_str_plain, + message + ); + if BUFFERING.load(Ordering::Relaxed) { + // When buffering is enabled, add to buffer for stderr if let Ok(mut buffer) = BUFFER.lock() { - buffer.push(formatted_message); + buffer.push(formatted_message.clone()); + } + // But ALSO write to file if configured (file logging bypasses buffering) + if let Ok(mut log_file) = LOG_FILE.lock() { + if let Some(file) = log_file.as_mut() { + let _ = writeln!(file, "{}", formatted_message_plain); + let _ = file.flush(); + } } } else { - eprintln!("{}", formatted_message); + // Write to file if configured, otherwise to stderr + if let Ok(mut log_file) = LOG_FILE.lock() { + if let Some(file) = log_file.as_mut() { + let _ = writeln!(file, "{}", formatted_message_plain); + let _ = file.flush(); + } else { + eprintln!("{}", formatted_message); + } + } else { + // If lock fails, fall back to stderr + eprintln!("{}", formatted_message); + } } } } @@ -139,13 +200,54 @@ pub fn flush_buffered_logs() { disable_buffering(); if let Ok(mut buffer) = BUFFER.lock() { - for message in buffer.iter() { - eprintln!("{}", message); + // Write to file if configured, otherwise to stderr + if let Ok(mut log_file) = LOG_FILE.lock() { + if let Some(file) = log_file.as_mut() { + for message in buffer.iter() { + // Strip ANSI codes for file output + let plain_message = strip_ansi_codes(message); + let _ = writeln!(file, "{}", plain_message); + } + let _ = file.flush(); + } else { + for message in buffer.iter() { + eprintln!("{}", message); + } + } + } else { + // If lock fails, fall back to stderr + for message in buffer.iter() { + eprintln!("{}", message); + } } buffer.clear(); } } +fn strip_ansi_codes(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + + while let Some(c) = chars.next() { + if c == '\x1b' { + // Skip ANSI escape sequence + if chars.peek() == Some(&'[') { + chars.next(); // consume '[' + while let Some(&ch) = chars.peek() { + chars.next(); + if ch.is_ascii_alphabetic() { + break; + } + } + } + } else { + result.push(c); + } + } + + result +} + #[macro_export] macro_rules! error { ($($arg:tt)*) => { diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs index eeb04d9..7e221c2 100644 --- a/soulseek-rs/src/cli.rs +++ b/soulseek-rs/src/cli.rs @@ -1,4 +1,5 @@ use clap::{Parser, Subcommand}; +use std::path::PathBuf; #[derive(Parser, Debug)] #[command( @@ -32,6 +33,9 @@ pub struct Cli { #[arg(short, long, global = true, action = clap::ArgAction::Count)] pub verbose: u8, + #[arg(long, env = "LOG_FILE", help = "Write logs to file instead of stderr")] + pub log_file: Option, + #[command(subcommand)] pub command: Option, diff --git a/soulseek-rs/src/main.rs b/soulseek-rs/src/main.rs index 682243d..0668342 100644 --- a/soulseek-rs/src/main.rs +++ b/soulseek-rs/src/main.rs @@ -29,6 +29,11 @@ fn main() -> Result<()> { }; env::set_var("LOG_LEVEL", log_level); + // Set LOG_FILE env var if provided via CLI + if let Some(log_file) = &cli.log_file { + env::set_var("LOG_FILE", log_file.to_string_lossy().to_string()); + } + let username = cli.username .ok_or_else(|| color_eyre::eyre::eyre!("Username required: use --username or set SOULSEEK_USERNAME env var"))?;