Add log rotation to server & UI improvements #157

Merged
schmelczer merged 6 commits from asch/logs into main 2025-11-02 17:52:05 +00:00
19 changed files with 508 additions and 38 deletions

View file

@ -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

4
.gitignore vendored
View file

@ -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

View file

@ -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,

View file

@ -4,6 +4,7 @@
background-color: var(--color-base-00);
copilot-pull-request-reviewer[bot] commented 2025-11-02 17:43:39 +00:00 (Migrated from github.com)

Inconsistent indentation: this line uses spaces while the rest of the file uses tabs. Change to tab indentation to match the file's style.

	word-break: break-word;
Inconsistent indentation: this line uses spaces while the rest of the file uses tabs. Change to tab indentation to match the file's style. ```suggestion word-break: break-word; ```
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 {

View file

@ -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)
});
});
}

View file

@ -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);

View file

@ -21,7 +21,7 @@ else
cargo fmt --all -- --check
fi
cargo machete
cargo machete --with-metadata
echo "Running checks in frontend"
cd ../frontend

17
sync-server/Cargo.lock generated
View file

@ -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",

View file

@ -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

View file

@ -24,3 +24,6 @@ users:
type: allow_list
allowed:
- default
logging:
log_directory: logs
log_rotation: 7days

View file

@ -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<OsString>) -> Result<Self> {
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<Self> {
let broadcasts = Broadcasts::new(&config.server);
let database = Database::try_new(&config.database, &broadcasts).await?;
let cursors: Cursors = Cursors::new(&config.database, &broadcasts);

View file

@ -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 {

View file

@ -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
}

View file

@ -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
copilot-pull-request-reviewer[bot] commented 2025-11-02 17:43:39 +00:00 (Migrated from github.com)

The Duration import is missing from this file. Add use std::time::Duration; at the top of the file to make this constant compile.

The Duration import is missing from this file. Add `use std::time::Duration;` at the top of the file to make this constant compile.

View file

@ -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 {

View file

@ -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)

View file

@ -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<OsString>) -> 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")?;

View file

@ -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;

View file

@ -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<Mutex<RotatingFileWriterInner>>,
}
struct RotatingFileWriterInner {
directory: PathBuf,
file_prefix: String,
rotation_duration: Duration,
current_file: Option<std::fs::File>,
next_rotation_time: SystemTime,
}
impl RotatingFileWriter {
pub fn new(
directory: impl AsRef<Path>,
file_prefix: &str,
rotation_duration: Duration,
) -> io::Result<Self> {
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<SystemTime> {
// 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<String> {
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| {
copilot-pull-request-reviewer[bot] commented 2025-11-02 17:43:39 +00:00 (Migrated from github.com)

The calculate_next_rotation_time function always returns the current time when no log files exist, but the function should return the next rotation time (current time + rotation_duration) to be consistent with its name and behavior. When a writer is created for the first time, the next rotation should be scheduled for rotation_duration in the future, not immediately.

            .map_or_else(|| SystemTime::now() + rotation_duration, |last_rotation| {
The `calculate_next_rotation_time` function always returns the current time when no log files exist, but the function should return the *next* rotation time (current time + rotation_duration) to be consistent with its name and behavior. When a writer is created for the first time, the next rotation should be scheduled for rotation_duration in the future, not immediately. ```suggestion .map_or_else(|| SystemTime::now() + rotation_duration, |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<usize> {
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();
}
}