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` 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!("  Opt out: set `FDL_NO_UPDATE_CHECK=1` or edit `update_check.enabled`");
161        eprintln!("           in {}", cfg_path.display());
162        cfg.update_check.first_run_seen = true;
163        printed_anything = true;
164    }
165
166    // Compare and nudge per crate.
167    let nudges = collect_nudges(
168        &cfg.update_check.latest_known,
169        env!("CARGO_PKG_VERSION"),
170        &project_versions,
171    );
172    if !nudges.is_empty() {
173        eprintln!();
174        for n in &nudges {
175            eprintln!("  {n}");
176        }
177        eprintln!();
178        eprintln!("  Update fdl: `fdl install --check`");
179        if nudges.iter().any(|n| !n.starts_with("flodl-cli ")) {
180            eprintln!("  Update flodl deps in your project: `cargo update`");
181        }
182        printed_anything = true;
183    }
184
185    // Persist config if anything changed (probe ran or banner shown).
186    if probed || printed_anything {
187        let _ = save_config(&cfg_path, &cfg);
188    }
189}
190
191// ---- Config IO ------------------------------------------------------------
192
193fn config_path() -> Option<PathBuf> {
194    let dir = config_dir()?;
195    Some(dir.join("flodl").join("config.json"))
196}
197
198/// Platform-specific config root, mirroring the `dirs` crate's
199/// `config_dir()` so we don't pull in an external crate for it.
200fn config_dir() -> Option<PathBuf> {
201    if cfg!(target_os = "macos") {
202        env::var_os("HOME").map(|h| PathBuf::from(h).join("Library").join("Application Support"))
203    } else if cfg!(target_os = "windows") {
204        env::var_os("APPDATA").map(PathBuf::from)
205    } else {
206        // Linux / BSD / unknown unix: XDG.
207        if let Some(xdg) = env::var_os("XDG_CONFIG_HOME") {
208            let p = PathBuf::from(xdg);
209            if p.is_absolute() {
210                return Some(p);
211            }
212        }
213        env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))
214    }
215}
216
217fn load_config(path: &Path) -> Config {
218    // Treat any failure (missing file, parse error, hand-edit broke
219    // schema) as "use defaults". Never crash on user state.
220    fs::read_to_string(path)
221        .ok()
222        .and_then(|s| serde_json::from_str(&s).ok())
223        .unwrap_or_default()
224}
225
226fn save_config(path: &Path, cfg: &Config) -> Result<(), String> {
227    if let Some(parent) = path.parent() {
228        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
229    }
230    let json = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
231    fs::write(path, json).map_err(|e| e.to_string())
232}
233
234// ---- Project detection ----------------------------------------------------
235
236/// Walk up from cwd looking for a `Cargo.lock`. Parse it for any of
237/// [`FRAMEWORK_CRATES`] and return their resolved versions. Returns
238/// empty map when we're not inside a cargo project, or when the
239/// project doesn't depend on any of the user-facing flodl crates.
240fn detect_project_crates() -> BTreeMap<String, String> {
241    let mut out = BTreeMap::new();
242
243    let cwd = match env::current_dir() {
244        Ok(p) => p,
245        Err(_) => return out,
246    };
247
248    let lock = match find_cargo_lock(&cwd) {
249        Some(p) => p,
250        None => return out,
251    };
252
253    let contents = match fs::read_to_string(&lock) {
254        Ok(s) => s,
255        Err(_) => return out,
256    };
257
258    // Cargo.lock is TOML with repeated `[[package]]` blocks. We do a
259    // tiny line-based scan rather than pulling in a TOML crate.
260    let mut current_name: Option<String> = None;
261    let mut current_version: Option<String> = None;
262    for line in contents.lines() {
263        let line = line.trim();
264        if line == "[[package]]" {
265            if let (Some(name), Some(version)) = (current_name.take(), current_version.take())
266                && FRAMEWORK_CRATES.contains(&name.as_str())
267            {
268                out.insert(name, version);
269            }
270        } else if let Some(rest) = line.strip_prefix("name = ") {
271            current_name = unquote(rest);
272        } else if let Some(rest) = line.strip_prefix("version = ") {
273            current_version = unquote(rest);
274        }
275    }
276    // Trailing block.
277    if let (Some(name), Some(version)) = (current_name, current_version)
278        && FRAMEWORK_CRATES.contains(&name.as_str())
279    {
280        out.insert(name, version);
281    }
282
283    out
284}
285
286fn unquote(s: &str) -> Option<String> {
287    let s = s.trim();
288    let s = s.strip_prefix('"')?.strip_suffix('"')?;
289    Some(s.to_string())
290}
291
292fn find_cargo_lock(start: &Path) -> Option<PathBuf> {
293    let mut dir = start;
294    loop {
295        let candidate = dir.join("Cargo.lock");
296        if candidate.is_file() {
297            return Some(candidate);
298        }
299        dir = dir.parent()?;
300    }
301}
302
303// ---- crates.io probe ------------------------------------------------------
304
305#[derive(Deserialize)]
306struct CratesIoResponse {
307    #[serde(rename = "crate")]
308    krate: CrateInfo,
309}
310
311#[derive(Deserialize)]
312struct CrateInfo {
313    max_stable_version: Option<String>,
314    max_version: String,
315}
316
317/// Latest published version of `crate_name`, straight from the
318/// crates.io API. Also `fdl init`'s source for the scaffold's flodl
319/// pin. The `-A` header is load-bearing: crates.io's data-access
320/// policy rejects unidentified clients, so a bare curl gets an error
321/// body, not JSON.
322pub(crate) fn probe_crates_io(crate_name: &str) -> Option<String> {
323    let url = format!("https://crates.io/api/v1/crates/{crate_name}");
324    let output = Command::new("curl")
325        .arg("--silent")
326        .arg("--fail")
327        .arg("--max-time")
328        .arg(HTTP_TIMEOUT_SECS.to_string())
329        .arg("-A")
330        .arg(concat!("flodl-cli/", env!("CARGO_PKG_VERSION")))
331        .arg(url)
332        .stdout(Stdio::piped())
333        .stderr(Stdio::null())
334        .output()
335        .ok()?;
336
337    if !output.status.success() {
338        return None;
339    }
340
341    let resp: CratesIoResponse = serde_json::from_slice(&output.stdout).ok()?;
342    Some(
343        resp.krate
344            .max_stable_version
345            .unwrap_or(resp.krate.max_version),
346    )
347}
348
349// ---- Comparison + nudges --------------------------------------------------
350
351fn collect_nudges(
352    latest_known: &BTreeMap<String, String>,
353    self_version: &str,
354    project_versions: &BTreeMap<String, String>,
355) -> Vec<String> {
356    let mut out = Vec::new();
357
358    if let Some(latest) = latest_known.get("flodl-cli")
359        && semver_lt(self_version, latest)
360    {
361        out.push(format!(
362            "flodl-cli {latest} is available (you have {self_version})"
363        ));
364    }
365
366    for (name, current) in project_versions {
367        if let Some(latest) = latest_known.get(name)
368            && semver_lt(current, latest)
369        {
370            out.push(format!(
371                "{name} {latest} is available (your project pins {current})"
372            ));
373        }
374    }
375
376    out
377}
378
379/// Strict-less semver compare on the leading `MAJOR.MINOR.PATCH` parts.
380/// Pre-release suffixes are dropped (we only nudge against stable
381/// releases via `max_stable_version`).
382fn semver_lt(a: &str, b: &str) -> bool {
383    let parse = |s: &str| -> (u64, u64, u64) {
384        let core = s.split(['-', '+']).next().unwrap_or(s);
385        let mut it = core.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
386        (
387            it.next().unwrap_or(0),
388            it.next().unwrap_or(0),
389            it.next().unwrap_or(0),
390        )
391    };
392    parse(a) < parse(b)
393}
394
395// ---- Misc -----------------------------------------------------------------
396
397fn unix_now() -> u64 {
398    SystemTime::now()
399        .duration_since(UNIX_EPOCH)
400        .map(|d| d.as_secs())
401        .unwrap_or(0)
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn semver_lt_basic() {
410        assert!(semver_lt("0.5.2", "0.5.3"));
411        assert!(semver_lt("0.5.2", "0.6.0"));
412        assert!(semver_lt("0.5.2", "1.0.0"));
413        assert!(!semver_lt("0.5.3", "0.5.3"));
414        assert!(!semver_lt("0.5.4", "0.5.3"));
415    }
416
417    #[test]
418    fn semver_lt_drops_prerelease_suffix() {
419        // Pre-release suffix on either side gets stripped before
420        // tuple compare. We only ever feed in stable versions, but be
421        // defensive.
422        assert!(!semver_lt("0.5.3", "0.5.3-alpha.1"));
423        assert!(!semver_lt("0.5.3-rc.1", "0.5.3"));
424    }
425
426    #[test]
427    fn semver_lt_handles_short_versions() {
428        // "0.5" parses as (0,5,0).
429        assert!(semver_lt("0.5", "0.5.1"));
430        assert!(!semver_lt("0.5.0", "0.5"));
431    }
432
433    #[test]
434    fn unquote_strips_double_quotes() {
435        assert_eq!(unquote("\"foo\""), Some("foo".to_string()));
436        assert_eq!(unquote("\"\""), Some("".to_string()));
437        assert_eq!(unquote("foo"), None);
438    }
439
440    #[test]
441    fn collect_nudges_self_outdated() {
442        let mut latest = BTreeMap::new();
443        latest.insert("flodl-cli".to_string(), "0.6.0".to_string());
444        let nudges = collect_nudges(&latest, "0.5.2", &BTreeMap::new());
445        assert_eq!(nudges.len(), 1);
446        assert!(nudges[0].contains("0.6.0"));
447        assert!(nudges[0].contains("0.5.2"));
448    }
449
450    #[test]
451    fn collect_nudges_self_current_no_nudge() {
452        let mut latest = BTreeMap::new();
453        latest.insert("flodl-cli".to_string(), "0.5.2".to_string());
454        let nudges = collect_nudges(&latest, "0.5.2", &BTreeMap::new());
455        assert!(nudges.is_empty());
456    }
457
458    #[test]
459    fn collect_nudges_project_dep_outdated() {
460        let mut latest = BTreeMap::new();
461        latest.insert("flodl-cli".to_string(), "0.5.2".to_string());
462        latest.insert("flodl".to_string(), "0.6.0".to_string());
463        let mut project = BTreeMap::new();
464        project.insert("flodl".to_string(), "0.5.2".to_string());
465        let nudges = collect_nudges(&latest, "0.5.2", &project);
466        assert_eq!(nudges.len(), 1);
467        assert!(nudges[0].starts_with("flodl 0.6.0"));
468    }
469
470    #[test]
471    fn collect_nudges_no_latest_known_no_nudge() {
472        // Empty latest_known (e.g. probe failed silently): no nudges.
473        let nudges = collect_nudges(&BTreeMap::new(), "0.5.2", &BTreeMap::new());
474        assert!(nudges.is_empty());
475    }
476}