use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
use crate::commands::LogEntryIn;
use crate::error::{Error, Result};
const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024;
const KEEP_FILES: usize = 5;
const MAX_AGE: Duration = Duration::from_secs(7 * 86400);
const FILE_NAME: &str = "deeprespond_im.log";
pub struct LogFiles {
dir: PathBuf,
enabled: bool,
file: Mutex<Option<(File, u64)>>,
}
impl LogFiles {
pub fn new(dir: PathBuf, enabled: bool) -> Self {
if enabled {
let _ = fs::create_dir_all(&dir);
remove_old(&dir);
}
Self { dir, enabled, file: Mutex::new(None) }
}
pub fn plugin(&self, level: &str, message: &str) {
match level {
"error" => log::error!("{message}"),
"warn" => log::warn!("{message}"),
"info" => log::info!("{message}"),
_ => log::debug!("{message}"),
}
self.write(json!({ "time": now_ms(), "level": level, "source": "plugin", "message": message }));
}
pub fn js(&self, source: &str, entries: &[LogEntryIn]) {
for e in entries {
let mut line = json!({ "time": e.time.map(|t| t as u64).unwrap_or_else(now_ms), "level": e.level, "source": source, "module": e.module, "message": e.message });
if let Some(code) = &e.code {
line["code"] = json!(code);
}
if let Some(reason) = &e.reason {
line["reason"] = json!(reason);
}
self.write(line);
}
}
fn write(&self, line: Value) {
if !self.enabled {
return;
}
let mut guard = self.file.lock().unwrap();
if guard.as_ref().is_some_and(|(_, size)| *size >= MAX_FILE_BYTES) {
*guard = None;
rotate(&self.dir);
}
if guard.is_none() {
let path = self.dir.join(FILE_NAME);
match OpenOptions::new().create(true).append(true).open(&path) {
Ok(file) => {
let size = file.metadata().map(|m| m.len()).unwrap_or(0);
*guard = Some((file, size));
}
Err(_) => return,
}
}
if let Some((file, size)) = guard.as_mut() {
let mut text = line.to_string();
text.push('\n');
if file.write_all(text.as_bytes()).is_ok() {
*size += text.len() as u64;
}
}
}
pub fn flush(&self) {
if let Some((file, _)) = self.file.lock().unwrap().as_mut() {
let _ = file.flush();
}
}
pub fn export(&self, temp: &Path, dest: Option<&Path>, info: &Value) -> Result<PathBuf> {
self.flush();
let name = format!("deeprespond_im-diagnostics-{}.zip", now_ms());
let path = match dest {
Some(d) if d.is_dir() => d.join(&name),
Some(d) => d.to_path_buf(),
None => {
fs::create_dir_all(temp).map_err(|e| Error::from_io(&e))?;
temp.join(&name)
}
};
let file = File::create(&path).map_err(|e| Error::from_io(&e))?;
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
zip.start_file("info.json", options).map_err(zip_err)?;
zip.write_all(serde_json::to_string_pretty(info).unwrap_or_default().as_bytes()).map_err(|e| Error::from_io(&e))?;
for (i, log) in log_files(&self.dir).into_iter().enumerate() {
if let Ok(bytes) = fs::read(&log) {
let entry = log.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| format!("log{i}"));
zip.start_file(format!("logs/{entry}"), options).map_err(zip_err)?;
zip.write_all(&bytes).map_err(|e| Error::from_io(&e))?;
}
}
zip.finish().map_err(zip_err)?;
Ok(path)
}
}
fn zip_err(err: zip::result::ZipError) -> Error {
Error::storage("io", err.to_string())
}
fn now_ms() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
}
fn log_files(dir: &Path) -> Vec<PathBuf> {
let mut out = vec![dir.join(FILE_NAME)];
for i in 1..=KEEP_FILES {
out.push(dir.join(format!("{FILE_NAME}.{i}")));
}
out.into_iter().filter(|p| p.exists()).collect()
}
fn rotate(dir: &Path) {
let _ = fs::remove_file(dir.join(format!("{FILE_NAME}.{KEEP_FILES}")));
for i in (1..KEEP_FILES).rev() {
let _ = fs::rename(dir.join(format!("{FILE_NAME}.{i}")), dir.join(format!("{FILE_NAME}.{}", i + 1)));
}
let _ = fs::rename(dir.join(FILE_NAME), dir.join(format!("{FILE_NAME}.1")));
}
fn remove_old(dir: &Path) {
let now = SystemTime::now();
for path in log_files(dir) {
let old = fs::metadata(&path).and_then(|m| m.modified()).ok().and_then(|m| now.duration_since(m).ok()).is_some_and(|age| age > MAX_AGE);
if old {
let _ = fs::remove_file(path);
}
}
}
pub fn redact_proxy(proxy: Option<&str>) -> Option<String> {
let proxy = proxy?;
match proxy.find("://").map(|i| i + 3) {
Some(start) => match proxy[start..].find('@') {
Some(at) => Some(format!("{}***@{}", &proxy[..start], &proxy[start + at + 1..])),
None => Some(proxy.to_string()),
},
None => Some(proxy.to_string()),
}
}
pub fn system_proxy() -> Value {
let mut out = serde_json::Map::new();
for key in ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"] {
if let Ok(value) = std::env::var(key) {
out.insert(key.into(), json!(redact_proxy(Some(&value))));
}
}
Value::Object(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn proxy_credentials_are_removed() {
assert_eq!(redact_proxy(Some("http://user:pw@proxy.local:8080")).unwrap(), "http://***@proxy.local:8080");
assert_eq!(redact_proxy(Some("socks5://proxy:1080")).unwrap(), "socks5://proxy:1080");
}
#[test]
fn rotates_and_exports() {
let dir = tempfile::tempdir().unwrap();
let logs = LogFiles::new(dir.path().join("logs"), true);
logs.plugin("warn", "测试");
logs.flush();
let text = fs::read_to_string(dir.path().join("logs").join(FILE_NAME)).unwrap();
assert!(text.contains("\"source\":\"plugin\""));
rotate(&dir.path().join("logs"));
assert!(dir.path().join("logs").join(format!("{FILE_NAME}.1")).exists());
let zip = logs.export(dir.path(), None, &json!({ "a": 1 })).unwrap();
assert!(zip.exists());
}
}