use std::path::PathBuf;
use chrono::Utc;
use tokio::fs::OpenOptions;
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex;
use crate::error::BridgeResult;
const DEFAULT_MAX_BYTES: u64 = 100 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PutOutcome {
Ok,
Denied,
Failed,
}
impl PutOutcome {
fn as_str(&self) -> &'static str {
match self {
Self::Ok => "OK",
Self::Denied => "DENIED",
Self::Failed => "FAILED",
}
}
}
pub struct PutLog {
path: PathBuf,
file: Mutex<Option<tokio::fs::File>>,
bytes_written: Mutex<u64>,
max_bytes: u64,
}
impl PutLog {
pub fn new(path: PathBuf) -> Self {
Self {
path,
file: Mutex::new(None),
bytes_written: Mutex::new(0),
max_bytes: DEFAULT_MAX_BYTES,
}
}
pub fn with_max_bytes(mut self, n: u64) -> Self {
self.max_bytes = n;
self
}
pub async fn log(
&self,
user: &str,
host: &str,
pv: &str,
value: &str,
outcome: PutOutcome,
) -> BridgeResult<()> {
let timestamp = Utc::now().to_rfc3339();
let line = format!(
"{} {}@{} {} {} {}\n",
timestamp,
user,
host,
pv,
value,
outcome.as_str()
);
let mut guard = self.file.lock().await;
if guard.is_none() {
let f = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.await?;
let len = f.metadata().await.map(|m| m.len()).unwrap_or(0);
*self.bytes_written.lock().await = len;
*guard = Some(f);
}
if let Some(f) = guard.as_mut() {
f.write_all(line.as_bytes()).await?;
f.flush().await?;
}
let mut counter = self.bytes_written.lock().await;
*counter = counter.saturating_add(line.len() as u64);
if *counter >= self.max_bytes {
*counter = 0;
*guard = None;
let backup = self.path.with_extension(
self.path
.extension()
.map(|e| format!("{}.1", e.to_string_lossy()))
.unwrap_or_else(|| "1".to_string()),
);
if let Err(e) = tokio::fs::rename(&self.path, &backup).await {
tracing::warn!(
error = %e,
src = %self.path.display(),
dst = %backup.display(),
"putlog rotation rename failed; continuing without rotation"
);
}
drop(guard);
drop(counter);
}
Ok(())
}
pub fn path(&self) -> &PathBuf {
&self.path
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn log_to_temp_file() {
let temp =
std::env::temp_dir().join(format!("ca_gateway_putlog_test_{}.log", std::process::id()));
let _ = std::fs::remove_file(&temp);
let log = PutLog::new(temp.clone());
log.log("alice", "host1", "TEMP", "25.0", PutOutcome::Ok)
.await
.unwrap();
log.log("bob", "host2", "PRESS", "100", PutOutcome::Denied)
.await
.unwrap();
log.log("eve", "host3", "VAC", "1e-6", PutOutcome::Failed)
.await
.unwrap();
let content = std::fs::read_to_string(&temp).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 3);
assert!(lines[0].contains("alice@host1 TEMP 25.0 OK"));
assert!(lines[1].contains("bob@host2 PRESS 100 DENIED"));
assert!(lines[2].contains("eve@host3 VAC 1e-6 FAILED"));
let _ = std::fs::remove_file(&temp);
}
#[test]
fn outcome_as_str() {
assert_eq!(PutOutcome::Ok.as_str(), "OK");
assert_eq!(PutOutcome::Denied.as_str(), "DENIED");
assert_eq!(PutOutcome::Failed.as_str(), "FAILED");
}
}