use serde::Serialize;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
const SIDECAR_SUFFIXES: [&str; 6] = [
"-wal",
"-shm",
"-wal-cert",
"-wal-cert-head",
"-ns",
"-journal",
];
const RECOVERY_ARTIFACT_LIMIT: usize = 50;
#[derive(Debug, Clone, Serialize)]
pub struct EngineFile {
pub file: String,
pub bytes: u64,
pub age_s: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OpenerLease {
pub file: String,
pub age_s: Option<u64>,
pub held: Option<bool>,
}
#[derive(Debug, Clone, Serialize)]
pub struct EngineBlock {
pub name: &'static str,
#[serde(rename = "crate")]
pub crate_name: &'static str,
pub version: &'static str,
pub database: String,
pub sidecars: Vec<EngineFile>,
pub sole_opener_lease: Option<OpenerLease>,
pub recovery_artifacts: Vec<EngineFile>,
pub recovery_artifacts_truncated: bool,
}
#[must_use]
pub const fn engine_version() -> &'static str {
match option_env!("BR_FSQLITE_VERSION") {
Some(version) => version,
None => "unknown",
}
}
fn age_seconds(metadata: &std::fs::Metadata) -> Option<u64> {
let modified = metadata.modified().ok()?;
SystemTime::now()
.duration_since(modified)
.ok()
.map(|age| age.as_secs())
}
fn engine_file(path: &Path) -> Option<EngineFile> {
let metadata = std::fs::metadata(path).ok()?;
Some(EngineFile {
file: path.display().to_string(),
bytes: metadata.len(),
age_s: age_seconds(&metadata),
})
}
#[cfg(unix)]
fn probe_lease_held(path: &Path) -> Option<bool> {
use rustix::fs::{FlockOperation, flock};
let file = std::fs::File::open(path).ok()?;
match flock(&file, FlockOperation::NonBlockingLockExclusive) {
Ok(()) => {
let _ = flock(&file, FlockOperation::Unlock);
Some(false)
}
Err(err) if err == rustix::io::Errno::WOULDBLOCK => Some(true),
Err(_) => None,
}
}
#[cfg(not(unix))]
fn probe_lease_held(_path: &Path) -> Option<bool> {
None
}
fn opener_lease(db_path: &Path) -> Option<OpenerLease> {
let parent = db_path.parent()?;
let entries = std::fs::read_dir(parent).ok()?;
let mut leases: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("lock"))
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with(".br-db-openers-"))
})
.collect();
leases.sort();
let path = leases.into_iter().next()?;
let age_s = std::fs::metadata(&path)
.ok()
.and_then(|metadata| age_seconds(&metadata));
Some(OpenerLease {
file: path.display().to_string(),
age_s,
held: probe_lease_held(&path),
})
}
fn recovery_artifacts(beads_dir: &Path) -> (Vec<EngineFile>, bool) {
let root = beads_dir.join(".br_recovery");
let Ok(entries) = std::fs::read_dir(&root) else {
return (Vec::new(), false);
};
let mut paths: Vec<PathBuf> = entries
.filter_map(Result::ok)
.flat_map(|entry| {
let path = entry.path();
if path.is_dir() {
std::fs::read_dir(&path)
.map(|inner| {
inner
.filter_map(Result::ok)
.map(|child| child.path())
.collect::<Vec<_>>()
})
.unwrap_or_else(|_| vec![path])
} else {
vec![path]
}
})
.collect();
paths.sort();
let truncated = paths.len() > RECOVERY_ARTIFACT_LIMIT;
paths.truncate(RECOVERY_ARTIFACT_LIMIT);
(
paths.iter().filter_map(|path| engine_file(path)).collect(),
truncated,
)
}
#[must_use]
pub fn engine_block(beads_dir: &Path, db_path: &Path) -> EngineBlock {
let db_string = db_path.to_string_lossy();
let sidecars = SIDECAR_SUFFIXES
.iter()
.filter_map(|suffix| engine_file(&PathBuf::from(format!("{db_string}{suffix}"))))
.collect();
let (recovery_artifacts, recovery_artifacts_truncated) = recovery_artifacts(beads_dir);
EngineBlock {
name: "frankensqlite",
crate_name: "fsqlite",
version: engine_version(),
database: db_path.display().to_string(),
sidecars,
sole_opener_lease: opener_lease(db_path),
recovery_artifacts,
recovery_artifacts_truncated,
}
}
impl EngineBlock {
#[must_use]
pub fn summary_line(&self) -> String {
let sidecars = if self.sidecars.is_empty() {
"none".to_string()
} else {
self.sidecars
.iter()
.filter_map(|sidecar| {
Path::new(&sidecar.file)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
})
.collect::<Vec<_>>()
.join(", ")
};
let lease = match &self.sole_opener_lease {
None => "no lease file".to_string(),
Some(lease) => match lease.held {
Some(true) => "lease held".to_string(),
Some(false) => "lease free".to_string(),
None => "lease present".to_string(),
},
};
format!(
"{} {} ({}); sidecars: {sidecars}; {lease}; recovery artifacts: {}{}",
self.name,
self.version,
self.crate_name,
self.recovery_artifacts.len(),
if self.recovery_artifacts_truncated {
"+"
} else {
""
}
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn engine_block_lists_existing_sidecars_and_artifacts_only() {
let temp = tempfile::tempdir().expect("tempdir");
let beads_dir = temp.path();
let db = beads_dir.join("beads.db");
std::fs::write(&db, b"db").unwrap();
std::fs::write(beads_dir.join("beads.db-wal"), b"wal").unwrap();
std::fs::create_dir_all(beads_dir.join(".br_recovery").join("run-1")).unwrap();
std::fs::write(
beads_dir
.join(".br_recovery")
.join("run-1")
.join("beads.db"),
b"old",
)
.unwrap();
let block = engine_block(beads_dir, &db);
assert_eq!(block.name, "frankensqlite");
assert_eq!(block.crate_name, "fsqlite");
assert!(!block.version.is_empty());
assert_eq!(block.sidecars.len(), 1);
assert!(block.sidecars[0].file.ends_with("beads.db-wal"));
assert_eq!(block.sidecars[0].bytes, 3);
assert_eq!(block.recovery_artifacts.len(), 1);
assert!(!block.recovery_artifacts_truncated);
assert!(block.sole_opener_lease.is_none());
let line = block.summary_line();
assert!(
line.contains("beads.db-wal") && line.contains("no lease file"),
"{line}"
);
}
#[cfg(unix)]
#[test]
fn opener_lease_probe_reports_held_and_free() {
use rustix::fs::{FlockOperation, flock};
let temp = tempfile::tempdir().expect("tempdir");
let db = temp.path().join("beads.db");
std::fs::write(&db, b"db").unwrap();
let lease_path = temp.path().join(".br-db-openers-abcdef.lock");
std::fs::write(&lease_path, b"").unwrap();
let free = engine_block(temp.path(), &db);
assert_eq!(
free.sole_opener_lease.as_ref().and_then(|lease| lease.held),
Some(false)
);
let holder = std::fs::File::open(&lease_path).unwrap();
flock(&holder, FlockOperation::LockShared).unwrap();
let held = engine_block(temp.path(), &db);
assert_eq!(
held.sole_opener_lease.as_ref().and_then(|lease| lease.held),
Some(true)
);
assert!(held.summary_line().contains("lease held"));
}
}