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 }
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 ¤t,
296 settings.interval_hours,
297 DEFAULT_FETCH_TIMEOUT_SECS,
298 &cache_path,
299 false,
300 now,
301 )?;
302 if !version_is_newer(&cache.latest, ¤t) {
303 return None;
304 }
305 let notice = format_update_notice(¤t, &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}