Skip to main content

update_kit/planner/
mod.rs

1//! Planner module — determines the update strategy based on install channel,
2//! confidence level, and available assets.
3//!
4//! This is a **pure** module: no I/O, no side effects.
5
6use 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/// Options for [`plan_update`].
16#[derive(Debug, Clone, Default)]
17pub struct PlanUpdateOptions {
18    /// Override the target version. When set, plans for this version instead of `status.latest`.
19    pub target_version: Option<String>,
20    /// Release assets for native-in-place strategy.
21    pub assets: Option<Vec<AssetInfo>>,
22}
23
24/// Create an update plan based on version status, installation detection, and configuration.
25///
26/// Returns `None` if no update is needed (status is not `Available` and no `target_version`
27/// specified, or the current and target versions are identical).
28pub 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        // Explicit target version (upgrade or downgrade)
42        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(&current);
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    // Without explicit target, only proceed for Available status
70    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    // Merge assets: explicit options take precedence over status assets
84    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
103// ──────────────────────────────────────────────
104// Channel dispatch
105// ──────────────────────────────────────────────
106
107fn 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
128// ──────────────────────────────────────────────
129// Channel-specific resolvers
130// ──────────────────────────────────────────────
131
132fn 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
232// ──────────────────────────────────────────────
233// Shared native-in-place resolution
234// ──────────────────────────────────────────────
235
236fn 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
267// ──────────────────────────────────────────────
268// PostAction resolution
269// ──────────────────────────────────────────────
270
271fn 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
291// ──────────────────────────────────────────────
292// Asset selection
293// ──────────────────────────────────────────────
294
295/// Select the appropriate asset for the current platform/architecture.
296///
297/// Matching priority:
298/// 1. User-defined `asset_pattern` with placeholder expansion
299/// 2. Auto-matching by platform/architecture keywords
300pub 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    // Priority 1: user-defined placeholder pattern
311    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    // Priority 2: auto-match by platform + arch keywords
320    auto_match_asset(assets, platform, arch)
321}
322
323/// Expand a placeholder-based asset pattern into a [`Regex`].
324///
325/// Supported placeholders:
326/// - `{app}` — `config.app_name` (literal)
327/// - `{version}` — any semver-like string
328/// - `{target}` — platform-arch (e.g. "darwin-arm64")
329/// - `{platform}` — platform aliases joined with `|`
330/// - `{arch}` — arch aliases joined with `|`
331/// - `{ext}` — common archive extensions
332pub 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    // Escape regex special chars, but preserve our placeholder braces
350    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            // Don't escape braces — they are used for placeholders
358            _ => 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
393/// Get platform aliases for asset matching.
394///
395/// Accepts both Rust (`std::env::consts::OS`) and Node.js platform names.
396pub fn get_platform_aliases(platform: &str) -> Vec<&'static str> {
397    match platform {
398        // Rust OS name
399        "macos" | "darwin" => vec!["darwin", "macos", "mac", "osx", "apple"],
400        "linux" => vec!["linux"],
401        "windows" | "win32" => vec!["win32", "windows", "win64"],
402        _ => vec![], // Return empty for unknown; caller can handle
403    }
404}
405
406/// Get architecture aliases for asset matching.
407///
408/// Accepts both Rust (`std::env::consts::ARCH`) and Node.js arch names.
409pub fn get_arch_aliases(arch: &str) -> Vec<&'static str> {
410    match arch {
411        // Rust arch name
412        "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
419/// Return the current platform string using `std::env::consts::OS`.
420fn current_platform() -> &'static str {
421    std::env::consts::OS
422}
423
424/// Return the current architecture string using `std::env::consts::ARCH`.
425fn 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    // ── plan_update tests ──
475
476    #[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    // ── alias tests ──
611
612    #[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        // Rust OS name "macos" should also work
622        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        // Rust arch name "x86_64" should also work
634        let aliases2 = get_arch_aliases("x86_64");
635        assert_eq!(aliases, aliases2);
636    }
637
638    // ── asset selection tests ──
639
640    #[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    // ── expand_asset_pattern tests ──
666
667    #[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        // This will only be Reexec if the asset matched the current platform;
759        // force platform matching by using select_asset directly
760        match &plan.kind {
761            PlanKind::NativeInPlace { .. } => {
762                assert_eq!(plan.post_action, PostAction::Reexec);
763            }
764            PlanKind::ManualInstall { .. } => {
765                // Asset didn't match this platform, post_action is None
766                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}