use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{self, Write};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use super::{PluginError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutoSyncConfig {
#[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 AutoSyncConfig {
fn default() -> Self {
Self {
enabled: default_enabled(),
mode: default_mode(),
interval_days: default_interval_days(),
}
}
}
impl AutoSyncConfig {
pub fn validate(&self) -> Result<()> {
if !["auto", "prompt", "disabled"].contains(&self.mode.as_str()) {
return Err(PluginError::ValidationError {
message: format!(
"Invalid auto_sync.mode '{}'. Must be one of: auto, prompt, disabled",
self.mode
),
});
}
if self.interval_days < 0.0 {
return Err(PluginError::ValidationError {
message: "auto_sync.interval_days must be >= 0 (0 means disabled)".to_string(),
});
}
Ok(())
}
pub fn is_disabled(&self) -> bool {
!self.enabled || self.mode == "disabled"
}
pub fn should_prompt(&self) -> bool {
self.mode == "prompt"
}
}
pub struct AutoSyncManager {
timestamp_file: PathBuf,
}
impl AutoSyncManager {
pub fn new() -> Result<Self> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map(PathBuf::from)
.map_err(|_| PluginError::HomeDirectoryError)?;
let linthis_dir = home.join(".linthis");
let timestamp_file = linthis_dir.join(".plugin_sync_last_check");
Ok(Self { timestamp_file })
}
pub fn timestamp_file_path(&self) -> &PathBuf {
&self.timestamp_file
}
pub fn get_last_sync_time(&self) -> Result<Option<u64>> {
if !self.timestamp_file.exists() {
return Ok(None);
}
let content = fs::read_to_string(&self.timestamp_file)?;
let timestamp = content
.trim()
.parse::<u64>()
.map_err(|_| PluginError::ConfigError {
message: "Invalid timestamp format in .plugin_sync_last_check file".to_string(),
})?;
Ok(Some(timestamp))
}
pub fn update_last_sync_time(&self) -> Result<()> {
if let Some(parent) = self.timestamp_file.parent() {
fs::create_dir_all(parent)?;
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| PluginError::ConfigError {
message: "System time is before UNIX epoch".to_string(),
})?
.as_secs();
fs::write(&self.timestamp_file, now.to_string())?;
Ok(())
}
fn current_time() -> Result<u64> {
Ok(SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| PluginError::ConfigError {
message: "System time is before UNIX epoch".to_string(),
})?
.as_secs())
}
pub fn should_sync(&self, config: &AutoSyncConfig) -> Result<bool> {
if config.is_disabled() {
return Ok(false);
}
let last_sync = match self.get_last_sync_time()? {
Some(time) => time,
None => {
return Ok(true);
}
};
if config.interval_days <= 0.0 {
return Ok(false); }
let now = Self::current_time()?;
let interval_seconds = (config.interval_days * 86400.0) as u64;
let elapsed = now.saturating_sub(last_sync);
Ok(elapsed >= interval_seconds)
}
pub fn prompt_user(&self) -> Result<bool> {
print!("Updates available for plugins. Update now? [Y/n]: ");
io::stdout().flush()?;
let mut response = String::new();
io::stdin().read_line(&mut response)?;
let response = response.trim().to_lowercase();
Ok(response.is_empty() || response == "y" || response == "yes")
}
pub fn time_since_last_sync(&self) -> Result<Option<String>> {
let last_sync = match self.get_last_sync_time()? {
Some(time) => time,
None => return Ok(None),
};
let now = Self::current_time()?;
let elapsed = now.saturating_sub(last_sync);
let days = elapsed / (24 * 60 * 60);
let hours = (elapsed % (24 * 60 * 60)) / (60 * 60);
let description = if days > 0 {
format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
} else if hours > 0 {
format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" })
} else {
"less than an hour ago".to_string()
};
Ok(Some(description))
}
}
impl Default for AutoSyncManager {
fn default() -> Self {
Self::new().expect("Failed to create AutoSyncManager")
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_temp_manager() -> (AutoSyncManager, TempDir) {
let temp_dir = TempDir::new().unwrap();
let timestamp_file = temp_dir.path().join(".plugin_sync_last_check");
let manager = AutoSyncManager { timestamp_file };
(manager, temp_dir)
}
#[test]
fn test_auto_sync_config_default() {
let config = AutoSyncConfig::default();
assert!(config.enabled);
assert_eq!(config.mode, "prompt");
assert!((config.interval_days - 7.0).abs() < f64::EPSILON);
}
#[test]
fn test_auto_sync_config_validate() {
let mut config = AutoSyncConfig::default();
assert!(config.validate().is_ok());
config.mode = "invalid".to_string();
assert!(config.validate().is_err());
config.mode = "auto".to_string();
assert!(config.validate().is_ok());
config.interval_days = 0.0;
assert!(config.validate().is_ok());
config.interval_days = -1.0;
assert!(config.validate().is_err());
}
#[test]
fn test_auto_sync_config_is_disabled() {
let mut config = AutoSyncConfig::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_auto_sync_config_should_prompt() {
let mut config = AutoSyncConfig::default();
assert!(config.should_prompt());
config.mode = "auto".to_string();
assert!(!config.should_prompt());
}
#[test]
fn test_get_last_sync_time_none() {
let (manager, _temp) = create_temp_manager();
let result = manager.get_last_sync_time().unwrap();
assert!(result.is_none());
}
#[test]
fn test_update_and_get_last_sync_time() {
let (manager, _temp) = create_temp_manager();
manager.update_last_sync_time().unwrap();
let result = manager.get_last_sync_time().unwrap();
assert!(result.is_some());
let timestamp = result.unwrap();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
assert!((now - timestamp) < 1);
}
#[test]
fn test_should_sync_never_synced() {
let (manager, _temp) = create_temp_manager();
let config = AutoSyncConfig::default();
assert!(manager.should_sync(&config).unwrap());
}
#[test]
fn test_should_sync_disabled() {
let (manager, _temp) = create_temp_manager();
let mut config = AutoSyncConfig::default();
config.enabled = false;
assert!(!manager.should_sync(&config).unwrap());
}
#[test]
fn test_should_sync_interval() {
let (manager, _temp) = create_temp_manager();
let config = AutoSyncConfig {
enabled: true,
mode: "auto".to_string(),
interval_days: 7.0,
};
manager.update_last_sync_time().unwrap();
assert!(!manager.should_sync(&config).unwrap());
let old_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
- (8 * 24 * 60 * 60);
fs::write(&manager.timestamp_file, old_time.to_string()).unwrap();
assert!(manager.should_sync(&config).unwrap());
}
#[test]
fn test_time_since_last_sync() {
let (manager, _temp) = create_temp_manager();
assert!(manager.time_since_last_sync().unwrap().is_none());
let two_days_ago = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
- (2 * 24 * 60 * 60);
fs::write(&manager.timestamp_file, two_days_ago.to_string()).unwrap();
let time_str = manager.time_since_last_sync().unwrap().unwrap();
assert!(time_str.contains("2 days"));
}
}