Skip to main content

flodl_cli/
update_check.rs

1//! Daily update check for `fdl` and the user-facing flodl crates.
2//!
3//! Probes crates.io once per day for newer versions of `flodl-cli`
4//! (this binary), plus `flodl` and `flodl-hf` when found in the
5//! current project's `Cargo.lock`. Caches results in
6//! `<config_dir>/flodl/config.json` and prints one nudge line per
7//! outdated crate at the end of the user's command.
8//!
9//! # Opt-out
10//!
11//! - `FDL_NO_UPDATE_CHECK=1` env var (wins over all else).
12//! - `update_check.enabled = false` in `<config_dir>/flodl/config.json`.
13//! - Auto-disabled when `CI=true` or running inside a Docker container
14//!   (container filesystems are ephemeral so the cache resets every run,
15//!   and CI runs already pin versions explicitly).
16//!
17//! # Network behaviour
18//!
19//! HTTP via `curl --max-time 2`, silent on every failure mode. The
20//! probe never blocks the user's command output: it runs from a
21//! [`Guard`](crate::update_check::Guard) that fires at process exit
22//! (Drop), after the user-visible work is done.
23
24use std::collections::BTreeMap;
25use std::env;
26use std::fs;
27use std::path::{Path, PathBuf};
28use std::process::{Command, Stdio};
29use std::time::{SystemTime, UNIX_EPOCH};
30
31use serde::{Deserialize, Serialize};
32
33use crate::util::system;
34
35/// Throttle window between probes — once per day per machine.
36const CHECK_INTERVAL_SECS: u64 = 24 * 3600;
37
38/// Cap on each curl probe so a slow / hung crates.io can't block exit.
39const HTTP_TIMEOUT_SECS: u64 = 2;
40
41/// User-bumpable framework crates we probe when we find them in the
42/// project's Cargo.lock. `flodl-cli` is checked separately (it's this
43/// binary). `flodl-sys` and `flodl-cli-macros` are transitive deps that
44/// ride along with `flodl` / `flodl-cli` upgrades — surfacing them adds
45/// noise without action.
46const FRAMEWORK_CRATES: &[&str] = &["flodl", "flodl-hf"];
47
48// ---- Config schema --------------------------------------------------------
49
50#[derive(Debug, Default, Serialize, Deserialize)]
51struct Config {
52    #[serde(default)]
53    update_check: UpdateCheck,
54}
55
56#[derive(Debug, Serialize, Deserialize)]
57struct UpdateCheck {
58    /// User-editable. `FDL_NO_UPDATE_CHECK=1` wins over this when set.
59    #[serde(default = "default_enabled")]
60    enabled: bool,
61    /// Last successful probe, epoch seconds. fdl-managed.
62    #[serde(default)]
63    last_check: u64,
64    /// Latest version per crate, as reported by crates.io. fdl-managed.
65    #[serde(default)]
66    latest_known: BTreeMap<String, String>,
67    /// First-run disclosure banner shown once. fdl-managed.
68    #[serde(default)]
69    first_run_seen: bool,
70}
71
72impl Default for UpdateCheck {
73    fn default() -> Self {
74        Self {
75            enabled: true,
76            last_check: 0,
77            latest_known: BTreeMap::new(),
78            first_run_seen: false,
79        }
80    }
81}
82
83fn default_enabled() -> bool {
84    true
85}
86
87// ---- Public surface -------------------------------------------------------
88
89/// RAII guard whose `Drop` runs the update check at process exit.
90///
91/// Hold one in `main()`; the check fires after the user's command
92/// output. Failures (network, parse, IO) are swallowed — the guard
93/// never returns errors to the caller.
94#[derive(Default)]
95pub struct Guard;
96
97impl Guard {
98    pub fn new() -> Self {
99        Self
100    }
101}
102
103impl Drop for Guard {
104    fn drop(&mut self) {
105        run_silent();
106    }
107}
108
109// ---- Orchestration --------------------------------------------------------
110
111fn run_silent() {
112    // Layered opt-outs: env var first (machine), CI second (automated
113    // env), in-container third (ephemeral fs), config file last (user
114    // policy).
115    if env::var("FDL_NO_UPDATE_CHECK").is_ok() {
116        return;
117    }
118    if env::var("CI").is_ok() {
119        return;
120    }
121    if system::is_inside_docker() {
122        return;
123    }
124
125    let cfg_path = match config_path() {
126        Some(p) => p,
127        None => return,
128    };
129
130    let mut cfg = load_config(&cfg_path);
131    if !cfg.update_check.enabled {
132        return;
133    }
134
135    // Decide what to probe and what to compare against.
136    let project_versions = detect_project_crates();
137    let mut crates_to_check: Vec<String> = vec!["flodl-cli".to_string()];
138    crates_to_check.extend(project_versions.keys().cloned());
139
140    // Refresh latest_known if 24h stale (or never probed).
141    let now = unix_now();
142    let mut probed = false;
143    if now.saturating_sub(cfg.update_check.last_check) >= CHECK_INTERVAL_SECS
144        && system::has_command("curl")
145    {
146        for name in &crates_to_check {
147            if let Some(latest) = probe_crates_io(name) {
148                cfg.update_check.latest_known.insert(name.clone(), latest);
149            }
150        }
151        cfg.update_check.last_check = now;
152        probed = true;
153    }
154
155    // First-run banner: print once, regardless of nudge presence.
156    let mut printed_anything = false;
157    if !cfg.update_check.first_run_seen {
158        eprintln!();
159        eprintln!("fdl checks for updates once a day.");
160        eprintln!(
161            "  Opt out: set `FDL_NO_UPDATE_CHECK=1` or edit `update_check.enabled`"
162        );
163        eprintln!("           in {}", cfg_path.display());
164        cfg.update_check.first_run_seen = true;
165        printed_anything = true;
166    }
167
168    // Compare and nudge per crate.
169    let nudges = collect_nudges(
170        &cfg.update_check.latest_known,
171        env!("CARGO_PKG_VERSION"),
172        &project_versions,
173    );
174    if !nudges.is_empty() {
175        eprintln!();
176        for n in &nudges {
177            eprintln!("  {n}");
178        }
179        eprintln!();
180        eprintln!("  Update fdl: `fdl install --check`");
181        if nudges.iter().any(|n| !n.starts_with("flodl-cli ")) {
182            eprintln!("  Update flodl deps in your project: `cargo update`");
183        }
184        printed_anything = true;
185    }
186
187    // Persist config if anything changed (probe ran or banner shown).
188    if probed || printed_anything {
189        let _ = save_config(&cfg_path, &cfg);
190    }
191}
192
193// ---- Config IO ------------------------------------------------------------
194
195fn config_path() -> Option<PathBuf> {
196    let dir = config_dir()?;
197    Some(dir.join("flodl").join("config.json"))
198}
199
200/// Platform-specific config root, mirroring the `dirs` crate's
201/// `config_dir()` so we don't pull in an external crate for it.
202fn config_dir() -> Option<PathBuf> {
203    if cfg!(target_os = "macos") {
204        env::var_os("HOME")
205            .map(|h| PathBuf::from(h).join("Library").join("Application Support"))
206    } else if cfg!(target_os = "windows") {
207        env::var_os("APPDATA").map(PathBuf::from)
208    } else {
209        // Linux / BSD / unknown unix: XDG.
210        if let Some(xdg) = env::var_os("XDG_CONFIG_HOME") {
211            let p = PathBuf::from(xdg);
212            if p.is_absolute() {
213                return Some(p);
214            }
215        }
216        env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))
217    }
218}
219
220fn load_config(path: &Path) -> Config {
221    // Treat any failure (missing file, parse error, hand-edit broke
222    // schema) as "use defaults". Never crash on user state.
223    fs::read_to_string(path)
224        .ok()
225        .and_then(|s| serde_json::from_str(&s).ok())
226        .unwrap_or_default()
227}
228
229fn save_config(path: &Path, cfg: &Config) -> Result<(), String> {
230    if let Some(parent) = path.parent() {
231        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
232    }
233    let json = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
234    fs::write(path, json).map_err(|e| e.to_string())
235}
236
237// ---- Project detection ----------------------------------------------------
238
239/// Walk up from cwd looking for a `Cargo.lock`. Parse it for any of
240/// [`FRAMEWORK_CRATES`] and return their resolved versions. Returns
241/// empty map when we're not inside a cargo project, or when the
242/// project doesn't depend on any of the user-facing flodl crates.
243fn detect_project_crates() -> BTreeMap<String, String> {
244    let mut out = BTreeMap::new();
245
246    let cwd = match env::current_dir() {
247        Ok(p) => p,
248        Err(_) => return out,
249    };
250
251    let lock = match find_cargo_lock(&cwd) {
252        Some(p) => p,
253        None => return out,
254    };
255
256    let contents = match fs::read_to_string(&lock) {
257        Ok(s) => s,
258        Err(_) => return out,
259    };
260
261    // Cargo.lock is TOML with repeated `[[package]]` blocks. We do a
262    // tiny line-based scan rather than pulling in a TOML crate.
263    let mut current_name: Option<String> = None;
264    let mut current_version: Option<String> = None;
265    for line in contents.lines() {
266        let line = line.trim();
267        if line == "[[package]]" {
268            if let (Some(name), Some(version)) = (current_name.take(), current_version.take()) {
269                if FRAMEWORK_CRATES.contains(&name.as_str()) {
270                    out.insert(name, version);
271                }
272            }
273        } else if let Some(rest) = line.strip_prefix("name = ") {
274            current_name = unquote(rest);
275        } else if let Some(rest) = line.strip_prefix("version = ") {
276            current_version = unquote(rest);
277        }
278    }
279    // Trailing block.
280    if let (Some(name), Some(version)) = (current_name, current_version) {
281        if FRAMEWORK_CRATES.contains(&name.as_str()) {
282            out.insert(name, version);
283        }
284    }
285
286    out
287}
288
289fn unquote(s: &str) -> Option<String> {
290    let s = s.trim();
291    let s = s.strip_prefix('"')?.strip_suffix('"')?;
292    Some(s.to_string())
293}
294
295fn find_cargo_lock(start: &Path) -> Option<PathBuf> {
296    let mut dir = start;
297    loop {
298        let candidate = dir.join("Cargo.lock");
299        if candidate.is_file() {
300            return Some(candidate);
301        }
302        dir = dir.parent()?;
303    }
304}
305
306// ---- crates.io probe ------------------------------------------------------
307
308#[derive(Deserialize)]
309struct CratesIoResponse {
310    #[serde(rename = "crate")]
311    krate: CrateInfo,
312}
313
314#[derive(Deserialize)]
315struct CrateInfo {
316    max_stable_version: Option<String>,
317    max_version: String,
318}
319
320fn probe_crates_io(crate_name: &str) -> Option<String> {
321    let url = format!("https://crates.io/api/v1/crates/{crate_name}");
322    let output = Command::new("curl")
323        .arg("--silent")
324        .arg("--fail")
325        .arg("--max-time")
326        .arg(HTTP_TIMEOUT_SECS.to_string())
327        .arg("-A")
328        .arg(concat!("flodl-cli/", env!("CARGO_PKG_VERSION")))
329        .arg(url)
330        .stdout(Stdio::piped())
331        .stderr(Stdio::null())
332        .output()
333        .ok()?;
334
335    if !output.status.success() {
336        return None;
337    }
338
339    let resp: CratesIoResponse = serde_json::from_slice(&output.stdout).ok()?;
340    Some(resp.krate.max_stable_version.unwrap_or(resp.krate.max_version))
341}
342
343// ---- Comparison + nudges --------------------------------------------------
344
345fn collect_nudges(
346    latest_known: &BTreeMap<String, String>,
347    self_version: &str,
348    project_versions: &BTreeMap<String, String>,
349) -> Vec<String> {
350    let mut out = Vec::new();
351
352    if let Some(latest) = latest_known.get("flodl-cli") {
353        if semver_lt(self_version, latest) {
354            out.push(format!(
355                "flodl-cli {latest} is available (you have {self_version})"
356            ));
357        }
358    }
359
360    for (name, current) in project_versions {
361        if let Some(latest) = latest_known.get(name) {
362            if semver_lt(current, latest) {
363                out.push(format!(
364                    "{name} {latest} is available (your project pins {current})"
365                ));
366            }
367        }
368    }
369
370    out
371}
372
373/// Strict-less semver compare on the leading `MAJOR.MINOR.PATCH` parts.
374/// Pre-release suffixes are dropped (we only nudge against stable
375/// releases via `max_stable_version`).
376fn semver_lt(a: &str, b: &str) -> bool {
377    let parse = |s: &str| -> (u64, u64, u64) {
378        let core = s.split(['-', '+']).next().unwrap_or(s);
379        let mut it = core.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
380        (
381            it.next().unwrap_or(0),
382            it.next().unwrap_or(0),
383            it.next().unwrap_or(0),
384        )
385    };
386    parse(a) < parse(b)
387}
388
389// ---- Misc -----------------------------------------------------------------
390
391fn unix_now() -> u64 {
392    SystemTime::now()
393        .duration_since(UNIX_EPOCH)
394        .map(|d| d.as_secs())
395        .unwrap_or(0)
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn semver_lt_basic() {
404        assert!(semver_lt("0.5.2", "0.5.3"));
405        assert!(semver_lt("0.5.2", "0.6.0"));
406        assert!(semver_lt("0.5.2", "1.0.0"));
407        assert!(!semver_lt("0.5.3", "0.5.3"));
408        assert!(!semver_lt("0.5.4", "0.5.3"));
409    }
410
411    #[test]
412    fn semver_lt_drops_prerelease_suffix() {
413        // Pre-release suffix on either side gets stripped before
414        // tuple compare. We only ever feed in stable versions, but be
415        // defensive.
416        assert!(!semver_lt("0.5.3", "0.5.3-alpha.1"));
417        assert!(!semver_lt("0.5.3-rc.1", "0.5.3"));
418    }
419
420    #[test]
421    fn semver_lt_handles_short_versions() {
422        // "0.5" parses as (0,5,0).
423        assert!(semver_lt("0.5", "0.5.1"));
424        assert!(!semver_lt("0.5.0", "0.5"));
425    }
426
427    #[test]
428    fn unquote_strips_double_quotes() {
429        assert_eq!(unquote("\"foo\""), Some("foo".to_string()));
430        assert_eq!(unquote("\"\""), Some("".to_string()));
431        assert_eq!(unquote("foo"), None);
432    }
433
434    #[test]
435    fn collect_nudges_self_outdated() {
436        let mut latest = BTreeMap::new();
437        latest.insert("flodl-cli".to_string(), "0.6.0".to_string());
438        let nudges = collect_nudges(&latest, "0.5.2", &BTreeMap::new());
439        assert_eq!(nudges.len(), 1);
440        assert!(nudges[0].contains("0.6.0"));
441        assert!(nudges[0].contains("0.5.2"));
442    }
443
444    #[test]
445    fn collect_nudges_self_current_no_nudge() {
446        let mut latest = BTreeMap::new();
447        latest.insert("flodl-cli".to_string(), "0.5.2".to_string());
448        let nudges = collect_nudges(&latest, "0.5.2", &BTreeMap::new());
449        assert!(nudges.is_empty());
450    }
451
452    #[test]
453    fn collect_nudges_project_dep_outdated() {
454        let mut latest = BTreeMap::new();
455        latest.insert("flodl-cli".to_string(), "0.5.2".to_string());
456        latest.insert("flodl".to_string(), "0.6.0".to_string());
457        let mut project = BTreeMap::new();
458        project.insert("flodl".to_string(), "0.5.2".to_string());
459        let nudges = collect_nudges(&latest, "0.5.2", &project);
460        assert_eq!(nudges.len(), 1);
461        assert!(nudges[0].starts_with("flodl 0.6.0"));
462    }
463
464    #[test]
465    fn collect_nudges_no_latest_known_no_nudge() {
466        // Empty latest_known (e.g. probe failed silently): no nudges.
467        let nudges = collect_nudges(&BTreeMap::new(), "0.5.2", &BTreeMap::new());
468        assert!(nudges.is_empty());
469    }
470}