use std::ffi::OsStr;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use pixelactions_core::audit::Event;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
fn state_dir(
xdg_state: Option<&OsStr>,
local_appdata: Option<&OsStr>,
user_profile: Option<&OsStr>,
home: Option<&OsStr>,
windows: bool,
) -> Option<PathBuf> {
if let Some(xdg) = xdg_state {
return Some(PathBuf::from(xdg));
}
if windows {
if let Some(local) = local_appdata {
return Some(PathBuf::from(local));
}
return user_profile.map(|p| PathBuf::from(p).join("AppData").join("Local"));
}
home.map(|home| PathBuf::from(home).join(".local").join("state"))
}
#[must_use]
pub fn log_path() -> Option<PathBuf> {
let dir = state_dir(
std::env::var_os("XDG_STATE_HOME").as_deref(),
std::env::var_os("LOCALAPPDATA").as_deref(),
std::env::var_os("USERPROFILE").as_deref(),
std::env::var_os("HOME").as_deref(),
cfg!(target_os = "windows"),
)?;
Some(dir.join("pixelactions").join("audit.ndjson"))
}
#[must_use]
pub fn now() -> String {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
}
pub fn append(event: &Event) {
let Some(path) = log_path() else { return };
if let Some(parent) = path.parent()
&& std::fs::create_dir_all(parent).is_err()
{
return;
}
let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&path) else {
return;
};
let _ = file.write_all(event.line().as_bytes());
}
#[cfg(test)]
mod tests {
use super::*;
fn dir(
xdg: Option<&str>,
local: Option<&str>,
profile: Option<&str>,
home: Option<&str>,
windows: bool,
) -> Option<String> {
state_dir(
xdg.map(OsStr::new),
local.map(OsStr::new),
profile.map(OsStr::new),
home.map(OsStr::new),
windows,
)
.map(|p| p.display().to_string())
}
#[test]
fn windows_resolves_without_xdg_or_home() {
assert_eq!(
dir(None, Some(r"C:\Users\me\AppData\Local"), None, None, true),
Some(r"C:\Users\me\AppData\Local".to_string())
);
}
#[test]
fn windows_falls_back_to_the_user_profile() {
let resolved =
state_dir(None, None, Some(OsStr::new(r"C:\Users\me")), None, true).expect("a path");
let tail: Vec<String> = resolved
.components()
.rev()
.take(2)
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
assert_eq!(tail, ["Local", "AppData"], "{}", resolved.display());
assert!(
resolved.display().to_string().starts_with(r"C:\Users\me"),
"{}",
resolved.display()
);
}
#[test]
fn unix_uses_the_xdg_default() {
let resolved =
state_dir(None, None, None, Some(OsStr::new("/home/me")), false).expect("a path");
let tail: Vec<String> = resolved
.components()
.rev()
.take(2)
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
assert_eq!(tail, ["state", ".local"], "{}", resolved.display());
}
#[test]
fn an_explicit_xdg_state_home_wins_on_every_platform() {
for windows in [true, false] {
assert_eq!(
dir(
Some("/somewhere"),
Some("C:/local"),
None,
Some("/home/me"),
windows
),
Some("/somewhere".to_string()),
"windows={windows}"
);
}
}
#[test]
fn nothing_to_go_on_resolves_to_nothing() {
assert_eq!(dir(None, None, None, None, true), None);
assert_eq!(dir(None, None, None, None, false), None);
}
#[test]
fn the_log_sits_under_the_state_directory() {
unsafe { std::env::set_var("XDG_STATE_HOME", "/tmp/pxa-audit-test") };
let path = log_path().expect("a path");
assert_eq!(
path,
PathBuf::from("/tmp/pxa-audit-test/pixelactions/audit.ndjson")
);
unsafe { std::env::remove_var("XDG_STATE_HOME") };
}
#[test]
fn now_is_rfc_3339_and_not_the_fallback() {
let stamp = now();
assert!(stamp.ends_with('Z'), "{stamp}");
assert!(stamp.len() >= 20, "{stamp}");
assert_ne!(stamp, "1970-01-01T00:00:00Z");
}
}