1use regex::Regex;
7
8use crate::checker::normalize_version;
9use crate::config::ResolvedConfig;
10use crate::types::{
11 AssetInfo, Channel, Confidence, DelegateMode, InstallDetection, PlanKind, PostAction,
12 UpdatePlan, UpdateStatus,
13};
14
15#[derive(Debug, Clone, Default)]
17pub struct PlanUpdateOptions {
18 pub target_version: Option<String>,
20 pub assets: Option<Vec<AssetInfo>>,
22}
23
24pub fn plan_update(
29 status: &UpdateStatus,
30 detection: &InstallDetection,
31 config: &ResolvedConfig,
32 options: Option<PlanUpdateOptions>,
33) -> Option<UpdatePlan> {
34 let opts = options.unwrap_or_default();
35 let PlanUpdateOptions {
36 target_version,
37 assets,
38 } = opts;
39
40 if let Some(ref target) = target_version {
41 let current = match status {
43 UpdateStatus::Available { current, .. } => current.clone(),
44 UpdateStatus::UpToDate { current } => current.clone(),
45 UpdateStatus::Unknown { .. } => config.current_version.to_string(),
46 };
47
48 let normalized_current = normalize_version(¤t);
49 let normalized_target = normalize_version(target);
50 if let (Some(nc), Some(nt)) = (&normalized_current, &normalized_target) {
51 if nc == nt {
52 return None;
53 }
54 }
55
56 let channel = &detection.channel;
57 let confidence = detection.confidence;
58 let kind = resolve_plan_kind(channel, confidence, target, config, assets.as_deref());
59 let post_action = resolve_post_action(&kind, confidence, config);
60
61 return Some(UpdatePlan {
62 kind,
63 from_version: current,
64 to_version: target.clone(),
65 post_action,
66 });
67 }
68
69 let (from_version, to_version, status_assets) = match status {
71 UpdateStatus::Available {
72 current,
73 latest,
74 assets: sa,
75 ..
76 } => (current.clone(), latest.clone(), sa.clone()),
77 _ => return None,
78 };
79
80 let channel = &detection.channel;
81 let confidence = detection.confidence;
82
83 let effective_assets = assets.or(status_assets);
85
86 let kind = resolve_plan_kind(
87 channel,
88 confidence,
89 &to_version,
90 config,
91 effective_assets.as_deref(),
92 );
93 let post_action = resolve_post_action(&kind, confidence, config);
94
95 Some(UpdatePlan {
96 kind,
97 from_version,
98 to_version,
99 post_action,
100 })
101}
102
103fn resolve_plan_kind(
108 channel: &Channel,
109 confidence: Confidence,
110 to_version: &str,
111 config: &ResolvedConfig,
112 assets: Option<&[AssetInfo]>,
113) -> PlanKind {
114 match channel {
115 Channel::Native => resolve_native_channel(confidence, config, assets),
116 Channel::Unmanaged => resolve_unmanaged_channel(confidence, config, assets),
117 Channel::NpmGlobal => resolve_npm_channel(confidence, to_version, config),
118 Channel::BrewCask => resolve_brew_channel(confidence, config),
119 Channel::Custom(name) => PlanKind::ManualInstall {
120 reason: format!("Unknown install channel: {}", name),
121 instructions: "Please update manually using your original installation method."
122 .to_string(),
123 download_url: None,
124 },
125 }
126}
127
128fn resolve_native_channel(
133 confidence: Confidence,
134 config: &ResolvedConfig,
135 assets: Option<&[AssetInfo]>,
136) -> PlanKind {
137 if confidence == Confidence::Low {
138 return PlanKind::ManualInstall {
139 reason: "Low confidence in native channel detection.".to_string(),
140 instructions: "Please download and install the update manually.".to_string(),
141 download_url: assets.and_then(|a| a.first().map(|x| x.url.clone())),
142 };
143 }
144
145 resolve_native_in_place(assets, config)
146}
147
148fn resolve_unmanaged_channel(
149 confidence: Confidence,
150 config: &ResolvedConfig,
151 assets: Option<&[AssetInfo]>,
152) -> PlanKind {
153 if confidence == Confidence::None {
154 return PlanKind::ManualInstall {
155 reason: "Unable to detect installation method.".to_string(),
156 instructions: "Please reinstall using your preferred installation method.".to_string(),
157 download_url: None,
158 };
159 }
160
161 resolve_native_in_place(assets, config)
162}
163
164fn resolve_npm_channel(
165 confidence: Confidence,
166 to_version: &str,
167 config: &ResolvedConfig,
168) -> PlanKind {
169 let package_name = config
170 .base
171 .npm_package_name
172 .as_deref()
173 .unwrap_or(&config.app_name);
174
175 if confidence == Confidence::Low {
176 return PlanKind::ManualInstall {
177 reason: "Low confidence in npm-global detection.".to_string(),
178 instructions: format!("Please update manually: npm install -g {}", package_name),
179 download_url: None,
180 };
181 }
182
183 let mode = config
184 .base
185 .delegate_mode
186 .unwrap_or(DelegateMode::PrintOnly);
187
188 PlanKind::DelegateCommand {
189 channel: Channel::NpmGlobal,
190 command: vec![
191 "npm".to_string(),
192 "install".to_string(),
193 "-g".to_string(),
194 format!("{}@{}", package_name, to_version),
195 ],
196 mode,
197 }
198}
199
200fn resolve_brew_channel(confidence: Confidence, config: &ResolvedConfig) -> PlanKind {
201 let cask_name = config
202 .base
203 .brew_cask_name
204 .as_deref()
205 .unwrap_or(&config.app_name);
206
207 if confidence == Confidence::Low {
208 return PlanKind::ManualInstall {
209 reason: "Low confidence in brew-cask detection.".to_string(),
210 instructions: format!("Please update manually: brew upgrade --cask {}", cask_name),
211 download_url: None,
212 };
213 }
214
215 let mode = config
216 .base
217 .delegate_mode
218 .unwrap_or(DelegateMode::PrintOnly);
219
220 PlanKind::DelegateCommand {
221 channel: Channel::BrewCask,
222 command: vec![
223 "brew".to_string(),
224 "upgrade".to_string(),
225 "--cask".to_string(),
226 cask_name.to_string(),
227 ],
228 mode,
229 }
230}
231
232fn resolve_native_in_place(assets: Option<&[AssetInfo]>, config: &ResolvedConfig) -> PlanKind {
237 let assets = match assets {
238 Some(a) if !a.is_empty() => a,
239 _ => {
240 return PlanKind::ManualInstall {
241 reason: "No release assets available.".to_string(),
242 instructions:
243 "Please check the project website for installation instructions.".to_string(),
244 download_url: None,
245 };
246 }
247 };
248
249 let platform = current_platform();
250 let arch = current_arch();
251
252 match select_asset(assets, config, platform, arch) {
253 Some(selected) => PlanKind::NativeInPlace {
254 download_url: selected.url.clone(),
255 checksum_url: selected.checksum_url.clone(),
256 expected_checksum: None,
257 },
258 None => PlanKind::ManualInstall {
259 reason: format!("No compatible asset found for {}/{}.", platform, arch),
260 instructions:
261 "Please download and install the update manually for your platform.".to_string(),
262 download_url: assets.first().map(|a| a.url.clone()),
263 },
264 }
265}
266
267fn resolve_post_action(
272 kind: &PlanKind,
273 confidence: Confidence,
274 config: &ResolvedConfig,
275) -> PostAction {
276 match kind {
277 PlanKind::NativeInPlace { .. } => {
278 if confidence == Confidence::High
279 && config.base.allow_reexec.unwrap_or(false)
280 {
281 PostAction::Reexec
282 } else {
283 PostAction::SuggestRestart
284 }
285 }
286 PlanKind::DelegateCommand { .. } => PostAction::ExitAfterApply,
287 PlanKind::ManualInstall { .. } => PostAction::None,
288 }
289}
290
291pub fn select_asset<'a>(
301 assets: &'a [AssetInfo],
302 config: &ResolvedConfig,
303 platform: &str,
304 arch: &str,
305) -> Option<&'a AssetInfo> {
306 if assets.is_empty() {
307 return None;
308 }
309
310 if let Some(ref pattern) = config.base.asset_pattern {
312 if let Ok(regex) = expand_asset_pattern(pattern, config, platform, arch) {
313 if let Some(matched) = assets.iter().find(|a| regex.is_match(&a.name)) {
314 return Some(matched);
315 }
316 }
317 }
318
319 auto_match_asset(assets, platform, arch)
321}
322
323pub fn expand_asset_pattern(
333 pattern: &str,
334 config: &ResolvedConfig,
335 platform: &str,
336 arch: &str,
337) -> Result<Regex, String> {
338 if pattern.len() > 256 {
339 return Err(format!(
340 "Asset pattern is too long ({} chars, max 256)",
341 pattern.len()
342 ));
343 }
344
345 let platform_group = get_platform_aliases(platform).join("|");
346 let arch_group = get_arch_aliases(arch).join("|");
347 let target_group = format!("({})[_-]({})", platform_group, arch_group);
348
349 let mut escaped = String::with_capacity(pattern.len() * 2);
351 for ch in pattern.chars() {
352 match ch {
353 '.' | '+' | '?' | '^' | '$' | '(' | ')' | '|' | '[' | ']' | '\\' | '*' => {
354 escaped.push('\\');
355 escaped.push(ch);
356 }
357 _ => escaped.push(ch),
359 }
360 }
361
362 let expanded = escaped
363 .replace("{app}", &escape_regex(&config.app_name))
364 .replace("{version}", "[\\w.+-]+")
365 .replace("{target}", &target_group)
366 .replace("{platform}", &format!("({})", platform_group))
367 .replace("{arch}", &format!("({})", arch_group))
368 .replace(
369 "{ext}",
370 "(tar\\.gz|tgz|zip|gz|dmg|exe|msi|deb|rpm|pkg)",
371 );
372
373 let full = format!("(?i)^{}$", expanded);
374 Regex::new(&full).map_err(|e| format!("Invalid asset pattern \"{}\": {}", pattern, e))
375}
376
377fn auto_match_asset<'a>(
378 assets: &'a [AssetInfo],
379 platform: &str,
380 arch: &str,
381) -> Option<&'a AssetInfo> {
382 let platform_aliases = get_platform_aliases(platform);
383 let arch_aliases = get_arch_aliases(arch);
384
385 assets.iter().find(|asset| {
386 let name = asset.name.to_lowercase();
387 let matches_platform = platform_aliases.iter().any(|alias| name.contains(alias));
388 let matches_arch = arch_aliases.iter().any(|alias| name.contains(alias));
389 matches_platform && matches_arch
390 })
391}
392
393pub fn get_platform_aliases(platform: &str) -> Vec<&'static str> {
397 match platform {
398 "macos" | "darwin" => vec!["darwin", "macos", "mac", "osx", "apple"],
400 "linux" => vec!["linux"],
401 "windows" | "win32" => vec!["win32", "windows", "win64"],
402 _ => vec![], }
404}
405
406pub fn get_arch_aliases(arch: &str) -> Vec<&'static str> {
410 match arch {
411 "x86_64" | "x64" => vec!["x64", "x86_64", "amd64"],
413 "aarch64" | "arm64" => vec!["arm64", "aarch64"],
414 "x86" | "ia32" | "i386" | "i686" => vec!["ia32", "x86", "i386", "i686"],
415 _ => vec![],
416 }
417}
418
419fn current_platform() -> &'static str {
421 std::env::consts::OS
422}
423
424fn current_arch() -> &'static str {
426 std::env::consts::ARCH
427}
428
429fn escape_regex(s: &str) -> String {
430 let mut out = String::with_capacity(s.len() * 2);
431 for ch in s.chars() {
432 match ch {
433 '.' | '*' | '+' | '?' | '^' | '$' | '{' | '}' | '(' | ')' | '|' | '[' | ']'
434 | '\\' => {
435 out.push('\\');
436 out.push(ch);
437 }
438 _ => out.push(ch),
439 }
440 }
441 out
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use crate::config::BaseConfig;
448
449 fn make_config() -> ResolvedConfig {
450 ResolvedConfig {
451 app_name: "my-app".to_string(),
452 current_version: semver::Version::new(1, 0, 0),
453 base: BaseConfig::default(),
454 }
455 }
456
457 fn make_detection(channel: Channel, confidence: Confidence) -> InstallDetection {
458 InstallDetection {
459 channel,
460 confidence,
461 evidence: vec![],
462 }
463 }
464
465 fn make_asset(name: &str, url: &str) -> AssetInfo {
466 AssetInfo {
467 name: name.to_string(),
468 url: url.to_string(),
469 size: None,
470 checksum_url: None,
471 }
472 }
473
474 #[test]
477 fn up_to_date_returns_none() {
478 let status = UpdateStatus::UpToDate {
479 current: "1.0.0".into(),
480 };
481 let detection = make_detection(Channel::Native, Confidence::High);
482 let config = make_config();
483
484 let plan = plan_update(&status, &detection, &config, None);
485 assert!(plan.is_none());
486 }
487
488 #[test]
489 fn native_high_confidence_gives_native_in_place() {
490 let assets = vec![make_asset(
491 "my-app-darwin-arm64.tar.gz",
492 "https://example.com/my-app-darwin-arm64.tar.gz",
493 )];
494 let status = UpdateStatus::Available {
495 current: "1.0.0".into(),
496 latest: "2.0.0".into(),
497 release_url: None,
498 release_notes: None,
499 assets: Some(assets),
500 };
501 let detection = make_detection(Channel::Native, Confidence::High);
502 let config = make_config();
503
504 let plan = plan_update(&status, &detection, &config, None).unwrap();
505 assert_eq!(plan.from_version, "1.0.0");
506 assert_eq!(plan.to_version, "2.0.0");
507 match &plan.kind {
508 PlanKind::NativeInPlace { download_url, .. } => {
509 assert!(download_url.contains("darwin-arm64"));
510 }
511 other => panic!("expected NativeInPlace, got {:?}", other),
512 }
513 }
514
515 #[test]
516 fn npm_channel_gives_delegate() {
517 let status = UpdateStatus::Available {
518 current: "1.0.0".into(),
519 latest: "2.0.0".into(),
520 release_url: None,
521 release_notes: None,
522 assets: None,
523 };
524 let detection = make_detection(Channel::NpmGlobal, Confidence::High);
525 let config = make_config();
526
527 let plan = plan_update(&status, &detection, &config, None).unwrap();
528 match &plan.kind {
529 PlanKind::DelegateCommand {
530 channel, command, ..
531 } => {
532 assert_eq!(*channel, Channel::NpmGlobal);
533 assert_eq!(command[0], "npm");
534 assert!(command[3].contains("2.0.0"));
535 }
536 other => panic!("expected DelegateCommand, got {:?}", other),
537 }
538 assert_eq!(plan.post_action, PostAction::ExitAfterApply);
539 }
540
541 #[test]
542 fn brew_channel_gives_delegate() {
543 let status = UpdateStatus::Available {
544 current: "1.0.0".into(),
545 latest: "2.0.0".into(),
546 release_url: None,
547 release_notes: None,
548 assets: None,
549 };
550 let detection = make_detection(Channel::BrewCask, Confidence::High);
551 let config = make_config();
552
553 let plan = plan_update(&status, &detection, &config, None).unwrap();
554 match &plan.kind {
555 PlanKind::DelegateCommand {
556 channel, command, ..
557 } => {
558 assert_eq!(*channel, Channel::BrewCask);
559 assert_eq!(command[0], "brew");
560 assert_eq!(command[2], "--cask");
561 }
562 other => panic!("expected DelegateCommand, got {:?}", other),
563 }
564 assert_eq!(plan.post_action, PostAction::ExitAfterApply);
565 }
566
567 #[test]
568 fn low_confidence_gives_manual() {
569 let status = UpdateStatus::Available {
570 current: "1.0.0".into(),
571 latest: "2.0.0".into(),
572 release_url: None,
573 release_notes: None,
574 assets: None,
575 };
576 let detection = make_detection(Channel::Native, Confidence::Low);
577 let config = make_config();
578
579 let plan = plan_update(&status, &detection, &config, None).unwrap();
580 match &plan.kind {
581 PlanKind::ManualInstall { reason, .. } => {
582 assert!(reason.contains("Low confidence"));
583 }
584 other => panic!("expected ManualInstall, got {:?}", other),
585 }
586 assert_eq!(plan.post_action, PostAction::None);
587 }
588
589 #[test]
590 fn no_assets_gives_manual() {
591 let status = UpdateStatus::Available {
592 current: "1.0.0".into(),
593 latest: "2.0.0".into(),
594 release_url: None,
595 release_notes: None,
596 assets: None,
597 };
598 let detection = make_detection(Channel::Native, Confidence::High);
599 let config = make_config();
600
601 let plan = plan_update(&status, &detection, &config, None).unwrap();
602 match &plan.kind {
603 PlanKind::ManualInstall { reason, .. } => {
604 assert!(reason.contains("No release assets"));
605 }
606 other => panic!("expected ManualInstall, got {:?}", other),
607 }
608 }
609
610 #[test]
613 fn platform_aliases_darwin() {
614 let aliases = get_platform_aliases("darwin");
615 assert!(aliases.contains(&"darwin"));
616 assert!(aliases.contains(&"macos"));
617 assert!(aliases.contains(&"mac"));
618 assert!(aliases.contains(&"osx"));
619 assert!(aliases.contains(&"apple"));
620
621 let aliases2 = get_platform_aliases("macos");
623 assert_eq!(aliases, aliases2);
624 }
625
626 #[test]
627 fn arch_aliases_x64() {
628 let aliases = get_arch_aliases("x64");
629 assert!(aliases.contains(&"x64"));
630 assert!(aliases.contains(&"x86_64"));
631 assert!(aliases.contains(&"amd64"));
632
633 let aliases2 = get_arch_aliases("x86_64");
635 assert_eq!(aliases, aliases2);
636 }
637
638 #[test]
641 fn select_asset_matches_platform_arch() {
642 let assets = vec![
643 make_asset("my-app-linux-x64.tar.gz", "https://example.com/linux-x64"),
644 make_asset(
645 "my-app-darwin-arm64.tar.gz",
646 "https://example.com/darwin-arm64",
647 ),
648 make_asset(
649 "my-app-windows-x64.zip",
650 "https://example.com/windows-x64",
651 ),
652 ];
653 let config = make_config();
654
655 let selected = select_asset(&assets, &config, "darwin", "arm64").unwrap();
656 assert_eq!(selected.name, "my-app-darwin-arm64.tar.gz");
657
658 let selected = select_asset(&assets, &config, "linux", "x64").unwrap();
659 assert_eq!(selected.name, "my-app-linux-x64.tar.gz");
660
661 let selected = select_asset(&assets, &config, "windows", "x64").unwrap();
662 assert_eq!(selected.name, "my-app-windows-x64.zip");
663 }
664
665 #[test]
668 fn expand_asset_pattern_basic() {
669 let config = make_config();
670 let regex = expand_asset_pattern("{app}-{version}-{platform}-{arch}.{ext}", &config, "darwin", "arm64").unwrap();
671
672 assert!(regex.is_match("my-app-1.2.3-darwin-arm64.tar.gz"));
673 assert!(regex.is_match("my-app-2.0.0-macos-aarch64.zip"));
674 assert!(!regex.is_match("other-app-1.0.0-darwin-arm64.tar.gz"));
675 }
676
677 #[test]
678 fn target_version_same_returns_none() {
679 let status = UpdateStatus::UpToDate {
680 current: "1.0.0".into(),
681 };
682 let detection = make_detection(Channel::Native, Confidence::High);
683 let config = make_config();
684
685 let plan = plan_update(
686 &status,
687 &detection,
688 &config,
689 Some(PlanUpdateOptions {
690 target_version: Some("1.0.0".into()),
691 assets: None,
692 }),
693 );
694 assert!(plan.is_none());
695 }
696
697 #[test]
698 fn target_version_different_returns_plan() {
699 let status = UpdateStatus::UpToDate {
700 current: "1.0.0".into(),
701 };
702 let detection = make_detection(Channel::NpmGlobal, Confidence::High);
703 let config = make_config();
704
705 let plan = plan_update(
706 &status,
707 &detection,
708 &config,
709 Some(PlanUpdateOptions {
710 target_version: Some("2.0.0".into()),
711 assets: None,
712 }),
713 )
714 .unwrap();
715 assert_eq!(plan.from_version, "1.0.0");
716 assert_eq!(plan.to_version, "2.0.0");
717 }
718
719 #[test]
720 fn unmanaged_none_confidence_gives_manual() {
721 let status = UpdateStatus::Available {
722 current: "1.0.0".into(),
723 latest: "2.0.0".into(),
724 release_url: None,
725 release_notes: None,
726 assets: None,
727 };
728 let detection = make_detection(Channel::Unmanaged, Confidence::None);
729 let config = make_config();
730
731 let plan = plan_update(&status, &detection, &config, None).unwrap();
732 match &plan.kind {
733 PlanKind::ManualInstall { reason, .. } => {
734 assert!(reason.contains("Unable to detect"));
735 }
736 other => panic!("expected ManualInstall, got {:?}", other),
737 }
738 }
739
740 #[test]
741 fn native_high_with_reexec_gives_reexec_post_action() {
742 let assets = vec![make_asset(
743 "my-app-darwin-arm64.tar.gz",
744 "https://example.com/asset",
745 )];
746 let status = UpdateStatus::Available {
747 current: "1.0.0".into(),
748 latest: "2.0.0".into(),
749 release_url: None,
750 release_notes: None,
751 assets: Some(assets),
752 };
753 let detection = make_detection(Channel::Native, Confidence::High);
754 let mut config = make_config();
755 config.base.allow_reexec = Some(true);
756
757 let plan = plan_update(&status, &detection, &config, None).unwrap();
758 match &plan.kind {
761 PlanKind::NativeInPlace { .. } => {
762 assert_eq!(plan.post_action, PostAction::Reexec);
763 }
764 PlanKind::ManualInstall { .. } => {
765 assert_eq!(plan.post_action, PostAction::None);
767 }
768 other => panic!("unexpected kind: {:?}", other),
769 }
770 }
771
772 #[test]
773 fn custom_channel_gives_manual() {
774 let status = UpdateStatus::Available {
775 current: "1.0.0".into(),
776 latest: "2.0.0".into(),
777 release_url: None,
778 release_notes: None,
779 assets: None,
780 };
781 let detection = make_detection(Channel::Custom("snap".into()), Confidence::High);
782 let config = make_config();
783
784 let plan = plan_update(&status, &detection, &config, None).unwrap();
785 match &plan.kind {
786 PlanKind::ManualInstall { reason, .. } => {
787 assert!(reason.contains("snap"));
788 }
789 other => panic!("expected ManualInstall, got {:?}", other),
790 }
791 }
792}