use std::collections::BTreeSet;
use std::path::PathBuf;
use std::process::Stdio;
use async_trait::async_trait;
use eyre::bail;
use serde::Deserialize;
use super::{InstallOpts, PackageRequest, PackageState, PackageStatus, SystemPackageManager};
use crate::result::Result;
const BUCKET_ALREADY_EXISTS: i32 = 2;
pub(crate) struct ScoopManager {}
impl ScoopManager {
pub(crate) fn new() -> Self {
Self {}
}
async fn refresh(&self, dry_run: bool) -> Result<()> {
apply(&refresh_args(), "update", dry_run, &[]).await
}
}
#[derive(Debug, Deserialize)]
struct ScoopExport {
#[serde(default)]
buckets: Vec<ScoopBucket>,
#[serde(default)]
apps: Vec<ScoopApp>,
}
#[derive(Debug, Deserialize)]
struct ScoopBucket {
#[serde(rename = "Name")]
name: String,
}
#[derive(Debug, Deserialize)]
struct ScoopApp {
#[serde(rename = "Name")]
name: String,
#[serde(rename = "Version")]
version: Option<String>,
#[serde(rename = "Info", default)]
info: String,
}
impl ScoopApp {
fn notes(&self) -> impl Iterator<Item = &str> {
self.info.split(',').map(str::trim)
}
fn failed(&self) -> bool {
self.notes().any(|note| note == "Install failed")
}
fn is_global(&self) -> bool {
self.notes().any(|note| note == "Global install")
}
}
fn split_bucket(name: &str) -> (Option<&str>, &str) {
let Some((bucket, app)) = name.split_once('/') else {
return (None, name);
};
let is_bucket = !bucket.is_empty()
&& !app.is_empty()
&& !app.contains('/')
&& bucket
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
if is_bucket {
(Some(bucket), app)
} else {
(None, name)
}
}
fn app_name(name: &str) -> &str {
split_bucket(name).1
}
fn unsupported_name(name: &str) -> bool {
let app = app_name(name);
app.contains('/') || app.contains('\\') || app.to_ascii_lowercase().ends_with(".json")
}
fn check_supported(pkgs: &[PackageRequest]) -> Result<()> {
let unsupported = pkgs
.iter()
.filter(|pkg| unsupported_name(&pkg.name))
.map(|pkg| format!("'{}'", pkg.name))
.collect::<Vec<_>>();
if unsupported.is_empty() {
return Ok(());
}
bail!(
"scoop: {} names a manifest URL or path; declare the app name Scoop installs it as, \
optionally qualified with a bucket",
unsupported.join(", ")
);
}
fn install_spec(request: &PackageRequest) -> String {
match &request.version {
Some(version) => format!("{}@{version}", request.name),
None => request.name.clone(),
}
}
fn install_args(specs: &[String]) -> Vec<String> {
let mut args = vec!["install".to_string(), "--no-update-scoop".to_string()];
args.extend(specs.iter().cloned());
args
}
fn upgrade_args(apps: &[String]) -> Vec<String> {
let mut args = vec!["update".to_string()];
args.extend(apps.iter().cloned());
args
}
fn uninstall_args(apps: &[String]) -> Vec<String> {
let mut args = vec!["uninstall".to_string()];
args.extend(apps.iter().cloned());
args
}
fn bucket_add_args(bucket: &str) -> Vec<String> {
vec!["bucket".to_string(), "add".to_string(), bucket.to_string()]
}
fn refresh_args() -> Vec<String> {
vec!["update".to_string()]
}
fn contains_ignore_case(names: &[String], name: &str) -> bool {
names.iter().any(|known| known.eq_ignore_ascii_case(name))
}
fn missing_buckets(pkgs: &[PackageRequest], export: &ScoopExport) -> Vec<String> {
let present = export
.buckets
.iter()
.map(|bucket| bucket.name.to_ascii_lowercase())
.collect::<BTreeSet<_>>();
let mut wanted = Vec::new();
for bucket in pkgs.iter().filter_map(|pkg| split_bucket(&pkg.name).0) {
let key = bucket.to_ascii_lowercase();
if !present.contains(&key) && !contains_ignore_case(&wanted, bucket) {
wanted.push(bucket.to_string());
}
}
wanted
}
fn pinned_reinstalls(pkgs: &[PackageRequest], export: &ScoopExport) -> Vec<String> {
let mut apps = Vec::new();
for pkg in pkgs {
let Some(pin) = &pkg.version else { continue };
let app = app_name(&pkg.name);
let Some(entry) = find_app(export, app) else {
continue;
};
if entry.is_global() {
continue;
}
if entry.version.as_deref() != Some(pin.as_str()) && !contains_ignore_case(&apps, app) {
apps.push(app.to_string());
}
}
apps
}
fn user_scope_apps(pkgs: &[PackageRequest], global: &[String]) -> Vec<String> {
let mut apps = Vec::new();
for pkg in pkgs {
let app = app_name(&pkg.name);
if !contains_ignore_case(global, app) && !contains_ignore_case(&apps, app) {
apps.push(app.to_string());
}
}
apps
}
fn has_global(export: &ScoopExport, app: &str) -> bool {
matching(export, app).any(ScoopApp::is_global)
}
fn has_local(export: &ScoopExport, app: &str) -> bool {
matching(export, app).any(|entry| !entry.is_global())
}
fn selected_apps(pkgs: &[PackageRequest], keep: impl Fn(&str) -> bool) -> Vec<String> {
let mut apps = Vec::new();
for pkg in pkgs {
let app = app_name(&pkg.name);
if keep(app) && !contains_ignore_case(&apps, app) {
apps.push(app.to_string());
}
}
apps
}
fn global_only(pkgs: &[PackageRequest], export: &ScoopExport) -> Vec<String> {
selected_apps(pkgs, |app| {
has_global(export, app) && !has_local(export, app)
})
}
fn global_copies(pkgs: &[PackageRequest], export: &ScoopExport) -> Vec<String> {
selected_apps(pkgs, |app| has_global(export, app))
}
fn matching<'a>(export: &'a ScoopExport, app: &str) -> impl Iterator<Item = &'a ScoopApp> {
let app = app.to_string();
export
.apps
.iter()
.filter(move |entry| entry.name.eq_ignore_ascii_case(&app))
}
fn find_app<'a>(export: &'a ScoopExport, app: &str) -> Option<&'a ScoopApp> {
let mut global = None;
for entry in matching(export, app) {
if !entry.is_global() {
return Some(entry);
}
global.get_or_insert(entry);
}
global
}
fn package_state(request: &PackageRequest, export: &ScoopExport) -> PackageState {
let Some(entry) = find_app(export, app_name(&request.name)) else {
return PackageState::Missing;
};
let installed = entry.version.clone();
if entry.failed() || installed.is_none() {
return PackageState::NeedsRepair {
installed: installed.unwrap_or_else(|| "unknown".to_string()),
};
}
let installed = installed.unwrap_or_default();
match &request.version {
Some(pin) if pin != &installed => PackageState::VersionMismatch { installed },
_ => PackageState::Installed { version: installed },
}
}
fn json_body(output: &str) -> &str {
let trimmed = output.trim_start_matches('\u{feff}').trim_start();
if trimmed.starts_with('{') {
return trimmed;
}
match trimmed.find("\n{") {
Some(offset) => &trimmed[offset + 1..],
None => trimmed,
}
}
fn parse_export(output: &str) -> Result<ScoopExport> {
let body = json_body(output);
serde_json::from_str(body).map_err(|err| {
eyre::eyre!(
"failed to parse `scoop export` output: {err}\n{}",
body.trim()
)
})
}
fn scoop_bin() -> Result<PathBuf> {
crate::file::which_spawnable("scoop").ok_or_else(|| eyre::eyre!("scoop not found on PATH"))
}
async fn export() -> Result<ScoopExport> {
let bin = scoop_bin()?;
debug!("$ scoop export");
let output = tokio::process::Command::new(&bin)
.arg("export")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await?;
let stdout = String::from_utf8_lossy(&output.stdout);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = [stdout.trim(), stderr.trim()]
.into_iter()
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("\n");
bail!("scoop export failed: {detail}");
}
parse_export(&stdout)
}
async fn run_scoop(args: &[String], action: &str, accepted_exit_codes: &[i32]) -> Result<()> {
let bin = scoop_bin()?;
debug!("$ scoop {}", args.join(" "));
let status = tokio::process::Command::new(&bin)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.await?;
let code = status.code();
if code != Some(0) && !code.is_some_and(|code| accepted_exit_codes.contains(&code)) {
bail!("scoop {action} failed with {status}");
}
Ok(())
}
async fn apply(
args: &[String],
action: &str,
dry_run: bool,
accepted_exit_codes: &[i32],
) -> Result<()> {
if dry_run {
miseprintln!("scoop {}", args.join(" "));
return Ok(());
}
run_scoop(args, action, accepted_exit_codes).await
}
#[async_trait(?Send)]
impl SystemPackageManager for ScoopManager {
fn name(&self) -> &str {
"scoop"
}
fn is_available(&self) -> bool {
cfg!(windows) && crate::file::which_spawnable("scoop").is_some()
}
fn unavailable_reason(&self) -> String {
if cfg!(windows) {
"scoop not found".to_string()
} else {
"only available on windows".to_string()
}
}
fn package_identity(&self, name: &str) -> Option<String> {
Some(app_name(name).to_ascii_lowercase())
}
async fn installed(&self, pkgs: &[PackageRequest]) -> Result<Vec<PackageStatus>> {
if pkgs.is_empty() {
return Ok(vec![]);
}
check_supported(pkgs)?;
let export = export().await?;
Ok(pkgs
.iter()
.map(|pkg| PackageStatus {
request: pkg.clone(),
state: package_state(pkg, &export),
})
.collect())
}
async fn install(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> {
if pkgs.is_empty() {
return Ok(());
}
let export = export().await?;
for bucket in missing_buckets(pkgs, &export) {
apply(
&bucket_add_args(&bucket),
&format!("bucket add {bucket}"),
opts.dry_run,
&[BUCKET_ALREADY_EXISTS],
)
.await?;
}
if opts.update {
self.refresh(opts.dry_run).await?;
}
let reinstall = pinned_reinstalls(pkgs, &export);
if !reinstall.is_empty() {
apply(&uninstall_args(&reinstall), "uninstall", opts.dry_run, &[]).await?;
}
let specs = pkgs.iter().map(install_spec).collect::<Vec<_>>();
apply(&install_args(&specs), "install", opts.dry_run, &[]).await
}
async fn upgrade(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> {
if pkgs.is_empty() {
return Ok(());
}
let (pinned, unpinned): (Vec<_>, Vec<_>) =
pkgs.iter().partition(|pkg| pkg.version.is_some());
for pkg in pinned {
warn!("scoop: '{pkg}' pins a version, skipping upgrade");
}
if unpinned.is_empty() {
return Ok(());
}
let unpinned = unpinned.into_iter().cloned().collect::<Vec<_>>();
let global = global_only(&unpinned, &export().await?);
for app in &global {
warn!("scoop: '{app}' is installed globally, skipping upgrade");
}
let apps = user_scope_apps(&unpinned, &global);
if apps.is_empty() {
return Ok(());
}
self.refresh(opts.dry_run).await?;
apply(&upgrade_args(&apps), "update", opts.dry_run, &[]).await
}
fn supports_remove(&self) -> bool {
true
}
async fn remove(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> {
if pkgs.is_empty() {
return Ok(());
}
let export = export().await?;
let apps = user_scope_apps(pkgs, &global_only(pkgs, &export));
if !apps.is_empty() {
apply(&uninstall_args(&apps), "uninstall", opts.dry_run, &[]).await?;
}
let global = global_copies(pkgs, &export);
if !global.is_empty() {
bail!(
"scoop: {} installed globally; mise manages the user scope only. \
Remove it with `scoop uninstall --global {}` from an elevated shell.",
global
.iter()
.map(|app| format!("'{app}'"))
.collect::<Vec<_>>()
.join(", "),
global.join(" ")
);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::system::packages::{PackageDesiredState, check_name_conflicts};
const EXPORT: &str = r#"{
"buckets": [
{
"Name": "main",
"Source": "https://github.com/ScoopInstaller/Main",
"Updated": "2026-09-15T10:00:00",
"Manifests": 1317
}
],
"apps": [
{
"Info": "",
"Source": "main",
"Name": "7zip",
"Updated": "2026-09-01T12:00:00",
"Version": "24.09"
},
{
"Info": "Held package",
"Source": "main",
"Name": "ripgrep",
"Updated": "2026-08-02T09:30:00",
"Version": "14.1.1"
}
]
}"#;
fn req(name: &str, version: Option<&str>) -> PackageRequest {
PackageRequest {
name: name.to_string(),
version: version.map(str::to_string),
tap_url: None,
desired: PackageDesiredState::Present,
}
}
fn absent(name: &str) -> PackageRequest {
PackageRequest {
desired: PackageDesiredState::Absent,
..req(name, None)
}
}
fn export_of(apps: &[(&str, Option<&str>, &str)], buckets: &[&str]) -> ScoopExport {
ScoopExport {
buckets: buckets
.iter()
.map(|name| ScoopBucket {
name: name.to_string(),
})
.collect(),
apps: apps
.iter()
.map(|(name, version, info)| ScoopApp {
name: name.to_string(),
version: version.map(str::to_string),
info: info.to_string(),
})
.collect(),
}
}
#[test]
fn splits_a_bucket_qualifier_but_leaves_a_manifest_url_alone() {
assert_eq!(split_bucket("ripgrep"), (None, "ripgrep"));
assert_eq!(split_bucket("extras/vscode"), (Some("extras"), "vscode"));
assert_eq!(
split_bucket("nerd-fonts/FiraCode-NF"),
(Some("nerd-fonts"), "FiraCode-NF")
);
let url = "https://example.com/bucket/runat.json";
assert_eq!(split_bucket(url), (None, url));
assert_eq!(split_bucket("extras/"), (None, "extras/"));
assert_eq!(split_bucket("/vscode"), (None, "/vscode"));
}
#[test]
fn a_manifest_url_or_path_is_rejected_rather_than_reinstalled_forever() {
for name in [
"https://example.com/bucket/runat.json",
r"C:\\manifests\\runat.json",
"runat.JSON",
] {
assert!(unsupported_name(name), "{name}");
}
for name in ["ripgrep", "extras/vscode", "nerd-fonts/FiraCode-NF"] {
assert!(!unsupported_name(name), "{name}");
}
let err = check_supported(&[
req("ripgrep", None),
req("https://example.com/bucket/runat.json", None),
])
.unwrap_err();
assert_eq!(
err.to_string(),
"scoop: 'https://example.com/bucket/runat.json' names a manifest URL or path; \
declare the app name Scoop installs it as, optionally qualified with a bucket"
);
check_supported(&[req("extras/vscode", Some("1.99.0"))]).unwrap();
}
#[test]
fn a_global_only_install_is_never_uninstalled_from_the_user_scope() {
let export = export_of(
&[
("git", Some("2.48.0"), "Global install"),
("ripgrep", Some("14.1.1"), ""),
],
&[],
);
assert_eq!(
pinned_reinstalls(
&[req("git", Some("2.51.0")), req("ripgrep", Some("13.0.0"))],
&export
),
vec!["ripgrep"]
);
assert_eq!(
global_only(&[req("git", None), req("ripgrep", None)], &export),
vec!["git"]
);
assert!(global_only(&[req("ripgrep", None), req("gh", None)], &export).is_empty());
}
#[test]
fn an_app_in_both_scopes_loses_its_local_copy_and_still_reports_the_global_one() {
let export = export_of(
&[
("git", Some("2.51.0"), ""),
("git", Some("2.48.0"), "Global install"),
("ripgrep", Some("14.1.1"), ""),
("gh", Some("2.60.0"), "Global install"),
],
&[],
);
let pkgs = [req("git", None), req("ripgrep", None), req("gh", None)];
assert_eq!(global_only(&pkgs, &export), vec!["gh"]);
assert_eq!(
user_scope_apps(&pkgs, &global_only(&pkgs, &export)),
vec!["git", "ripgrep"]
);
assert_eq!(global_copies(&pkgs, &export), vec!["git", "gh"]);
assert_eq!(
user_scope_apps(&pkgs, &global_only(&pkgs, &export)),
vec!["git", "ripgrep"]
);
assert!(has_global(&export, "git") && has_local(&export, "git"));
assert!(has_global(&export, "gh") && !has_local(&export, "gh"));
assert!(!has_global(&export, "ripgrep") && has_local(&export, "ripgrep"));
}
#[test]
fn one_app_declared_both_present_and_absent_is_rejected() {
let err = check_name_conflicts(
&ScoopManager::new(),
&[
req("ripgrep", None),
req("extras/Vscode", Some("1.99.0")),
absent("extras/vscode"),
],
)
.unwrap_err();
assert_eq!(
err.to_string(),
"[bootstrap.packages]: 'scoop:extras/Vscode' and 'scoop:extras/vscode' name the \
same package but ask for opposite states; declare it once"
);
for pkgs in [
vec![req("Git", Some("2.51.0")), req("git", Some("2.51.0"))],
vec![absent("git"), absent("Git")],
vec![req("git", None), absent("ripgrep")],
vec![req("extras/vscode", None), req("vscode", None)],
] {
check_name_conflicts(&ScoopManager::new(), &pkgs).unwrap();
}
}
#[test]
fn one_app_declared_at_two_versions_is_rejected() {
let err = check_name_conflicts(
&ScoopManager::new(),
&[req("Git", None), req("git", Some("2.51.0"))],
)
.unwrap_err();
assert_eq!(
err.to_string(),
"[bootstrap.packages]: 'scoop:Git' and 'scoop:git' name the same package but ask \
for different versions (latest and 2.51.0); declare it once"
);
let err = check_name_conflicts(
&ScoopManager::new(),
&[
req("extras/vscode", Some("1.99.0")),
req("Vscode", Some("1.100.0")),
],
)
.unwrap_err();
assert_eq!(
err.to_string(),
"[bootstrap.packages]: 'scoop:extras/vscode' and 'scoop:Vscode' name the same \
package but ask for different versions (1.99.0 and 1.100.0); declare it once"
);
check_name_conflicts(&ScoopManager::new(), &[absent("git"), absent("Git")]).unwrap();
}
#[test]
fn names_are_deduplicated_the_way_scoop_compares_them() {
let export = export_of(&[("git", Some("2.48.0"), "Global install")], &["main"]);
let pkgs = [
req("Extras/vscode", None),
req("extras/other", None),
req("Git", None),
req("git", None),
];
assert_eq!(missing_buckets(&pkgs, &export), vec!["Extras"]);
assert_eq!(global_only(&pkgs, &export), vec!["Git"]);
assert_eq!(global_copies(&pkgs, &export), vec!["Git"]);
assert_eq!(
user_scope_apps(&pkgs, &global_only(&pkgs, &export)),
vec!["vscode", "other"]
);
assert_eq!(
user_scope_apps(&[req("Git", None)], &["git".to_string()]),
Vec::<String>::new()
);
let installed = export_of(&[("ripgrep", Some("14.1.1"), "")], &[]);
assert_eq!(
pinned_reinstalls(
&[
req("RipGrep", Some("13.0.0")),
req("ripgrep", Some("13.0.0"))
],
&installed
),
vec!["RipGrep"]
);
}
#[test]
fn one_global_app_does_not_strand_the_rest_of_a_batch() {
let pkgs = [
req("git", None),
req("extras/vscode", None),
req("ripgrep", None),
];
let global = vec!["git".to_string()];
assert_eq!(user_scope_apps(&pkgs, &global), vec!["vscode", "ripgrep"]);
assert_eq!(
uninstall_args(&user_scope_apps(&pkgs, &global)),
vec!["uninstall", "vscode", "ripgrep"]
);
assert!(user_scope_apps(&pkgs, &["git", "vscode", "ripgrep"].map(String::from)).is_empty());
assert_eq!(
user_scope_apps(&pkgs, &[]),
vec!["git", "vscode", "ripgrep"]
);
}
#[test]
fn keeps_the_bucket_on_install_and_drops_it_for_installed_app_names() {
assert_eq!(install_spec(&req("extras/vscode", None)), "extras/vscode");
assert_eq!(
install_spec(&req("extras/vscode", Some("1.100.0"))),
"extras/vscode@1.100.0"
);
assert_eq!(app_name("extras/vscode"), "vscode");
assert_eq!(app_name("ripgrep"), "ripgrep");
}
#[test]
fn install_does_not_sync_scoop_as_a_side_effect() {
assert_eq!(
install_args(&["ripgrep".to_string(), "extras/vscode@1.100.0".to_string()]),
vec![
"install",
"--no-update-scoop",
"ripgrep",
"extras/vscode@1.100.0"
]
);
assert_eq!(refresh_args(), vec!["update"]);
}
#[test]
fn parses_installed_apps_and_buckets_from_scoop_export() {
let export = parse_export(EXPORT).unwrap();
assert_eq!(
export
.buckets
.iter()
.map(|bucket| bucket.name.as_str())
.collect::<Vec<_>>(),
vec!["main"]
);
assert_eq!(
package_state(&req("7zip", None), &export),
PackageState::Installed {
version: "24.09".to_string()
}
);
assert_eq!(
package_state(&req("ripgrep", Some("14.1.1")), &export),
PackageState::Installed {
version: "14.1.1".to_string()
}
);
assert_eq!(
package_state(&req("ripgrep", Some("13.0.0")), &export),
PackageState::VersionMismatch {
installed: "14.1.1".to_string()
}
);
assert_eq!(
package_state(&req("extras/vscode", None), &export),
PackageState::Missing
);
}
#[test]
fn an_empty_scoop_installation_parses_as_no_apps() {
let export = parse_export("{\r\n \"buckets\": [],\r\n \"apps\": []\r\n}").unwrap();
assert_eq!(
package_state(&req("ripgrep", None), &export),
PackageState::Missing
);
assert!(missing_buckets(&[req("extras/vscode", None)], &export) == vec!["extras"]);
}
#[test]
fn a_line_printed_before_the_document_does_not_break_parsing() {
let noisy = format!("\u{feff}Updating Scoop...\n{EXPORT}");
assert_eq!(
package_state(&req("7zip", None), &parse_export(&noisy).unwrap()),
PackageState::Installed {
version: "24.09".to_string()
}
);
}
#[test]
fn unparseable_output_reports_what_scoop_printed() {
let err = parse_export("scoop is not recognized").unwrap_err();
assert!(err.to_string().contains("scoop is not recognized"), "{err}");
}
#[test]
fn a_failed_or_versionless_install_needs_repair() {
let export = export_of(
&[
("neovim", Some("0.11.0"), "Install failed"),
("gh", None, ""),
],
&[],
);
assert_eq!(
package_state(&req("neovim", None), &export),
PackageState::NeedsRepair {
installed: "0.11.0".to_string()
}
);
assert_eq!(
package_state(&req("gh", Some("2.60.0")), &export),
PackageState::NeedsRepair {
installed: "unknown".to_string()
}
);
}
#[test]
fn a_local_install_wins_over_a_global_one_with_the_same_name() {
let export = export_of(
&[
("git", Some("2.48.0"), "Global install"),
("git", Some("2.51.0"), ""),
],
&[],
);
assert_eq!(
package_state(&req("git", None), &export),
PackageState::Installed {
version: "2.51.0".to_string()
}
);
let global_only = export_of(&[("git", Some("2.48.0"), "Global install")], &[]);
assert_eq!(
package_state(&req("git", None), &global_only),
PackageState::Installed {
version: "2.48.0".to_string()
}
);
}
#[test]
fn only_buckets_scoop_is_missing_are_added_once_each() {
let export = export_of(&[], &["main", "Extras"]);
assert_eq!(
missing_buckets(
&[
req("ripgrep", None),
req("extras/vscode", None),
req("versions/go1.21", None),
req("versions/python27", None),
],
&export
),
vec!["versions"]
);
}
#[test]
fn a_pin_that_differs_from_the_installed_version_uninstalls_first() {
let export = export_of(
&[
("ripgrep", Some("14.1.1"), ""),
("vscode", Some("1.100.0"), ""),
("gh", None, "Install failed"),
],
&[],
);
assert_eq!(
pinned_reinstalls(
&[
req("ripgrep", Some("14.1.1")),
req("extras/vscode", Some("1.99.0")),
req("gh", Some("2.60.0")),
req("neovim", Some("0.11.0")),
req("7zip", None),
],
&export
),
vec!["vscode", "gh"]
);
assert_eq!(
uninstall_args(&["vscode".to_string()]),
vec!["uninstall", "vscode"]
);
}
#[test]
fn upgrade_targets_bare_app_names() {
assert_eq!(
upgrade_args(&["ripgrep".to_string(), "vscode".to_string()]),
vec!["update", "ripgrep", "vscode"]
);
assert_eq!(bucket_add_args("extras"), vec!["bucket", "add", "extras"]);
}
#[tokio::test]
async fn upgrade_skips_pinned_entries_without_running_scoop() {
let manager = ScoopManager::new();
manager.upgrade(&[], &InstallOpts::default()).await.unwrap();
manager
.upgrade(
&[req("extras/vscode", Some("1.99.0"))],
&InstallOpts::default(),
)
.await
.unwrap();
}
#[tokio::test]
async fn empty_batches_never_run_scoop() {
let manager = ScoopManager::new();
assert!(manager.installed(&[]).await.unwrap().is_empty());
manager.install(&[], &InstallOpts::default()).await.unwrap();
manager.remove(&[], &InstallOpts::default()).await.unwrap();
}
#[test]
fn is_a_windows_only_manager_that_supports_removal() {
let manager = ScoopManager::new();
assert_eq!(manager.name(), "scoop");
assert!(manager.supports_remove());
assert!(manager.supports_version_pins());
if cfg!(windows) {
assert_eq!(manager.unavailable_reason(), "scoop not found");
} else {
assert!(!manager.is_available());
assert_eq!(manager.unavailable_reason(), "only available on windows");
}
}
}