Skip to main content

linthis/
self_update.rs

1// Copyright 2024 zhlinh and linthis Project Authors. All rights reserved.
2// Use of this source code is governed by a MIT-style
3// license that can be found at
4//
5// https://opensource.org/license/MIT
6//
7// The above copyright notice and this permission
8// notice shall be included in all copies or
9// substantial portions of the Software.
10
11//! Self-update functionality for linthis itself.
12//!
13//! Provides automatic update checking and installation via pip,
14//! inspired by oh-my-zsh's auto-update mechanism.
15
16use 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/// How linthis was installed — determines the upgrade command.
25#[derive(Debug, Clone, PartialEq)]
26pub enum InstallMethod {
27    /// Installed via `cargo install linthis` (or `cargo install --path .`)
28    Cargo,
29    /// Installed via `brew install linthis`
30    Homebrew,
31    /// Installed via `uv tool install linthis`
32    UvTool,
33    /// Installed via `pipx install linthis`
34    PipX,
35    /// Installed via `pip install linthis` (or `uv pip install linthis`)
36    Pip,
37    /// Unknown installation method
38    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
54/// Detect how linthis was installed by inspecting the current executable path.
55pub 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    // cargo install: ~/.cargo/bin/linthis
64    if path_str.contains(".cargo/bin") || path_str.contains("\\.cargo\\bin") {
65        return InstallMethod::Cargo;
66    }
67
68    // brew install:
69    //   macOS Apple Silicon: /opt/homebrew/Cellar/ or /opt/homebrew/bin/
70    //   macOS Intel:         /usr/local/Cellar/ or /usr/local/bin/
71    //   Linux (linuxbrew):   /home/linuxbrew/.linuxbrew/
72    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    // uv tool install:
80    //   Linux: ~/.local/share/uv/tools/
81    //   macOS: ~/Library/Application Support/uv/tools/
82    //   Windows: %APPDATA%\uv\tools\
83    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    // pipx install: ~/.local/pipx/venvs/
91    if path_str.contains(".local/pipx/venvs") || path_str.contains("\\pipx\\venvs") {
92        return InstallMethod::PipX;
93    }
94
95    // Fallback: assume pip-based installation for any other Python-related path
96    // (.local/bin, site-packages, venv, etc.)
97    InstallMethod::Pip
98}
99
100/// Configuration for self-update
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
102pub struct SelfUpdateConfig {
103    /// Enable/disable self-update checks
104    #[serde(default = "default_enabled")]
105    pub enabled: bool,
106
107    /// Update mode: "auto", "prompt", or "disabled"
108    #[serde(default = "default_mode")]
109    pub mode: String,
110
111    /// Check for updates every N days (supports decimals, e.g. 0.5 = 12 hours; 0 = disabled)
112    #[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    /// Check if auto-update is disabled
140    pub fn is_disabled(&self) -> bool {
141        !self.enabled || self.mode == "disabled"
142    }
143
144    /// Check if should prompt user before updating
145    pub fn should_prompt(&self) -> bool {
146        self.mode == "prompt"
147    }
148
149    /// Validate configuration
150    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/// Manages self-update timing and execution
167#[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    /// Create a new self-update manager
180    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    /// Check if it's time to check for updates.
189    /// Returns false if interval_days is 0 (disabled).
190    pub fn should_check(&self, interval_days: f64) -> bool {
191        if interval_days <= 0.0 {
192            return false; // 0 = disabled
193        }
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, // Never checked before
205        }
206    }
207
208    /// Get the last check timestamp
209    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    /// Update the last check timestamp to current time
216    pub fn update_last_check_time(&self) -> io::Result<()> {
217        // Ensure parent directory exists
218        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    /// Get current linthis version
231    pub fn get_current_version(&self) -> String {
232        env!("CARGO_PKG_VERSION").to_string()
233    }
234
235    /// Get the latest version, dispatching to the appropriate source
236    /// based on the install method.
237    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    /// Check PyPI for the latest version via pip.
246    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        // Parse output: "Available versions: 0.17.1, 0.17.0, ..."
259        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    /// Check crates.io for the latest version via API.
273    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    /// Check if an update is available
301    pub fn has_update(&self) -> bool {
302        let current = self.get_current_version();
303
304        match self.get_latest_version() {
305            Some(latest) => {
306                // Simple version comparison
307                self.compare_versions(&current, &latest) < 0
308            }
309            None => false,
310        }
311    }
312
313    /// Compare two version strings (simple lexicographic comparison)
314    /// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
315    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    /// Prompt user for confirmation
334    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    /// Execute upgrade using the detected install method.
349    pub fn upgrade(&self) -> io::Result<bool> {
350        self.run_upgrade(false)
351    }
352
353    /// Execute force upgrade using the detected install method.
354    pub fn force_upgrade(&self) -> io::Result<bool> {
355        self.run_upgrade(true)
356    }
357
358    /// Run the upgrade command, optionally forcing reinstall.
359    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    /// Install a specific version of linthis.
417    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        // interval_days = 0 is valid (means 12 hours)
519        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        // Should match CARGO_PKG_VERSION
541        assert_eq!(version, env!("CARGO_PKG_VERSION"));
542    }
543
544    #[test]
545    fn test_should_check_never_checked() {
546        let manager = SelfUpdateManager::new();
547        // Clean up any existing timestamp file from previous runs
548        let _ = fs::remove_file(&manager.timestamp_file);
549
550        // If never checked, should always return true
551        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        // Clean up any existing timestamp file from previous runs
559        let _ = fs::remove_file(&manager.timestamp_file);
560
561        // Update timestamp
562        let result = manager.update_last_check_time();
563        assert!(result.is_ok());
564
565        // Should be able to read it back
566        let timestamp = manager.get_last_check_time();
567        assert!(timestamp.is_some());
568
569        // Should be a recent timestamp (within last minute)
570        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        // Clean up after test
578        let _ = fs::remove_file(&manager.timestamp_file);
579    }
580}