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
14pub 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
39pub 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
53pub 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(¤t)) {
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
70pub 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
134pub 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 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); }
263
264 #[test]
265 fn test_print_update_warning_no_panic() {
266 print_update_warning("2.0.0");
268 print_update_warning("v1.5.0");
269 print_update_warning("");
270 }
271}