use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use log::LevelFilter;
use simplelog::{
ColorChoice, CombinedLogger, ConfigBuilder, SharedLogger, TermLogger, TerminalMode, WriteLogger,
};
const MAX_LOG_SIZE: u64 = 5 * 1024 * 1024;
pub fn log_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".purple").join("purple.log"))
}
fn rotate_if_needed(path: &Path) {
if let Ok(meta) = fs::metadata(path) {
if meta.len() > MAX_LOG_SIZE {
let backup = path.with_file_name("purple.log.1");
let _ = fs::rename(path, backup);
}
}
}
fn resolve_level(verbose: bool, env_override: Option<&str>) -> LevelFilter {
if let Some(val) = env_override {
match val.to_lowercase().as_str() {
"trace" => return LevelFilter::Trace,
"debug" => return LevelFilter::Debug,
"info" => return LevelFilter::Info,
"warn" => return LevelFilter::Warn,
"error" => return LevelFilter::Error,
"off" => return LevelFilter::Off,
_ => {}
}
}
if verbose {
LevelFilter::Debug
} else {
LevelFilter::Warn
}
}
pub fn init(verbose: bool, cli_stderr: bool) {
let Some(path) = log_path() else { return };
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
rotate_if_needed(&path);
let env_val = std::env::var("PURPLE_LOG").ok();
let level = resolve_level(verbose, env_val.as_deref());
let config = ConfigBuilder::new()
.set_time_format_rfc3339()
.set_target_level(LevelFilter::Off)
.set_thread_level(LevelFilter::Off)
.build();
let mut loggers: Vec<Box<dyn SharedLogger>> = Vec::with_capacity(2);
if let Ok(file) = fs::OpenOptions::new().create(true).append(true).open(&path) {
loggers.push(WriteLogger::new(level, config.clone(), file));
}
if cli_stderr {
loggers.push(TermLogger::new(
level,
config,
TerminalMode::Stderr,
ColorChoice::Auto,
));
}
if !loggers.is_empty() {
if let Err(e) = CombinedLogger::init(loggers) {
eprintln!("[purple] Failed to initialize logger: {e}");
}
}
}
fn format_now_utc() -> String {
let now = std::time::SystemTime::now();
let secs = now
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let days = secs / 86400;
let time_of_day = secs % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
let (year, month, day) = epoch_days_to_date(days);
format!("{year:04}-{month:02}-{day:02} {hours:02}:{minutes:02}:{seconds:02}Z")
}
fn epoch_days_to_date(days: u64) -> (u64, u64, u64) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
pub struct BannerInfo<'a> {
pub version: &'a str,
pub config_path: &'a str,
pub providers: &'a [String],
pub askpass_sources: &'a [String],
pub vault_ssh_info: Option<&'a str>,
pub ssh_version: &'a str,
pub term: &'a str,
pub colorterm: &'a str,
pub level: &'a str,
}
pub fn write_banner(info: &BannerInfo<'_>) {
let Some(path) = log_path() else { return };
let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(&path) else {
return;
};
let now = format_now_utc();
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
let providers_joined = if info.providers.is_empty() {
"none".to_string()
} else {
info.providers.join(",")
};
let askpass_joined = if info.askpass_sources.is_empty() {
"none".to_string()
} else {
info.askpass_sources.join(",")
};
let mut banner = format!(
"--- purple v{} started at {now} ---\n\
\x20 os={os} arch={arch} config={}\n\
\x20 ssh={}\n\
\x20 term={} colorterm={}\n\
\x20 providers={providers_joined}\n\
\x20 askpass={askpass_joined}\n",
info.version, info.config_path, info.ssh_version, info.term, info.colorterm,
);
if let Some(vault_info) = info.vault_ssh_info {
banner.push_str(&format!(" vault_ssh={vault_info}\n"));
}
banner.push_str(&format!(" log_level={}\n", info.level));
let _ = file.write_all(banner.as_bytes());
}
pub fn level_name(verbose: bool) -> String {
let env_val = std::env::var("PURPLE_LOG").ok();
resolve_level(verbose, env_val.as_deref())
.as_str()
.to_lowercase()
}
pub fn detect_ssh_version() -> String {
use std::sync::mpsc;
use std::time::Duration;
let child = std::process::Command::new("ssh")
.arg("-V")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn();
let Ok(child) = child else {
eprintln!("[purple] Failed to detect SSH version. Is ssh installed?");
return "unknown".to_string();
};
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(child.wait_with_output());
});
match rx.recv_timeout(Duration::from_secs(2)) {
Ok(Ok(output)) => {
let out = if output.stderr.is_empty() {
output.stdout
} else {
output.stderr
};
String::from_utf8(out)
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "unknown".to_string())
}
_ => "unknown".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn rotate_if_needed_renames_large_file() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("purple.log");
let backup = dir.path().join("purple.log.1");
let mut f = fs::File::create(&log).unwrap();
let data = vec![0u8; (MAX_LOG_SIZE + 1) as usize];
f.write_all(&data).unwrap();
drop(f);
rotate_if_needed(&log);
assert!(!log.exists());
assert!(backup.exists());
assert!(fs::metadata(&backup).unwrap().len() > MAX_LOG_SIZE);
}
#[test]
fn rotate_if_needed_leaves_small_file() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("purple.log");
fs::write(&log, "small content").unwrap();
rotate_if_needed(&log);
assert!(log.exists());
assert!(!dir.path().join("purple.log.1").exists());
}
#[test]
fn rotate_if_needed_handles_missing_file() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("purple.log");
rotate_if_needed(&log);
}
#[test]
fn resolve_level_defaults_to_warn() {
assert_eq!(resolve_level(false, None), LevelFilter::Warn);
}
#[test]
fn resolve_level_verbose_returns_debug() {
assert_eq!(resolve_level(true, None), LevelFilter::Debug);
}
#[test]
fn resolve_level_env_overrides_verbose() {
assert_eq!(resolve_level(false, Some("trace")), LevelFilter::Trace);
assert_eq!(resolve_level(true, Some("error")), LevelFilter::Error);
}
#[test]
fn resolve_level_ignores_unknown_env_value() {
assert_eq!(resolve_level(false, Some("bogus")), LevelFilter::Warn);
assert_eq!(resolve_level(true, Some("bogus")), LevelFilter::Debug);
}
#[test]
fn epoch_days_to_date_unix_epoch() {
assert_eq!(epoch_days_to_date(0), (1970, 1, 1));
}
#[test]
fn epoch_days_to_date_known_date() {
assert_eq!(epoch_days_to_date(20553), (2026, 4, 10));
}
#[test]
fn epoch_days_to_date_leap_year() {
assert_eq!(epoch_days_to_date(11016), (2000, 2, 29));
}
#[test]
fn format_now_utc_returns_valid_timestamp() {
let ts = format_now_utc();
assert_eq!(ts.len(), 20);
assert_eq!(&ts[4..5], "-");
assert_eq!(&ts[7..8], "-");
assert_eq!(&ts[10..11], " ");
assert_eq!(&ts[13..14], ":");
assert_eq!(&ts[16..17], ":");
assert!(ts.ends_with('Z'));
}
#[test]
fn log_path_ends_with_purple_log() {
let path = log_path().expect("home dir should exist in test");
assert!(path.ends_with(".purple/purple.log"));
}
#[test]
fn level_name_defaults_to_warn() {
let name = level_name(false);
assert!(name == "warn" || std::env::var("PURPLE_LOG").is_ok());
}
#[test]
fn write_banner_creates_output() {
let info = BannerInfo {
version: "0.0.0-test",
config_path: "/tmp/config",
providers: &["testprov".to_string()],
askpass_sources: &["keychain:".to_string()],
vault_ssh_info: Some("enabled (addr=https://vault:8200)"),
ssh_version: "OpenSSH_9.0",
term: "xterm-256color",
colorterm: "truecolor",
level: "warn",
};
assert_eq!(info.version, "0.0.0-test");
assert_eq!(info.providers.len(), 1);
assert!(info.vault_ssh_info.is_some());
let ts = format_now_utc();
assert!(ts.ends_with('Z'));
assert_eq!(ts.len(), 20);
}
}