use std::path::{Path, PathBuf};
use chrono::Local;
use parking_lot::Mutex as SyncMutex;
use tokio::fs::{File, OpenOptions};
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex as AsyncMutex;
use crate::procserv::error::{ProcServError, ProcServResult};
pub struct LogFile {
file: AsyncMutex<File>,
path: PathBuf,
stamp_log: bool,
stamp_format: String,
in_line: SyncMutex<bool>,
}
impl LogFile {
pub async fn open(
path: &Path,
stamp_log: bool,
stamp_format: impl Into<String>,
) -> ProcServResult<Self> {
let file = Self::open_handle(path).await?;
Ok(Self {
file: AsyncMutex::new(file),
path: path.to_path_buf(),
stamp_log,
stamp_format: stamp_format.into(),
in_line: SyncMutex::new(false),
})
}
async fn open_handle(path: &Path) -> ProcServResult<File> {
OpenOptions::new()
.create(true)
.append(true)
.open(path)
.await
.map_err(ProcServError::Io)
}
pub async fn reopen(&self) -> ProcServResult<()> {
let fresh = Self::open_handle(&self.path).await?;
*self.file.lock().await = fresh;
*self.in_line.lock() = false;
Ok(())
}
pub async fn write_chunk(&self, chunk: &[u8]) -> ProcServResult<()> {
if !self.stamp_log {
let mut file = self.file.lock().await;
file.write_all(chunk).await.map_err(ProcServError::Io)?;
file.flush().await.map_err(ProcServError::Io)?;
return Ok(());
}
let out: Vec<u8> = {
let stamp = self.format_stamp();
let mut buf: Vec<u8> = Vec::with_capacity(chunk.len() + 32);
let mut in_line = self.in_line.lock();
let mut prev = 0usize;
for (i, &b) in chunk.iter().enumerate() {
if !*in_line {
buf.extend_from_slice(stamp.as_bytes());
*in_line = true;
}
if b == b'\n' {
buf.extend_from_slice(&chunk[prev..=i]);
prev = i + 1;
*in_line = false;
}
}
if prev < chunk.len() {
buf.extend_from_slice(&chunk[prev..]);
}
buf
};
let mut file = self.file.lock().await;
file.write_all(&out).await.map_err(ProcServError::Io)?;
file.flush().await.map_err(ProcServError::Io)?;
Ok(())
}
fn format_stamp(&self) -> String {
Local::now().format(&self.stamp_format).to_string()
}
}
pub fn write_pid_file(path: &Path, pid: i32) -> ProcServResult<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let tmp = parent.join(format!(
".{}.tmp",
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("procserv.pid")
));
std::fs::write(&tmp, format!("{pid}\n")).map_err(ProcServError::Io)?;
std::fs::rename(&tmp, path).map_err(ProcServError::Io)?;
Ok(())
}
pub fn remove_pid_file(path: &Path) {
if let Err(e) = std::fs::remove_file(path) {
tracing::warn!(path = %path.display(), error = %e, "procserv-rs: failed to remove pid file");
}
}
pub fn write_info_file(path: &Path, info: &InfoSnapshot) -> ProcServResult<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let tmp = parent.join(format!(
".{}.tmp",
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("procserv.info")
));
let body = render_procserv_info(info);
std::fs::write(&tmp, body).map_err(ProcServError::Io)?;
std::fs::rename(&tmp, path).map_err(ProcServError::Io)?;
Ok(())
}
#[derive(Debug, Clone)]
pub struct InfoSnapshot {
pub procserv_pid: i32,
pub child_pid: Option<i32>,
pub child_exe: PathBuf,
pub child_args: Vec<String>,
}
pub fn render_procserv_info(info: &InfoSnapshot) -> String {
let mut out = String::new();
out.push_str(&format!("procservpid={}\n", info.procserv_pid));
if let Some(p) = info.child_pid {
out.push_str(&format!("childpid={p}\n"));
}
out.push_str(&format!("childexe={}\n", info.child_exe.display()));
out.push_str(&format!("childargs={}\n", info.child_args.join(" ")));
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_info_keys_match_c_procserv_convention() {
let info = InfoSnapshot {
procserv_pid: 1234,
child_pid: Some(1235),
child_exe: PathBuf::from("/usr/bin/softIoc"),
child_args: vec!["-d".into(), "test.db".into()],
};
let rendered = render_procserv_info(&info);
assert!(rendered.contains("procservpid=1234"));
assert!(rendered.contains("childpid=1235"));
assert!(rendered.contains("childexe=/usr/bin/softIoc"));
assert!(rendered.contains("childargs=-d test.db"));
}
#[tokio::test]
async fn log_file_prefixes_each_line_with_timestamp() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.log");
let log = LogFile::open(&path, true, "[%Y-%m-%dT%H:%M:%S] ".to_string())
.await
.unwrap();
log.write_chunk(b"line1\nline2\n").await.unwrap();
log.write_chunk(b"partial").await.unwrap();
log.write_chunk(b" continued\n").await.unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 3);
for line in &lines {
assert!(line.starts_with('['), "no stamp on: {line}");
}
assert!(lines[0].ends_with("line1"));
assert!(lines[1].ends_with("line2"));
assert!(lines[2].ends_with("partial continued"));
}
#[tokio::test]
async fn log_stamp_is_applied_raw_without_added_brackets() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("raw.log");
let log = LogFile::open(&path, true, "RAWSTAMP ".to_string())
.await
.unwrap();
log.write_chunk(b"hello\n").await.unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let line = content.lines().next().unwrap();
assert!(
line.starts_with("RAWSTAMP "),
"stamp format must be applied raw, got: {line}"
);
assert!(!line.contains('['), "writer must not add brackets: {line}");
assert!(line.ends_with("hello"));
}
#[tokio::test]
async fn unstamped_log_is_byte_identical_to_child_output() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("plain.log");
let log = LogFile::open(&path, false, "[%Y] ".to_string())
.await
.unwrap();
log.write_chunk(b"line1\nline2\n").await.unwrap();
log.write_chunk(b"partial").await.unwrap();
log.write_chunk(b" continued\n").await.unwrap();
let content = std::fs::read(&path).unwrap();
assert_eq!(content, b"line1\nline2\npartial continued\n");
}
#[tokio::test]
async fn reopen_writes_to_a_fresh_file_after_rotation() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rot.log");
let log = LogFile::open(&path, true, "%Y-%m-%dT%H:%M:%S".to_string())
.await
.unwrap();
log.write_chunk(b"before\n").await.unwrap();
let rotated = dir.path().join("rot.log.1");
std::fs::rename(&path, &rotated).unwrap();
log.reopen().await.unwrap();
log.write_chunk(b"after\n").await.unwrap();
let fresh = std::fs::read_to_string(&path).unwrap();
assert!(
fresh.contains("after"),
"new file should hold post-reopen line"
);
assert!(
!fresh.contains("before"),
"new file must not contain pre-rotation content"
);
let old = std::fs::read_to_string(&rotated).unwrap();
assert!(
old.contains("before"),
"rotated file keeps pre-rotation line"
);
assert!(
!old.contains("after"),
"rotated file must not gain new writes"
);
}
#[test]
fn pid_file_atomic_write() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.pid");
write_pid_file(&path, 12345).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content.trim(), "12345");
}
}