use crate::config::Config;
use crate::error::QbakError;
use crate::Result;
use chrono::{DateTime, Utc};
use std::path::{Path, PathBuf};
pub fn generate_backup_name(source: &Path, config: &Config) -> Result<PathBuf> {
let timestamp = Utc::now();
let timestamp_str = format_timestamp(×tamp, &config.timestamp_format);
let source_name = source
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| QbakError::validation("Invalid source filename"))?;
let (stem, extension) = split_filename(source_name);
let backup_name = if extension.is_empty() {
let suffix = &config.backup_suffix;
format!("{stem}-{timestamp_str}-{suffix}")
} else {
format!(
"{stem}-{timestamp_str}-{}.{extension}",
config.backup_suffix
)
};
validate_filename_length(&backup_name, config.max_filename_length)?;
validate_filesystem_chars(&backup_name)?;
let parent = source.parent().unwrap_or(Path::new("."));
let backup_path = parent.join(&backup_name);
Ok(backup_path)
}
pub fn resolve_collision(base_path: &Path) -> Result<PathBuf> {
if !base_path.exists() {
return Ok(base_path.to_path_buf());
}
let parent = base_path.parent().unwrap_or(Path::new("."));
let filename = base_path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| QbakError::validation("Invalid backup filename"))?;
let (stem, extension) = split_filename(filename);
for counter in 1..=9999 {
let new_name = if extension.is_empty() {
format!("{stem}-{counter}")
} else {
format!("{stem}-{counter}.{extension}")
};
let new_path = parent.join(&new_name);
if !new_path.exists() {
return Ok(new_path);
}
}
Err(QbakError::validation("Too many backup collisions (>9999)"))
}
fn format_timestamp(timestamp: &DateTime<Utc>, format: &str) -> String {
match format {
"YYYYMMDDTHHMMSS" => timestamp.format("%Y%m%dT%H%M%S").to_string(),
_ => {
timestamp.format("%Y%m%dT%H%M%S").to_string()
}
}
}
fn split_filename(filename: &str) -> (&str, &str) {
if let Some(dot_pos) = filename.rfind('.') {
if dot_pos > 0 && dot_pos < filename.len() - 1 {
return (&filename[..dot_pos], &filename[dot_pos + 1..]);
} else if dot_pos == filename.len() - 1 {
return (&filename[..dot_pos], "");
}
}
(filename, "")
}
fn validate_filename_length(filename: &str, max_length: usize) -> Result<()> {
if filename.len() > max_length {
return Err(QbakError::FilenameTooLong {
length: filename.len(),
max: max_length,
});
}
Ok(())
}
fn validate_filesystem_chars(filename: &str) -> Result<()> {
const INVALID_CHARS: &[char] = &['<', '>', ':', '"', '|', '?', '*'];
const RESERVED_NAMES: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
let invalid_chars: Vec<char> = filename
.chars()
.filter(|c| INVALID_CHARS.contains(c))
.collect();
if !invalid_chars.is_empty() {
let chars_str: String = invalid_chars.into_iter().collect();
return Err(QbakError::InvalidFilesystemChars { chars: chars_str });
}
let stem = split_filename(filename).0.to_uppercase();
if RESERVED_NAMES.contains(&stem.as_str()) {
return Err(QbakError::InvalidFilesystemChars {
chars: format!("Reserved name: {stem}"),
});
}
if filename.chars().any(|c| c.is_control()) {
return Err(QbakError::InvalidFilesystemChars {
chars: "Control characters".to_string(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::default_config;
use std::fs::File;
use tempfile::tempdir;
#[test]
fn test_split_filename() {
assert_eq!(split_filename("file.txt"), ("file", "txt"));
assert_eq!(split_filename("file.tar.gz"), ("file.tar", "gz"));
assert_eq!(split_filename("file"), ("file", ""));
assert_eq!(split_filename(".hidden"), (".hidden", ""));
assert_eq!(split_filename("file."), ("file", ""));
}
#[test]
fn test_generate_backup_name() {
let config = default_config();
let source = Path::new("/tmp/test.txt");
let backup_path = generate_backup_name(source, &config).unwrap();
let backup_name = backup_path.file_name().unwrap().to_str().unwrap();
assert!(backup_name.contains("-qbak"));
assert!(backup_name.ends_with(".txt"));
assert!(backup_name.starts_with("test-"));
}
#[test]
fn test_generate_backup_name_no_extension() {
let config = default_config();
let source = Path::new("/tmp/makefile");
let backup_path = generate_backup_name(source, &config).unwrap();
let backup_name = backup_path.file_name().unwrap().to_str().unwrap();
assert!(backup_name.contains("-qbak"));
assert!(!backup_name.contains("."));
assert!(backup_name.starts_with("makefile-"));
}
#[test]
fn test_resolve_collision() {
let dir = tempdir().unwrap();
let base_path = dir.path().join("test-20250101T120000-qbak.txt");
let resolved = resolve_collision(&base_path).unwrap();
assert_eq!(resolved, base_path);
File::create(&base_path).unwrap();
let resolved = resolve_collision(&base_path).unwrap();
assert_eq!(resolved, dir.path().join("test-20250101T120000-qbak-1.txt"));
File::create(&resolved).unwrap();
let resolved2 = resolve_collision(&base_path).unwrap();
assert_eq!(
resolved2,
dir.path().join("test-20250101T120000-qbak-2.txt")
);
}
#[test]
fn test_validate_filesystem_chars() {
assert!(validate_filesystem_chars("normal-file.txt").is_ok());
assert!(validate_filesystem_chars("file_with-chars.123").is_ok());
assert!(validate_filesystem_chars("file<test>.txt").is_err());
assert!(validate_filesystem_chars("file:test.txt").is_err());
assert!(validate_filesystem_chars("file|test.txt").is_err());
assert!(validate_filesystem_chars("CON.txt").is_err());
assert!(validate_filesystem_chars("con.txt").is_err());
assert!(validate_filesystem_chars("COM1.txt").is_err());
}
#[test]
fn test_filename_length_validation() {
let short_name = "test.txt";
let long_name = "a".repeat(300);
assert!(validate_filename_length(short_name, 255).is_ok());
assert!(validate_filename_length(&long_name, 255).is_err());
}
}