Skip to main content

plugin_packager/
upgrade.rs

1// Copyright 2024 Vincents AI
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Plugin upgrade functionality
5//!
6//! This module provides version comparison, upgrade detection, and rollback support.
7
8use anyhow::Result;
9use serde::{Deserialize, Serialize};
10use std::fs;
11use std::path::{Path, PathBuf};
12
13/// Semantic version for comparison
14#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
15pub struct SemanticVersion {
16    pub major: u32,
17    pub minor: u32,
18    pub patch: u32,
19    pub prerelease: Option<String>,
20}
21
22impl SemanticVersion {
23    /// Parse version from string (e.g., "1.2.3" or "1.2.3-beta")
24    pub fn parse(version_str: &str) -> Result<Self> {
25        let (base, prerelease) = if let Some(dash_pos) = version_str.find('-') {
26            (
27                &version_str[..dash_pos],
28                Some(version_str[dash_pos + 1..].to_string()),
29            )
30        } else {
31            (version_str, None)
32        };
33
34        let parts: Vec<&str> = base.split('.').collect();
35        if parts.len() < 3 {
36            anyhow::bail!("Invalid version format: {}", version_str);
37        }
38
39        let major = parts[0].parse::<u32>()?;
40        let minor = parts[1].parse::<u32>()?;
41        let patch = parts[2].parse::<u32>()?;
42
43        Ok(SemanticVersion {
44            major,
45            minor,
46            patch,
47            prerelease,
48        })
49    }
50
51    /// Check if this version is newer than another
52    pub fn is_newer_than(&self, other: &SemanticVersion) -> bool {
53        if self.major != other.major {
54            return self.major > other.major;
55        }
56        if self.minor != other.minor {
57            return self.minor > other.minor;
58        }
59        if self.patch != other.patch {
60            return self.patch > other.patch;
61        }
62
63        // Prerelease versions are lower than release versions
64        match (&self.prerelease, &other.prerelease) {
65            (None, Some(_)) => true,  // release > prerelease
66            (Some(_), None) => false, // prerelease < release
67            _ => false,               // equal or prerelease comparison
68        }
69    }
70
71    /// Check if this is a breaking change (major version bump)
72    pub fn is_breaking_change(&self, previous: &SemanticVersion) -> bool {
73        self.major != previous.major
74    }
75}
76
77/// Upgrade information
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct UpgradeInfo {
80    /// Plugin name
81    pub name: String,
82    /// Current installed version
83    pub current_version: String,
84    /// Available newer version
85    pub new_version: String,
86    /// Whether this is a breaking change
87    pub is_breaking: bool,
88    /// Available as of this time
89    pub available_since: String,
90}
91
92impl UpgradeInfo {
93    /// Check if upgrade is available from current to new
94    pub fn is_available(current: &str, new: &str) -> Result<bool> {
95        let current_ver = SemanticVersion::parse(current)?;
96        let new_ver = SemanticVersion::parse(new)?;
97        Ok(new_ver.is_newer_than(&current_ver))
98    }
99
100    /// Create upgrade info
101    pub fn new(name: String, current_version: String, new_version: String) -> Result<Self> {
102        let current_ver = SemanticVersion::parse(&current_version)?;
103        let new_ver = SemanticVersion::parse(&new_version)?;
104
105        Ok(UpgradeInfo {
106            name,
107            current_version,
108            new_version,
109            is_breaking: new_ver.is_breaking_change(&current_ver),
110            available_since: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
111        })
112    }
113}
114
115/// Backup record for rollback support
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct BackupRecord {
118    /// Plugin name
119    pub plugin_name: String,
120    /// Version that was backed up
121    pub version: String,
122    /// Backup location
123    pub backup_path: PathBuf,
124    /// When backup was created
125    pub created_at: String,
126    /// Whether this backup is valid for rollback
127    pub valid: bool,
128}
129
130impl BackupRecord {
131    /// Create new backup record
132    pub fn new(plugin_name: String, version: String, backup_path: PathBuf) -> Self {
133        Self {
134            plugin_name,
135            version,
136            backup_path,
137            created_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
138            valid: true,
139        }
140    }
141
142    /// Mark backup as invalid (e.g., if source is deleted)
143    pub fn invalidate(&mut self) {
144        self.valid = false;
145    }
146}
147
148/// Backup manager for plugin upgrades
149pub struct BackupManager {
150    backup_dir: PathBuf,
151    records: Vec<BackupRecord>,
152}
153
154impl BackupManager {
155    /// Create new backup manager
156    pub fn new(backup_dir: PathBuf) -> Result<Self> {
157        fs::create_dir_all(&backup_dir)?;
158        Ok(Self {
159            backup_dir,
160            records: Vec::new(),
161        })
162    }
163
164    /// Create backup of plugin directory
165    pub fn backup_plugin(
166        &mut self,
167        plugin_name: &str,
168        version: &str,
169        plugin_path: &Path,
170    ) -> Result<PathBuf> {
171        if !plugin_path.exists() {
172            anyhow::bail!("Plugin directory does not exist: {}", plugin_path.display());
173        }
174
175        // Create backup directory structure: backups/plugin-name/version-timestamp
176        let timestamp = std::time::SystemTime::now()
177            .duration_since(std::time::UNIX_EPOCH)?
178            .as_secs();
179
180        let backup_path = self
181            .backup_dir
182            .join(format!("{}-{}-{}", plugin_name, version, timestamp));
183
184        fs::create_dir_all(&backup_path)?;
185
186        // Copy plugin directory to backup
187        copy_dir_recursive(plugin_path, &backup_path)?;
188
189        // Record backup
190        let record = BackupRecord::new(
191            plugin_name.to_string(),
192            version.to_string(),
193            backup_path.clone(),
194        );
195        self.records.push(record);
196
197        Ok(backup_path)
198    }
199
200    /// Restore plugin from backup
201    pub fn restore_plugin(&mut self, backup_path: &Path, restore_to: &Path) -> Result<()> {
202        if !backup_path.exists() {
203            anyhow::bail!("Backup directory does not exist: {}", backup_path.display());
204        }
205
206        // Remove current installation
207        if restore_to.exists() {
208            fs::remove_dir_all(restore_to)?;
209        }
210
211        // Copy backup back
212        copy_dir_recursive(backup_path, restore_to)?;
213
214        Ok(())
215    }
216
217    /// Get all backups for a plugin
218    pub fn list_backups(&self, plugin_name: &str) -> Vec<BackupRecord> {
219        self.records
220            .iter()
221            .filter(|r| r.plugin_name == plugin_name && r.valid)
222            .cloned()
223            .collect()
224    }
225
226    /// Remove old backups (keep last N)
227    pub fn prune_backups(&mut self, plugin_name: &str, keep_count: usize) -> Result<usize> {
228        let mut backups_for_plugin: Vec<usize> = self
229            .records
230            .iter()
231            .enumerate()
232            .filter(|(_, r)| r.plugin_name == plugin_name && r.valid)
233            .map(|(idx, _)| idx)
234            .collect();
235
236        // Sort by created_at descending (newest first) - get indices of records sorted
237        backups_for_plugin.sort_by(|&idx_a, &idx_b| {
238            self.records[idx_b]
239                .created_at
240                .cmp(&self.records[idx_a].created_at)
241        });
242
243        let mut removed = 0;
244
245        // Remove old backups beyond keep_count
246        for idx in backups_for_plugin.iter().skip(keep_count) {
247            if self.records[*idx].backup_path.exists() {
248                fs::remove_dir_all(&self.records[*idx].backup_path)?;
249            }
250            self.records[*idx].invalidate();
251            removed += 1;
252        }
253
254        Ok(removed)
255    }
256
257    /// Get number of backups
258    pub fn count_backups(&self) -> usize {
259        self.records.iter().filter(|r| r.valid).count()
260    }
261}
262
263/// Helper function to recursively copy directories
264fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
265    fs::create_dir_all(dst)?;
266
267    for entry in fs::read_dir(src)? {
268        let entry = entry?;
269        let path = entry.path();
270        let file_name = entry.file_name();
271        let dst_path = dst.join(file_name);
272
273        if path.is_dir() {
274            copy_dir_recursive(&path, &dst_path)?;
275        } else {
276            fs::copy(&path, &dst_path)?;
277        }
278    }
279
280    Ok(())
281}
282
283/// Upgrade result
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct UpgradeResult {
286    /// Plugin name
287    pub plugin_name: String,
288    /// Previous version
289    pub from_version: String,
290    /// New version
291    pub to_version: String,
292    /// Whether upgrade was successful
293    pub success: bool,
294    /// Backup path (if backup was created)
295    pub backup_path: Option<PathBuf>,
296    /// Error message (if failed)
297    pub error: Option<String>,
298}
299
300impl UpgradeResult {
301    /// Create successful upgrade result
302    pub fn success(
303        plugin_name: String,
304        from_version: String,
305        to_version: String,
306        backup_path: Option<PathBuf>,
307    ) -> Self {
308        Self {
309            plugin_name,
310            from_version,
311            to_version,
312            success: true,
313            backup_path,
314            error: None,
315        }
316    }
317
318    /// Create failed upgrade result
319    pub fn failure(
320        plugin_name: String,
321        from_version: String,
322        to_version: String,
323        error: String,
324    ) -> Self {
325        Self {
326            plugin_name,
327            from_version,
328            to_version,
329            success: false,
330            backup_path: None,
331            error: Some(error),
332        }
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn test_semantic_version_parse() {
342        let v = SemanticVersion::parse("1.2.3").unwrap();
343        assert_eq!(v.major, 1);
344        assert_eq!(v.minor, 2);
345        assert_eq!(v.patch, 3);
346        assert_eq!(v.prerelease, None);
347    }
348
349    #[test]
350    fn test_semantic_version_parse_prerelease() {
351        let v = SemanticVersion::parse("1.2.3-beta").unwrap();
352        assert_eq!(v.major, 1);
353        assert_eq!(v.minor, 2);
354        assert_eq!(v.patch, 3);
355        assert_eq!(v.prerelease, Some("beta".to_string()));
356    }
357
358    #[test]
359    fn test_version_comparison() {
360        let v1 = SemanticVersion::parse("1.2.4").unwrap();
361        let v2 = SemanticVersion::parse("1.2.3").unwrap();
362        assert!(v1.is_newer_than(&v2));
363        assert!(!v2.is_newer_than(&v1));
364    }
365
366    #[test]
367    fn test_breaking_change_detection() {
368        let v1 = SemanticVersion::parse("2.0.0").unwrap();
369        let v2 = SemanticVersion::parse("1.5.0").unwrap();
370        assert!(v1.is_breaking_change(&v2));
371    }
372
373    #[test]
374    fn test_upgrade_info_creation() {
375        let info = UpgradeInfo::new(
376            "test-plugin".to_string(),
377            "1.0.0".to_string(),
378            "1.1.0".to_string(),
379        )
380        .unwrap();
381        assert_eq!(info.name, "test-plugin");
382        assert_eq!(info.current_version, "1.0.0");
383        assert_eq!(info.new_version, "1.1.0");
384        assert!(!info.is_breaking);
385    }
386
387    #[test]
388    fn test_upgrade_info_breaking_change() {
389        let info = UpgradeInfo::new(
390            "test-plugin".to_string(),
391            "1.0.0".to_string(),
392            "2.0.0".to_string(),
393        )
394        .unwrap();
395        assert!(info.is_breaking);
396    }
397
398    #[test]
399    fn test_backup_record_creation() {
400        let record = BackupRecord::new(
401            "test-plugin".to_string(),
402            "1.0.0".to_string(),
403            PathBuf::from("/backups/test-plugin-1.0.0"),
404        );
405        assert_eq!(record.plugin_name, "test-plugin");
406        assert_eq!(record.version, "1.0.0");
407        assert!(record.valid);
408    }
409
410    #[test]
411    fn test_upgrade_result_success() {
412        let result = UpgradeResult::success(
413            "test-plugin".to_string(),
414            "1.0.0".to_string(),
415            "1.1.0".to_string(),
416            None,
417        );
418        assert!(result.success);
419        assert_eq!(result.from_version, "1.0.0");
420        assert_eq!(result.to_version, "1.1.0");
421    }
422
423    #[test]
424    fn test_upgrade_result_failure() {
425        let result = UpgradeResult::failure(
426            "test-plugin".to_string(),
427            "1.0.0".to_string(),
428            "1.1.0".to_string(),
429            "Installation failed".to_string(),
430        );
431        assert!(!result.success);
432        assert!(result.error.is_some());
433    }
434
435    #[test]
436    fn test_backup_manager_creation() -> Result<()> {
437        let temp_dir = tempfile::tempdir()?;
438        let manager = BackupManager::new(temp_dir.path().to_path_buf())?;
439        assert_eq!(manager.count_backups(), 0);
440        Ok(())
441    }
442}