use std::path::{Path, PathBuf};
use jiff::civil::Date;
use miette::{IntoDiagnostic, Result, WrapErr as _, miette};
use uuid::Uuid;
pub const SEGMENT_EXT: &str = ".json-seq";
pub const DAY_FILE_EXT: &str = ".json-seq.zst";
const PREFIX: &str = "audit-";
pub const DIRECTORY_LOCK: &str = "audit.lock";
pub const LOCK_EXT: &str = ".lock";
pub const TEMP_SUFFIX: &str = ".tmp";
pub const LEGACY_NAMES: &[&str] = &["audit-main.redb", "history.redb"];
pub const LEGACY_PREFIXES: &[&str] = &["audit-working-", "audit-orphaned-"];
pub const IMPORTED_SUFFIX: &str = ".imported";
pub const PART_SUFFIX: &str = ".part";
pub fn set_aside(legacy: &Path, on: Date) -> PathBuf {
let mut name = legacy.as_os_str().to_os_string();
name.push(format!(".{on}{IMPORTED_SUFFIX}"));
PathBuf::from(name)
}
pub fn part_of(segment: &Path) -> PathBuf {
let mut name = segment.as_os_str().to_os_string();
name.push(PART_SUFFIX);
PathBuf::from(name)
}
pub fn list_set_aside(dir: &Path) -> Result<Vec<(PathBuf, Date)>> {
let mut found = Vec::new();
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(found),
Err(err) => return Err(err).into_diagnostic(),
};
for entry in entries {
let entry = entry.into_diagnostic()?;
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let Some(rest) = name.strip_suffix(IMPORTED_SUFFIX) else {
continue;
};
let Some(at) = rest.len().checked_sub(11) else {
continue;
};
let Some((stem, dated)) = rest.split_at_checked(at) else {
continue;
};
let Some(date) = dated.strip_prefix('.') else {
continue;
};
if !is_legacy(stem) {
continue;
}
if let Ok(date) = date.parse() {
found.push((entry.path(), date));
}
}
found.sort();
Ok(found)
}
pub fn segment_name(date: Date, instance: Uuid) -> String {
format!("{PREFIX}{date}-{instance}{SEGMENT_EXT}")
}
pub fn day_file_name(date: Date) -> String {
format!("{PREFIX}{date}{DAY_FILE_EXT}")
}
pub fn lock_of(segment: &Path) -> PathBuf {
let mut name = segment.as_os_str().to_os_string();
name.push(LOCK_EXT);
PathBuf::from(name)
}
pub fn list_locks(dir: &Path) -> Result<Vec<(PathBuf, PathBuf)>> {
let mut found = Vec::new();
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(found),
Err(err) => return Err(err).into_diagnostic(),
};
for entry in entries {
let entry = entry.into_diagnostic()?;
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let Some(segment) = name.strip_suffix(LOCK_EXT) else {
continue;
};
if classify(segment).is_some_and(|kind| kind.instance().is_some()) {
found.push((entry.path(), dir.join(segment)));
}
}
found.sort();
Ok(found)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditFile {
Segment { date: Date, instance: Uuid },
DayFile { date: Date },
}
impl AuditFile {
pub fn date(&self) -> Date {
match self {
Self::Segment { date, .. } | Self::DayFile { date } => *date,
}
}
pub fn instance(&self) -> Option<Uuid> {
match self {
Self::Segment { instance, .. } => Some(*instance),
Self::DayFile { .. } => None,
}
}
}
pub fn classify(name: &str) -> Option<AuditFile> {
let rest = name.strip_prefix(PREFIX)?;
if let Some(stem) = rest.strip_suffix(DAY_FILE_EXT) {
return Some(AuditFile::DayFile {
date: stem.parse().ok()?,
});
}
let stem = rest.strip_suffix(SEGMENT_EXT)?;
let (date, instance) = stem.split_at_checked(10)?;
Some(AuditFile::Segment {
date: date.parse().ok()?,
instance: instance.strip_prefix('-')?.parse().ok()?,
})
}
pub fn is_legacy(name: &str) -> bool {
LEGACY_NAMES.contains(&name)
|| (LEGACY_PREFIXES.iter().any(|p| name.starts_with(p)) && name.ends_with(".redb"))
}
pub fn list(dir: &Path) -> Result<Vec<(PathBuf, AuditFile)>> {
let mut found = Vec::new();
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(found),
Err(err) => return Err(err).into_diagnostic(),
};
for entry in entries {
let entry = entry.into_diagnostic()?;
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if let Some(kind) = classify(name) {
found.push((entry.path(), kind));
}
}
found.sort_by(|(left, left_kind), (right, right_kind)| {
(left_kind.date(), left_kind.instance().is_none(), left).cmp(&(
right_kind.date(),
right_kind.instance().is_none(),
right,
))
});
Ok(found)
}
pub fn list_legacy(dir: &Path) -> Result<Vec<PathBuf>> {
let mut found = Vec::new();
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(found),
Err(err) => return Err(err).into_diagnostic(),
};
for entry in entries {
let entry = entry.into_diagnostic()?;
let name = entry.file_name();
if name.to_str().is_some_and(is_legacy) {
found.push(entry.path());
}
}
found.sort();
Ok(found)
}
pub fn default_dir() -> Result<PathBuf> {
let dir = platform_default_dir()?;
create_dir(&dir)?;
Ok(dir)
}
pub fn create_dir(dir: &Path) -> Result<()> {
if !dir.is_dir() {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt as _;
builder.mode(0o700);
}
builder
.create(dir)
.into_diagnostic()
.wrap_err_with(|| format!("creating audit directory {}", dir.display()))?;
}
make_private(dir);
Ok(())
}
fn make_private(dir: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let Ok(metadata) = std::fs::metadata(dir) else {
return;
};
let mode = metadata.permissions().mode() & 0o7777;
if mode & 0o077 == 0 {
return;
}
let narrowed = mode & !0o077;
match std::fs::set_permissions(dir, std::fs::Permissions::from_mode(narrowed)) {
Ok(()) => {
tracing::debug!(
?dir,
from = format!("{mode:o}"),
to = format!("{narrowed:o}"),
"narrowed the audit directory to its owner"
);
eprintln!(
"note: the audit directory {} was readable by other users of this machine (mode {:o}); it is now private",
dir.display(),
mode & 0o777,
);
}
Err(err) => {
tracing::warn!(
?dir,
?err,
mode = format!("{mode:o}"),
"audit directory is not private and cannot be narrowed"
);
eprintln!(
"warning: the audit directory {} can be read by other users of this machine (mode {:o})",
dir.display(),
mode & 0o777,
);
eprintln!("warning: it holds the full text of every statement this session runs");
}
}
}
#[cfg(not(unix))]
let _ = dir;
}
pub fn private() -> std::fs::OpenOptions {
let mut options = std::fs::OpenOptions::new();
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
options
}
pub fn default_dir_for_help() -> String {
if let Ok(path) = platform_default_dir() {
return path.display().to_string();
}
#[cfg(target_os = "macos")]
{
"~/Library/Application Support/bestool-psql".into()
}
#[cfg(target_os = "windows")]
{
"%LOCALAPPDATA%\\bestool-psql".into()
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
"~/.local/state/bestool-psql".into()
}
}
fn platform_default_dir() -> Result<PathBuf> {
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
if let Some(dir) = dirs::state_dir() {
Ok(dir.join("bestool-psql"))
} else if let Some(dir) = std::env::var_os("XDG_STATE_HOME") {
Ok(PathBuf::from(dir).join("bestool-psql"))
} else if let Some(home) = std::env::var_os("HOME") {
Ok(PathBuf::from(home)
.join(".local")
.join("state")
.join("bestool-psql"))
} else {
Err(miette!("could not determine home directory"))
}
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
{
if let Some(dir) = dirs::data_local_dir() {
return Ok(dir.join("bestool-psql"));
}
#[cfg(target_os = "macos")]
{
if let Some(home) = std::env::var_os("HOME") {
Ok(PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("bestool-psql"))
} else {
Err(miette!("could not determine home directory"))
}
}
#[cfg(target_os = "windows")]
{
if let Some(localappdata) = std::env::var_os("LOCALAPPDATA") {
Ok(PathBuf::from(localappdata).join("bestool-psql"))
} else {
Err(miette!("could not determine LOCALAPPDATA directory"))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const UUID: &str = "7d2c0f4e-1b3a-4c5d-8e9f-0a1b2c3d4e5f";
fn date(s: &str) -> Date {
s.parse().unwrap()
}
#[test]
fn segment_names_round_trip() {
let instance: Uuid = UUID.parse().unwrap();
let name = segment_name(date("2026-09-08"), instance);
assert_eq!(name, format!("audit-2026-09-08-{UUID}.json-seq"));
assert_eq!(
classify(&name),
Some(AuditFile::Segment {
date: date("2026-09-08"),
instance
})
);
}
#[test]
fn day_file_names_round_trip() {
let name = day_file_name(date("2026-09-08"));
assert_eq!(name, "audit-2026-09-08.json-seq.zst");
assert_eq!(
classify(&name),
Some(AuditFile::DayFile {
date: date("2026-09-08")
})
);
}
#[test]
fn unrelated_names_are_not_audit_files() {
for name in [
DIRECTORY_LOCK,
"audit-2026-09-08-7d2c0f4e-1b3a-4c5d-8e9f-0a1b2c3d4e5f.json-seq.lock",
"audit-main.redb",
"audit-working-7d2c.redb",
"notes.txt",
"audit-2026-09-08.json-seq.zst.tmp",
"audit-not-a-date-7d2c0f4e-1b3a-4c5d-8e9f-0a1b2c3d4e5f.json-seq",
"audit-2026-09-08-not-a-uuid.json-seq",
] {
assert_eq!(classify(name), None, "{name} should not classify");
}
}
#[test]
fn legacy_names_are_recognised() {
for name in [
"audit-main.redb",
"history.redb",
"audit-working-7d2c.redb",
"audit-orphaned-7d2c.redb",
] {
assert!(is_legacy(name), "{name} should be legacy");
}
for name in ["audit-2026-09-08.json-seq.zst", "notes.redb.txt"] {
assert!(!is_legacy(name), "{name} should not be legacy");
}
}
#[test]
fn only_a_legacy_name_reads_as_set_aside() {
let dir = tempfile::tempdir().unwrap();
for name in [
"audit-main.redb.2026-09-09.imported",
"notes.txt.2026-09-09.imported",
"audit-main.redb.not-a-date.imported",
"audit-main.redb",
] {
std::fs::write(dir.path().join(name), b"").unwrap();
}
let found = list_set_aside(dir.path()).unwrap();
assert_eq!(found.len(), 1);
assert_eq!(
found[0].0.file_name().unwrap(),
"audit-main.redb.2026-09-09.imported"
);
}
#[test]
fn a_stray_name_is_passed_over_rather_than_panicked_on() {
let dir = tempfile::tempdir().unwrap();
for name in ["éabcdefghij.imported", ".imported", "ú.imported"] {
std::fs::write(dir.path().join(name), b"").unwrap();
}
assert!(list_set_aside(dir.path()).unwrap().is_empty());
}
#[test]
fn listing_orders_by_day_and_puts_day_files_last() {
let dir = tempfile::tempdir().unwrap();
let a: Uuid = UUID.parse().unwrap();
for name in [
day_file_name(date("2026-09-09")),
segment_name(date("2026-09-09"), a),
segment_name(date("2026-09-07"), a),
DIRECTORY_LOCK.to_string(),
] {
std::fs::write(dir.path().join(name), b"").unwrap();
}
let found = list(dir.path()).unwrap();
let kinds: Vec<_> = found.iter().map(|(_, kind)| *kind).collect();
assert_eq!(
kinds,
vec![
AuditFile::Segment {
date: date("2026-09-07"),
instance: a
},
AuditFile::Segment {
date: date("2026-09-09"),
instance: a
},
AuditFile::DayFile {
date: date("2026-09-09")
},
]
);
}
}