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 agent = ureq::Agent::config_builder()
315 .timeout_global(Some(Duration::from_secs(5)))
316 .build()
317 .new_agent();
318 let response = agent
319 .get(url)
320 .header("User-Agent", "fresh-editor-update-checker")
321 .header("Accept", "application/vnd.github.v3+json")
322 .call()
323 .map_err(|e| {
324 tracing::debug!("HTTP request failed: {}", e);
325 format!("HTTP request failed: {}", e)
326 })?;
327
328 let body = response
329 .into_body()
330 .read_to_string()
331 .map_err(|e| format!("Failed to read response body: {}", e))?;
332
333 let version = parse_version_from_json(&body)?;
334 tracing::debug!("Latest version: {}", version);
335 Ok(version)
336}
337
338fn parse_version_from_json(json: &str) -> Result<String, String> {
340 let tag_name_key = "\"tag_name\"";
341 let start = json
342 .find(tag_name_key)
343 .ok_or_else(|| "tag_name not found in response".to_string())?;
344
345 let after_key = &json[start + tag_name_key.len()..];
346
347 let value_start = after_key
348 .find('"')
349 .ok_or_else(|| "Invalid JSON: missing quote after tag_name".to_string())?;
350
351 let value_content = &after_key[value_start + 1..];
352 let value_end = value_content
353 .find('"')
354 .ok_or_else(|| "Invalid JSON: unclosed quote".to_string())?;
355
356 let tag = &value_content[..value_end];
357
358 Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
360}
361
362pub fn detect_install_method() -> InstallMethod {
364 match env::current_exe() {
365 Ok(path) => detect_install_method_from_path(&path),
366 Err(_) => InstallMethod::Unknown,
367 }
368}
369
370pub fn detect_install_method_from_path(exe_path: &Path) -> InstallMethod {
372 let path_str = exe_path.to_string_lossy();
373
374 if path_str.contains("/opt/homebrew/")
376 || path_str.contains("/usr/local/Cellar/")
377 || path_str.contains("/home/linuxbrew/")
378 || path_str.contains("/.linuxbrew/")
379 {
380 return InstallMethod::Homebrew;
381 }
382
383 if path_str.contains("/.cargo/bin/") || path_str.contains("\\.cargo\\bin\\") {
385 return InstallMethod::Cargo;
386 }
387
388 if path_str.contains("/node_modules/")
390 || path_str.contains("\\node_modules\\")
391 || path_str.contains("/npm/")
392 || path_str.contains("/lib/node_modules/")
393 {
394 return InstallMethod::Npm;
395 }
396
397 if path_str.starts_with("/usr/bin/") && is_arch_linux() {
399 return InstallMethod::Aur;
400 }
401
402 if path_str.starts_with("/usr/bin/")
404 || path_str.starts_with("/usr/local/bin/")
405 || path_str.starts_with("/bin/")
406 {
407 return InstallMethod::PackageManager;
408 }
409
410 InstallMethod::Unknown
411}
412
413fn is_arch_linux() -> bool {
415 std::fs::read_to_string("/etc/os-release")
416 .map(|content| content.contains("Arch Linux") || content.contains("ID=arch"))
417 .unwrap_or(false)
418}
419
420pub fn is_newer_version(current: &str, latest: &str) -> bool {
423 let parse_version = |v: &str| -> Option<(u32, u32, u32)> {
424 let parts: Vec<&str> = v.split('.').collect();
425 if parts.len() >= 3 {
426 Some((
427 parts[0].parse().ok()?,
428 parts[1].parse().ok()?,
429 parts[2].split('-').next()?.parse().ok()?,
430 ))
431 } else if parts.len() == 2 {
432 Some((parts[0].parse().ok()?, parts[1].parse().ok()?, 0))
433 } else {
434 None
435 }
436 };
437
438 match (parse_version(current), parse_version(latest)) {
439 (Some((c_major, c_minor, c_patch)), Some((l_major, l_minor, l_patch))) => {
440 (l_major, l_minor, l_patch) > (c_major, c_minor, c_patch)
441 }
442 _ => false,
443 }
444}
445
446pub fn check_for_update(releases_url: &str) -> Result<ReleaseCheckResult, String> {
448 let latest_version = fetch_latest_version(releases_url)?;
449 let install_method = detect_install_method();
450 let update_available = is_newer_version(CURRENT_VERSION, &latest_version);
451
452 tracing::debug!(
453 current = CURRENT_VERSION,
454 latest = %latest_version,
455 update_available,
456 install_method = ?install_method,
457 "Release check complete"
458 );
459
460 Ok(ReleaseCheckResult {
461 latest_version,
462 update_available,
463 install_method,
464 })
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use std::path::PathBuf;
471
472 #[test]
473 fn test_is_newer_version_major() {
474 assert!(is_newer_version("0.1.26", "1.0.0"));
475 assert!(is_newer_version("1.0.0", "2.0.0"));
476 }
477
478 #[test]
479 fn test_is_newer_version_minor() {
480 assert!(is_newer_version("0.1.26", "0.2.0"));
481 assert!(is_newer_version("0.1.26", "0.2.26"));
482 }
483
484 #[test]
485 fn test_is_newer_version_patch() {
486 assert!(is_newer_version("0.1.26", "0.1.27"));
487 assert!(is_newer_version("0.1.26", "0.1.100"));
488 }
489
490 #[test]
491 fn test_is_newer_version_same() {
492 assert!(!is_newer_version("0.1.26", "0.1.26"));
493 }
494
495 #[test]
496 fn test_is_newer_version_older() {
497 assert!(!is_newer_version("0.1.26", "0.1.25"));
498 assert!(!is_newer_version("0.2.0", "0.1.26"));
499 assert!(!is_newer_version("1.0.0", "0.1.26"));
500 }
501
502 #[test]
503 fn test_is_newer_version_with_v_prefix() {
504 assert!(is_newer_version("0.1.26", "0.1.27"));
505 }
506
507 #[test]
508 fn test_is_newer_version_with_prerelease() {
509 assert!(is_newer_version("0.1.26-alpha", "0.1.27"));
510 assert!(is_newer_version("0.1.26", "0.1.27-beta"));
511 }
512
513 #[test]
514 fn test_detect_install_method_homebrew_macos() {
515 let path = PathBuf::from("/opt/homebrew/Cellar/fresh/0.1.26/bin/fresh");
516 assert_eq!(
517 detect_install_method_from_path(&path),
518 InstallMethod::Homebrew
519 );
520 }
521
522 #[test]
523 fn test_detect_install_method_homebrew_intel_mac() {
524 let path = PathBuf::from("/usr/local/Cellar/fresh/0.1.26/bin/fresh");
525 assert_eq!(
526 detect_install_method_from_path(&path),
527 InstallMethod::Homebrew
528 );
529 }
530
531 #[test]
532 fn test_detect_install_method_homebrew_linux() {
533 let path = PathBuf::from("/home/linuxbrew/.linuxbrew/bin/fresh");
534 assert_eq!(
535 detect_install_method_from_path(&path),
536 InstallMethod::Homebrew
537 );
538 }
539
540 #[test]
541 fn test_detect_install_method_cargo() {
542 let path = PathBuf::from("/home/user/.cargo/bin/fresh");
543 assert_eq!(detect_install_method_from_path(&path), InstallMethod::Cargo);
544 }
545
546 #[test]
547 fn test_detect_install_method_cargo_windows() {
548 let path = PathBuf::from("C:\\Users\\user\\.cargo\\bin\\fresh.exe");
549 assert_eq!(detect_install_method_from_path(&path), InstallMethod::Cargo);
550 }
551
552 #[test]
553 fn test_detect_install_method_npm() {
554 let path = PathBuf::from("/usr/local/lib/node_modules/fresh-editor/bin/fresh");
555 assert_eq!(detect_install_method_from_path(&path), InstallMethod::Npm);
556 }
557
558 #[test]
559 fn test_detect_install_method_package_manager() {
560 let path = PathBuf::from("/usr/local/bin/fresh");
561 assert_eq!(
562 detect_install_method_from_path(&path),
563 InstallMethod::PackageManager
564 );
565 }
566
567 #[test]
568 fn test_detect_install_method_unknown() {
569 let path = PathBuf::from("/home/user/downloads/fresh");
570 assert_eq!(
571 detect_install_method_from_path(&path),
572 InstallMethod::Unknown
573 );
574 }
575
576 #[test]
577 fn test_parse_version_from_json() {
578 let json = r#"{"tag_name": "v0.1.27", "name": "Release 0.1.27"}"#;
579 assert_eq!(parse_version_from_json(json).unwrap(), "0.1.27");
580 }
581
582 #[test]
583 fn test_parse_version_from_json_no_v_prefix() {
584 let json = r#"{"tag_name": "0.1.27", "name": "Release 0.1.27"}"#;
585 assert_eq!(parse_version_from_json(json).unwrap(), "0.1.27");
586 }
587
588 #[test]
589 fn test_parse_version_from_json_full_response() {
590 let json = r#"{
591 "url": "https://api.github.com/repos/sinelaw/fresh/releases/12345",
592 "tag_name": "v0.2.0",
593 "target_commitish": "main",
594 "name": "v0.2.0",
595 "draft": false,
596 "prerelease": false
597 }"#;
598 assert_eq!(parse_version_from_json(json).unwrap(), "0.2.0");
599 }
600
601 #[test]
602 fn test_current_version_is_valid() {
603 let parts: Vec<&str> = CURRENT_VERSION.split('.').collect();
604 assert!(parts.len() >= 2, "Version should have at least major.minor");
605 assert!(
606 parts[0].parse::<u32>().is_ok(),
607 "Major version should be a number"
608 );
609 assert!(
610 parts[1].parse::<u32>().is_ok(),
611 "Minor version should be a number"
612 );
613 }
614
615 #[test]
616 fn test_version_parsing_with_mock_data() {
617 let json = r#"{"tag_name": "v99.0.0"}"#;
618 let version = parse_version_from_json(json).unwrap();
619 assert!(is_newer_version(CURRENT_VERSION, &version));
620 }
621
622 use std::sync::mpsc as std_mpsc;
623
624 fn start_mock_release_server(version: &str) -> (std_mpsc::Sender<()>, String) {
627 let server = tiny_http::Server::http("127.0.0.1:0").expect("Failed to start test server");
628 let port = server.server_addr().to_ip().unwrap().port();
629 let url = format!("http://127.0.0.1:{}/releases/latest", port);
630
631 let (stop_tx, stop_rx) = std_mpsc::channel::<()>();
632
633 let version = version.to_string();
635 thread::spawn(move || {
636 loop {
637 if stop_rx.try_recv().is_ok() {
639 break;
640 }
641
642 match server.recv_timeout(Duration::from_millis(100)) {
644 Ok(Some(request)) => {
645 let response_body = format!(r#"{{"tag_name": "v{}"}}"#, version);
646 let response = tiny_http::Response::from_string(response_body).with_header(
647 tiny_http::Header::from_bytes(
648 &b"Content-Type"[..],
649 &b"application/json"[..],
650 )
651 .unwrap(),
652 );
653 let _ = request.respond(response);
654 }
655 Ok(None) => {
656 }
658 Err(_) => {
659 break;
661 }
662 }
663 }
664 });
665
666 (stop_tx, url)
667 }
668
669 #[test]
670 fn test_periodic_update_checker_with_local_server() {
671 let (stop_tx, url) = start_mock_release_server("99.0.0");
673 let time_source = super::super::time_source::TestTimeSource::shared();
674 let temp_dir = tempfile::tempdir().unwrap();
675
676 let mut checker = start_periodic_update_check_with_interval(
677 &url,
678 Duration::from_millis(50),
679 time_source,
680 temp_dir.path().to_path_buf(),
681 );
682
683 let start = Instant::now();
685 while start.elapsed() < Duration::from_secs(2) {
686 if checker.poll_result().is_some() {
687 break;
688 }
689 thread::sleep(Duration::from_millis(10));
690 }
691
692 assert!(
694 checker.is_update_available(),
695 "Should detect update available"
696 );
697 assert_eq!(checker.latest_version(), Some("99.0.0"));
698 assert!(checker.get_cached_result().is_some());
699
700 drop(checker);
701 let _ = stop_tx.send(());
702 }
703
704 #[test]
705 fn test_periodic_update_checker_shutdown_clean() {
706 let (stop_tx, url) = start_mock_release_server("99.0.0");
708 let time_source = super::super::time_source::TestTimeSource::shared();
709 let temp_dir = tempfile::tempdir().unwrap();
710
711 let checker = start_periodic_update_check_with_interval(
712 &url,
713 Duration::from_millis(50),
714 time_source,
715 temp_dir.path().to_path_buf(),
716 );
717
718 thread::sleep(Duration::from_millis(100));
720
721 let start = Instant::now();
723 drop(checker);
724 let elapsed = start.elapsed();
725
726 assert!(
728 elapsed < Duration::from_secs(2),
729 "Shutdown took too long: {:?}",
730 elapsed
731 );
732
733 let _ = stop_tx.send(());
734 }
735
736 #[test]
737 fn test_periodic_update_checker_multiple_cycles() {
738 let (stop_tx, url) = start_mock_release_server("99.0.0");
740 let time_source = super::super::time_source::TestTimeSource::shared();
741 let temp_dir = tempfile::tempdir().unwrap();
742
743 let mut checker = start_periodic_update_check_with_interval(
744 &url,
745 Duration::from_secs(86400),
746 time_source.clone(),
747 temp_dir.path().to_path_buf(),
748 );
749
750 let mut result_count = 0;
751 let start = Instant::now();
752 let timeout = Duration::from_secs(2);
753
754 while start.elapsed() < timeout && result_count < 1 {
756 if checker.poll_result().is_some() {
757 result_count += 1;
758 }
759 thread::sleep(Duration::from_millis(10));
760 }
761
762 time_source.advance(Duration::from_secs(86400));
764
765 let start2 = Instant::now();
767 while start2.elapsed() < timeout && result_count < 2 {
768 if checker.poll_result().is_some() {
769 result_count += 1;
770 }
771 thread::sleep(Duration::from_millis(10));
772 }
773
774 assert!(
775 result_count >= 2,
776 "Expected at least 2 results, got {}",
777 result_count
778 );
779
780 drop(checker);
781 let _ = stop_tx.send(());
782 }
783
784 #[test]
785 fn test_periodic_update_checker_no_update_when_current() {
786 let (stop_tx, url) = start_mock_release_server(CURRENT_VERSION);
788 let time_source = super::super::time_source::TestTimeSource::shared();
789 let temp_dir = tempfile::tempdir().unwrap();
790
791 let mut checker = start_periodic_update_check_with_interval(
792 &url,
793 Duration::from_secs(3600),
794 time_source,
795 temp_dir.path().to_path_buf(),
796 );
797
798 let start = Instant::now();
800 while start.elapsed() < Duration::from_secs(2) {
801 if checker.poll_result().is_some() {
802 break;
803 }
804 thread::sleep(Duration::from_millis(10));
805 }
806
807 assert!(!checker.is_update_available());
809 assert!(checker.latest_version().is_none()); assert!(checker.get_cached_result().is_some()); drop(checker);
813 let _ = stop_tx.send(());
814 }
815
816 #[test]
817 fn test_periodic_update_checker_api_before_result() {
818 let (stop_tx, url) = start_mock_release_server("99.0.0");
820 let time_source = super::super::time_source::TestTimeSource::shared();
821 let temp_dir = tempfile::tempdir().unwrap();
822
823 let checker = start_periodic_update_check_with_interval(
825 &url,
826 Duration::from_secs(3600),
827 time_source,
828 temp_dir.path().to_path_buf(),
829 );
830
831 assert!(!checker.is_update_available());
833 assert!(checker.latest_version().is_none());
834 assert!(checker.get_cached_result().is_none());
835
836 drop(checker);
837 let _ = stop_tx.send(());
838 }
839}