1use serde::{Deserialize, Serialize};
17use std::fmt;
18use std::fs;
19use std::io::{self, Write};
20use std::path::PathBuf;
21use std::process::Command;
22use std::time::{SystemTime, UNIX_EPOCH};
23
24#[derive(Debug, Clone, PartialEq)]
26pub enum InstallMethod {
27 Cargo,
29 Homebrew,
31 UvTool,
33 PipX,
35 Pip,
37 Unknown,
39}
40
41impl fmt::Display for InstallMethod {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 match self {
44 InstallMethod::Cargo => write!(f, "cargo install"),
45 InstallMethod::Homebrew => write!(f, "brew install"),
46 InstallMethod::UvTool => write!(f, "uv tool install"),
47 InstallMethod::PipX => write!(f, "pipx install"),
48 InstallMethod::Pip => write!(f, "pip install"),
49 InstallMethod::Unknown => write!(f, "unknown"),
50 }
51 }
52}
53
54pub fn detect_install_method() -> InstallMethod {
56 let exe_path = match std::env::current_exe() {
57 Ok(p) => p,
58 Err(_) => return InstallMethod::Unknown,
59 };
60
61 let path_str = exe_path.to_string_lossy();
62
63 if path_str.contains(".cargo/bin") || path_str.contains("\\.cargo\\bin") {
65 return InstallMethod::Cargo;
66 }
67
68 if path_str.contains("/opt/homebrew/")
73 || path_str.contains("/usr/local/Cellar/")
74 || path_str.contains("/home/linuxbrew/")
75 {
76 return InstallMethod::Homebrew;
77 }
78
79 if path_str.contains("/uv/tools/")
84 || path_str.contains("\\uv\\tools\\")
85 || path_str.contains("Application Support/uv/tools")
86 {
87 return InstallMethod::UvTool;
88 }
89
90 if path_str.contains(".local/pipx/venvs") || path_str.contains("\\pipx\\venvs") {
92 return InstallMethod::PipX;
93 }
94
95 InstallMethod::Pip
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
102pub struct SelfUpdateConfig {
103 #[serde(default = "default_enabled")]
105 pub enabled: bool,
106
107 #[serde(default = "default_mode")]
109 pub mode: String,
110
111 #[serde(default = "default_interval_days")]
113 pub interval_days: f64,
114}
115
116fn default_enabled() -> bool {
117 true
118}
119
120fn default_mode() -> String {
121 "prompt".to_string()
122}
123
124fn default_interval_days() -> f64 {
125 7.0
126}
127
128impl Default for SelfUpdateConfig {
129 fn default() -> Self {
130 Self {
131 enabled: default_enabled(),
132 mode: default_mode(),
133 interval_days: default_interval_days(),
134 }
135 }
136}
137
138impl SelfUpdateConfig {
139 pub fn is_disabled(&self) -> bool {
141 !self.enabled || self.mode == "disabled"
142 }
143
144 pub fn should_prompt(&self) -> bool {
146 self.mode == "prompt"
147 }
148
149 pub fn validate(&self) -> Result<(), String> {
151 if !["auto", "prompt", "disabled"].contains(&self.mode.as_str()) {
152 return Err(format!(
153 "Invalid mode '{}'. Must be 'auto', 'prompt', or 'disabled'",
154 self.mode
155 ));
156 }
157
158 if self.interval_days < 0.0 {
159 return Err("interval_days must be >= 0 (0 means disabled)".to_string());
160 }
161
162 Ok(())
163 }
164}
165
166#[derive(Debug)]
168pub struct SelfUpdateManager {
169 timestamp_file: PathBuf,
170}
171
172impl Default for SelfUpdateManager {
173 fn default() -> Self {
174 Self::new()
175 }
176}
177
178impl SelfUpdateManager {
179 pub fn new() -> Self {
181 let home_dir = crate::utils::home_dir().expect("Failed to get home directory");
182 let linthis_dir = home_dir.join(".linthis");
183 let timestamp_file = linthis_dir.join(".self_update_last_check");
184
185 Self { timestamp_file }
186 }
187
188 pub fn should_check(&self, interval_days: f64) -> bool {
191 if interval_days <= 0.0 {
192 return false; }
194 match self.get_last_check_time() {
195 Some(last_check) => {
196 let now = SystemTime::now()
197 .duration_since(UNIX_EPOCH)
198 .unwrap()
199 .as_secs();
200 let elapsed_secs = now.saturating_sub(last_check);
201 let interval_secs = (interval_days * 86400.0) as u64;
202 elapsed_secs >= interval_secs
203 }
204 None => true, }
206 }
207
208 pub fn get_last_check_time(&self) -> Option<u64> {
210 fs::read_to_string(&self.timestamp_file)
211 .ok()
212 .and_then(|content| content.trim().parse::<u64>().ok())
213 }
214
215 pub fn update_last_check_time(&self) -> io::Result<()> {
217 if let Some(parent) = self.timestamp_file.parent() {
219 fs::create_dir_all(parent)?;
220 }
221
222 let now = SystemTime::now()
223 .duration_since(UNIX_EPOCH)
224 .unwrap()
225 .as_secs();
226
227 fs::write(&self.timestamp_file, now.to_string())
228 }
229
230 pub fn get_current_version(&self) -> String {
232 env!("CARGO_PKG_VERSION").to_string()
233 }
234
235 pub fn get_latest_version(&self) -> Option<String> {
238 let method = detect_install_method();
239 match method {
240 InstallMethod::Cargo | InstallMethod::Homebrew => self.get_latest_version_crates_io(),
241 _ => self.get_latest_version_pypi(),
242 }
243 }
244
245 pub fn get_latest_version_pypi(&self) -> Option<String> {
247 let output = Command::new("pip")
248 .args(["index", "versions", "linthis"])
249 .output()
250 .ok()?;
251
252 if !output.status.success() {
253 return None;
254 }
255
256 let stdout = String::from_utf8_lossy(&output.stdout);
257
258 for line in stdout.lines() {
260 if line.contains("Available versions:") {
261 if let Some(versions_str) = line.split(':').nth(1) {
262 if let Some(latest) = versions_str.split(',').next() {
263 return Some(latest.trim().to_string());
264 }
265 }
266 }
267 }
268
269 None
270 }
271
272 pub fn get_latest_version_crates_io(&self) -> Option<String> {
274 #[derive(Deserialize)]
275 struct CrateResponse {
276 #[serde(rename = "crate")]
277 krate: CrateInfo,
278 }
279 #[derive(Deserialize)]
280 struct CrateInfo {
281 max_version: String,
282 }
283
284 let client = reqwest::blocking::Client::builder()
285 .user_agent("linthis-self-update")
286 .timeout(std::time::Duration::from_secs(10))
287 .build()
288 .ok()?;
289
290 let resp: CrateResponse = client
291 .get("https://crates.io/api/v1/crates/linthis")
292 .send()
293 .ok()?
294 .json()
295 .ok()?;
296
297 Some(resp.krate.max_version)
298 }
299
300 pub fn has_update(&self) -> bool {
302 let current = self.get_current_version();
303
304 match self.get_latest_version() {
305 Some(latest) => {
306 self.compare_versions(¤t, &latest) < 0
308 }
309 None => false,
310 }
311 }
312
313 pub fn compare_versions(&self, v1: &str, v2: &str) -> i32 {
316 let parts1: Vec<u32> = v1.split('.').filter_map(|s| s.parse().ok()).collect();
317 let parts2: Vec<u32> = v2.split('.').filter_map(|s| s.parse().ok()).collect();
318
319 for i in 0..parts1.len().max(parts2.len()) {
320 let p1 = parts1.get(i).unwrap_or(&0);
321 let p2 = parts2.get(i).unwrap_or(&0);
322
323 if p1 < p2 {
324 return -1;
325 } else if p1 > p2 {
326 return 1;
327 }
328 }
329
330 0
331 }
332
333 pub fn prompt_user(&self, current: &str, latest: &str) -> bool {
335 print!(
336 "A new version of linthis is available: {} → {}. Update now? [Y/n]: ",
337 current, latest
338 );
339 io::stdout().flush().unwrap();
340
341 let mut input = String::new();
342 io::stdin().read_line(&mut input).unwrap();
343
344 let response = input.trim().to_lowercase();
345 response.is_empty() || response == "y" || response == "yes"
346 }
347
348 pub fn upgrade(&self) -> io::Result<bool> {
350 self.run_upgrade(false)
351 }
352
353 pub fn force_upgrade(&self) -> io::Result<bool> {
355 self.run_upgrade(true)
356 }
357
358 fn run_upgrade(&self, force: bool) -> io::Result<bool> {
360 let method = detect_install_method();
361 let (cmd, args, label) = match method {
362 InstallMethod::Cargo => {
363 let mut args = vec!["install", "linthis", "--force"];
364 if force {
365 args.push("--force");
366 }
367 ("cargo", args, "cargo")
368 }
369 InstallMethod::Homebrew => {
370 let args = if force {
371 vec!["reinstall", "linthis"]
372 } else {
373 vec!["upgrade", "linthis"]
374 };
375 ("brew", args, "brew")
376 }
377 InstallMethod::UvTool => {
378 let args = if force {
379 vec!["tool", "install", "--force", "linthis"]
380 } else {
381 vec!["tool", "upgrade", "linthis"]
382 };
383 ("uv", args, "uv tool")
384 }
385 InstallMethod::PipX => {
386 let args = if force {
387 vec!["install", "--force", "linthis"]
388 } else {
389 vec!["upgrade", "linthis"]
390 };
391 ("pipx", args, "pipx")
392 }
393 InstallMethod::Pip | InstallMethod::Unknown => {
394 let mut args = vec!["install", "--upgrade", "linthis"];
395 if force {
396 args.push("--force-reinstall");
397 }
398 ("pip", args, "pip")
399 }
400 };
401
402 println!("↓ Upgrading linthis via {}...", label);
403
404 let output = Command::new(cmd).args(&args).output()?;
405
406 if output.status.success() {
407 println!("✓ linthis upgraded successfully via {}", label);
408 Ok(true)
409 } else {
410 let stderr = String::from_utf8_lossy(&output.stderr);
411 eprintln!("✗ Failed to upgrade linthis via {}: {}", label, stderr);
412 Ok(false)
413 }
414 }
415
416 pub fn install_version(&self, version: &str) -> io::Result<bool> {
418 let method = detect_install_method();
419
420 if method == InstallMethod::Homebrew {
421 eprintln!(
422 "Homebrew does not support installing a specific version directly.\n\
423 To pin a version, use: brew install linthis@{version}\n\
424 Or switch to cargo: cargo install linthis@{version} --force"
425 );
426 return Ok(false);
427 }
428
429 let version_spec = format!("linthis=={}", version);
430 let cargo_version_spec = format!("linthis@{}", version);
431
432 let (cmd, args, label) = match method {
433 InstallMethod::Cargo => (
434 "cargo",
435 vec!["install", &cargo_version_spec, "--force"],
436 "cargo",
437 ),
438 InstallMethod::UvTool => (
439 "uv",
440 vec!["tool", "install", &version_spec, "--force"],
441 "uv tool",
442 ),
443 InstallMethod::PipX => ("pipx", vec!["install", &version_spec, "--force"], "pipx"),
444 InstallMethod::Homebrew => unreachable!(),
445 InstallMethod::Pip | InstallMethod::Unknown => {
446 ("pip", vec!["install", &version_spec], "pip")
447 }
448 };
449
450 println!("↓ Installing linthis {} via {}...", version, label);
451
452 let output = Command::new(cmd).args(&args).output()?;
453
454 if output.status.success() {
455 println!("✓ linthis {} installed successfully via {}", version, label);
456 Ok(true)
457 } else {
458 let stderr = String::from_utf8_lossy(&output.stderr);
459 eprintln!(
460 "✗ Failed to install linthis {} via {}: {}",
461 version, label, stderr
462 );
463 Ok(false)
464 }
465 }
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471
472 #[test]
473 fn test_self_update_config_default() {
474 let config = SelfUpdateConfig::default();
475 assert!(config.enabled);
476 assert_eq!(config.mode, "prompt");
477 assert!((config.interval_days - 7.0).abs() < f64::EPSILON);
478 }
479
480 #[test]
481 fn test_self_update_config_is_disabled() {
482 let mut config = SelfUpdateConfig::default();
483 assert!(!config.is_disabled());
484
485 config.enabled = false;
486 assert!(config.is_disabled());
487
488 config.enabled = true;
489 config.mode = "disabled".to_string();
490 assert!(config.is_disabled());
491 }
492
493 #[test]
494 fn test_self_update_config_should_prompt() {
495 let mut config = SelfUpdateConfig::default();
496 assert!(config.should_prompt());
497
498 config.mode = "auto".to_string();
499 assert!(!config.should_prompt());
500
501 config.mode = "disabled".to_string();
502 assert!(!config.should_prompt());
503 }
504
505 #[test]
506 fn test_self_update_config_validate() {
507 let config = SelfUpdateConfig::default();
508 assert!(config.validate().is_ok());
509
510 let mut bad_config = config.clone();
511 bad_config.mode = "invalid".to_string();
512 assert!(bad_config.validate().is_err());
513
514 let mut bad_config2 = config.clone();
515 bad_config2.interval_days = -1.0;
516 assert!(bad_config2.validate().is_err());
517
518 let mut zero_config = config.clone();
520 zero_config.interval_days = 0.0;
521 assert!(zero_config.validate().is_ok());
522 }
523
524 #[test]
525 fn test_version_comparison() {
526 let manager = SelfUpdateManager::new();
527
528 assert_eq!(manager.compare_versions("0.0.1", "0.0.2"), -1);
529 assert_eq!(manager.compare_versions("0.0.2", "0.0.1"), 1);
530 assert_eq!(manager.compare_versions("0.0.1", "0.0.1"), 0);
531 assert_eq!(manager.compare_versions("1.0.0", "0.9.9"), 1);
532 assert_eq!(manager.compare_versions("0.0.10", "0.0.9"), 1);
533 }
534
535 #[test]
536 fn test_get_current_version() {
537 let manager = SelfUpdateManager::new();
538 let version = manager.get_current_version();
539 assert!(!version.is_empty());
540 assert_eq!(version, env!("CARGO_PKG_VERSION"));
542 }
543
544 #[test]
545 fn test_should_check_never_checked() {
546 let manager = SelfUpdateManager::new();
547 let _ = fs::remove_file(&manager.timestamp_file);
549
550 assert!(manager.should_check(7.0));
552 }
553
554 #[test]
555 fn test_update_and_get_last_check_time() {
556 let manager = SelfUpdateManager::new();
557
558 let _ = fs::remove_file(&manager.timestamp_file);
560
561 let result = manager.update_last_check_time();
563 assert!(result.is_ok());
564
565 let timestamp = manager.get_last_check_time();
567 assert!(timestamp.is_some());
568
569 let now = SystemTime::now()
571 .duration_since(UNIX_EPOCH)
572 .unwrap()
573 .as_secs();
574 let last_check = timestamp.unwrap();
575 assert!(now - last_check < 60);
576
577 let _ = fs::remove_file(&manager.timestamp_file);
579 }
580}