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!(" 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 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 if probed || printed_anything {
187 let _ = save_config(&cfg_path, &cfg);
188 }
189}
190
191fn config_path() -> Option<PathBuf> {
194 let dir = config_dir()?;
195 Some(dir.join("flodl").join("config.json"))
196}
197
198fn 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 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 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
234fn 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 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 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#[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
317pub(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
349fn 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
379fn 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
395fn 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 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 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 let nudges = collect_nudges(&BTreeMap::new(), "0.5.2", &BTreeMap::new());
474 assert!(nudges.is_empty());
475 }
476}