Add log rotation to server & UI improvements (#157)
This commit is contained in:
parent
2b9d77d165
commit
cd57ea6682
19 changed files with 508 additions and 38 deletions
|
|
@ -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
4
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
17
sync-server/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -24,3 +24,6 @@ users:
|
|||
type: allow_list
|
||||
allowed:
|
||||
- default
|
||||
logging:
|
||||
log_directory: logs
|
||||
log_rotation: 7days
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
34
sync-server/src/config/logging_config.rs
Normal file
34
sync-server/src/config/logging_config.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
364
sync-server/src/utils/rotating_file_writer.rs
Normal file
364
sync-server/src/utils/rotating_file_writer.rs
Normal 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| {
|
||||
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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue