use clap::{ArgAction, Parser};
use crossterm::style::Stylize;
use std::path::PathBuf;
use super::install;
use crate::Result;
#[derive(Debug, Parser)]
pub struct Args {
file: Option<PathBuf>,
#[arg(short = 'y', long, action = ArgAction::SetTrue)]
assume_yes: bool,
#[arg(long)]
arch: Option<String>,
}
pub fn execute(args: Args, session: &scoop_rs::Session) -> Result<()> {
let input = match &args.file {
Some(path) => std::fs::read_to_string(path)?,
None => {
let mut input = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut input)?;
input
}
};
let (buckets, apps) = parse_export(&input)?;
if apps.is_empty() {
println!("Nothing to import.");
return Ok(());
}
if !buckets.is_empty() {
let existing: Vec<String> = scoop_rs::operation::bucket_list(session)?
.iter()
.map(|b| b.name().to_owned())
.collect();
for bucket in &buckets {
if existing
.iter()
.any(|e| e.eq_ignore_ascii_case(&bucket.name))
{
continue;
}
if bucket.source.is_empty() {
eprintln!(
"warning: bucket '{}' has no source URL; skipping",
bucket.name
);
continue;
}
println!("Adding bucket {}...", bucket.name);
if let Err(e) = scoop_rs::operation::bucket_add(session, &bucket.name, &bucket.source) {
eprintln!("warning: could not add bucket '{}': {e}", bucket.name);
}
}
}
let mut queries = vec![];
let mut held = vec![];
let mut skipped = vec![];
for app in &apps {
let name = app.name.as_str();
if name.is_empty() {
continue;
}
let bucket = app.bucket.as_str();
if bucket == "__isolated__" {
skipped.push(name.to_owned());
continue;
}
let arch = match args.arch.as_deref() {
Some(arch) => Some(arch.to_owned()),
None => app.arch.clone(),
};
let query = match bucket.is_empty() {
true => name.to_owned(),
false => format!("{bucket}/{name}"),
};
queries.push((arch, query));
if app.held {
held.push(name.to_owned());
}
}
if !skipped.is_empty() {
println!(
"{} {}",
"Skipping isolated apps (no bucket manifest):".yellow(),
skipped.join(", ")
);
}
if queries.is_empty() {
println!("Nothing installable to import.");
return Ok(());
}
println!("Importing {} app(s).", queries.len());
queries.sort_by(|a, b| a.0.cmp(&b.0));
let mut idx = 0;
while idx < queries.len() {
let arch = queries[idx].0.clone();
let mut end = idx;
while end < queries.len() && queries[end].0 == arch {
end += 1;
}
let group: Vec<String> = queries[idx..end]
.iter()
.map(|(_, query)| query.clone())
.collect();
install::execute(
install::Args::from_packages(group, args.assume_yes, arch),
session,
)?;
idx = end;
}
scoop_rs::arch::clear_override();
for name in &held {
if let Err(e) = scoop_rs::operation::package_hold(session, name, true) {
eprintln!("Could not re-hold '{}': {}", name, e);
}
}
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ExportBucket {
name: String,
source: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ExportApp {
name: String,
bucket: String,
arch: Option<String>,
held: bool,
}
fn str_field(obj: &serde_json::Map<String, serde_json::Value>, keys: &[&str]) -> String {
keys.iter()
.filter_map(|k| obj.get(*k)?.as_str())
.next()
.unwrap_or("")
.to_owned()
}
fn parse_export(input: &str) -> Result<(Vec<ExportBucket>, Vec<ExportApp>)> {
let value: serde_json::Value = serde_json::from_str(input)?;
let (buckets_value, apps_value) = match &value {
serde_json::Value::Object(map) => (
map.get("buckets").or(map.get("Buckets")),
map.get("apps")
.or(map.get("Apps"))
.cloned()
.unwrap_or(serde_json::Value::Array(vec![])),
),
serde_json::Value::Array(_) => (None, value.clone()),
_ => {
return Err(anyhow::anyhow!(
"export file must contain an app list (array, or {{\"buckets\", \"apps\"}} object)"
))
}
};
let mut buckets = Vec::new();
if let Some(list) = buckets_value.and_then(|v| v.as_array()) {
for entry in list {
let Some(obj) = entry.as_object() else {
continue;
};
let name = str_field(obj, &["name", "Name", "bucket"]);
if name.is_empty() || name == "__isolated__" {
continue;
}
buckets.push(ExportBucket {
name,
source: str_field(obj, &["source", "Source", "url", "URL"]),
});
}
}
let mut apps = Vec::new();
let list = apps_value.as_array().ok_or_else(|| {
anyhow::anyhow!(
"export file must contain an app list (array, or {{\"buckets\", \"apps\"}} object)"
)
})?;
for entry in list {
let Some(obj) = entry.as_object() else {
continue;
};
let name = str_field(obj, &["Name", "name"]);
if name.is_empty() {
continue;
}
let arch = str_field(obj, &["architecture", "Architecture"]);
apps.push(ExportApp {
name,
bucket: str_field(obj, &["Source", "source", "bucket"]),
arch: (!arch.is_empty()).then_some(arch),
held: obj
.get("held")
.or(obj.get("Held"))
.and_then(|v| v.as_bool())
.unwrap_or(false),
});
}
Ok((buckets, apps))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_upstream_export_shape() {
let input = r#"{
"buckets": [{"Name": "extras", "Source": "https://github.com/ScoopInstaller/Extras"}],
"apps": [
{"Name": "bat", "Version": "0.26.1", "Source": "extras", "Updated": "2026-01-01", "Info": ""},
{"Name": "", "Source": "main"}
]
}"#;
let (buckets, apps) = parse_export(input).unwrap();
assert_eq!(
buckets,
vec![ExportBucket {
name: "extras".to_owned(),
source: "https://github.com/ScoopInstaller/Extras".to_owned(),
}]
);
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].name, "bat");
assert_eq!(apps[0].bucket, "extras");
assert_eq!(apps[0].arch, None);
assert!(!apps[0].held);
}
#[test]
fn parses_legacy_array_shape() {
let input = r#"[
{"name": "zstd", "bucket": "main", "version": "1.5.7", "architecture": "64bit", "held": true},
{"name": "lonely", "bucket": "__isolated__", "version": "1.0", "architecture": "", "held": false}
]"#;
let (buckets, apps) = parse_export(input).unwrap();
assert!(buckets.is_empty());
assert_eq!(apps.len(), 2);
assert_eq!(apps[0].arch, Some("64bit".to_owned()));
assert!(apps[0].held);
assert_eq!(apps[1].bucket, "__isolated__");
}
#[test]
fn rejects_non_list_exports() {
assert!(parse_export("{}").unwrap().1.is_empty());
assert!(parse_export("42").is_err());
}
}