1use super::time_source::SharedTimeSource;
10use std::env;
11use std::path::{Path, PathBuf};
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::mpsc::{self, Receiver, TryRecvError};
14use std::sync::Arc;
15use std::thread::{self, JoinHandle};
16use std::time::{Duration, Instant};
17
18pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
20
21pub const DEFAULT_RELEASES_URL: &str = "https://api.github.com/repos/sinelaw/fresh/releases/latest";
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum InstallMethod {
27 Homebrew,
29 Cargo,
31 Npm,
33 PackageManager,
35 Aur,
37 Unknown,
39}
40
41impl InstallMethod {
42 pub fn update_command(&self) -> Option<&'static str> {
44 Some(match self {
45 Self::Homebrew => " brew upgrade fresh-editor",
46 Self::Cargo => "cargo install fresh-editor",
47 Self::Npm => "npm update -g @fresh-editor/fresh-editor",
48 Self::Aur => "yay -Syu fresh-editor # or use your AUR helper",
49 Self::PackageManager => "Update using your system package manager",
50 Self::Unknown => return None,
51 })
52 }
53}
54
55#[derive(Debug, Clone)]
57pub struct ReleaseCheckResult {
58 pub latest_version: String,
60 pub update_available: bool,
62 pub install_method: InstallMethod,
64}
65
66pub struct UpdateCheckHandle {
70 receiver: Receiver<Result<ReleaseCheckResult, String>>,
71 #[allow(dead_code)]
72 thread: JoinHandle<()>,
73}
74
75impl UpdateCheckHandle {
76 pub fn try_get_result(self) -> Option<Result<ReleaseCheckResult, String>> {
80 match self.receiver.try_recv() {
81 Ok(result) => {
82 tracing::debug!("Update check completed");
83 Some(result)
84 }
85 Err(TryRecvError::Empty) => {
86 tracing::debug!("Update check still running, abandoning");
88 drop(self.thread);
89 None
90 }
91 Err(TryRecvError::Disconnected) => {
92 tracing::debug!("Update check thread disconnected");
94 None
95 }
96 }
97 }
98}
99
100pub const DEFAULT_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
102
103pub struct PeriodicUpdateChecker {
108 receiver: Receiver<Result<ReleaseCheckResult, String>>,
110 stop_signal: Arc<AtomicBool>,
112 #[allow(dead_code)]
114 thread: JoinHandle<()>,
115 last_result: Option<ReleaseCheckResult>,
117 last_check_time: Option<Instant>,
119}
120
121impl PeriodicUpdateChecker {
122 pub fn poll_result(&mut self) -> Option<Result<ReleaseCheckResult, String>> {
127 match self.receiver.try_recv() {
128 Ok(result) => {
129 self.last_check_time = Some(Instant::now());
130 if let Ok(ref release_result) = result {
131 tracing::debug!(
132 "Periodic update check completed: update_available={}",
133 release_result.update_available
134 );
135 self.last_result = Some(release_result.clone());
136 }
137 Some(result)
138 }
139 Err(TryRecvError::Empty) => None,
140 Err(TryRecvError::Disconnected) => {
141 tracing::debug!("Periodic update checker thread disconnected");
142 None
143 }
144 }
145 }
146
147 pub fn get_cached_result(&self) -> Option<&ReleaseCheckResult> {
149 self.last_result.as_ref()
150 }
151
152 pub fn is_update_available(&self) -> bool {
154 self.last_result
155 .as_ref()
156 .map(|r| r.update_available)
157 .unwrap_or(false)
158 }
159
160 pub fn latest_version(&self) -> Option<&str> {
162 self.last_result.as_ref().and_then(|r| {
163 if r.update_available {
164 Some(r.latest_version.as_str())
165 } else {
166 None
167 }
168 })
169 }
170}
171
172impl Drop for PeriodicUpdateChecker {
173 fn drop(&mut self) {
174 self.stop_signal.store(true, Ordering::SeqCst);
176 }
177}
178
179pub fn start_periodic_update_check(
184 releases_url: &str,
185 time_source: SharedTimeSource,
186 data_dir: PathBuf,
187) -> PeriodicUpdateChecker {
188 start_periodic_update_check_with_interval(
189 releases_url,
190 DEFAULT_UPDATE_CHECK_INTERVAL,
191 time_source,
192 data_dir,
193 )
194}
195
196pub fn start_periodic_update_check_with_interval(
207 releases_url: &str,
208 check_interval: Duration,
209 time_source: SharedTimeSource,
210 data_dir: PathBuf,
211) -> PeriodicUpdateChecker {
212 tracing::debug!(
213 "Starting periodic update checker with interval {:?}",
214 check_interval
215 );
216 let url = releases_url.to_string();
217 let (tx, rx) = mpsc::channel();
218 let stop_signal = Arc::new(AtomicBool::new(false));
219 let stop_signal_clone = stop_signal.clone();
220
221 let sleep_increment = if check_interval < Duration::from_secs(10) {
223 Duration::from_millis(10)
224 } else {
225 Duration::from_secs(1)
226 };
227
228 let handle = thread::spawn(move || {
229 if let Some(unique_id) =
231 super::telemetry::should_run_daily_check(time_source.as_ref(), &data_dir)
232 {
233 super::telemetry::track_open(&unique_id);
234 let result = check_for_update(&url);
235 if tx.send(result).is_err() {
236 return; }
238 }
239
240 loop {
242 let sleep_end = time_source.now() + check_interval;
244 while time_source.now() < sleep_end {
245 if stop_signal_clone.load(Ordering::SeqCst) {
246 tracing::debug!("Periodic update checker stopping");
247 return;
248 }
249 time_source.sleep(sleep_increment);
250 }
251
252 if stop_signal_clone.load(Ordering::SeqCst) {
254 tracing::debug!("Periodic update checker stopping");
255 return;
256 }
257
258 tracing::debug!("Periodic update check starting");
259 if let Some(unique_id) =
261 super::telemetry::should_run_daily_check(time_source.as_ref(), &data_dir)
262 {
263 super::telemetry::track_open(&unique_id);
264 let result = check_for_update(&url);
265 if tx.send(result).is_err() {
266 return; }
268 }
269 }
270 });
271
272 PeriodicUpdateChecker {
273 receiver: rx,
274 stop_signal,
275 thread: handle,
276 last_result: None,
277 last_check_time: None,
278 }
279}
280
281pub fn start_update_check(
287 releases_url: &str,
288 time_source: SharedTimeSource,
289 data_dir: PathBuf,
290) -> UpdateCheckHandle {
291 tracing::debug!("Starting background update check");
292 let url = releases_url.to_string();
293 let (tx, rx) = mpsc::channel();
294
295 let handle = thread::spawn(move || {
296 if let Some(unique_id) =
297 super::telemetry::should_run_daily_check(time_source.as_ref(), &data_dir)
298 {
299 super::telemetry::track_open(&unique_id);
300 let result = check_for_update(&url);
301 let _ = tx.send(result);
302 }
303 });
304
305 UpdateCheckHandle {
306 receiver: rx,
307 thread: handle,
308 }
309}
310
311pub fn fetch_latest_version(url: &str) -> Result<String, String> {
313 tracing::debug!("Fetching latest version from {}", url);
314 let response = ureq::get(url)
315 .set("User-Agent", "fresh-editor-update-checker")
316 .set("Accept", "application/vnd.github.v3+json")
317 .timeout(Duration::from_secs(5))
318 .call()
319 .map_err(|e| {
320 tracing::debug!("HTTP request failed: {}", e);
321 format!("HTTP request failed: {}", e)
322 })?;
323
324 let body = response
325 .into_string()
326 .map_err(|e| format!("Failed to read response body: {}", e))?;
327
328 let version = parse_version_from_json(&body)?;
329 tracing::debug!("Latest version: {}", version);
330 Ok(version)
331}
332
333fn parse_version_from_json(json: &str) -> Result<String, String> {
335 let tag_name_key = "\"tag_name\"";
336 let start = json
337 .find(tag_name_key)
338 .ok_or_else(|| "tag_name not found in response".to_string())?;
339
340 let after_key = &json[start + tag_name_key.len()..];
341
342 let value_start = after_key
343 .find('"')
344 .ok_or_else(|| "Invalid JSON: missing quote after tag_name".to_string())?;
345
346 let value_content = &after_key[value_start + 1..];
347 let value_end = value_content
348 .find('"')
349 .ok_or_else(|| "Invalid JSON: unclosed quote".to_string())?;
350
351 let tag = &value_content[..value_end];
352
353 Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
355}
356
357pub fn detect_install_method() -> InstallMethod {
359 match env::current_exe() {
360 Ok(path) => detect_install_method_from_path(&path),
361 Err(_) => InstallMethod::Unknown,
362 }
363}
364
365pub fn detect_install_method_from_path(exe_path: &Path) -> InstallMethod {
367 let path_str = exe_path.to_string_lossy();
368
369 if path_str.contains("/opt/homebrew/")
371 || path_str.contains("/usr/local/Cellar/")
372 || path_str.contains("/home/linuxbrew/")
373 || path_str.contains("/.linuxbrew/")
374 {
375 return InstallMethod::Homebrew;
376 }
377
378 if path_str.contains("/.cargo/bin/") || path_str.contains("\\.cargo\\bin\\") {
380 return InstallMethod::Cargo;
381 }
382
383 if path_str.contains("/node_modules/")
385 || path_str.contains("\\node_modules\\")
386 || path_str.contains("/npm/")
387 || path_str.contains("/lib/node_modules/")
388 {
389 return InstallMethod::Npm;
390 }
391
392 if path_str.starts_with("/usr/bin/") && is_arch_linux() {
394 return InstallMethod::Aur;
395 }
396
397 if path_str.starts_with("/usr/bin/")
399 || path_str.starts_with("/usr/local/bin/")
400 || path_str.starts_with("/bin/")
401 {
402 return InstallMethod::PackageManager;
403 }
404
405 InstallMethod::Unknown
406}
407
408fn is_arch_linux() -> bool {
410 std::fs::read_to_string("/etc/os-release")
411 .map(|content| content.contains("Arch Linux") || content.contains("ID=arch"))
412 .unwrap_or(false)
413}
414
415pub fn is_newer_version(current: &str, latest: &str) -> bool {
418 let parse_version = |v: &str| -> Option<(u32, u32, u32)> {
419 let parts: Vec<&str> = v.split('.').collect();
420 if parts.len() >= 3 {
421 Some((
422 parts[0].parse().ok()?,
423 parts[1].parse().ok()?,
424 parts[2].split('-').next()?.parse().ok()?,
425 ))
426 } else if parts.len() == 2 {
427 Some((parts[0].parse().ok()?, parts[1].parse().ok()?, 0))
428 } else {
429 None
430 }
431 };
432
433 match (parse_version(current), parse_version(latest)) {
434 (Some((c_major, c_minor, c_patch)), Some((l_major, l_minor, l_patch))) => {
435 (l_major, l_minor, l_patch) > (c_major, c_minor, c_patch)
436 }
437 _ => false,
438 }
439}
440
441pub fn check_for_update(releases_url: &str) -> Result<ReleaseCheckResult, String> {
443 let latest_version = fetch_latest_version(releases_url)?;
444 let install_method = detect_install_method();
445 let update_available = is_newer_version(CURRENT_VERSION, &latest_version);
446
447 tracing::debug!(
448 current = CURRENT_VERSION,
449 latest = %latest_version,
450 update_available,
451 install_method = ?install_method,
452 "Release check complete"
453 );
454
455 Ok(ReleaseCheckResult {
456 latest_version,
457 update_available,
458 install_method,
459 })
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use std::path::PathBuf;
466
467 #[test]
468 fn test_is_newer_version_major() {
469 assert!(is_newer_version("0.1.26", "1.0.0"));
470 assert!(is_newer_version("1.0.0", "2.0.0"));
471 }
472
473 #[test]
474 fn test_is_newer_version_minor() {
475 assert!(is_newer_version("0.1.26", "0.2.0"));
476 assert!(is_newer_version("0.1.26", "0.2.26"));
477 }
478
479 #[test]
480 fn test_is_newer_version_patch() {
481 assert!(is_newer_version("0.1.26", "0.1.27"));
482 assert!(is_newer_version("0.1.26", "0.1.100"));
483 }
484
485 #[test]
486 fn test_is_newer_version_same() {
487 assert!(!is_newer_version("0.1.26", "0.1.26"));
488 }
489
490 #[test]
491 fn test_is_newer_version_older() {
492 assert!(!is_newer_version("0.1.26", "0.1.25"));
493 assert!(!is_newer_version("0.2.0", "0.1.26"));
494 assert!(!is_newer_version("1.0.0", "0.1.26"));
495 }
496
497 #[test]
498 fn test_is_newer_version_with_v_prefix() {
499 assert!(is_newer_version("0.1.26", "0.1.27"));
500 }
501
502 #[test]
503 fn test_is_newer_version_with_prerelease() {
504 assert!(is_newer_version("0.1.26-alpha", "0.1.27"));
505 assert!(is_newer_version("0.1.26", "0.1.27-beta"));
506 }
507
508 #[test]
509 fn test_detect_install_method_homebrew_macos() {
510 let path = PathBuf::from("/opt/homebrew/Cellar/fresh/0.1.26/bin/fresh");
511 assert_eq!(
512 detect_install_method_from_path(&path),
513 InstallMethod::Homebrew
514 );
515 }
516
517 #[test]
518 fn test_detect_install_method_homebrew_intel_mac() {
519 let path = PathBuf::from("/usr/local/Cellar/fresh/0.1.26/bin/fresh");
520 assert_eq!(
521 detect_install_method_from_path(&path),
522 InstallMethod::Homebrew
523 );
524 }
525
526 #[test]
527 fn test_detect_install_method_homebrew_linux() {
528 let path = PathBuf::from("/home/linuxbrew/.linuxbrew/bin/fresh");
529 assert_eq!(
530 detect_install_method_from_path(&path),
531 InstallMethod::Homebrew
532 );
533 }
534
535 #[test]
536 fn test_detect_install_method_cargo() {
537 let path = PathBuf::from("/home/user/.cargo/bin/fresh");
538 assert_eq!(detect_install_method_from_path(&path), InstallMethod::Cargo);
539 }
540
541 #[test]
542 fn test_detect_install_method_cargo_windows() {
543 let path = PathBuf::from("C:\\Users\\user\\.cargo\\bin\\fresh.exe");
544 assert_eq!(detect_install_method_from_path(&path), InstallMethod::Cargo);
545 }
546
547 #[test]
548 fn test_detect_install_method_npm() {
549 let path = PathBuf::from("/usr/local/lib/node_modules/fresh-editor/bin/fresh");
550 assert_eq!(detect_install_method_from_path(&path), InstallMethod::Npm);
551 }
552
553 #[test]
554 fn test_detect_install_method_package_manager() {
555 let path = PathBuf::from("/usr/local/bin/fresh");
556 assert_eq!(
557 detect_install_method_from_path(&path),
558 InstallMethod::PackageManager
559 );
560 }
561
562 #[test]
563 fn test_detect_install_method_unknown() {
564 let path = PathBuf::from("/home/user/downloads/fresh");
565 assert_eq!(
566 detect_install_method_from_path(&path),
567 InstallMethod::Unknown
568 );
569 }
570
571 #[test]
572 fn test_parse_version_from_json() {
573 let json = r#"{"tag_name": "v0.1.27", "name": "Release 0.1.27"}"#;
574 assert_eq!(parse_version_from_json(json).unwrap(), "0.1.27");
575 }
576
577 #[test]
578 fn test_parse_version_from_json_no_v_prefix() {
579 let json = r#"{"tag_name": "0.1.27", "name": "Release 0.1.27"}"#;
580 assert_eq!(parse_version_from_json(json).unwrap(), "0.1.27");
581 }
582
583 #[test]
584 fn test_parse_version_from_json_full_response() {
585 let json = r#"{
586 "url": "https://api.github.com/repos/sinelaw/fresh/releases/12345",
587 "tag_name": "v0.2.0",
588 "target_commitish": "main",
589 "name": "v0.2.0",
590 "draft": false,
591 "prerelease": false
592 }"#;
593 assert_eq!(parse_version_from_json(json).unwrap(), "0.2.0");
594 }
595
596 #[test]
597 fn test_current_version_is_valid() {
598 let parts: Vec<&str> = CURRENT_VERSION.split('.').collect();
599 assert!(parts.len() >= 2, "Version should have at least major.minor");
600 assert!(
601 parts[0].parse::<u32>().is_ok(),
602 "Major version should be a number"
603 );
604 assert!(
605 parts[1].parse::<u32>().is_ok(),
606 "Minor version should be a number"
607 );
608 }
609
610 #[test]
611 fn test_version_parsing_with_mock_data() {
612 let json = r#"{"tag_name": "v99.0.0"}"#;
613 let version = parse_version_from_json(json).unwrap();
614 assert!(is_newer_version(CURRENT_VERSION, &version));
615 }
616
617 use std::sync::mpsc as std_mpsc;
618
619 fn start_mock_release_server(version: &str) -> (std_mpsc::Sender<()>, String) {
622 let server = tiny_http::Server::http("127.0.0.1:0").expect("Failed to start test server");
623 let port = server.server_addr().to_ip().unwrap().port();
624 let url = format!("http://127.0.0.1:{}/releases/latest", port);
625
626 let (stop_tx, stop_rx) = std_mpsc::channel::<()>();
627
628 let version = version.to_string();
630 thread::spawn(move || {
631 loop {
632 if stop_rx.try_recv().is_ok() {
634 break;
635 }
636
637 match server.recv_timeout(Duration::from_millis(100)) {
639 Ok(Some(request)) => {
640 let response_body = format!(r#"{{"tag_name": "v{}"}}"#, version);
641 let response = tiny_http::Response::from_string(response_body).with_header(
642 tiny_http::Header::from_bytes(
643 &b"Content-Type"[..],
644 &b"application/json"[..],
645 )
646 .unwrap(),
647 );
648 let _ = request.respond(response);
649 }
650 Ok(None) => {
651 }
653 Err(_) => {
654 break;
656 }
657 }
658 }
659 });
660
661 (stop_tx, url)
662 }
663
664 #[test]
665 fn test_periodic_update_checker_with_local_server() {
666 let (stop_tx, url) = start_mock_release_server("99.0.0");
668 let time_source = super::super::time_source::TestTimeSource::shared();
669 let temp_dir = tempfile::tempdir().unwrap();
670
671 let mut checker = start_periodic_update_check_with_interval(
672 &url,
673 Duration::from_millis(50),
674 time_source,
675 temp_dir.path().to_path_buf(),
676 );
677
678 let start = Instant::now();
680 while start.elapsed() < Duration::from_secs(2) {
681 if checker.poll_result().is_some() {
682 break;
683 }
684 thread::sleep(Duration::from_millis(10));
685 }
686
687 assert!(
689 checker.is_update_available(),
690 "Should detect update available"
691 );
692 assert_eq!(checker.latest_version(), Some("99.0.0"));
693 assert!(checker.get_cached_result().is_some());
694
695 drop(checker);
696 let _ = stop_tx.send(());
697 }
698
699 #[test]
700 fn test_periodic_update_checker_shutdown_clean() {
701 let (stop_tx, url) = start_mock_release_server("99.0.0");
703 let time_source = super::super::time_source::TestTimeSource::shared();
704 let temp_dir = tempfile::tempdir().unwrap();
705
706 let checker = start_periodic_update_check_with_interval(
707 &url,
708 Duration::from_millis(50),
709 time_source,
710 temp_dir.path().to_path_buf(),
711 );
712
713 thread::sleep(Duration::from_millis(100));
715
716 let start = Instant::now();
718 drop(checker);
719 let elapsed = start.elapsed();
720
721 assert!(
723 elapsed < Duration::from_secs(2),
724 "Shutdown took too long: {:?}",
725 elapsed
726 );
727
728 let _ = stop_tx.send(());
729 }
730
731 #[test]
732 fn test_periodic_update_checker_multiple_cycles() {
733 let (stop_tx, url) = start_mock_release_server("99.0.0");
735 let time_source = super::super::time_source::TestTimeSource::shared();
736 let temp_dir = tempfile::tempdir().unwrap();
737
738 let mut checker = start_periodic_update_check_with_interval(
739 &url,
740 Duration::from_secs(86400),
741 time_source.clone(),
742 temp_dir.path().to_path_buf(),
743 );
744
745 let mut result_count = 0;
746 let start = Instant::now();
747 let timeout = Duration::from_secs(2);
748
749 while start.elapsed() < timeout && result_count < 1 {
751 if checker.poll_result().is_some() {
752 result_count += 1;
753 }
754 thread::sleep(Duration::from_millis(10));
755 }
756
757 time_source.advance(Duration::from_secs(86400));
759
760 let start2 = Instant::now();
762 while start2.elapsed() < timeout && result_count < 2 {
763 if checker.poll_result().is_some() {
764 result_count += 1;
765 }
766 thread::sleep(Duration::from_millis(10));
767 }
768
769 assert!(
770 result_count >= 2,
771 "Expected at least 2 results, got {}",
772 result_count
773 );
774
775 drop(checker);
776 let _ = stop_tx.send(());
777 }
778
779 #[test]
780 fn test_periodic_update_checker_no_update_when_current() {
781 let (stop_tx, url) = start_mock_release_server(CURRENT_VERSION);
783 let time_source = super::super::time_source::TestTimeSource::shared();
784 let temp_dir = tempfile::tempdir().unwrap();
785
786 let mut checker = start_periodic_update_check_with_interval(
787 &url,
788 Duration::from_secs(3600),
789 time_source,
790 temp_dir.path().to_path_buf(),
791 );
792
793 let start = Instant::now();
795 while start.elapsed() < Duration::from_secs(2) {
796 if checker.poll_result().is_some() {
797 break;
798 }
799 thread::sleep(Duration::from_millis(10));
800 }
801
802 assert!(!checker.is_update_available());
804 assert!(checker.latest_version().is_none()); assert!(checker.get_cached_result().is_some()); drop(checker);
808 let _ = stop_tx.send(());
809 }
810
811 #[test]
812 fn test_periodic_update_checker_api_before_result() {
813 let (stop_tx, url) = start_mock_release_server("99.0.0");
815 let time_source = super::super::time_source::TestTimeSource::shared();
816 let temp_dir = tempfile::tempdir().unwrap();
817
818 let checker = start_periodic_update_check_with_interval(
820 &url,
821 Duration::from_secs(3600),
822 time_source,
823 temp_dir.path().to_path_buf(),
824 );
825
826 assert!(!checker.is_update_available());
828 assert!(checker.latest_version().is_none());
829 assert!(checker.get_cached_result().is_none());
830
831 drop(checker);
832 let _ = stop_tx.send(());
833 }
834}