Skip to main content

auto_commit_rs/
update.rs

1use anyhow::{Context, Result};
2use colored::Colorize;
3use std::time::Duration;
4
5const GITHUB_REPO: &str = "gtkacz/smart-commit-rs";
6const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
7
8pub struct VersionCheck {
9    pub latest: String,
10    pub current: String,
11    pub update_available: bool,
12}
13
14/// Fetch the latest release tag from GitHub API with a short timeout
15pub fn fetch_latest_version() -> Result<String> {
16    let url = format!(
17        "https://api.github.com/repos/{}/releases/latest",
18        GITHUB_REPO
19    );
20    let agent = ureq::AgentBuilder::new()
21        .timeout(Duration::from_secs(5))
22        .build();
23    let response: serde_json::Value = agent
24        .get(&url)
25        .set("User-Agent", "cgen")
26        .set("Accept", "application/vnd.github.v3+json")
27        .call()
28        .context("Failed to reach GitHub API")?
29        .into_json()
30        .context("Failed to parse GitHub API response")?;
31
32    let tag = response["tag_name"]
33        .as_str()
34        .context("No tag_name in GitHub release response")?;
35
36    Ok(tag.to_string())
37}
38
39/// Parse a version string (strips leading 'v' if present) into (major, minor, patch)
40pub fn parse_semver(version: &str) -> Option<(u64, u64, u64)> {
41    let v = version.strip_prefix('v').unwrap_or(version);
42    let parts: Vec<&str> = v.split('.').collect();
43    if parts.len() != 3 {
44        return None;
45    }
46    Some((
47        parts[0].parse().ok()?,
48        parts[1].parse().ok()?,
49        parts[2].parse().ok()?,
50    ))
51}
52
53/// Check if a newer version is available on GitHub
54pub fn check_version() -> Result<VersionCheck> {
55    let latest = fetch_latest_version()?;
56    let current = CURRENT_VERSION.to_string();
57
58    let update_available = match (parse_semver(&latest), parse_semver(&current)) {
59        (Some(latest_v), Some(current_v)) => latest_v > current_v,
60        _ => false,
61    };
62
63    Ok(VersionCheck {
64        latest,
65        current,
66        update_available,
67    })
68}
69
70/// Run the appropriate update command for the current platform
71pub fn run_update() -> Result<()> {
72    if is_cargo_available() {
73        println!("{}", "Updating via cargo...".cyan().bold());
74        let status = std::process::Command::new("cargo")
75            .args(["install", "smart-commit-rs"])
76            .status()
77            .context("Failed to run cargo install")?;
78
79        if !status.success() {
80            anyhow::bail!("cargo install failed with exit code {}", status);
81        }
82    } else {
83        run_platform_installer()?;
84    }
85
86    println!("{}", "Update complete!".green().bold());
87    Ok(())
88}
89
90fn is_cargo_available() -> bool {
91    std::process::Command::new("cargo")
92        .arg("--version")
93        .stdout(std::process::Stdio::null())
94        .stderr(std::process::Stdio::null())
95        .status()
96        .map(|s| s.success())
97        .unwrap_or(false)
98}
99
100fn run_platform_installer() -> Result<()> {
101    if cfg!(target_os = "windows") {
102        println!("{}", "Updating via PowerShell installer...".cyan().bold());
103        let status = std::process::Command::new("powershell")
104            .args([
105                "-ExecutionPolicy",
106                "Bypass",
107                "-Command",
108                "irm https://raw.githubusercontent.com/gtkacz/smart-commit-rs/main/scripts/install.ps1 | iex",
109            ])
110            .status()
111            .context("Failed to run PowerShell installer")?;
112
113        if !status.success() {
114            anyhow::bail!("PowerShell installer failed");
115        }
116    } else {
117        println!("{}", "Updating via install script...".cyan().bold());
118        let status = std::process::Command::new("bash")
119            .args([
120                "-c",
121                "curl -fsSL https://raw.githubusercontent.com/gtkacz/smart-commit-rs/main/scripts/install.sh | bash",
122            ])
123            .status()
124            .context("Failed to run install script")?;
125
126        if !status.success() {
127            anyhow::bail!("Install script failed");
128        }
129    }
130
131    Ok(())
132}
133
134/// Print a warning that a newer version is available
135pub fn print_update_warning(latest: &str) {
136    eprintln!(
137        "\n{}  {} → {}  (run {} to update)",
138        "Update available!".yellow().bold(),
139        CURRENT_VERSION.dimmed(),
140        latest.green(),
141        "cgen update".cyan(),
142    );
143}
144
145pub fn current_version() -> &'static str {
146    CURRENT_VERSION
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn test_github_repo_constant() {
155        assert_eq!(GITHUB_REPO, "gtkacz/smart-commit-rs");
156    }
157
158    #[test]
159    fn test_current_version_not_empty() {
160        assert!(!CURRENT_VERSION.is_empty());
161    }
162
163    #[test]
164    fn test_current_version_is_semver() {
165        let version = current_version();
166        assert!(parse_semver(version).is_some());
167    }
168
169    #[test]
170    fn test_parse_semver_basic() {
171        let v = parse_semver("1.2.3").unwrap();
172        assert_eq!(v, (1, 2, 3));
173    }
174
175    #[test]
176    fn test_parse_semver_with_v() {
177        let v = parse_semver("v1.2.3").unwrap();
178        assert_eq!(v, (1, 2, 3));
179    }
180
181    #[test]
182    fn test_parse_semver_invalid_parts() {
183        assert!(parse_semver("1.2").is_none());
184        assert!(parse_semver("1").is_none());
185        assert!(parse_semver("").is_none());
186        assert!(parse_semver("1.2.3.4").is_none());
187    }
188
189    #[test]
190    fn test_parse_semver_non_numeric() {
191        assert!(parse_semver("a.b.c").is_none());
192        assert!(parse_semver("1.2.x").is_none());
193    }
194
195    #[test]
196    fn test_parse_semver_large_numbers() {
197        let v = parse_semver("100.200.300").unwrap();
198        assert_eq!(v, (100, 200, 300));
199    }
200
201    #[test]
202    fn test_parse_semver_zeros() {
203        let v = parse_semver("0.0.0").unwrap();
204        assert_eq!(v, (0, 0, 0));
205    }
206
207    #[test]
208    fn test_version_check_struct() {
209        let check = VersionCheck {
210            latest: "2.0.0".into(),
211            current: "1.0.0".into(),
212            update_available: true,
213        };
214        assert_eq!(check.latest, "2.0.0");
215        assert_eq!(check.current, "1.0.0");
216        assert!(check.update_available);
217    }
218
219    #[test]
220    fn test_version_comparison_logic() {
221        // Simulate the comparison logic used in check_version
222        let latest = "2.0.0";
223        let current = "1.5.0";
224        let update_available = match (parse_semver(latest), parse_semver(current)) {
225            (Some(latest_v), Some(current_v)) => latest_v > current_v,
226            _ => false,
227        };
228        assert!(update_available);
229    }
230
231    #[test]
232    fn test_version_comparison_no_update() {
233        let latest = "1.0.0";
234        let current = "1.5.0";
235        let update_available = match (parse_semver(latest), parse_semver(current)) {
236            (Some(latest_v), Some(current_v)) => latest_v > current_v,
237            _ => false,
238        };
239        assert!(!update_available);
240    }
241
242    #[test]
243    fn test_version_comparison_same() {
244        let latest = "1.5.0";
245        let current = "1.5.0";
246        let update_available = match (parse_semver(latest), parse_semver(current)) {
247            (Some(latest_v), Some(current_v)) => latest_v > current_v,
248            _ => false,
249        };
250        assert!(!update_available);
251    }
252
253    #[test]
254    fn test_version_comparison_invalid() {
255        let latest = "invalid";
256        let current = "1.0.0";
257        let update_available = match (parse_semver(latest), parse_semver(current)) {
258            (Some(latest_v), Some(current_v)) => latest_v > current_v,
259            _ => false,
260        };
261        assert!(!update_available); // Falls back to false for invalid
262    }
263
264    #[test]
265    fn test_print_update_warning_no_panic() {
266        // Just ensure it doesn't panic
267        print_update_warning("2.0.0");
268        print_update_warning("v1.5.0");
269        print_update_warning("");
270    }
271}