From cd57ea66821f922ce008c875cc8c558b01aa326e Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 2 Nov 2025 17:52:04 +0000 Subject: [PATCH] Add log rotation to server & UI improvements (#157) --- .editorconfig | 1 + .gitignore | 4 +- .../src/views/cursors/file-explorer.ts | 2 +- .../src/views/history/history-view.scss | 2 +- .../sync-operations/unrestricted-syncer.ts | 9 +- .../sync-client/src/tracing/sync-history.ts | 3 +- scripts/check.sh | 2 +- sync-server/Cargo.lock | 17 + sync-server/Cargo.toml | 4 + sync-server/config-e2e.yml | 3 + sync-server/src/app_state.rs | 10 +- sync-server/src/config.rs | 4 + sync-server/src/config/logging_config.rs | 34 ++ sync-server/src/consts.rs | 3 + sync-server/src/errors.rs | 1 + sync-server/src/main.rs | 74 +++- sync-server/src/server.rs | 8 +- sync-server/src/utils.rs | 1 + sync-server/src/utils/rotating_file_writer.rs | 364 ++++++++++++++++++ 19 files changed, 508 insertions(+), 38 deletions(-) create mode 100644 sync-server/src/config/logging_config.rs create mode 100644 sync-server/src/utils/rotating_file_writer.rs diff --git a/.editorconfig b/.editorconfig index 9c63a68d..7074dff5 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,6 +9,7 @@ trim_trailing_whitespace = true charset = utf-8 indent_style = space indent_size = 4 +tab_width = 4 [*.{yml,yaml}] indent_size = 2 diff --git a/.gitignore b/.gitignore index ef64105e..a1c1ac4f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,6 @@ node_modules # Exclude macOS Finder (System Explorer) View States .DS_Store - - # Frontend build folders frontend/*/dist @@ -19,3 +17,5 @@ sync-server/bindings/*.ts *.log *.sqlx + +target diff --git a/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts b/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts index be71c058..78bf3e4f 100644 --- a/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts +++ b/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts @@ -34,7 +34,7 @@ export function renderCursorsInFileExplorer( (parent) => { cursors.forEach((cursor) => { cursor.documentsWithCursors.forEach((document) => { - if (document.relative_path === key) { + if (document.relative_path.startsWith(key)) { parent.appendChild( createSpan({ text: cursor.userName, diff --git a/frontend/obsidian-plugin/src/views/history/history-view.scss b/frontend/obsidian-plugin/src/views/history/history-view.scss index 6033fd2b..fb93fa30 100644 --- a/frontend/obsidian-plugin/src/views/history/history-view.scss +++ b/frontend/obsidian-plugin/src/views/history/history-view.scss @@ -4,6 +4,7 @@ background-color: var(--color-base-00); border-radius: var(--radius-l); container-type: inline-size; + word-break: break-word; &.clickable { cursor: pointer; @@ -38,7 +39,6 @@ display: flex; align-items: center; gap: var(--size-4-2); - word-break: break-all; margin: 0; > span { diff --git a/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts b/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts index 0d0f45ef..1f7e908c 100644 --- a/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts +++ b/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts @@ -228,7 +228,8 @@ export class UnrestrictedSyncer { }, message: "File has been deleted remotely, so we deleted it locally", - author: response.userId + author: response.userId, + timestamp: new Date(response.updatedDate) }); this.database.delete(document.relativePath); @@ -325,7 +326,8 @@ export class UnrestrictedSyncer { status: SyncStatus.SUCCESS, details: actualUpdateDetails, message: `Successfully downloaded remotely updated file from the server`, - author: response.userId + author: response.userId, + timestamp: new Date(response.updatedDate) }); } }); @@ -429,7 +431,8 @@ export class UnrestrictedSyncer { status: SyncStatus.SUCCESS, details: updateDetails, message: `Successfully downloaded remote file which hadn't existed locally`, - author: remoteVersion.userId + author: remoteVersion.userId, + timestamp: new Date(remoteVersion.updatedDate) }); }); } diff --git a/frontend/sync-client/src/tracing/sync-history.ts b/frontend/sync-client/src/tracing/sync-history.ts index 6890688b..92904ce6 100644 --- a/frontend/sync-client/src/tracing/sync-history.ts +++ b/frontend/sync-client/src/tracing/sync-history.ts @@ -39,6 +39,7 @@ export interface CommonHistoryEntry { message: string; details: SyncDetails; author?: string; + timestamp?: Date; } export enum SyncType { @@ -92,7 +93,7 @@ export class SyncHistory { public addHistoryEntry(entry: CommonHistoryEntry): void { const historyEntry = { ...entry, - timestamp: new Date() + timestamp: entry.timestamp ?? new Date() }; const candidate = this.findSimilarRecentUpdateEntry(historyEntry); diff --git a/scripts/check.sh b/scripts/check.sh index 0a28653c..eccc5714 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -21,7 +21,7 @@ else cargo fmt --all -- --check fi -cargo machete +cargo machete --with-metadata echo "Running checks in frontend" cd ../frontend diff --git a/sync-server/Cargo.lock b/sync-server/Cargo.lock index d217f1bf..29ab184c 100644 --- a/sync-server/Cargo.lock +++ b/sync-server/Cargo.lock @@ -991,6 +991,22 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + [[package]] name = "hyper" version = "1.5.1" @@ -2314,6 +2330,7 @@ dependencies = [ "clap", "clap-verbosity-flag", "futures", + "humantime-serde", "log", "rand 0.9.0", "reconcile-text", diff --git a/sync-server/Cargo.toml b/sync-server/Cargo.toml index 016c6386..db5702a0 100644 --- a/sync-server/Cargo.toml +++ b/sync-server/Cargo.toml @@ -20,6 +20,7 @@ axum_typed_multipart = "0.11.0" tower-http = { version = "0.6.1", features = ["cors", "trace", "limit", "timeout"] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["fmt", "env-filter"]} +humantime-serde = "1.1.1" sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono"] } chrono = { version = "0.4.41", features = ["serde"] } rand = "0.9.0" @@ -87,3 +88,6 @@ similar_names = { level = "allow", priority = 1 } missing_docs_in_private_items = { level = "allow", priority = 1 } pedantic = { level = "warn", priority = 0 } + +[package.metadata.cargo-machete] +ignored = ["humantime-serde"] # only used in serde macro diff --git a/sync-server/config-e2e.yml b/sync-server/config-e2e.yml index 5f2346d6..0b8491ee 100644 --- a/sync-server/config-e2e.yml +++ b/sync-server/config-e2e.yml @@ -24,3 +24,6 @@ users: type: allow_list allowed: - default +logging: + log_directory: logs + log_rotation: 7days diff --git a/sync-server/src/app_state.rs b/sync-server/src/app_state.rs index a61467d5..2019e08e 100644 --- a/sync-server/src/app_state.rs +++ b/sync-server/src/app_state.rs @@ -2,14 +2,12 @@ pub mod cursors; pub mod database; pub mod websocket; -use std::ffi::OsString; - use anyhow::Result; use cursors::Cursors; use database::Database; use websocket::broadcasts::Broadcasts; -use crate::{config::Config, consts::DEFAULT_CONFIG_PATH}; +use crate::config::Config; #[derive(Clone, Debug)] pub struct AppState { @@ -20,11 +18,7 @@ pub struct AppState { } impl AppState { - pub async fn try_new(config_path: Option) -> Result { - let config_path = config_path.unwrap_or_else(|| OsString::from(DEFAULT_CONFIG_PATH)); - let path = std::path::PathBuf::from(config_path); - - let config = Config::read_or_create(&path).await?; + pub async fn try_new(config: Config) -> Result { let broadcasts = Broadcasts::new(&config.server); let database = Database::try_new(&config.database, &broadcasts).await?; let cursors: Cursors = Cursors::new(&config.database, &broadcasts); diff --git a/sync-server/src/config.rs b/sync-server/src/config.rs index 700b1ea8..2e1a6e39 100644 --- a/sync-server/src/config.rs +++ b/sync-server/src/config.rs @@ -3,12 +3,14 @@ use std::path::Path; use anyhow::{Context as _, Result}; use database_config::DatabaseConfig; use log::info; +use logging_config::LoggingConfig; use serde::{Deserialize, Serialize}; use server_config::ServerConfig; use tokio::fs; use user_config::UserConfig; pub mod database_config; +pub mod logging_config; pub mod server_config; pub mod user_config; @@ -20,6 +22,8 @@ pub struct Config { pub server: ServerConfig, #[serde(default)] pub users: UserConfig, + #[serde(default)] + pub logging: LoggingConfig, } impl Config { diff --git a/sync-server/src/config/logging_config.rs b/sync-server/src/config/logging_config.rs new file mode 100644 index 00000000..95ab9350 --- /dev/null +++ b/sync-server/src/config/logging_config.rs @@ -0,0 +1,34 @@ +use std::time::Duration; + +use log::debug; +use serde::{Deserialize, Serialize}; + +use crate::consts::{DEFAULT_LOG_DIRECTORY, DEFAULT_LOG_ROTATION_INTERVAL}; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct LoggingConfig { + #[serde(default = "default_log_directory")] + pub log_directory: String, + + #[serde(default = "default_log_rotation", with = "humantime_serde")] + pub log_rotation: Duration, +} + +impl Default for LoggingConfig { + fn default() -> Self { + Self { + log_directory: default_log_directory(), + log_rotation: default_log_rotation(), + } + } +} + +fn default_log_directory() -> String { + debug!("Using default log directory: {DEFAULT_LOG_DIRECTORY}"); + DEFAULT_LOG_DIRECTORY.to_owned() +} + +fn default_log_rotation() -> Duration { + debug!("Using default log rotation: {DEFAULT_LOG_ROTATION_INTERVAL:?}"); + DEFAULT_LOG_ROTATION_INTERVAL +} diff --git a/sync-server/src/consts.rs b/sync-server/src/consts.rs index df5a2844..d973ca4a 100644 --- a/sync-server/src/consts.rs +++ b/sync-server/src/consts.rs @@ -11,3 +11,6 @@ pub const DEFAULT_PORT: u16 = 3000; pub const DEFAULT_MAX_BODY_SIZE_MB: usize = 4096; pub const DEFAULT_RESPONSE_TIMEOUT_SECONDS: u64 = 60; pub const DEFAULT_MAX_CLIENTS_PER_VAULT: usize = 256; + +pub const DEFAULT_LOG_DIRECTORY: &str = "logs"; +pub const DEFAULT_LOG_ROTATION_INTERVAL: Duration = Duration::from_secs(60 * 60 * 24); // 1 day diff --git a/sync-server/src/errors.rs b/sync-server/src/errors.rs index 987c3011..831b0e86 100644 --- a/sync-server/src/errors.rs +++ b/sync-server/src/errors.rs @@ -55,6 +55,7 @@ pub struct SerializedError { impl Display for SerializedError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.error_type, self.message)?; if !self.causes.is_empty() { write!(f, "\nCauses:\n")?; for cause in &self.causes { diff --git a/sync-server/src/main.rs b/sync-server/src/main.rs index 83556542..aba6574e 100644 --- a/sync-server/src/main.rs +++ b/sync-server/src/main.rs @@ -6,24 +6,45 @@ mod errors; mod server; mod utils; -use std::process::ExitCode; +use std::{ffi::OsString, path::PathBuf, process::ExitCode}; use anyhow::{Context as _, Result}; use clap::Parser; use cli::args::Args; +use config::Config; +use consts::DEFAULT_CONFIG_PATH; use errors::{SyncServerError, init_error}; use log::info; use server::create_server; -use tracing_subscriber::{EnvFilter, fmt::format, util::SubscriberInitExt}; +use tracing_subscriber::{EnvFilter, fmt::format, layer::SubscriberExt, util::SubscriberInitExt}; +use utils::rotating_file_writer::RotatingFileWriter; #[tokio::main] async fn main() -> ExitCode { let args = Args::parse(); - let mut result = set_up_logging(&args); + let config_path = args + .config_path + .clone() + .unwrap_or_else(|| OsString::from(DEFAULT_CONFIG_PATH)); + let path = PathBuf::from(config_path); + + let config = match Config::read_or_create(&path) + .await + .context("Failed to start server") + .map_err(init_error) + { + Ok(config) => config, + Err(e) => { + eprintln!("{}", e.serialize()); + return ExitCode::FAILURE; + } + }; + + let mut result = set_up_logging(&args, &config.logging); if result.is_ok() { - result = start_server(args).await; + result = start_server(config).await; } match result { @@ -35,7 +56,10 @@ async fn main() -> ExitCode { } } -fn set_up_logging(args: &Args) -> Result<(), SyncServerError> { +fn set_up_logging( + args: &Args, + logging_config: &config::logging_config::LoggingConfig, +) -> Result<(), SyncServerError> { let level_filter = match args.verbose.log_level_filter() { // We don't want to allow disabling all logging log::LevelFilter::Off | log::LevelFilter::Error => tracing::Level::ERROR, @@ -55,17 +79,33 @@ fn set_up_logging(args: &Args) -> Result<(), SyncServerError> { let is_debug_mode = args.verbose.log_level_filter() >= log::LevelFilter::Debug; - tracing_subscriber::fmt() + let file_appender = RotatingFileWriter::new( + &logging_config.log_directory, + "vault-link", + logging_config.log_rotation, + ) + .context("Failed to create rotating file writer") + .map_err(init_error)?; + + let format = format() + .with_target(is_debug_mode) + .with_line_number(is_debug_mode) + .compact(); + + let stdout_layer = tracing_subscriber::fmt::layer() .with_ansi(use_colors) - .with_env_filter(env_filter) - .event_format( - format() - .without_time() - .with_target(is_debug_mode) - .with_line_number(is_debug_mode) - .compact(), - ) - .finish() + .with_writer(std::io::stdout) + .event_format(format.clone()); + + let file_layer = tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_writer(file_appender) + .event_format(format); + + tracing_subscriber::registry() + .with(env_filter) + .with(stdout_layer) + .with(file_layer) .try_init() .context("Failed to initialise tracing") .map_err(init_error)?; @@ -73,13 +113,13 @@ fn set_up_logging(args: &Args) -> Result<(), SyncServerError> { Ok(()) } -async fn start_server(args: Args) -> Result<(), SyncServerError> { +async fn start_server(config: Config) -> Result<(), SyncServerError> { info!( "Starting VaultLink server version {}", env!("CARGO_PKG_VERSION") ); - create_server(args.config_path) + create_server(config) .await .context("Failed to start server") .map_err(init_error) diff --git a/sync-server/src/server.rs b/sync-server/src/server.rs index cddcc1b5..f63ef551 100644 --- a/sync-server/src/server.rs +++ b/sync-server/src/server.rs @@ -13,7 +13,7 @@ mod responses; mod update_document; mod websocket; -use std::{ffi::OsString, time::Duration}; +use std::time::Duration; use anyhow::{Context as _, Result, anyhow}; use auth::auth_middleware; @@ -42,12 +42,12 @@ use tracing::{Level, info_span}; use crate::{ app_state::AppState, - config::server_config::ServerConfig, + config::{Config, server_config::ServerConfig}, errors::{client_error, not_found_error}, }; -pub async fn create_server(config_path: Option) -> Result<()> { - let app_state = AppState::try_new(config_path) +pub async fn create_server(config: Config) -> Result<()> { + let app_state = AppState::try_new(config) .await .context("Failed to initialise app state")?; diff --git a/sync-server/src/utils.rs b/sync-server/src/utils.rs index 010524de..b70705f6 100644 --- a/sync-server/src/utils.rs +++ b/sync-server/src/utils.rs @@ -1,4 +1,5 @@ pub mod dedup_paths; pub mod is_file_type_mergable; pub mod normalize; +pub mod rotating_file_writer; pub mod sanitize_path; diff --git a/sync-server/src/utils/rotating_file_writer.rs b/sync-server/src/utils/rotating_file_writer.rs new file mode 100644 index 00000000..9f59c5e5 --- /dev/null +++ b/sync-server/src/utils/rotating_file_writer.rs @@ -0,0 +1,364 @@ +use std::{ + fs::{self, OpenOptions}, + io::{self, Write}, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use chrono::{Local, NaiveDateTime}; +use tracing_subscriber::fmt::MakeWriter; + +#[derive(Clone)] +pub struct RotatingFileWriter { + inner: Arc>, +} + +struct RotatingFileWriterInner { + directory: PathBuf, + file_prefix: String, + rotation_duration: Duration, + current_file: Option, + next_rotation_time: SystemTime, +} + +impl RotatingFileWriter { + pub fn new( + directory: impl AsRef, + file_prefix: &str, + rotation_duration: Duration, + ) -> io::Result { + let directory = directory.as_ref().to_path_buf(); + + fs::create_dir_all(&directory)?; + + let next_rotation_time = + Self::calculate_next_rotation_time(&directory, file_prefix, rotation_duration); + + let inner = RotatingFileWriterInner { + directory, + file_prefix: file_prefix.to_owned(), + rotation_duration, + current_file: None, + next_rotation_time, + }; + + Ok(Self { + inner: Arc::new(Mutex::new(inner)), + }) + } + + /// Parse timestamp from log filename and return as `SystemTime` + fn parse_log_timestamp(filename: &str, file_prefix: &str) -> Option { + // Expected format: {prefix}.{timestamp}.log where timestamp is %Y-%m-%d_%H-%M-%S + let prefix_len = file_prefix.len() + 1; // +1 for the dot + let timestamp_str = filename.get(prefix_len..filename.len().checked_sub(4)?)?; + + let dt = NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d_%H-%M-%S").ok()?; + let timestamp = dt.and_local_timezone(Local).single()?; + let secs: u64 = timestamp.timestamp().try_into().ok()?; + + Some(UNIX_EPOCH + Duration::from_secs(secs)) + } + + fn find_latest_log_file(directory: &Path, file_prefix: &str) -> Option { + fs::read_dir(directory) + .ok()? + .filter_map(Result::ok) + .filter_map(|entry| { + let filename = entry.file_name().into_string().ok()?; + let has_correct_prefix = filename.starts_with(file_prefix); + let has_log_extension = Path::new(&filename) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("log")); + + (has_correct_prefix && has_log_extension).then_some(filename) + }) + .max() + } + + fn calculate_next_rotation_time( + directory: &Path, + file_prefix: &str, + rotation_duration: Duration, + ) -> SystemTime { + Self::find_latest_log_file(directory, file_prefix) + .and_then(|filename| Self::parse_log_timestamp(&filename, file_prefix)) + .map_or_else(SystemTime::now, |last_rotation| { + last_rotation + rotation_duration + }) + } + + fn should_rotate(inner: &RotatingFileWriterInner) -> bool { + SystemTime::now() >= inner.next_rotation_time + } + + fn rotate(inner: &mut RotatingFileWriterInner) -> io::Result<()> { + let timestamp = Local::now().format("%Y-%m-%d_%H-%M-%S"); + let filename = format!("{}.{}.log", inner.file_prefix, timestamp); + let filepath = inner.directory.join(filename); + + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&filepath)?; + + inner.current_file = Some(file); + inner.next_rotation_time = SystemTime::now() + inner.rotation_duration; + + Ok(()) + } +} + +impl Write for RotatingFileWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + let mut inner = self.inner.lock().unwrap(); + + if inner.current_file.is_none() || Self::should_rotate(&inner) { + Self::rotate(&mut inner)?; + } + + if let Some(ref mut file) = inner.current_file { + file.write(buf) + } else { + Err(io::Error::other("Failed to open log file")) + } + } + + fn flush(&mut self) -> io::Result<()> { + let mut inner = self.inner.lock().unwrap(); + if let Some(ref mut file) = inner.current_file { + file.flush() + } else { + Ok(()) + } + } +} + +impl<'a> MakeWriter<'a> for RotatingFileWriter { + type Writer = Self; + + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + + #[test] + fn test_write_creates_log_file_and_directory() { + let temp_dir = std::env::temp_dir().join("test_write_creates_log_file_and_directory"); + + let mut writer = + RotatingFileWriter::new(&temp_dir, "test", Duration::from_secs(3600)).unwrap(); + writer.write_all(b"test log message\n").unwrap(); + writer.flush().unwrap(); + + // Check that a log file was created + let entries: Vec<_> = fs::read_dir(&temp_dir) + .unwrap() + .filter_map(Result::ok) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "log")) + .collect(); + + assert!(temp_dir.exists()); + assert_eq!(entries.len(), 1); + + fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_rotation_after_duration() { + let temp_dir = std::env::temp_dir().join("test_rotation_after_duration"); + + // Use a very short rotation duration + // Note: We need to wait at least 1 second between rotations since + // filename timestamps only have second precision + let mut writer = + RotatingFileWriter::new(&temp_dir, "test", Duration::from_millis(500)).unwrap(); + + writer.write_all(b"first message\n").unwrap(); + writer.flush().unwrap(); + + // Wait for rotation time to pass (at least 1 second for different timestamp) + thread::sleep(Duration::from_millis(1100)); + + writer.write_all(b"second message\n").unwrap(); + writer.flush().unwrap(); + + // Check that two log files were created + let entries: Vec<_> = fs::read_dir(&temp_dir) + .unwrap() + .filter_map(Result::ok) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "log")) + .collect(); + + assert_eq!(entries.len(), 2); + + fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_calculate_next_rotation_time_no_existing_logs() { + let temp_dir = + std::env::temp_dir().join("test_calculate_next_rotation_time_no_existing_logs"); + + fs::create_dir_all(&temp_dir).unwrap(); + + let before = SystemTime::now(); + let next_rotation = RotatingFileWriter::calculate_next_rotation_time( + &temp_dir, + "test", + Duration::from_secs(3600), + ); + let after = SystemTime::now(); + + // Should return current time (within a small window) + assert!(next_rotation >= before && next_rotation <= after + Duration::from_secs(1)); + + fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_calculate_next_rotation_time_with_existing_log() { + let temp_dir = + std::env::temp_dir().join("test_calculate_next_rotation_time_with_existing_log"); + + fs::create_dir_all(&temp_dir).unwrap(); + + // Create a log file with a known timestamp + let timestamp_str = "2025-10-26_14-30-00"; + let filename = format!("test.{timestamp_str}.log"); + fs::write(temp_dir.join(&filename), b"test").unwrap(); + + let rotation_duration = Duration::from_secs(3600); + let next_rotation = + RotatingFileWriter::calculate_next_rotation_time(&temp_dir, "test", rotation_duration); + + // Parse the expected time + let expected_dt = + NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d_%H-%M-%S").unwrap(); + let expected_timestamp = expected_dt.and_local_timezone(Local).single().unwrap(); + let expected_duration = + Duration::from_secs(expected_timestamp.timestamp().try_into().unwrap()); + let expected_next = UNIX_EPOCH + expected_duration + rotation_duration; + + // Allow 1 second tolerance for timing differences + let diff = if next_rotation > expected_next { + next_rotation.duration_since(expected_next).unwrap() + } else { + expected_next.duration_since(next_rotation).unwrap() + }; + + assert!( + diff < Duration::from_secs(2), + "Expected {expected_next:?}, got {next_rotation:?}" + ); + + fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_picks_latest_log_file() { + let temp_dir = std::env::temp_dir().join("test_picks_latest_log_file"); + + fs::create_dir_all(&temp_dir).unwrap(); + + // Create multiple log files + fs::write(temp_dir.join("test.2025-10-26_10-00-00.log"), b"old").unwrap(); + fs::write(temp_dir.join("test.2025-10-26_14-00-00.log"), b"newer").unwrap(); + fs::write(temp_dir.join("test.2025-10-26_12-00-00.log"), b"middle").unwrap(); + + let rotation_duration = Duration::from_secs(3600); + let next_rotation = + RotatingFileWriter::calculate_next_rotation_time(&temp_dir, "test", rotation_duration); + + // Should use the latest file (2025-10-26_14-00-00) + let expected_dt = + NaiveDateTime::parse_from_str("2025-10-26_14-00-00", "%Y-%m-%d_%H-%M-%S").unwrap(); + let expected_timestamp = expected_dt.and_local_timezone(Local).single().unwrap(); + let expected_duration = + Duration::from_secs(expected_timestamp.timestamp().try_into().unwrap()); + let expected_next = UNIX_EPOCH + expected_duration + rotation_duration; + + let diff = if next_rotation > expected_next { + next_rotation.duration_since(expected_next).unwrap() + } else { + expected_next.duration_since(next_rotation).unwrap() + }; + + assert!(diff < Duration::from_secs(2)); + + fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_ignores_malformed_filenames() { + let temp_dir = std::env::temp_dir().join("test_ignores_malformed_filenames"); + + fs::create_dir_all(&temp_dir).unwrap(); + + // Create log files with various malformed names + fs::write(temp_dir.join("test.invalid.log"), b"bad").unwrap(); + fs::write(temp_dir.join("test.log"), b"bad2").unwrap(); + fs::write( + temp_dir.join("other.2025-10-26_14-00-00.log"), + b"wrong prefix", + ) + .unwrap(); + fs::write(temp_dir.join("test.2025-10-26_14-00-00.txt"), b"wrong ext").unwrap(); + + let before = SystemTime::now(); + let next_rotation = RotatingFileWriter::calculate_next_rotation_time( + &temp_dir, + "test", + Duration::from_secs(3600), + ); + let after = SystemTime::now(); + + // Should fall back to current time since no valid logs exist + assert!(next_rotation >= before && next_rotation <= after + Duration::from_secs(1)); + + fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_restart_behavior() { + let temp_dir = std::env::temp_dir().join("test_restart_behavior"); + + // Create initial writer and write some data + { + let mut writer = + RotatingFileWriter::new(&temp_dir, "test", Duration::from_secs(3600)).unwrap(); + writer.write_all(b"before restart\n").unwrap(); + writer.flush().unwrap(); + } + + // Simulate restart by creating a new writer + thread::sleep(Duration::from_millis(100)); + { + let mut writer = + RotatingFileWriter::new(&temp_dir, "test", Duration::from_secs(3600)).unwrap(); + writer.write_all(b"after restart\n").unwrap(); + writer.flush().unwrap(); + } + + // Should still have only one log file (no premature rotation) + let entries: Vec<_> = fs::read_dir(&temp_dir) + .unwrap() + .filter_map(Result::ok) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "log")) + .collect(); + + assert_eq!( + entries.len(), + 1, + "Should not create new log file on restart within rotation period" + ); + + fs::remove_dir_all(&temp_dir).unwrap(); + } +}