1use 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
35const CHECK_INTERVAL_SECS: u64 = 24 * 3600;
37
38const HTTP_TIMEOUT_SECS: u64 = 2;
40
41const FRAMEWORK_CRATES: &[&str] = &["flodl", "flodl-hf"];
47
48#[derive(Debug, Default, Serialize, Deserialize)]
51struct Config {
52 #[serde(default)]
53 update_check: UpdateCheck,
54}
55
56#[derive(Debug, Serialize, Deserialize)]
57struct UpdateCheck {
58 #[serde(default = "default_enabled")]
60 enabled: bool,
61 #[serde(default)]
63 last_check: u64,
64 #[serde(default)]
66 latest_known: BTreeMap<String, String>,
67 #[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#[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
109fn run_silent() {
112 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 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 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 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 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 if probed || printed_anything {
189 let _ = save_config(&cfg_path, &cfg);
190 }
191}
192
193fn config_path() -> Option<PathBuf> {
196 let dir = config_dir()?;
197 Some(dir.join("flodl").join("config.json"))
198}
199
200fn 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 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 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
237fn 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 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 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#[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
343fn 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
373fn 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
389fn 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 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 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 let nudges = collect_nudges(&BTreeMap::new(), "0.5.2", &BTreeMap::new());
468 assert!(nudges.is_empty());
469 }
470}