use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::thread;
use std::time::{Duration, SystemTime};
use self_update::update::Release;
use serde::{Deserialize, Serialize};
pub const CACHE_DURATION: Duration = Duration::from_secs(24 * 60 * 60);
const REPO_OWNER: &str = "Dicklesworthstone";
const REPO_NAME: &str = "destructive_command_guard";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionCheckResult {
pub current_version: String,
pub latest_version: String,
pub update_available: bool,
pub release_url: String,
pub release_notes: Option<String>,
pub checked_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CachedCheck {
result: VersionCheckResult,
cached_at_secs: u64,
}
#[derive(Debug)]
pub enum VersionCheckError {
NetworkError(String),
ParseError(String),
CacheError(String),
CurrentVersionError(String),
UpdateError(String),
BackupError(String),
NoUpdateAvailable,
}
impl std::fmt::Display for VersionCheckError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NetworkError(msg) => write!(f, "Network error: {msg}"),
Self::ParseError(msg) => write!(f, "Parse error: {msg}"),
Self::CacheError(msg) => write!(f, "Cache error: {msg}"),
Self::CurrentVersionError(msg) => write!(f, "Version error: {msg}"),
Self::UpdateError(msg) => write!(f, "Update error: {msg}"),
Self::BackupError(msg) => write!(f, "Backup error: {msg}"),
Self::NoUpdateAvailable => write!(f, "No update available"),
}
}
}
impl std::error::Error for VersionCheckError {}
const MAX_BACKUPS: usize = 3;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupEntry {
pub version: String,
pub created_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_name: Option<String>,
pub original_path: PathBuf,
}
#[must_use]
pub fn backup_dir() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join("dcg").join("backups"))
}
fn backup_artifact_name(entry: &BackupEntry) -> String {
entry
.artifact_name
.clone()
.unwrap_or_else(|| format!("dcg-{}-{}", entry.version, entry.created_at))
}
fn generate_unique_backup_name(dir: &Path, version: &str, timestamp: u64) -> String {
let base = format!("dcg-{version}-{timestamp}");
let base_path = dir.join(&base);
let base_metadata_path = dir.join(format!("{base}.json"));
if !base_path.exists() && !base_metadata_path.exists() {
return base;
}
let nanos_suffix = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |duration| duration.subsec_nanos());
let fallback = format!("{base}-{nanos_suffix:09}");
let fallback_path = dir.join(&fallback);
let fallback_metadata_path = dir.join(format!("{fallback}.json"));
if !fallback_path.exists() && !fallback_metadata_path.exists() {
return fallback;
}
for counter in 1..=u32::MAX {
let candidate = format!("{base}-{nanos_suffix:09}-{counter}");
let candidate_path = dir.join(&candidate);
let candidate_metadata_path = dir.join(format!("{candidate}.json"));
if !candidate_path.exists() && !candidate_metadata_path.exists() {
return candidate;
}
}
format!("{base}-{nanos_suffix:09}-overflow")
}
pub fn list_backups() -> Result<Vec<BackupEntry>, VersionCheckError> {
let dir = backup_dir().ok_or_else(|| {
VersionCheckError::BackupError("Could not determine backup directory".to_string())
})?;
if !dir.exists() {
return Ok(Vec::new());
}
let mut entries = Vec::new();
for entry in fs::read_dir(&dir).map_err(|e| {
VersionCheckError::BackupError(format!("Failed to read backup directory: {e}"))
})? {
let entry = entry.map_err(|e| {
VersionCheckError::BackupError(format!("Failed to read directory entry: {e}"))
})?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json") {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(backup) = serde_json::from_str::<BackupEntry>(&content) {
entries.push(backup);
}
}
}
}
entries.sort_by_key(|entry| std::cmp::Reverse(entry.created_at));
Ok(entries)
}
pub fn create_backup() -> Result<PathBuf, VersionCheckError> {
let dir = backup_dir().ok_or_else(|| {
VersionCheckError::BackupError("Could not determine backup directory".to_string())
})?;
fs::create_dir_all(&dir).map_err(|e| {
VersionCheckError::BackupError(format!("Failed to create backup directory: {e}"))
})?;
let current_exe = std::env::current_exe().map_err(|e| {
VersionCheckError::BackupError(format!("Failed to get current executable path: {e}"))
})?;
let version = current_version();
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|e| VersionCheckError::BackupError(format!("Failed to get timestamp: {e}")))?
.as_secs();
let backup_name = generate_unique_backup_name(&dir, version, timestamp);
let backup_path = dir.join(&backup_name);
let metadata_path = dir.join(format!("{backup_name}.json"));
fs::copy(¤t_exe, &backup_path).map_err(|e| {
VersionCheckError::BackupError(format!("Failed to copy binary to backup: {e}"))
})?;
let entry = BackupEntry {
version: version.to_string(),
created_at: timestamp,
artifact_name: Some(backup_name.clone()),
original_path: current_exe,
};
let metadata_content = serde_json::to_string_pretty(&entry).map_err(|e| {
VersionCheckError::BackupError(format!("Failed to serialize backup metadata: {e}"))
})?;
fs::write(&metadata_path, metadata_content).map_err(|e| {
VersionCheckError::BackupError(format!("Failed to write backup metadata: {e}"))
})?;
prune_old_backups()?;
Ok(backup_path)
}
fn prune_old_backups() -> Result<(), VersionCheckError> {
let backups = list_backups()?;
if backups.len() <= MAX_BACKUPS {
return Ok(());
}
let dir = backup_dir().ok_or_else(|| {
VersionCheckError::BackupError("Could not determine backup directory".to_string())
})?;
for backup in backups.into_iter().skip(MAX_BACKUPS) {
let backup_name = backup_artifact_name(&backup);
let backup_path = dir.join(&backup_name);
let metadata_path = dir.join(format!("{backup_name}.json"));
let _ = fs::remove_file(&backup_path);
let _ = fs::remove_file(&metadata_path);
}
Ok(())
}
pub fn rollback(target_version: Option<&str>) -> Result<String, VersionCheckError> {
let backups = list_backups()?;
if backups.is_empty() {
return Err(VersionCheckError::BackupError(
"No backup versions available".to_string(),
));
}
let backup = if let Some(version) = target_version {
let version_clean = version.trim_start_matches('v');
backups
.iter()
.find(|b| b.version == version_clean || b.version == version)
.ok_or_else(|| {
VersionCheckError::BackupError(format!(
"No backup found for version {version}. Available versions: {}",
backups
.iter()
.map(|b| b.version.as_str())
.collect::<Vec<_>>()
.join(", ")
))
})?
} else {
backups.first().ok_or_else(|| {
VersionCheckError::BackupError("No backup versions available".to_string())
})?
};
let dir = backup_dir().ok_or_else(|| {
VersionCheckError::BackupError("Could not determine backup directory".to_string())
})?;
let backup_name = backup_artifact_name(backup);
let backup_path = dir.join(&backup_name);
if !backup_path.exists() {
return Err(VersionCheckError::BackupError(format!(
"Backup file not found: {}",
backup_path.display()
)));
}
let current_exe = std::env::current_exe().map_err(|e| {
VersionCheckError::BackupError(format!("Failed to get current executable path: {e}"))
})?;
create_backup()?;
#[cfg(unix)]
{
fs::copy(&backup_path, ¤t_exe).map_err(|e| {
VersionCheckError::BackupError(format!("Failed to restore backup: {e}"))
})?;
}
#[cfg(windows)]
{
let backup_old = current_exe.with_extension("exe.old");
fs::rename(¤t_exe, &backup_old).map_err(|e| {
VersionCheckError::BackupError(format!("Failed to move current executable: {e}"))
})?;
fs::copy(&backup_path, ¤t_exe).map_err(|e| {
let _ = fs::rename(&backup_old, ¤t_exe);
VersionCheckError::BackupError(format!("Failed to restore backup: {e}"))
})?;
let _ = fs::remove_file(&backup_old);
}
Ok(format!(
"Successfully rolled back to version {} (was at {})",
backup.version,
current_version()
))
}
#[must_use]
pub fn format_backup_list(backups: &[BackupEntry], use_color: bool) -> String {
use std::fmt::Write;
if backups.is_empty() {
return if use_color {
"\x1b[33mNo backup versions available.\x1b[0m\n\
Run 'dcg update' to create a backup of the current version."
.to_string()
} else {
"No backup versions available.\n\
Run 'dcg update' to create a backup of the current version."
.to_string()
};
}
let mut output = String::new();
if use_color {
writeln!(output, "\x1b[1mAvailable backup versions:\x1b[0m").ok();
} else {
writeln!(output, "Available backup versions:").ok();
}
writeln!(output).ok();
for (i, backup) in backups.iter().enumerate() {
let datetime = i64::try_from(backup.created_at)
.ok()
.and_then(|secs| chrono::DateTime::from_timestamp(secs, 0))
.map_or_else(
|| backup.created_at.to_string(),
|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
);
let marker = if i == 0 { " (most recent)" } else { "" };
if use_color {
writeln!(
output,
" \x1b[1mv{}\x1b[0m{} - backed up {}",
backup.version, marker, datetime
)
.ok();
} else {
writeln!(
output,
" v{}{} - backed up {}",
backup.version, marker, datetime
)
.ok();
}
}
writeln!(output).ok();
if use_color {
writeln!(
output,
"Use '\x1b[1mdcg update --rollback\x1b[0m' to restore the most recent backup"
)
.ok();
writeln!(
output,
"Use '\x1b[1mdcg update --rollback <version>\x1b[0m' to restore a specific version"
)
.ok();
} else {
writeln!(
output,
"Use 'dcg update --rollback' to restore the most recent backup"
)
.ok();
writeln!(
output,
"Use 'dcg update --rollback <version>' to restore a specific version"
)
.ok();
}
output
}
fn cache_path() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("dcg").join("version_check.json"))
}
fn read_cache() -> Option<VersionCheckResult> {
let path = cache_path()?;
let content = fs::read_to_string(&path).ok()?;
let cached: CachedCheck = serde_json::from_str(&content).ok()?;
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.ok()?
.as_secs();
if now.saturating_sub(cached.cached_at_secs) < CACHE_DURATION.as_secs() {
Some(cached.result)
} else {
None
}
}
#[must_use]
pub fn read_cached_check() -> Option<VersionCheckResult> {
read_cache()
}
pub fn spawn_update_check_if_needed() {
if read_cache().is_some() {
return;
}
let _ = thread::Builder::new()
.name("dcg-update-check".to_string())
.spawn(|| {
let _ = check_for_update(false);
});
}
fn write_cache(result: &VersionCheckResult) -> Result<(), VersionCheckError> {
let path = cache_path().ok_or_else(|| {
VersionCheckError::CacheError("Could not determine cache directory".to_string())
})?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
VersionCheckError::CacheError(format!("Failed to create cache directory: {e}"))
})?;
}
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|e| VersionCheckError::CacheError(format!("Failed to get current time: {e}")))?
.as_secs();
let cached = CachedCheck {
result: result.clone(),
cached_at_secs: now,
};
let content = serde_json::to_string_pretty(&cached)
.map_err(|e| VersionCheckError::CacheError(format!("Failed to serialize cache: {e}")))?;
fs::write(&path, content)
.map_err(|e| VersionCheckError::CacheError(format!("Failed to write cache: {e}")))?;
Ok(())
}
#[must_use]
pub const fn current_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
pub fn check_for_update(force_refresh: bool) -> Result<VersionCheckResult, VersionCheckError> {
if !force_refresh {
if let Some(cached) = read_cache() {
return Ok(cached);
}
}
let result = fetch_latest_version()?;
if let Err(e) = write_cache(&result) {
eprintln!("Warning: Failed to cache version check: {e}");
}
Ok(result)
}
fn fetch_latest_version() -> Result<VersionCheckResult, VersionCheckError> {
let current = current_version();
let releases = self_update::backends::github::ReleaseList::configure()
.repo_owner(REPO_OWNER)
.repo_name(REPO_NAME)
.build()
.map_err(|e| {
VersionCheckError::NetworkError(format!("Failed to configure release list: {e}"))
})?
.fetch()
.map_err(|e| VersionCheckError::NetworkError(format!("Failed to fetch releases: {e}")))?;
let latest = select_latest_release(&releases)
.ok_or_else(|| VersionCheckError::ParseError("No releases found".to_string()))?;
let latest_version = latest.version.trim_start_matches('v').to_string();
let current_clean = current.trim_start_matches('v');
let update_available = match (
semver::Version::parse(current_clean),
semver::Version::parse(&latest_version),
) {
(Ok(curr), Ok(lat)) => lat > curr,
_ => {
latest_version != current_clean
}
};
let checked_at = chrono::Utc::now().to_rfc3339();
let release_notes = latest
.body
.as_ref()
.map(|body| truncate_release_notes(body, 500));
let result = VersionCheckResult {
current_version: current.to_string(),
latest_version,
update_available,
release_url: release_url_for_tag(&latest.version),
release_notes,
checked_at,
};
Ok(result)
}
fn select_latest_release(releases: &[Release]) -> Option<&Release> {
let mut best_stable: Option<(&Release, semver::Version)> = None;
let mut best_any: Option<(&Release, semver::Version)> = None;
for release in releases {
let version_str = release.version.trim_start_matches('v');
let Ok(version) = semver::Version::parse(version_str) else {
continue;
};
if best_any
.as_ref()
.is_none_or(|(_, current)| version > *current)
{
best_any = Some((release, version.clone()));
}
if version.pre.is_empty()
&& best_stable
.as_ref()
.is_none_or(|(_, current)| version > *current)
{
best_stable = Some((release, version));
}
}
best_stable
.map(|(release, _)| release)
.or_else(|| best_any.map(|(release, _)| release))
.or_else(|| releases.first())
}
fn truncate_release_notes(body: &str, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
if max_chars <= 3 {
if body.chars().count() <= max_chars {
return body.to_string();
}
return ".".repeat(max_chars);
}
let mut chars = body.chars();
for _ in 0..max_chars {
if chars.next().is_none() {
return body.to_string();
}
}
if chars.next().is_none() {
return body.to_string();
}
let visible_limit = max_chars.saturating_sub(3);
let truncated: String = body.chars().take(visible_limit).collect();
format!("{truncated}...")
}
fn release_url_for_tag(tag: &str) -> String {
let trimmed = tag.trim();
if trimmed.is_empty() {
format!("https://github.com/{REPO_OWNER}/{REPO_NAME}/releases/latest")
} else {
format!("https://github.com/{REPO_OWNER}/{REPO_NAME}/releases/tag/{trimmed}")
}
}
pub fn clear_cache() -> Result<(), VersionCheckError> {
if let Some(path) = cache_path() {
if path.exists() {
fs::remove_file(&path).map_err(|e| {
VersionCheckError::CacheError(format!("Failed to remove cache: {e}"))
})?;
}
}
Ok(())
}
#[must_use]
pub fn format_check_result(result: &VersionCheckResult, use_color: bool) -> String {
use std::fmt::Write;
let mut output = String::new();
if use_color {
writeln!(
output,
"\x1b[1mCurrent version:\x1b[0m {}",
result.current_version
)
.ok();
writeln!(
output,
"\x1b[1mLatest version:\x1b[0m {}",
result.latest_version
)
.ok();
writeln!(output).ok();
if result.update_available {
writeln!(
output,
"\x1b[33m✨ Update available!\x1b[0m Run '\x1b[1mdcg update\x1b[0m' to upgrade"
)
.ok();
} else {
writeln!(output, "\x1b[32m✓ You're up to date!\x1b[0m").ok();
}
} else {
writeln!(output, "Current version: {}", result.current_version).ok();
writeln!(output, "Latest version: {}", result.latest_version).ok();
writeln!(output).ok();
if result.update_available {
writeln!(output, "Update available! Run 'dcg update' to upgrade").ok();
} else {
writeln!(output, "You're up to date!").ok();
}
}
output
}
pub fn format_check_result_json(result: &VersionCheckResult) -> Result<String, VersionCheckError> {
serde_json::to_string_pretty(result)
.map_err(|e| VersionCheckError::ParseError(format!("Failed to serialize result: {e}")))
}
pub fn spawn_background_check() {
std::thread::spawn(|| {
let _ = check_for_update(false);
});
}
#[must_use]
pub fn get_update_notice(use_color: bool) -> Option<String> {
let cached = read_cache()?;
if !cached.update_available {
return None;
}
let notice = if use_color {
format!(
"\x1b[33m!\x1b[0m A new version of dcg is available: {} -> {}\n Run '\x1b[1mdcg update\x1b[0m' to upgrade",
cached.current_version, cached.latest_version
)
} else {
format!(
"! A new version of dcg is available: {} -> {}\n Run 'dcg update' to upgrade",
cached.current_version, cached.latest_version
)
};
Some(notice)
}
#[must_use]
pub fn is_update_check_enabled() -> bool {
is_update_check_enabled_with(|key| std::env::var(key).ok())
}
fn is_update_check_enabled_with<F>(mut get_env: F) -> bool
where
F: FnMut(&str) -> Option<String>,
{
get_env("DCG_NO_UPDATE_CHECK").is_none_or(|v| v.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_current_version() {
let version = current_version();
assert!(!version.is_empty());
assert!(semver::Version::parse(version).is_ok());
}
#[test]
fn test_truncate_release_notes_utf8_safe() {
let body = "Release ✅ notes with emoji 🚀 and accents café";
let truncated = truncate_release_notes(body, 10);
assert!(truncated.ends_with("..."));
assert_eq!(truncated.chars().count(), 10);
let untrimmed = truncate_release_notes(body, 200);
assert_eq!(untrimmed, body);
let tiny = truncate_release_notes(body, 2);
assert_eq!(tiny, "..");
}
fn make_release(version: &str) -> Release {
Release {
name: version.to_string(),
version: version.to_string(),
date: "2026-01-01T00:00:00Z".to_string(),
body: None,
assets: Vec::new(),
}
}
#[test]
fn test_select_latest_release_prefers_stable() {
let releases = vec![
make_release("2.0.0-beta.1"),
make_release("1.9.0"),
make_release("2.0.0-rc.1"),
];
let selected = select_latest_release(&releases).expect("select");
assert_eq!(selected.version, "1.9.0");
}
#[test]
fn test_select_latest_release_highest_semver() {
let releases = vec![
make_release("1.0.0"),
make_release("2.0.0"),
make_release("1.5.0"),
];
let selected = select_latest_release(&releases).expect("select");
assert_eq!(selected.version, "2.0.0");
}
#[test]
fn test_select_latest_release_with_v_prefix() {
let releases = vec![
make_release("v1.0.0"),
make_release("v2.1.0"),
make_release("v2.0.5"),
];
let selected = select_latest_release(&releases).expect("select");
assert_eq!(selected.version, "v2.1.0");
}
#[test]
fn test_release_url_for_tag_uses_exact_tag() {
assert_eq!(
release_url_for_tag("v2.1.0"),
"https://github.com/Dicklesworthstone/destructive_command_guard/releases/tag/v2.1.0"
);
assert_eq!(
release_url_for_tag("2.1.0"),
"https://github.com/Dicklesworthstone/destructive_command_guard/releases/tag/2.1.0"
);
}
#[test]
fn test_release_url_for_tag_empty_uses_latest() {
assert_eq!(
release_url_for_tag(""),
"https://github.com/Dicklesworthstone/destructive_command_guard/releases/latest"
);
}
#[test]
fn test_generate_unique_backup_name_avoids_collision() {
let temp = tempfile::tempdir().expect("tempdir");
let dir = temp.path();
let timestamp = 1_737_200_000_u64;
let base = "dcg-0.2.12-1737200000";
std::fs::write(dir.join(base), b"binary").expect("seed backup artifact");
std::fs::write(dir.join(format!("{base}.json")), b"{}").expect("seed backup metadata");
let generated = generate_unique_backup_name(dir, "0.2.12", timestamp);
assert_ne!(generated, base);
assert!(generated.starts_with(&format!("{base}-")));
}
#[test]
fn test_backup_artifact_name_prefers_metadata() {
let with_name = BackupEntry {
version: "0.2.12".to_string(),
created_at: 1_737_200_000,
artifact_name: Some("dcg-0.2.12-1737200000-123456789".to_string()),
original_path: std::path::PathBuf::from("/usr/local/bin/dcg"),
};
assert_eq!(
backup_artifact_name(&with_name),
"dcg-0.2.12-1737200000-123456789"
);
let legacy = BackupEntry {
version: "0.2.11".to_string(),
created_at: 1_737_100_000,
artifact_name: None,
original_path: std::path::PathBuf::from("/usr/local/bin/dcg"),
};
assert_eq!(backup_artifact_name(&legacy), "dcg-0.2.11-1737100000");
}
#[test]
fn test_version_check_result_serialization() {
let result = VersionCheckResult {
current_version: "0.2.12".to_string(),
latest_version: "0.3.0".to_string(),
update_available: true,
release_url: "https://github.com/test/repo/releases/latest".to_string(),
release_notes: Some("Bug fixes".to_string()),
checked_at: "2026-01-17T00:00:00Z".to_string(),
};
let json = serde_json::to_string(&result).unwrap();
let parsed: VersionCheckResult = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.current_version, result.current_version);
assert_eq!(parsed.latest_version, result.latest_version);
assert_eq!(parsed.update_available, result.update_available);
}
#[test]
fn test_format_check_result_up_to_date() {
let result = VersionCheckResult {
current_version: "1.0.0".to_string(),
latest_version: "1.0.0".to_string(),
update_available: false,
release_url: "https://example.com".to_string(),
release_notes: None,
checked_at: "2026-01-17T00:00:00Z".to_string(),
};
let output = format_check_result(&result, false);
assert!(output.contains("You're up to date"));
assert!(output.contains("1.0.0"));
}
#[test]
fn test_format_check_result_update_available() {
let result = VersionCheckResult {
current_version: "1.0.0".to_string(),
latest_version: "2.0.0".to_string(),
update_available: true,
release_url: "https://example.com".to_string(),
release_notes: None,
checked_at: "2026-01-17T00:00:00Z".to_string(),
};
let output = format_check_result(&result, false);
assert!(output.contains("Update available"));
assert!(output.contains("dcg update"));
}
#[test]
fn test_is_update_check_enabled_default() {
let env_map: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
assert!(is_update_check_enabled_with(|key| {
env_map.get(key).map(|v| (*v).to_string())
}));
}
#[test]
fn test_is_update_check_disabled_by_env() {
let env_map: std::collections::HashMap<&str, &str> =
std::collections::HashMap::from([("DCG_NO_UPDATE_CHECK", "1")]);
assert!(!is_update_check_enabled_with(|key| {
env_map.get(key).map(|v| (*v).to_string())
}));
}
#[test]
fn test_get_update_notice_no_cache() {
let notice = get_update_notice(false);
let _ = notice;
}
#[test]
fn test_backup_entry_serialization() {
let entry = BackupEntry {
version: "0.2.12".to_string(),
created_at: 1_737_200_000,
artifact_name: Some("dcg-0.2.12-1737200000".to_string()),
original_path: std::path::PathBuf::from("/usr/local/bin/dcg"),
};
let json = serde_json::to_string(&entry).unwrap();
let parsed: BackupEntry = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.version, entry.version);
assert_eq!(parsed.created_at, entry.created_at);
assert_eq!(parsed.artifact_name, entry.artifact_name);
assert_eq!(parsed.original_path, entry.original_path);
}
#[test]
fn test_format_backup_list_empty() {
let output = format_backup_list(&[], false);
assert!(output.contains("No backup versions available"));
}
#[test]
fn test_format_backup_list_with_entries() {
let entries = vec![
BackupEntry {
version: "0.2.12".to_string(),
created_at: 1_737_200_000,
artifact_name: None,
original_path: std::path::PathBuf::from("/usr/local/bin/dcg"),
},
BackupEntry {
version: "0.2.11".to_string(),
created_at: 1_737_100_000,
artifact_name: None,
original_path: std::path::PathBuf::from("/usr/local/bin/dcg"),
},
];
let output = format_backup_list(&entries, false);
assert!(output.contains("v0.2.12"));
assert!(output.contains("v0.2.11"));
assert!(output.contains("most recent"));
}
#[test]
fn test_backup_dir_exists() {
let dir = backup_dir();
assert!(dir.is_some());
let path = dir.unwrap();
assert!(path.to_string_lossy().contains("dcg"));
assert!(path.to_string_lossy().contains("backups"));
}
#[test]
fn test_list_backups_no_dir() {
let result = list_backups();
assert!(result.is_ok());
}
#[test]
fn test_version_comparison_newer_available() {
let current = semver::Version::parse("1.9.0").unwrap();
let latest = semver::Version::parse("2.0.0").unwrap();
assert!(latest > current, "2.0.0 should be newer than 1.9.0");
}
#[test]
fn test_version_comparison_already_current() {
let current = semver::Version::parse("2.0.0").unwrap();
let latest = semver::Version::parse("2.0.0").unwrap();
assert!(latest <= current, "Same version should not need update");
}
#[test]
fn test_version_comparison_patch_update() {
let current = semver::Version::parse("1.9.0").unwrap();
let latest = semver::Version::parse("1.9.1").unwrap();
assert!(latest > current, "1.9.1 should be newer than 1.9.0");
}
#[test]
fn test_version_comparison_minor_update() {
let current = semver::Version::parse("1.9.0").unwrap();
let latest = semver::Version::parse("1.10.0").unwrap();
assert!(latest > current, "1.10.0 should be newer than 1.9.0");
}
#[test]
fn test_version_comparison_major_update() {
let current = semver::Version::parse("1.9.0").unwrap();
let latest = semver::Version::parse("2.0.0").unwrap();
assert!(latest > current, "2.0.0 should be newer than 1.9.0");
}
#[test]
fn test_version_comparison_prerelease_vs_stable() {
let prerelease = semver::Version::parse("2.0.0-beta.1").unwrap();
let stable = semver::Version::parse("2.0.0").unwrap();
assert!(
stable > prerelease,
"Stable 2.0.0 should be greater than 2.0.0-beta.1"
);
}
#[test]
fn test_version_comparison_prerelease_ordering() {
let alpha = semver::Version::parse("2.0.0-alpha.1").unwrap();
let beta = semver::Version::parse("2.0.0-beta.1").unwrap();
let rc = semver::Version::parse("2.0.0-rc.1").unwrap();
assert!(beta > alpha, "beta should be greater than alpha");
assert!(rc > beta, "rc should be greater than beta");
}
#[test]
fn test_version_comparison_with_v_prefix() {
let version_str = "v1.2.3";
let clean = version_str.trim_start_matches('v');
let parsed = semver::Version::parse(clean).unwrap();
assert_eq!(parsed.major, 1);
assert_eq!(parsed.minor, 2);
assert_eq!(parsed.patch, 3);
}
#[test]
fn test_version_comparison_downgrade_detection() {
let current = semver::Version::parse("2.0.0").unwrap();
let latest = semver::Version::parse("1.9.0").unwrap();
assert!(
latest <= current,
"1.9.0 should not trigger update when current is 2.0.0"
);
}
#[test]
fn test_version_check_error_display_network() {
let err = VersionCheckError::NetworkError("Connection refused".to_string());
let display = format!("{err}");
assert!(display.contains("Network error"));
assert!(display.contains("Connection refused"));
}
#[test]
fn test_version_check_error_display_parse() {
let err = VersionCheckError::ParseError("Invalid JSON".to_string());
let display = format!("{err}");
assert!(display.contains("Parse error"));
assert!(display.contains("Invalid JSON"));
}
#[test]
fn test_version_check_error_display_cache() {
let err = VersionCheckError::CacheError("Permission denied".to_string());
let display = format!("{err}");
assert!(display.contains("Cache error"));
assert!(display.contains("Permission denied"));
}
#[test]
fn test_version_check_error_display_update() {
let err = VersionCheckError::UpdateError("Download failed".to_string());
let display = format!("{err}");
assert!(display.contains("Update error"));
assert!(display.contains("Download failed"));
}
#[test]
fn test_version_check_error_display_backup() {
let err = VersionCheckError::BackupError("Disk full".to_string());
let display = format!("{err}");
assert!(display.contains("Backup error"));
assert!(display.contains("Disk full"));
}
#[test]
fn test_version_check_error_display_no_update() {
let err = VersionCheckError::NoUpdateAvailable;
let display = format!("{err}");
assert!(display.contains("No update available"));
}
#[test]
fn test_format_check_result_with_release_notes() {
let result = VersionCheckResult {
current_version: "1.0.0".to_string(),
latest_version: "2.0.0".to_string(),
update_available: true,
release_url: "https://example.com".to_string(),
release_notes: Some("Bug fixes and improvements".to_string()),
checked_at: "2026-01-17T00:00:00Z".to_string(),
};
let output = format_check_result(&result, false);
assert!(output.contains("2.0.0"));
}
#[test]
fn test_format_check_result_json_output() {
let result = VersionCheckResult {
current_version: "1.0.0".to_string(),
latest_version: "2.0.0".to_string(),
update_available: true,
release_url: "https://example.com".to_string(),
release_notes: None,
checked_at: "2026-01-17T00:00:00Z".to_string(),
};
let json = format_check_result_json(&result).unwrap();
assert!(json.contains("\"current_version\""));
assert!(json.contains("\"latest_version\""));
assert!(json.contains("\"update_available\""));
}
#[test]
fn test_format_check_result_with_color() {
let result = VersionCheckResult {
current_version: "1.0.0".to_string(),
latest_version: "2.0.0".to_string(),
update_available: true,
release_url: "https://example.com".to_string(),
release_notes: None,
checked_at: "2026-01-17T00:00:00Z".to_string(),
};
let output = format_check_result(&result, true);
assert!(output.contains("\x1b["));
}
#[test]
fn test_format_check_result_no_color() {
let result = VersionCheckResult {
current_version: "1.0.0".to_string(),
latest_version: "2.0.0".to_string(),
update_available: true,
release_url: "https://example.com".to_string(),
release_notes: None,
checked_at: "2026-01-17T00:00:00Z".to_string(),
};
let output = format_check_result(&result, false);
assert!(!output.contains("\x1b["));
}
#[test]
fn test_backup_list_sorting_by_date() {
let mut entries = [
BackupEntry {
version: "0.2.10".to_string(),
created_at: 1_737_000_000, artifact_name: None,
original_path: std::path::PathBuf::from("/usr/local/bin/dcg"),
},
BackupEntry {
version: "0.2.12".to_string(),
created_at: 1_737_200_000, artifact_name: None,
original_path: std::path::PathBuf::from("/usr/local/bin/dcg"),
},
BackupEntry {
version: "0.2.11".to_string(),
created_at: 1_737_100_000, artifact_name: None,
original_path: std::path::PathBuf::from("/usr/local/bin/dcg"),
},
];
entries.sort_by_key(|entry| std::cmp::Reverse(entry.created_at));
assert_eq!(entries[0].version, "0.2.12");
assert_eq!(entries[1].version, "0.2.11");
assert_eq!(entries[2].version, "0.2.10");
}
#[test]
fn test_format_backup_list_with_color() {
let entries = vec![BackupEntry {
version: "0.2.12".to_string(),
created_at: 1_737_200_000,
artifact_name: None,
original_path: std::path::PathBuf::from("/usr/local/bin/dcg"),
}];
let output = format_backup_list(&entries, true);
assert!(output.contains("\x1b["));
assert!(output.contains("v0.2.12"));
}
#[test]
fn test_format_backup_list_empty_with_color() {
let output = format_backup_list(&[], true);
assert!(output.contains("\x1b["));
assert!(output.contains("No backup versions available"));
}
#[test]
fn test_cache_duration_is_24_hours() {
assert_eq!(CACHE_DURATION.as_secs(), 24 * 60 * 60);
}
#[test]
fn test_max_backups_limit() {
assert_eq!(MAX_BACKUPS, 3);
}
#[test]
fn test_is_update_check_enabled_empty_value() {
let env_map: std::collections::HashMap<&str, &str> =
std::collections::HashMap::from([("DCG_NO_UPDATE_CHECK", "")]);
assert!(is_update_check_enabled_with(|key| {
env_map.get(key).map(|v| (*v).to_string())
}));
}
#[test]
fn test_is_update_check_disabled_various_values() {
for val in &["1", "true", "yes", "anything"] {
let env_map: std::collections::HashMap<&str, &str> =
std::collections::HashMap::from([("DCG_NO_UPDATE_CHECK", *val)]);
assert!(
!is_update_check_enabled_with(|key| { env_map.get(key).map(|v| (*v).to_string()) }),
"DCG_NO_UPDATE_CHECK={val} should disable update check"
);
}
}
}