Skip to main content

loom_core/
update.rs

1use anyhow::Result;
2use std::fs;
3use std::path::PathBuf;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use self_update::backends::github::UpdateBuilder;
7use self_update::cargo_crate_version;
8
9const REPO_OWNER: &str = "subotic";
10const REPO_NAME: &str = "loom";
11const BIN_NAME: &str = "loom";
12
13/// How often to check for updates (in seconds).
14const CHECK_INTERVAL_SECS: u64 = 3600; // 1 hour
15
16/// Return a pre-configured update builder for GitHub Releases.
17///
18/// Callers can add options (e.g., `.show_download_progress()`) before `.build()`.
19fn updater() -> UpdateBuilder {
20    let mut builder = self_update::backends::github::Update::configure();
21    builder
22        .repo_owner(REPO_OWNER)
23        .repo_name(REPO_NAME)
24        .bin_name(BIN_NAME)
25        .current_version(cargo_crate_version!());
26    builder
27}
28
29/// Check for updates and apply immediately if found.
30///
31/// Used by `loom update` (explicit). Not used on startup — startup uses
32/// `check_version_throttled()` which only checks without downloading.
33///
34/// - `show_progress`: show download progress bar
35///
36/// Returns the new version string if updated, `None` if already up-to-date.
37pub fn check_and_update(show_progress: bool) -> Result<Option<String>> {
38    let status = updater()
39        .show_download_progress(show_progress)
40        .no_confirm(true)
41        .build()?
42        .update()?;
43
44    if status.updated() {
45        Ok(Some(status.version().to_string()))
46    } else {
47        Ok(None)
48    }
49}
50
51/// Lightweight version check with hourly rate limiting.
52///
53/// Used on startup to notify the user of available updates without
54/// downloading or replacing the binary.
55///
56/// - `force`: bypass the hourly rate limit
57///
58/// Returns `Ok(Some(latest_version))` if a newer version is available,
59/// `Ok(None)` if up-to-date or throttled.
60///
61/// Records the check timestamp on both success and failure to prevent
62/// retry storms when the network is unreachable.
63pub fn check_version_throttled(force: bool) -> Result<Option<String>> {
64    if !force && !should_check()? {
65        return Ok(None);
66    }
67
68    let result = check_version();
69
70    // Record timestamp regardless of outcome — prevents retry storms
71    // when the GitHub API is unreachable (e.g., corporate firewalls).
72    let _ = record_check();
73
74    let (current, latest) = result?;
75    if latest != current {
76        Ok(Some(latest))
77    } else {
78        Ok(None)
79    }
80}
81
82/// Fetch the latest release version from GitHub without downloading or applying.
83///
84/// Returns `(current_version, latest_version)`.
85pub fn check_version() -> Result<(String, String)> {
86    let current = cargo_crate_version!().to_string();
87    let latest = updater().build()?.get_latest_release()?;
88    Ok((current, latest.version))
89}
90
91/// Whether updates are disabled via `LOOM_DISABLE_UPDATE` env var.
92///
93/// Accepts truthy values: `"1"`, `"true"`, `"yes"` (case-insensitive).
94///
95/// Note: config-based opt-out (`update.enabled = false`) is checked separately
96/// in `main.rs` after config is loaded.
97pub fn is_disabled_by_env() -> bool {
98    std::env::var("LOOM_DISABLE_UPDATE")
99        .is_ok_and(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes"))
100}
101
102// --- Rate limiting ---
103
104fn timestamp_path() -> Result<PathBuf> {
105    let home =
106        dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
107    Ok(home.join(".config").join("loom").join("last_update_check"))
108}
109
110fn should_check() -> Result<bool> {
111    let path = timestamp_path()?;
112    if !path.exists() {
113        return Ok(true);
114    }
115
116    let content = fs::read_to_string(&path)?;
117    let last_check: u64 = content.trim().parse().unwrap_or(0);
118    let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
119
120    Ok(now.saturating_sub(last_check) >= CHECK_INTERVAL_SECS)
121}
122
123fn record_check() -> Result<()> {
124    let path = timestamp_path()?;
125    if let Some(parent) = path.parent() {
126        fs::create_dir_all(parent)?;
127    }
128    let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
129    fs::write(&path, now.to_string())?;
130    Ok(())
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn test_is_disabled_by_env_truthy_values() {
139        for (value, expected) in [
140            ("1", true),
141            ("true", true),
142            ("TRUE", true),
143            ("yes", true),
144            ("Yes", true),
145            ("0", false),
146            ("false", false),
147            ("no", false),
148            ("", false),
149        ] {
150            // SAFETY: test runs single-threaded; no concurrent env var access.
151            unsafe {
152                std::env::set_var("LOOM_DISABLE_UPDATE", value);
153            }
154            assert_eq!(
155                is_disabled_by_env(),
156                expected,
157                "LOOM_DISABLE_UPDATE={value:?} should be {expected}"
158            );
159        }
160
161        // SAFETY: test runs single-threaded; no concurrent env var access.
162        unsafe {
163            std::env::remove_var("LOOM_DISABLE_UPDATE");
164        }
165        assert!(!is_disabled_by_env(), "unset should be false");
166    }
167
168    #[test]
169    fn test_timestamp_path() {
170        let path = timestamp_path().unwrap();
171        assert!(path.ends_with(".config/loom/last_update_check"));
172    }
173}