use crate::error::{BeadsError, Result};
use crate::sync::path::validate_sync_path_with_external;
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::{self, File, OpenOptions};
use std::io::{self, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
#[derive(Debug, Clone)]
pub struct HistoryConfig {
pub enabled: bool,
pub max_count: usize,
pub max_age_days: u32,
}
impl Default for HistoryConfig {
fn default() -> Self {
Self {
enabled: true,
max_count: 100,
max_age_days: 30,
}
}
}
#[derive(Debug, Clone)]
pub struct BackupEntry {
pub path: PathBuf,
pub timestamp: DateTime<Utc>,
pub size: u64,
pub target_path: PathBuf,
pub target_key: String,
}
struct BackupFileGuard {
path: PathBuf,
persist: bool,
}
impl BackupFileGuard {
fn new(path: PathBuf) -> Self {
Self {
path,
persist: false,
}
}
fn persist(&mut self) {
self.persist = true;
}
}
impl Drop for BackupFileGuard {
fn drop(&mut self) {
if !self.persist {
if let Err(cleanup_err) = fs::remove_file(&self.path)
&& cleanup_err.kind() != io::ErrorKind::NotFound
{
tracing::warn!(
backup = %self.path.display(),
error = %cleanup_err,
"Failed to remove partially written history backup"
);
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum BackupTarget {
Relative { path: String },
Absolute { path: String },
}
impl BackupTarget {
fn from_target_path(beads_dir: &Path, target_path: &Path) -> Self {
if let Ok(relative) = target_path.strip_prefix(beads_dir) {
return Self::Relative {
path: relative.to_string_lossy().into_owned(),
};
}
Self::Absolute {
path: target_path.to_string_lossy().into_owned(),
}
}
fn resolve_path(&self, beads_dir: &Path) -> PathBuf {
match self {
Self::Relative { path } => beads_dir.join(path),
Self::Absolute { path } => PathBuf::from(path),
}
}
fn key(&self) -> String {
match self {
Self::Relative { path } => format!("relative:{path}"),
Self::Absolute { path } => format!("absolute:{path}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct BackupMetadata {
target: BackupTarget,
}
impl BackupMetadata {
fn from_target_path(beads_dir: &Path, target_path: &Path) -> Self {
Self {
target: BackupTarget::from_target_path(beads_dir, target_path),
}
}
}
fn parse_backup_timestamp(ts_str: &str) -> Option<DateTime<Utc>> {
for fmt in ["%Y%m%d_%H%M%S_%f", "%Y%m%d_%H%M%S"] {
if let Ok(dt) = NaiveDateTime::parse_from_str(ts_str, fmt) {
return Some(Utc.from_utc_datetime(&dt));
}
}
None
}
static BACKUP_FILENAME_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?P<stem>.+?)\.(?P<ts>\d{8}_\d{6}(?:_\d{1,9})?)(\.\d+)?$")
.expect("static regex compilation must not fail")
});
pub(crate) fn parse_backup_filename(filename: &str) -> Option<(String, DateTime<Utc>)> {
let without_ext = filename.strip_suffix(".jsonl")?;
let caps = BACKUP_FILENAME_REGEX.captures(without_ext)?;
let stem = caps.name("stem")?.as_str().to_string();
let timestamp_str = caps.name("ts")?.as_str();
let timestamp = parse_backup_timestamp(timestamp_str)?;
Some((stem, timestamp))
}
fn create_backup_file(history_dir: &Path, file_stem: &str) -> Result<(PathBuf, File)> {
let timestamp = Utc::now().format("%Y%m%d_%H%M%S_%f").to_string();
for collision_idx in 0..1024_u32 {
let backup_name = if collision_idx == 0 {
format!("{file_stem}.{timestamp}.jsonl")
} else {
format!("{file_stem}.{timestamp}.{collision_idx}.jsonl")
};
let backup_path = history_dir.join(backup_name);
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&backup_path)
{
Ok(file) => return Ok((backup_path, file)),
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
Err(err) => return Err(err.into()),
}
}
Err(BeadsError::Config(format!(
"Failed to allocate unique backup path for {file_stem}"
)))
}
fn backup_metadata_path(backup_path: &Path) -> PathBuf {
backup_path.with_extension("jsonl.meta.json")
}
pub(crate) fn validate_history_dir_path(history_dir: &Path) -> Result<bool> {
match fs::symlink_metadata(history_dir) {
Ok(metadata) => {
let file_type = metadata.file_type();
if file_type.is_symlink() {
return Err(BeadsError::Config(format!(
"History directory '{}' must not be a symlink",
history_dir.display()
)));
}
if !file_type.is_dir() {
return Err(BeadsError::Config(format!(
"History directory '{}' must be a directory",
history_dir.display()
)));
}
Ok(true)
}
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(BeadsError::Io(err)),
}
}
fn ensure_history_dir_path(history_dir: &Path) -> Result<()> {
if validate_history_dir_path(history_dir)? {
return Ok(());
}
fs::create_dir_all(history_dir).map_err(BeadsError::Io)?;
if validate_history_dir_path(history_dir)? {
Ok(())
} else {
Err(BeadsError::Config(format!(
"Failed to create history directory '{}'",
history_dir.display()
)))
}
}
fn history_artifact_metadata(path: &Path, label: &str) -> Result<Option<fs::Metadata>> {
match fs::symlink_metadata(path) {
Ok(metadata) => {
let file_type = metadata.file_type();
if file_type.is_symlink() {
return Err(BeadsError::Config(format!(
"History {label} '{}' must not be a symlink",
path.display()
)));
}
if !file_type.is_file() {
return Err(BeadsError::Config(format!(
"History {label} '{}' must be a regular file",
path.display()
)));
}
Ok(Some(metadata))
}
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(BeadsError::Io(err)),
}
}
fn remove_history_artifact_if_present(path: &Path, label: &str) -> Result<()> {
match fs::symlink_metadata(path) {
Ok(metadata) => {
let file_type = metadata.file_type();
if file_type.is_symlink() || file_type.is_file() {
if let Err(err) = fs::remove_file(path)
&& err.kind() != io::ErrorKind::NotFound
{
return Err(BeadsError::Io(err));
}
return Ok(());
}
Err(BeadsError::Config(format!(
"History {label} '{}' must be a regular file",
path.display()
)))
}
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(BeadsError::Io(err)),
}
}
fn remove_backup_artifacts(backup_path: &Path) -> Result<()> {
remove_history_artifact_if_present(backup_path, "backup file")?;
let metadata_path = backup_metadata_path(backup_path);
remove_history_artifact_if_present(&metadata_path, "backup metadata")?;
Ok(())
}
fn read_backup_metadata(backup_path: &Path) -> Result<Option<BackupMetadata>> {
let metadata_path = backup_metadata_path(backup_path);
if history_artifact_metadata(&metadata_path, "backup metadata")?.is_none() {
return Ok(None);
}
let contents = fs::read(&metadata_path).map_err(BeadsError::Io)?;
let metadata = serde_json::from_slice(&contents).map_err(|err| {
BeadsError::Config(format!(
"Failed to parse history backup metadata '{}': {err}",
metadata_path.display()
))
})?;
Ok(Some(metadata))
}
fn write_backup_metadata(beads_dir: &Path, target_path: &Path, backup_path: &Path) -> Result<()> {
let metadata = BackupMetadata::from_target_path(beads_dir, target_path);
let contents = serde_json::to_vec(&metadata).map_err(|err| {
BeadsError::Config(format!(
"Failed to serialize history backup metadata for '{}': {err}",
backup_path.display()
))
})?;
let metadata_path = backup_metadata_path(backup_path);
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&metadata_path)
.map_err(BeadsError::Io)?;
let write_result = (|| -> Result<()> {
file.write_all(&contents).map_err(BeadsError::Io)?;
file.sync_all().map_err(BeadsError::Io)?;
Ok(())
})();
drop(file);
if let Err(err) = write_result {
if let Err(cleanup_err) = fs::remove_file(&metadata_path) {
tracing::warn!(
metadata = %metadata_path.display(),
cleanup_error = %cleanup_err,
"Failed to remove partially written history metadata"
);
}
return Err(err);
}
crate::util::sync_parent_directory(&metadata_path).map_err(BeadsError::Io)?;
Ok(())
}
fn legacy_backup_target_path(beads_dir: &Path, backup_name: &str) -> Result<PathBuf> {
let Some((stem, _timestamp)) = parse_backup_filename(backup_name) else {
return Err(BeadsError::Config(format!(
"Invalid backup filename format: {backup_name}"
)));
};
Ok(beads_dir.join(format!("{stem}.jsonl")))
}
fn invalid_metadata_target_path(backup_name: &str) -> PathBuf {
PathBuf::from(format!("<invalid metadata: {backup_name}>"))
}
fn backup_target_details(
history_dir: &Path,
backup_path: &Path,
backup_name: &str,
) -> (PathBuf, String) {
let Some(beads_dir) = history_dir.parent() else {
let fallback = PathBuf::from(backup_name);
return (fallback, format!("orphan-history:{backup_name}"));
};
match read_backup_metadata(backup_path) {
Ok(Some(metadata)) => {
let target_path = metadata.target.resolve_path(beads_dir);
(target_path, metadata.target.key())
}
Ok(None) => legacy_backup_target_path(beads_dir, backup_name).map_or_else(
|_| {
let fallback = PathBuf::from(backup_name);
(fallback, format!("legacy-name:{backup_name}"))
},
|target_path| {
let target_key = BackupMetadata::from_target_path(beads_dir, &target_path)
.target
.key();
(target_path, target_key)
},
),
Err(err) => {
tracing::warn!(
backup = %backup_path.display(),
error = %err,
"Ignoring unreadable history backup metadata during history rotation/listing"
);
(
invalid_metadata_target_path(backup_name),
format!("invalid-metadata:{backup_name}"),
)
}
}
}
fn target_key_for_path(beads_dir: &Path, target_path: &Path) -> String {
BackupMetadata::from_target_path(beads_dir, target_path)
.target
.key()
}
pub(crate) fn resolve_backup_target_path(beads_dir: &Path, backup_path: &Path) -> Result<PathBuf> {
let backup_name = backup_path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
BeadsError::Config(format!(
"Invalid backup filename format: {}",
backup_path.display()
))
})?;
if parse_backup_filename(backup_name).is_none() {
return Err(BeadsError::Config(format!(
"Invalid backup filename format: {backup_name}"
)));
}
let metadata = read_backup_metadata(backup_path)?.ok_or_else(|| {
BeadsError::Config(format!(
"History backup '{backup_name}' is missing target metadata and cannot be safely restored or diffed"
))
})?;
let target_path = metadata.target.resolve_path(beads_dir);
validate_sync_path_with_external(&target_path, beads_dir, true)?;
Ok(target_path)
}
pub fn backup_before_export(
beads_dir: &Path,
config: &HistoryConfig,
target_path: &Path,
) -> Result<()> {
if !config.enabled {
return Ok(());
}
let history_dir = beads_dir.join(".br_history");
if !target_path.exists() {
return Ok(());
}
ensure_history_dir_path(&history_dir)?;
let file_stem = target_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("issues");
let target_key = target_key_for_path(beads_dir, target_path);
if let Some(latest) = get_latest_backup(&history_dir, &target_key)?
&& files_are_identical(target_path, &latest.path)?
{
tracing::debug!(
"Skipping backup: identical to latest {}",
latest.path.display()
);
return Ok(());
}
let (backup_path, mut backup_file) = create_backup_file(&history_dir, file_stem)?;
let mut backup_guard = BackupFileGuard::new(backup_path.clone());
let mut source = File::open(target_path).map_err(BeadsError::Io)?;
io::copy(&mut source, &mut backup_file).map_err(BeadsError::Io)?;
backup_file.sync_all().map_err(BeadsError::Io)?;
crate::util::sync_parent_directory(&backup_path).map_err(BeadsError::Io)?;
write_backup_metadata(beads_dir, target_path, &backup_path)?;
backup_guard.persist();
tracing::debug!("Created backup: {}", backup_path.display());
rotate_history(&history_dir, config, &target_key)?;
Ok(())
}
fn rotate_history(history_dir: &Path, config: &HistoryConfig, target_key: &str) -> Result<()> {
let mut backups: Vec<_> = list_backups(history_dir, None)?
.into_iter()
.filter(|entry| entry.target_key == target_key)
.collect();
if backups.is_empty() {
return Ok(());
}
backups.sort_by_key(|b| std::cmp::Reverse(b.timestamp));
let now = Utc::now();
let max_safe_days = 365_i64 * 1000;
let days_i64 = i64::from(config.max_age_days).min(max_safe_days);
let cutoff = now - chrono::Duration::days(days_i64);
let mut deleted_count = 0;
for (idx, entry) in backups.iter().enumerate() {
let is_too_old = entry.timestamp < cutoff;
let is_dominated = idx >= config.max_count;
if is_too_old || is_dominated {
remove_backup_artifacts(&entry.path)?;
deleted_count += 1;
}
}
if deleted_count > 0 {
tracing::debug!("Pruned {} old backup(s) for {}", deleted_count, target_key);
}
Ok(())
}
pub fn list_backups(history_dir: &Path, filter_prefix: Option<&str>) -> Result<Vec<BackupEntry>> {
if !validate_history_dir_path(history_dir)? {
return Ok(Vec::new());
}
let mut backups = Vec::new();
for entry in fs::read_dir(history_dir)? {
let entry = entry?;
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if let Some(prefix) = filter_prefix
&& !name.starts_with(prefix)
{
continue;
}
let is_jsonl = path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl"));
if !is_jsonl {
continue;
}
let Some((_, timestamp)) = parse_backup_filename(name) else {
continue;
};
let metadata = match history_artifact_metadata(&path, "backup file") {
Ok(Some(metadata)) => metadata,
Ok(None) => continue,
Err(err) => {
tracing::warn!(
backup = %path.display(),
error = %err,
"Ignoring unsafe history backup entry during history listing"
);
continue;
}
};
let (target_path, target_key) = backup_target_details(history_dir, &path, name);
backups.push(BackupEntry {
path,
timestamp,
size: metadata.len(),
target_path,
target_key,
});
}
backups.sort_by_key(|b| std::cmp::Reverse(b.timestamp));
Ok(backups)
}
fn get_latest_backup(history_dir: &Path, target_key: &str) -> Result<Option<BackupEntry>> {
Ok(list_backups(history_dir, None)?
.into_iter()
.find(|entry| entry.target_key == target_key))
}
fn files_are_identical(p1: &Path, p2: &Path) -> Result<bool> {
let f1 = File::open(p1).map_err(BeadsError::Io)?;
let f2 = File::open(p2).map_err(BeadsError::Io)?;
let len1 = f1.metadata().map_err(BeadsError::Io)?.len();
let len2 = f2.metadata().map_err(BeadsError::Io)?.len();
if len1 != len2 {
return Ok(false);
}
let mut reader1 = BufReader::new(f1);
let mut reader2 = BufReader::new(f2);
let mut buf1 = [0u8; 8192];
let mut buf2 = [0u8; 8192];
loop {
let n1 = reader1.read(&mut buf1).map_err(BeadsError::Io)?;
if n1 == 0 {
break;
}
let mut n2_total = 0;
while n2_total < n1 {
let n2 = reader2
.read(&mut buf2[n2_total..n1])
.map_err(BeadsError::Io)?;
if n2 == 0 {
return Ok(false); }
n2_total += n2;
}
if buf1[..n1] != buf2[..n1] {
return Ok(false);
}
}
Ok(true)
}
pub fn prune_backups(
history_dir: &Path,
keep: usize,
older_than_days: Option<u32>,
) -> Result<usize> {
let cutoff = older_than_days.map(|days| {
let max_safe_days = 365_i64 * 1000;
let days_i64 = i64::from(days).min(max_safe_days);
Utc::now() - chrono::Duration::days(days_i64)
});
let mut backups_by_target: HashMap<String, Vec<BackupEntry>> = HashMap::new();
for entry in list_backups(history_dir, None)? {
backups_by_target
.entry(entry.target_key.clone())
.or_default()
.push(entry);
}
let mut deleted_count = 0;
for backups in backups_by_target.values_mut() {
backups.sort_by_key(|b| std::cmp::Reverse(b.timestamp));
for (i, entry) in backups.iter().enumerate() {
let is_count_exceeded = i >= keep;
let is_age_exceeded = cutoff.is_some_and(|c| entry.timestamp < c);
if is_count_exceeded || is_age_exceeded {
remove_backup_artifacts(&entry.path).map_err(|err| {
BeadsError::Config(format!(
"Failed to delete backup '{}': {err}",
entry.path.display()
))
})?;
deleted_count += 1;
}
}
}
Ok(deleted_count)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::symlink;
use tempfile::TempDir;
#[test]
fn test_backup_rotation() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let history_dir = beads_dir.join(".br_history");
fs::create_dir_all(&history_dir).unwrap();
let config = HistoryConfig {
enabled: true,
max_count: 2,
max_age_days: 30,
};
let now = Utc::now();
let t1 = (now - chrono::Duration::hours(3)).format("%Y%m%d_%H%M%S");
let t2 = (now - chrono::Duration::hours(2)).format("%Y%m%d_%H%M%S");
let t3 = (now - chrono::Duration::hours(1)).format("%Y%m%d_%H%M%S");
let file1 = format!("issues.{t1}.jsonl");
let file2 = format!("issues.{t2}.jsonl");
let file3 = format!("issues.{t3}.jsonl");
let test_files = [&file1, &file2, &file3];
for name in &test_files {
File::create(history_dir.join(name)).unwrap();
}
let backups = list_backups(&history_dir, None).unwrap();
assert_eq!(backups.len(), 3);
let target_key = target_key_for_path(&beads_dir, &beads_dir.join("issues.jsonl"));
rotate_history(&history_dir, &config, &target_key).unwrap();
let remaining = list_backups(&history_dir, None).unwrap();
assert_eq!(remaining.len(), 2);
assert!(
!remaining
.iter()
.any(|b| b.path.to_string_lossy().contains(&t1.to_string()))
);
assert!(
remaining
.iter()
.any(|b| b.path.to_string_lossy().contains(&t2.to_string()))
);
assert!(
remaining
.iter()
.any(|b| b.path.to_string_lossy().contains(&t3.to_string()))
);
}
#[test]
fn test_backup_before_export_keeps_same_stem_targets_separate() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let history_dir = beads_dir.join(".br_history");
let external_dir = temp.path().join("external");
fs::create_dir_all(&beads_dir).unwrap();
fs::create_dir_all(&external_dir).unwrap();
let config = HistoryConfig {
enabled: true,
max_count: 10,
max_age_days: 30,
};
let internal_target = beads_dir.join("issues.jsonl");
let external_target = external_dir.join("issues.jsonl");
fs::write(&internal_target, "internal\n").unwrap();
fs::write(&external_target, "external\n").unwrap();
backup_before_export(&beads_dir, &config, &internal_target).unwrap();
backup_before_export(&beads_dir, &config, &external_target).unwrap();
let backups = list_backups(&history_dir, Some("issues.")).unwrap();
assert_eq!(backups.len(), 2);
assert!(
backups
.iter()
.any(|entry| entry.target_path == internal_target)
);
assert!(
backups
.iter()
.any(|entry| entry.target_path == external_target)
);
assert_eq!(
backups
.iter()
.map(|entry| entry.target_key.as_str())
.collect::<std::collections::HashSet<_>>()
.len(),
2
);
}
#[test]
fn test_prune_backups_removes_metadata_sidecars() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let history_dir = beads_dir.join(".br_history");
fs::create_dir_all(&beads_dir).unwrap();
let config = HistoryConfig {
enabled: true,
max_count: 10,
max_age_days: 30,
};
let target = beads_dir.join("issues.jsonl");
fs::write(&target, "issue\n").unwrap();
backup_before_export(&beads_dir, &config, &target).unwrap();
let backup = list_backups(&history_dir, None)
.unwrap()
.into_iter()
.next()
.expect("backup entry");
let metadata_path = backup_metadata_path(&backup.path);
assert!(metadata_path.is_file());
let deleted = prune_backups(&history_dir, 0, None).unwrap();
assert_eq!(deleted, 1);
assert!(!backup.path.exists());
assert!(!metadata_path.exists());
}
#[test]
fn test_deduplication() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let history_dir = beads_dir.join(".br_history");
fs::create_dir_all(&beads_dir).unwrap();
let jsonl_path = beads_dir.join("issues.jsonl");
File::create(&jsonl_path)
.unwrap()
.write_all(b"content")
.unwrap();
let config = HistoryConfig::default();
backup_before_export(&beads_dir, &config, &jsonl_path).unwrap();
backup_before_export(&beads_dir, &config, &jsonl_path).unwrap();
let backups = list_backups(&history_dir, None).unwrap();
assert_eq!(backups.len(), 1);
}
#[test]
fn test_list_backups_parsing() {
let temp = TempDir::new().unwrap();
let history_dir = temp.path();
File::create(history_dir.join("issues.20230101_100000.jsonl")).unwrap();
File::create(history_dir.join("issues.20230102_100000.jsonl")).unwrap();
File::create(history_dir.join("issues.invalid_name.jsonl")).unwrap();
let backups = list_backups(history_dir, None).unwrap();
assert_eq!(backups.len(), 2);
assert!(backups[0].path.to_string_lossy().contains("20230102"));
assert!(backups[1].path.to_string_lossy().contains("20230101"));
}
#[test]
fn test_list_backups_parses_high_precision_timestamps_and_collision_suffix() {
let temp = TempDir::new().unwrap();
let history_dir = temp.path();
File::create(history_dir.join("issues.20230101_100000_123456.1.jsonl")).unwrap();
File::create(history_dir.join("issues.20230101_100001_654321.jsonl")).unwrap();
File::create(history_dir.join("issues.20230101_100002_123456789.jsonl")).unwrap();
let backups = list_backups(history_dir, None).unwrap();
assert_eq!(backups.len(), 3);
assert!(
backups[0]
.path
.to_string_lossy()
.contains("20230101_100002_123456789")
);
assert!(
backups[1]
.path
.to_string_lossy()
.contains("20230101_100001_654321")
);
assert!(
backups[2]
.path
.to_string_lossy()
.contains("20230101_100000_123456.1")
);
}
#[test]
fn test_rapid_distinct_backups_do_not_collide() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let target = beads_dir.join("issues.jsonl");
let config = HistoryConfig::default();
File::create(&target)
.unwrap()
.write_all(b"version-1")
.unwrap();
backup_before_export(&beads_dir, &config, &target).unwrap();
File::create(&target)
.unwrap()
.write_all(b"version-2")
.unwrap();
backup_before_export(&beads_dir, &config, &target).unwrap();
let backups = list_backups(&beads_dir.join(".br_history"), Some("issues.")).unwrap();
assert_eq!(backups.len(), 2);
}
#[test]
fn test_prune_backups() {
let temp = TempDir::new().unwrap();
let history_dir = temp.path();
for i in 0..5 {
let ts = Utc::now() - chrono::Duration::days(i64::from(i));
let ts_str = ts.format("%Y%m%d_%H%M%S");
File::create(history_dir.join(format!("issues.{ts_str}.jsonl"))).unwrap();
}
let deleted = prune_backups(history_dir, 3, None).unwrap();
assert_eq!(deleted, 2);
assert_eq!(list_backups(history_dir, None).unwrap().len(), 3);
let deleted_age = prune_backups(history_dir, 100, Some(2)).unwrap();
assert_eq!(deleted_age, 1);
assert_eq!(list_backups(history_dir, None).unwrap().len(), 2);
}
#[test]
fn test_prune_backups_applies_keep_per_stem() {
let temp = TempDir::new().unwrap();
let history_dir = temp.path();
let now = Utc::now();
for (stem, hours_ago) in [
("issues", 4_i64),
("issues", 3),
("archive", 2),
("archive", 1),
] {
let ts = (now - chrono::Duration::hours(hours_ago)).format("%Y%m%d_%H%M%S");
File::create(history_dir.join(format!("{stem}.{ts}.jsonl"))).unwrap();
}
let deleted = prune_backups(history_dir, 1, None).unwrap();
assert_eq!(deleted, 2);
assert_eq!(list_backups(history_dir, Some("issues.")).unwrap().len(), 1);
assert_eq!(
list_backups(history_dir, Some("archive.")).unwrap().len(),
1
);
}
#[cfg(unix)]
#[test]
fn test_list_backups_ignores_symlinked_backup_files() {
let temp = TempDir::new().unwrap();
let history_dir = temp.path();
let outside = temp.path().join("outside.jsonl");
fs::write(&outside, "outside\n").unwrap();
symlink(&outside, history_dir.join("issues.20260307_120000.jsonl")).unwrap();
let backups = list_backups(history_dir, None).unwrap();
assert!(backups.is_empty(), "symlinked backup files must be ignored");
}
#[cfg(unix)]
#[test]
fn test_list_backups_rejects_symlinked_history_directory() {
let temp = TempDir::new().unwrap();
let outside_dir = temp.path().join("outside");
let history_dir = temp.path().join(".br_history");
fs::create_dir_all(&outside_dir).unwrap();
fs::write(outside_dir.join("issues.20260307_120000.jsonl"), "backup\n").unwrap();
symlink(&outside_dir, &history_dir).unwrap();
let err = list_backups(&history_dir, None).unwrap_err();
assert!(
matches!(&err, BeadsError::Config(_)),
"unexpected error: {err:?}"
);
let BeadsError::Config(message) = err else {
return;
};
assert!(
message.contains("must not be a symlink"),
"unexpected message: {message}"
);
}
#[test]
fn test_prune_backups_returns_error_on_partial_deletion_failure() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let history_dir = beads_dir.join(".br_history");
fs::create_dir_all(&history_dir).unwrap();
let target_path = beads_dir.join("issues.jsonl");
fs::write(&target_path, "issue\n").unwrap();
let backup_path = history_dir.join("issues.20260307_120000_000000.jsonl");
fs::write(&backup_path, "backup\n").unwrap();
write_backup_metadata(&beads_dir, &target_path, &backup_path).unwrap();
fs::remove_file(backup_metadata_path(&backup_path)).unwrap();
fs::create_dir(backup_metadata_path(&backup_path)).unwrap();
let err = prune_backups(&history_dir, 0, None).unwrap_err();
assert!(
matches!(&err, BeadsError::Config(_)),
"unexpected error: {err:?}"
);
let BeadsError::Config(message) = err else {
return;
};
assert!(
message.contains("Failed to delete backup"),
"unexpected message: {message}"
);
}
#[test]
fn test_list_backups_marks_invalid_metadata_without_guessing_target() {
let temp = TempDir::new().unwrap();
let history_dir = temp.path();
let backup_name = "issues.20260307_120000.jsonl";
let backup_path = history_dir.join(backup_name);
fs::write(&backup_path, "backup\n").unwrap();
fs::write(backup_metadata_path(&backup_path), "{not-json").unwrap();
let backups = list_backups(history_dir, None).unwrap();
assert_eq!(backups.len(), 1);
assert_eq!(
backups[0].target_key,
format!("invalid-metadata:{backup_name}")
);
assert_eq!(
backups[0].target_path,
invalid_metadata_target_path(backup_name)
);
}
#[cfg(unix)]
#[test]
fn test_write_backup_metadata_rejects_existing_symlink() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let history_dir = beads_dir.join(".br_history");
let outside_dir = temp.path().join("outside");
fs::create_dir_all(&history_dir).unwrap();
fs::create_dir_all(&outside_dir).unwrap();
let target_path = beads_dir.join("issues.jsonl");
fs::write(&target_path, "issue\n").unwrap();
let backup_path = history_dir.join("issues.20260307_120000_000000.jsonl");
fs::write(&backup_path, "backup\n").unwrap();
let metadata_target = outside_dir.join("captured.json");
fs::write(&metadata_target, "do-not-touch").unwrap();
symlink(&metadata_target, backup_metadata_path(&backup_path)).unwrap();
let err = write_backup_metadata(&beads_dir, &target_path, &backup_path).unwrap_err();
assert!(matches!(err, BeadsError::Io(_)), "unexpected error: {err}");
assert_eq!(
fs::read_to_string(&metadata_target).unwrap(),
"do-not-touch",
"existing metadata symlink target should not be overwritten"
);
}
#[cfg(unix)]
#[test]
fn test_backup_before_export_rejects_symlinked_history_directory() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let outside_dir = temp.path().join("outside");
let history_dir = beads_dir.join(".br_history");
fs::create_dir_all(&beads_dir).unwrap();
fs::create_dir_all(&outside_dir).unwrap();
symlink(&outside_dir, &history_dir).unwrap();
let target_path = beads_dir.join("issues.jsonl");
fs::write(&target_path, "issue\n").unwrap();
let err =
backup_before_export(&beads_dir, &HistoryConfig::default(), &target_path).unwrap_err();
assert!(
matches!(&err, BeadsError::Config(_)),
"unexpected error: {err:?}"
);
let BeadsError::Config(message) = err else {
return;
};
assert!(
message.contains("must not be a symlink"),
"unexpected message: {message}"
);
assert!(
fs::read_dir(&outside_dir).unwrap().next().is_none(),
"symlinked history directory must not receive backups"
);
}
}