use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{self, Write};
use std::path::PathBuf;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SelfUpdateConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_mode")]
pub mode: String,
#[serde(default = "default_interval_days")]
pub interval_days: f64,
}
fn default_enabled() -> bool {
true
}
fn default_mode() -> String {
"prompt".to_string()
}
fn default_interval_days() -> f64 {
7.0
}
impl Default for SelfUpdateConfig {
fn default() -> Self {
Self {
enabled: default_enabled(),
mode: default_mode(),
interval_days: default_interval_days(),
}
}
}
impl SelfUpdateConfig {
pub fn is_disabled(&self) -> bool {
!self.enabled || self.mode == "disabled"
}
pub fn should_prompt(&self) -> bool {
self.mode == "prompt"
}
pub fn validate(&self) -> Result<(), String> {
if !["auto", "prompt", "disabled"].contains(&self.mode.as_str()) {
return Err(format!(
"Invalid mode '{}'. Must be 'auto', 'prompt', or 'disabled'",
self.mode
));
}
if self.interval_days < 0.0 {
return Err("interval_days must be >= 0 (0 means disabled)".to_string());
}
Ok(())
}
}
#[derive(Debug)]
pub struct SelfUpdateManager {
timestamp_file: PathBuf,
}
impl Default for SelfUpdateManager {
fn default() -> Self {
Self::new()
}
}
impl SelfUpdateManager {
pub fn new() -> Self {
let home_dir = Self::get_home_dir().expect("Failed to get home directory");
let linthis_dir = home_dir.join(".linthis");
let timestamp_file = linthis_dir.join(".self_update_last_check");
Self { timestamp_file }
}
fn get_home_dir() -> Option<PathBuf> {
std::env::var("HOME")
.ok()
.map(PathBuf::from)
.or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
}
pub fn should_check(&self, interval_days: f64) -> bool {
if interval_days <= 0.0 {
return false; }
match self.get_last_check_time() {
Some(last_check) => {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let elapsed_secs = now.saturating_sub(last_check);
let interval_secs = (interval_days * 86400.0) as u64;
elapsed_secs >= interval_secs
}
None => true, }
}
pub fn get_last_check_time(&self) -> Option<u64> {
fs::read_to_string(&self.timestamp_file)
.ok()
.and_then(|content| content.trim().parse::<u64>().ok())
}
pub fn update_last_check_time(&self) -> io::Result<()> {
if let Some(parent) = self.timestamp_file.parent() {
fs::create_dir_all(parent)?;
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
fs::write(&self.timestamp_file, now.to_string())
}
pub fn get_current_version(&self) -> String {
env!("CARGO_PKG_VERSION").to_string()
}
pub fn get_latest_version(&self) -> Option<String> {
let output = Command::new("pip")
.args(["index", "versions", "linthis"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.contains("Available versions:") {
if let Some(versions_str) = line.split(':').nth(1) {
if let Some(latest) = versions_str.split(',').next() {
return Some(latest.trim().to_string());
}
}
}
}
None
}
pub fn has_update(&self) -> bool {
let current = self.get_current_version();
match self.get_latest_version() {
Some(latest) => {
self.compare_versions(¤t, &latest) < 0
}
None => false,
}
}
fn compare_versions(&self, v1: &str, v2: &str) -> i32 {
let parts1: Vec<u32> = v1.split('.').filter_map(|s| s.parse().ok()).collect();
let parts2: Vec<u32> = v2.split('.').filter_map(|s| s.parse().ok()).collect();
for i in 0..parts1.len().max(parts2.len()) {
let p1 = parts1.get(i).unwrap_or(&0);
let p2 = parts2.get(i).unwrap_or(&0);
if p1 < p2 {
return -1;
} else if p1 > p2 {
return 1;
}
}
0
}
pub fn prompt_user(&self, current: &str, latest: &str) -> bool {
print!(
"A new version of linthis is available: {} → {}. Update now? [Y/n]: ",
current, latest
);
io::stdout().flush().unwrap();
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap();
let response = input.trim().to_lowercase();
response.is_empty() || response == "y" || response == "yes"
}
pub fn upgrade(&self) -> io::Result<bool> {
println!("↓ Upgrading linthis via pip...");
let output = Command::new("pip")
.args(["install", "--upgrade", "linthis"])
.output()?;
if output.status.success() {
println!("✓ linthis upgraded successfully");
Ok(true)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("✗ Failed to upgrade linthis: {}", stderr);
Ok(false)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_self_update_config_default() {
let config = SelfUpdateConfig::default();
assert!(config.enabled);
assert_eq!(config.mode, "prompt");
assert!((config.interval_days - 7.0).abs() < f64::EPSILON);
}
#[test]
fn test_self_update_config_is_disabled() {
let mut config = SelfUpdateConfig::default();
assert!(!config.is_disabled());
config.enabled = false;
assert!(config.is_disabled());
config.enabled = true;
config.mode = "disabled".to_string();
assert!(config.is_disabled());
}
#[test]
fn test_self_update_config_should_prompt() {
let mut config = SelfUpdateConfig::default();
assert!(config.should_prompt());
config.mode = "auto".to_string();
assert!(!config.should_prompt());
config.mode = "disabled".to_string();
assert!(!config.should_prompt());
}
#[test]
fn test_self_update_config_validate() {
let config = SelfUpdateConfig::default();
assert!(config.validate().is_ok());
let mut bad_config = config.clone();
bad_config.mode = "invalid".to_string();
assert!(bad_config.validate().is_err());
let mut bad_config2 = config.clone();
bad_config2.interval_days = -1.0;
assert!(bad_config2.validate().is_err());
let mut zero_config = config.clone();
zero_config.interval_days = 0.0;
assert!(zero_config.validate().is_ok());
}
#[test]
fn test_version_comparison() {
let manager = SelfUpdateManager::new();
assert_eq!(manager.compare_versions("0.0.1", "0.0.2"), -1);
assert_eq!(manager.compare_versions("0.0.2", "0.0.1"), 1);
assert_eq!(manager.compare_versions("0.0.1", "0.0.1"), 0);
assert_eq!(manager.compare_versions("1.0.0", "0.9.9"), 1);
assert_eq!(manager.compare_versions("0.0.10", "0.0.9"), 1);
}
#[test]
fn test_get_current_version() {
let manager = SelfUpdateManager::new();
let version = manager.get_current_version();
assert!(!version.is_empty());
assert_eq!(version, env!("CARGO_PKG_VERSION"));
}
#[test]
fn test_should_check_never_checked() {
let manager = SelfUpdateManager::new();
let _ = fs::remove_file(&manager.timestamp_file);
assert!(manager.should_check(7.0));
}
#[test]
fn test_update_and_get_last_check_time() {
let manager = SelfUpdateManager::new();
let _ = fs::remove_file(&manager.timestamp_file);
let result = manager.update_last_check_time();
assert!(result.is_ok());
let timestamp = manager.get_last_check_time();
assert!(timestamp.is_some());
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let last_check = timestamp.unwrap();
assert!(now - last_check < 60);
let _ = fs::remove_file(&manager.timestamp_file);
}
}