Skip to main content

call_coding_clis/
update_check.rs

1use std::env;
2use std::fs;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use serde_json::Value;
9
10const CRATES_API_URL: &str = "https://crates.io/api/v1/crates/ccc";
11const GITHUB_RELEASE_URL: &str = "https://api.github.com/repos/clankercode/ccc/releases/latest";
12const DEFAULT_INTERVAL_HOURS: u64 = 24;
13const DEFAULT_FETCH_TIMEOUT_SECS: u64 = 2;
14const CACHE_DIR_NAME: &str = "ccc";
15const CACHE_FILE_NAME: &str = "update-check.json";
16
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct UpdateSettings {
19    pub check: bool,
20    pub auto_update: bool,
21    pub interval_hours: u64,
22}
23
24impl Default for UpdateSettings {
25    fn default() -> Self {
26        Self {
27            check: true,
28            auto_update: false,
29            interval_hours: DEFAULT_INTERVAL_HOURS,
30        }
31    }
32}
33
34#[derive(Clone, Debug, PartialEq)]
35pub struct UpdateCache {
36    pub checked_at: f64,
37    pub current: String,
38    pub latest: String,
39    pub source: String,
40}
41
42impl UpdateCache {
43    pub fn update_available(&self) -> bool {
44        version_is_newer(&self.latest, &self.current)
45    }
46}
47
48pub fn resolve_update_settings(
49    config_check: bool,
50    config_auto_update: bool,
51    config_interval_hours: u64,
52) -> UpdateSettings {
53    let mut settings = UpdateSettings {
54        check: env_bool("CCC_UPDATE_CHECK", config_check),
55        auto_update: env_bool("CCC_AUTO_UPDATE", config_auto_update),
56        interval_hours: if config_interval_hours == 0 {
57            DEFAULT_INTERVAL_HOURS
58        } else {
59            config_interval_hours
60        },
61    };
62    if let Ok(raw) = env::var("CCC_UPDATE_INTERVAL_HOURS") {
63        let trimmed = raw.trim();
64        if !trimmed.is_empty() {
65            if let Ok(parsed) = trimmed.parse::<u64>() {
66                if parsed > 0 {
67                    settings.interval_hours = parsed;
68                }
69            }
70        }
71    }
72    settings
73}
74
75pub fn current_version() -> String {
76    option_env!("CCC_VERSION")
77        .unwrap_or(env!("CARGO_PKG_VERSION"))
78        .to_string()
79}
80
81pub fn resolve_cache_path() -> PathBuf {
82    if let Ok(explicit) = env::var("CCC_UPDATE_CACHE") {
83        let trimmed = explicit.trim();
84        if !trimmed.is_empty() {
85            return PathBuf::from(trimmed);
86        }
87    }
88    if let Ok(xdg_cache) = env::var("XDG_CACHE_HOME") {
89        let trimmed = xdg_cache.trim();
90        if !trimmed.is_empty() {
91            return PathBuf::from(trimmed)
92                .join(CACHE_DIR_NAME)
93                .join(CACHE_FILE_NAME);
94        }
95    }
96    home_dir()
97        .unwrap_or_else(|| PathBuf::from("."))
98        .join(".cache")
99        .join(CACHE_DIR_NAME)
100        .join(CACHE_FILE_NAME)
101}
102
103pub fn load_cache(path: &Path) -> Option<UpdateCache> {
104    let text = fs::read_to_string(path).ok()?;
105    let payload: Value = serde_json::from_str(&text).ok()?;
106    let checked_at = payload.get("checked_at")?.as_f64()?;
107    let current = payload.get("current")?.as_str()?.to_string();
108    let latest = payload.get("latest")?.as_str()?.to_string();
109    if current.is_empty() || latest.is_empty() {
110        return None;
111    }
112    let source = payload
113        .get("source")
114        .and_then(Value::as_str)
115        .unwrap_or("")
116        .to_string();
117    Some(UpdateCache {
118        checked_at,
119        current,
120        latest,
121        source,
122    })
123}
124
125pub fn save_cache(cache: &UpdateCache, path: &Path) -> bool {
126    if let Some(parent) = path.parent() {
127        if fs::create_dir_all(parent).is_err() {
128            return false;
129        }
130    }
131    let payload = serde_json::json!({
132        "checked_at": cache.checked_at,
133        "current": cache.current,
134        "latest": cache.latest,
135        "source": cache.source,
136    });
137    match fs::write(path, format!("{payload}\n")) {
138        Ok(()) => true,
139        Err(_) => false,
140    }
141}
142
143pub fn cache_is_fresh(cache: &UpdateCache, interval_hours: u64, now: f64) -> bool {
144    let max_age = interval_hours.max(1) as f64 * 3600.0;
145    (now - cache.checked_at) < max_age
146}
147
148pub fn parse_version_tuple(version: &str) -> Option<(u64, u64, u64)> {
149    let text = version.trim();
150    if text.is_empty() || text.eq_ignore_ascii_case("unknown") {
151        return None;
152    }
153    let stripped = text.strip_prefix('v').unwrap_or(text);
154    let core = stripped
155        .split_once(['-', '+'])
156        .map(|(head, _)| head)
157        .unwrap_or(stripped);
158    let mut parts = core.split('.');
159    let major = parts.next()?.parse::<u64>().ok()?;
160    let minor = parts
161        .next()
162        .map(|part| part.parse::<u64>().ok())
163        .unwrap_or(Some(0))?;
164    let patch = parts
165        .next()
166        .map(|part| part.parse::<u64>().ok())
167        .unwrap_or(Some(0))?;
168    if parts.next().is_some() {
169        // Allow only major.minor.patch core numbers.
170    }
171    Some((major, minor, patch))
172}
173
174pub fn version_is_newer(latest: &str, current: &str) -> bool {
175    match (parse_version_tuple(latest), parse_version_tuple(current)) {
176        (Some(latest_parts), Some(current_parts)) => latest_parts > current_parts,
177        _ => false,
178    }
179}
180
181pub fn fetch_latest_version(timeout_secs: u64) -> Option<(String, String)> {
182    if let Some(body) = http_get(CRATES_API_URL, timeout_secs) {
183        if let Some(latest) = parse_crates_latest(&body) {
184            return Some((latest, "crates.io".to_string()));
185        }
186    }
187    if let Some(body) = http_get(GITHUB_RELEASE_URL, timeout_secs) {
188        if let Some(latest) = parse_github_latest(&body) {
189            return Some((latest, "github".to_string()));
190        }
191    }
192    None
193}
194
195pub fn refresh_cache(
196    current: &str,
197    interval_hours: u64,
198    timeout_secs: u64,
199    cache_path: &Path,
200    force: bool,
201    now: f64,
202) -> Option<UpdateCache> {
203    let existing = load_cache(cache_path);
204    if let Some(ref cache) = existing {
205        if !force && cache_is_fresh(cache, interval_hours, now) {
206            return existing;
207        }
208    }
209    let (latest, source) = fetch_latest_version(timeout_secs)?;
210    let cache = UpdateCache {
211        checked_at: now,
212        current: current.to_string(),
213        latest,
214        source,
215    };
216    save_cache(&cache, cache_path);
217    Some(cache)
218}
219
220pub fn format_update_notice(current: &str, latest: &str, auto_update: bool) -> String {
221    if auto_update {
222        format!(
223            "warning: ccc {latest} is available (you have {current}); starting background update via `cargo install ccc`"
224        )
225    } else {
226        format!(
227            "warning: ccc {latest} is available (you have {current}); update with: cargo install ccc"
228        )
229    }
230}
231
232pub fn detect_install_method(executable: Option<&str>) -> String {
233    let raw = executable
234        .map(str::to_string)
235        .or_else(|| env::args().next())
236        .unwrap_or_default();
237    let path = PathBuf::from(&raw);
238    let text = path.to_string_lossy().replace('\\', "/");
239    if text.contains("/cargo/bin/") || text.ends_with("/.cargo/bin/ccc") {
240        return "cargo".to_string();
241    }
242    if let Some(home) = home_dir() {
243        let cargo_bin = home.join(".cargo").join("bin").join("ccc");
244        if cargo_bin.exists() {
245            if let (Ok(left), Ok(right)) = (fs::canonicalize(&cargo_bin), fs::canonicalize(&path))
246            {
247                if left == right {
248                    return "cargo".to_string();
249                }
250            }
251        }
252    }
253    "unknown".to_string()
254}
255
256pub fn spawn_background_update(log_path: Option<&Path>) -> Option<PathBuf> {
257    if detect_install_method(None) != "cargo" {
258        return None;
259    }
260    let log = log_path
261        .map(Path::to_path_buf)
262        .unwrap_or_else(|| resolve_cache_path().with_file_name("auto-update.log"));
263    if let Some(parent) = log.parent() {
264        fs::create_dir_all(parent).ok()?;
265    }
266    let mut file = fs::OpenOptions::new()
267        .create(true)
268        .append(true)
269        .open(&log)
270        .ok()?;
271    let _ = writeln!(
272        file,
273        "\n--- auto-update started {} ---",
274        unix_timestamp_secs()
275    );
276    let stdout = file.try_clone().ok()?;
277    Command::new("cargo")
278        .args(["install", "ccc", "--force"])
279        .stdin(Stdio::null())
280        .stdout(Stdio::from(stdout))
281        .stderr(Stdio::from(file))
282        .spawn()
283        .ok()?;
284    Some(log)
285}
286
287pub fn emit_post_run_update_notice(settings: &UpdateSettings) -> Option<String> {
288    if !settings.check {
289        return None;
290    }
291    let current = current_version();
292    let cache_path = resolve_cache_path();
293    let now = unix_timestamp();
294    let cache = refresh_cache(
295        &current,
296        settings.interval_hours,
297        DEFAULT_FETCH_TIMEOUT_SECS,
298        &cache_path,
299        false,
300        now,
301    )?;
302    if !version_is_newer(&cache.latest, &current) {
303        return None;
304    }
305    let notice = format_update_notice(&current, &cache.latest, settings.auto_update);
306    eprintln!("{notice}");
307    if settings.auto_update {
308        match spawn_background_update(None) {
309            Some(log) => eprintln!("warning: auto-update log: {}", log.display()),
310            None => {
311                if detect_install_method(None) != "cargo" {
312                    eprintln!(
313                        "warning: auto_update is enabled but this ccc install is not cargo-based; update manually with: cargo install ccc"
314                    );
315                }
316            }
317        }
318    }
319    Some(notice)
320}
321
322fn http_get(url: &str, timeout_secs: u64) -> Option<String> {
323    let current = current_version();
324    let user_agent = format!("ccc/{current} (https://github.com/clankercode/ccc)");
325    let timeout = timeout_secs.max(1).to_string();
326    let output = Command::new("curl")
327        .args([
328            "-fsSL",
329            "--max-time",
330            &timeout,
331            "-H",
332            &format!("User-Agent: {user_agent}"),
333            "-H",
334            "Accept: application/json",
335            url,
336        ])
337        .output()
338        .ok()?;
339    if !output.status.success() {
340        return None;
341    }
342    String::from_utf8(output.stdout).ok()
343}
344
345fn parse_crates_latest(body: &str) -> Option<String> {
346    let payload: Value = serde_json::from_str(body).ok()?;
347    let crate_obj = payload.get("crate")?;
348    for key in ["max_stable_version", "max_version", "newest_version"] {
349        if let Some(value) = crate_obj.get(key).and_then(Value::as_str) {
350            let cleaned = value.trim().trim_start_matches('v');
351            if parse_version_tuple(cleaned).is_some() {
352                return Some(cleaned.to_string());
353            }
354        }
355    }
356    None
357}
358
359fn parse_github_latest(body: &str) -> Option<String> {
360    let payload: Value = serde_json::from_str(body).ok()?;
361    let tag = payload
362        .get("tag_name")
363        .or_else(|| payload.get("name"))
364        .and_then(Value::as_str)?;
365    let cleaned = tag.trim().trim_start_matches('v');
366    if parse_version_tuple(cleaned).is_some() {
367        Some(cleaned.to_string())
368    } else {
369        None
370    }
371}
372
373fn env_bool(name: &str, default: bool) -> bool {
374    match env::var(name) {
375        Ok(value) => {
376            let normalized = value.trim().to_ascii_lowercase();
377            match normalized.as_str() {
378                "" | "1" | "true" | "yes" | "on" => true,
379                "0" | "false" | "no" | "off" | "n" => false,
380                _ => default,
381            }
382        }
383        Err(_) => default,
384    }
385}
386
387fn unix_timestamp() -> f64 {
388    SystemTime::now()
389        .duration_since(UNIX_EPOCH)
390        .map(|duration| duration.as_secs_f64())
391        .unwrap_or(0.0)
392}
393
394fn unix_timestamp_secs() -> u64 {
395    SystemTime::now()
396        .duration_since(UNIX_EPOCH)
397        .map(|duration| duration.as_secs())
398        .unwrap_or(0)
399}
400
401fn home_dir() -> Option<PathBuf> {
402    env::var_os("HOME")
403        .map(PathBuf::from)
404        .or_else(|| env::var_os("USERPROFILE").map(PathBuf::from))
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use std::time::{SystemTime, UNIX_EPOCH};
411
412    #[test]
413    fn version_compare_handles_simple_semver() {
414        assert!(version_is_newer("0.5.0", "0.4.1"));
415        assert!(!version_is_newer("0.4.1", "0.4.1"));
416        assert!(!version_is_newer("0.4.0", "0.4.1"));
417        assert!(version_is_newer("v1.2.3", "1.2.2"));
418    }
419
420    #[test]
421    fn parse_crates_and_github_payloads() {
422        let crates = r#"{"crate":{"max_stable_version":"0.5.0","max_version":"0.5.0"}}"#;
423        assert_eq!(parse_crates_latest(crates).as_deref(), Some("0.5.0"));
424        let github = r#"{"tag_name":"v0.5.1","name":"ccc 0.5.1"}"#;
425        assert_eq!(parse_github_latest(github).as_deref(), Some("0.5.1"));
426    }
427
428    #[test]
429    fn cache_roundtrip_and_freshness() {
430        let dir = std::env::temp_dir().join(format!(
431            "ccc-update-cache-{}-{}",
432            std::process::id(),
433            SystemTime::now()
434                .duration_since(UNIX_EPOCH)
435                .unwrap()
436                .as_nanos()
437        ));
438        let path = dir.join("update-check.json");
439        let cache = UpdateCache {
440            checked_at: 1000.0,
441            current: "0.4.1".into(),
442            latest: "0.5.0".into(),
443            source: "crates.io".into(),
444        };
445        assert!(save_cache(&cache, &path));
446        let loaded = load_cache(&path).expect("cache loads");
447        assert_eq!(loaded.latest, "0.5.0");
448        assert!(cache_is_fresh(&loaded, 24, 1000.0 + 10.0));
449        assert!(!cache_is_fresh(&loaded, 24, 1000.0 + 90_000.0));
450        let _ = fs::remove_dir_all(dir);
451    }
452
453    #[test]
454    fn format_notice_mentions_update_command() {
455        let notice = format_update_notice("0.4.1", "0.5.0", false);
456        assert!(notice.contains("0.5.0"));
457        assert!(notice.contains("cargo install ccc"));
458        let auto = format_update_notice("0.4.1", "0.5.0", true);
459        assert!(auto.contains("background update"));
460    }
461}