use crate::version::Version;
use crate::vpm::structs::package::PackageJson;
use crate::vpm::structs::repository::Repository;
use crate::vpm::{AddPackageRequest, download_remote_repository, Environment, PackageInfo, UnityProject, VersionSelector};
use clap::{Parser, Subcommand, Args};
use reqwest::Url;
use serde::Serialize;
use std::collections::HashMap;
use std::error::Error as StdError;
use std::ffi::{OsStr, OsString};
use std::fmt::{Debug, Display};
use std::num::NonZeroU32;
use std::path::{Path, PathBuf};
use std::process::exit;
use std::str::FromStr;
use indexmap::IndexMap;
use reqwest::header::{HeaderName, HeaderValue, InvalidHeaderName, InvalidHeaderValue};
use tokio::fs::{read_dir, remove_file};
use crate::vpm::structs::setting::UserRepoSetting;
macro_rules! multi_command {
($class: ident is $($variant: ident),*) => {
impl $class {
pub async fn run(self) {
match self {
$($class::$variant(cmd) => cmd.run().await,)*
}
}
}
};
}
macro_rules! exit_with {
($($tt:tt)*) => {{
eprintln!($($tt)*);
exit(1)
}};
}
async fn load_env(client: Option<reqwest::Client>) -> Environment {
let mut env = Environment::load_default(client)
.await
.exit_context("loading global config");
env.load_package_infos().await.exit_context("loading repositories");
env.save().await.exit_context("saving repositories updates");
env
}
async fn load_unity(path: Option<PathBuf>) -> UnityProject {
UnityProject::find_unity_project(path)
.await
.exit_context("loading unity project")
}
fn get_package<'env>(
env: &'env Environment,
name: &str,
version_selector: VersionSelector,
) -> PackageInfo<'env> {
env.find_package_by_name(&name, version_selector)
.unwrap_or_else(|| exit_with!("no matching package not found"))
}
async fn mark_and_sweep(unity: &mut UnityProject) {
for x in unity
.mark_and_sweep()
.await
.exit_context("sweeping unused packages")
{
eprintln!("removed {x} which is unused");
}
}
async fn save_unity(unity: &mut UnityProject) {
unity.save().await.exit_context("saving manifest file");
}
async fn save_env(env: &mut Environment) {
env.save().await.exit_context("saving global config");
}
fn confirm_prompt(msg: &str) -> bool {
use std::io;
use std::io::Write;
fn _impl(msg: &str) -> io::Result<bool> {
let mut stdout = io::stdout();
let stdin = io::stdin();
let mut buf = String::new();
loop {
write!(stdout, "{}? [y/n] ", msg)?;
stdout.flush()?;
buf.clear();
stdin.read_line(&mut buf)?;
buf.make_ascii_lowercase();
match buf.trim() {
"y" | "yes" => return Ok(true),
"n" | "no" => return Ok(false),
_ => continue
}
}
}
_impl(msg).unwrap_or(false)
}
fn print_prompt_install(request: &AddPackageRequest, yes: bool) {
if request.locked().len() == 0 && request.dependencies().len() == 0 {
exit_with!("nothing to do")
}
let mut prompt = false;
if request.locked().len() != 0 {
println!("You're installing the following packages:");
for x in request.locked() {
println!("- {} version {}", x.name(), x.version());
}
prompt = prompt || request.locked().len() > 1;
}
if request.legacy_folders().len() != 0 || request.legacy_files().len() != 0 {
println!("You're removing the following legacy assets:");
for x in request.legacy_folders().iter().chain(request.legacy_files()) {
println!("- {}", x.display());
}
prompt = true;
}
if prompt {
if yes {
println!("--yes is set. skipping confirm");
} else {
if !confirm_prompt("Do you want to continue install?") {
exit(1);
}
}
}
}
trait ResultExt<T, E>: Sized {
fn exit_context(self, context: &str) -> T
where
E: Display;
}
impl<T, E> ResultExt<T, E> for Result<T, E> {
fn exit_context(self, context: &str) -> T
where
E: Display,
{
match self {
Ok(value) => value,
Err(err) => exit_with!("error {context}: {err}"),
}
}
}
#[derive(Parser)]
#[command(author, version, about)]
pub enum Command {
#[command(alias = "i", alias = "resolve")]
Install(Install),
#[command(alias = "rm")]
Remove(Remove),
Outdated(Outdated),
Upgrade(Upgrade),
Search(Search),
#[command(subcommand)]
Repo(Repo),
}
multi_command!(Command is Install, Remove, Outdated, Upgrade, Search, Repo);
#[derive(Parser)]
#[command(author, version)]
pub struct Install {
#[arg()]
name: Option<String>,
#[arg(id = "VERSION")]
version: Option<Version>,
#[arg(long = "prerelease")]
prerelease: bool,
#[arg(short = 'p', long = "project")]
project: Option<PathBuf>,
#[arg(long)]
offline: bool,
#[arg(short, long)]
yes: bool,
}
impl Install {
pub async fn run(self) {
let client = crate::create_client(self.offline);
let env = load_env(client).await;
let mut unity = load_unity(self.project).await;
if let Some(name) = self.name {
let version_selector = match self.version {
None if self.prerelease => VersionSelector::LatestIncluidingPrerelease,
None => VersionSelector::Latest,
Some(ref version) => VersionSelector::Specific(version),
};
let package = get_package(&env, &name, version_selector);
let request = unity.add_package_request(&env, vec![package], true, self.prerelease)
.await
.exit_context("collecting packages to be installed");
print_prompt_install(&request, self.yes);
unity.do_add_package_request(&env, request).await.exit_context("adding package");
mark_and_sweep(&mut unity).await;
} else {
unity.resolve(&env).await.exit_context("resolving packages");
}
unity.save().await.exit_context("saving manifest file");
}
}
#[derive(Parser)]
#[command(author, version)]
pub struct Remove {
#[arg()]
names: Vec<String>,
#[arg(short = 'p', long = "project")]
project: Option<PathBuf>,
}
impl Remove {
pub async fn run(self) {
let mut unity = load_unity(self.project).await;
unity
.remove(&self.names.iter().map(String::as_ref).collect::<Vec<_>>())
.await
.exit_context("removing package");
mark_and_sweep(&mut unity).await;
save_unity(&mut unity).await;
}
}
#[derive(Parser)]
#[command(author, version)]
pub struct Outdated {
#[arg(short = 'p', long = "project")]
project: Option<PathBuf>,
#[arg(long = "prerelease")]
prerelease: bool,
#[arg(long = "json-format")]
json_format: Option<NonZeroU32>,
#[arg(long)]
offline: bool,
}
impl Outdated {
pub async fn run(self) {
let client = crate::create_client(self.offline);
let env = load_env(client).await;
let unity = load_unity(self.project).await;
let mut outdated_packages = HashMap::new();
let selector = if self.prerelease {
VersionSelector::LatestIncluidingPrerelease
} else {
VersionSelector::Latest
};
for (name, dep) in unity.locked_packages() {
match env.find_package_by_name(name, selector)
{
None => log::error!("package {} not found.", name),
Some(pkg) if dep.version < *pkg.version() => {
outdated_packages.insert(pkg.name(), (pkg, &dep.version));
}
Some(_) => (),
}
}
for (_, dependencies) in unity.all_dependencies() {
for (name, range) in dependencies {
if let Some((outdated, _)) = outdated_packages.get(name.as_str()) {
if !range.matches(&outdated.version()) {
outdated_packages.remove(name.as_str());
}
}
}
}
match self.json_format.map(|x| x.get()).unwrap_or(0) {
0 => {
for (name, (found, installed)) in &outdated_packages {
println!(
"{}: installed: {}, found: {}",
name, installed, &found.version()
);
}
}
1 => {
#[derive(Serialize)]
struct OutdatedInfo<'a> {
package_name: &'a str,
installed_version: &'a Version,
newer_version: &'a Version,
}
let info = outdated_packages
.into_iter()
.map(|(package_name, (found, installed))| OutdatedInfo {
package_name,
installed_version: installed,
newer_version: found.version(),
})
.collect::<Vec<_>>();
println!("{}", serde_json::to_string(&info).unwrap());
}
v => exit_with!("unsupported json version: {v}"),
}
}
}
#[derive(Parser)]
#[command(author, version)]
pub struct Upgrade {
#[arg()]
name: Option<String>,
#[arg(id = "VERSION")]
version: Option<Version>,
#[arg(long = "prerelease")]
prerelease: bool,
#[arg(short = 'p', long = "project")]
project: Option<PathBuf>,
#[arg(long)]
offline: bool,
#[arg(short, long)]
yes: bool,
}
impl Upgrade {
pub async fn run(self) {
let client = crate::create_client(self.offline);
let env = load_env(client).await;
let mut unity = load_unity(self.project).await;
let updates = if let Some(name) = self.name {
let version_selector = match self.version {
None if self.prerelease => VersionSelector::LatestIncluidingPrerelease,
None => VersionSelector::Latest,
Some(ref version) => VersionSelector::Specific(version),
};
let package = get_package(&env, &name, version_selector);
vec![package]
} else {
let version_selector = match self.prerelease {
true => VersionSelector::LatestIncluidingPrerelease,
false => VersionSelector::Latest,
};
unity.locked_packages()
.keys()
.map(|name| get_package(&env, &name, version_selector))
.collect()
};
let request = unity.add_package_request(&env, updates, false, self.prerelease)
.await
.exit_context("collecting packages to be upgraded");
print_prompt_install(&request, self.yes);
let updates = request.locked().iter().map(|x| (x.name().clone(), x.version().clone())).collect::<Vec<_>>();
unity.do_add_package_request(&env, request).await.exit_context("upgrading packages");
for (name, version) in updates {
println!("upgraded {} to {}", name, version);
}
mark_and_sweep(&mut unity).await;
save_unity(&mut unity).await;
}
}
#[derive(Parser)]
#[command(author, version)]
pub struct Search {
#[arg(required = true, name = "QUERY")]
queries: Vec<String>,
#[arg(long)]
offline: bool,
}
impl Search {
pub async fn run(self) {
let client = crate::create_client(self.offline);
let env = load_env(client).await;
let mut queries = self.queries;
for query in &mut queries {
query.make_ascii_lowercase();
}
fn search_targets(pkg: &PackageJson) -> Vec<String> {
let mut sources = Vec::with_capacity(3);
sources.push(pkg.name.as_str().to_ascii_lowercase());
sources.extend(pkg.display_name.as_deref().map(|x| x.to_ascii_lowercase()));
sources.extend(pkg.description.as_deref().map(|x| x.to_ascii_lowercase()));
sources
}
let found_packages = env
.find_whole_all_packages(|pkg| {
let search_targets = search_targets(pkg);
queries
.iter()
.all(|query| search_targets.iter().any(|x| x.contains(query)))
});
if found_packages.is_empty() {
println!("No matching package found!")
} else {
for x in found_packages {
if let Some(name) = &x.display_name {
println!("{} version {}", name, x.version);
println!("({})", x.name);
} else {
println!("{} version {}", x.name, x.version);
}
if let Some(description) = &x.description {
println!("{}", description);
}
println!();
}
}
}
}
#[derive(Subcommand)]
#[command(author, version)]
pub enum Repo {
List(RepoList),
Add(RepoAdd),
Remove(RepoRemove),
Cleanup(RepoCleanup),
Packages(RepoPackages),
}
multi_command!(Repo is List, Add, Remove, Cleanup, Packages);
#[derive(Parser)]
#[command(author, version)]
pub struct RepoList {
#[arg(long)]
offline: bool,
}
impl RepoList {
pub async fn run(self) {
let client = crate::create_client(self.offline);
let env = load_env(client).await;
for (local_path, repo) in env.get_repo_with_path() {
println!(
"{}: {} (from {} at {})",
repo.id().or(repo.url()).unwrap_or("(no id)"),
repo.name().unwrap_or("(unnamed)"),
repo.url().unwrap_or("(no remote)"),
local_path.display(),
);
}
}
}
#[derive(Parser)]
#[command(author, version)]
pub struct RepoAdd {
#[arg()]
path_or_url: String,
#[arg()]
name: Option<String>,
#[arg(short='H', long, value_parser = HeaderPair::from_str)]
header: Vec<HeaderPair>,
#[arg(long)]
offline: bool,
}
#[derive(Clone)]
struct HeaderPair(HeaderName, HeaderValue);
impl FromStr for HeaderPair {
type Err = HeaderPairErr;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (name, value) = s.split_once(":").ok_or(HeaderPairErr::NoComma)?;
Ok(HeaderPair(name.parse()?, value.parse()?))
}
}
#[derive(Debug)]
enum HeaderPairErr {
NoComma,
HeaderNameErr(InvalidHeaderName),
HeaderValueErr(InvalidHeaderValue),
}
impl From<InvalidHeaderName> for HeaderPairErr {
fn from(value: InvalidHeaderName) -> Self {
Self::HeaderNameErr(value)
}
}
impl From<InvalidHeaderValue> for HeaderPairErr {
fn from(value: InvalidHeaderValue) -> Self {
Self::HeaderValueErr(value)
}
}
impl Display for HeaderPairErr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HeaderPairErr::NoComma => f.write_str("no ':' found"),
HeaderPairErr::HeaderNameErr(e) => Display::fmt(e, f),
HeaderPairErr::HeaderValueErr(e) => Display::fmt(e, f),
}
}
}
impl StdError for HeaderPairErr {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
match self {
HeaderPairErr::NoComma => None,
HeaderPairErr::HeaderNameErr(e) => Some(e),
HeaderPairErr::HeaderValueErr(e) => Some(e),
}
}
}
impl RepoAdd {
pub async fn run(self) {
let client = crate::create_client(self.offline);
let mut env = load_env(client).await;
if let Ok(url) = Url::parse(&self.path_or_url) {
let mut headers = IndexMap::<String, String>::new();
for HeaderPair(name, value) in self.header {
headers.insert(name.to_string(), value.to_str().unwrap().to_string());
}
env.add_remote_repo(url, self.name.as_deref(), headers)
.await
.exit_context("adding repository")
} else {
env.add_local_repo(Path::new(&self.path_or_url), self.name.as_deref())
.exit_context("adding repository")
}
save_env(&mut env).await;
}
}
#[derive(Parser)]
#[command(author, version)]
pub struct RepoRemove {
#[arg()]
finder: String,
#[clap(flatten)]
searcher: RepoSearcherArgs,
#[arg(long)]
offline: bool,
}
#[derive(Args)]
#[group(multiple = false)]
struct RepoSearcherArgs {
#[arg(long)]
id: bool,
#[arg(long)]
url: bool,
#[arg(long)]
name: bool,
#[arg(long)]
path: bool,
}
impl RepoSearcherArgs {
fn as_searcher(&self) -> RepoSearcher {
match () {
() if self.id => RepoSearcher::Id,
() if self.url => RepoSearcher::Url,
() if self.name => RepoSearcher::Name,
() if self.path => RepoSearcher::Path,
() => RepoSearcher::Id,
}
}
}
#[derive(Copy, Clone)]
enum RepoSearcher {
Id,
Url,
Name,
Path,
}
impl Display for RepoSearcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RepoSearcher::Id => f.write_str("id"),
RepoSearcher::Url => f.write_str("url"),
RepoSearcher::Name => f.write_str("name"),
RepoSearcher::Path => f.write_str("path"),
}
}
}
impl RepoSearcher {
fn get(self, repo: &UserRepoSetting) -> Option<&OsStr> {
match self {
RepoSearcher::Id => repo.id.as_deref().map(|x| OsStr::new(x)),
RepoSearcher::Url => repo.url.as_deref().map(|x| OsStr::new(x)),
RepoSearcher::Name => repo.name.as_deref().map(|x| OsStr::new(x)),
RepoSearcher::Path => Some(repo.local_path.as_os_str())
}
}
}
impl RepoRemove {
pub async fn run(self) {
let client = crate::create_client(self.offline);
let mut env = load_env(client).await;
let finder = OsStr::new(self.finder.as_str());
let searcher = self.searcher.as_searcher();
let count = env.remove_repo(|x| searcher.get(x) == Some(finder))
.await
.exit_context("removing repository");
println!("removed {} repositories with {}", count, searcher);
save_env(&mut env).await;
}
}
#[derive(Parser)]
#[command(author, version)]
pub struct RepoCleanup {
#[arg(long)]
offline: bool,
}
impl RepoCleanup {
pub async fn run(self) {
let client = crate::create_client(self.offline);
let env = load_env(client).await;
let mut uesr_repo_file_names = vec![
OsString::from("vrc-official.json"),
OsString::from("vrc-curated.json"),
OsString::from("package-cache.json"),
];
let repos_base = env.get_repos_dir();
for x in env.get_user_repos().exit_context("reading user repos") {
if let Ok(relative) = x.local_path.strip_prefix(&repos_base) {
if let Some(file_name) = relative.file_name() {
if relative
.parent()
.map(|x| x.as_os_str().is_empty())
.unwrap_or(true)
{
uesr_repo_file_names.push(file_name.to_owned());
}
}
}
}
let mut entry = read_dir(repos_base).await.exit_context("reading dir");
while let Some(entry) = entry.next_entry().await.exit_context("reading dir") {
let path = entry.path();
if tokio::fs::metadata(&path)
.await
.map(|x| x.is_file())
.unwrap_or(false)
&& path.extension() == Some(OsStr::new("json"))
&& !uesr_repo_file_names.contains(&entry.file_name())
{
remove_file(path)
.await
.exit_context("removing unused files");
}
}
}
}
#[derive(Parser)]
#[command(author, version)]
pub struct RepoPackages {
name_or_url: String,
#[arg(long)]
offline: bool,
}
impl RepoPackages {
pub async fn run(self) {
fn print_repo<'a>(packages: &Repository) {
for versions in packages.get_packages() {
if let Some((_, pkg)) = versions.versions.iter().max_by_key(|(_, pkg)| &pkg.version) {
let package = &pkg.name;
if let Some(display_name) = &pkg.display_name {
println!("{} | {}", display_name, package);
} else {
println!("{}", package);
}
if let Some(description) = &pkg.description {
println!("{}", description);
}
for (version, pkg) in &versions.versions {
println!("{}: {}", version, pkg.url);
}
println!();
}
}
}
let client = crate::create_client(self.offline);
if let Some(url) = Url::parse(&self.name_or_url).ok() {
let Some(client) = client else {
exit_with!("remote repository specified but offline mode.");
};
let repo = download_remote_repository(&client, url, None, None)
.await
.exit_context("downloading repository")
.unwrap()
.0;
print_repo(&repo);
} else {
let env = load_env(client).await;
let some_name = Some(self.name_or_url.as_str());
let mut found = false;
for repo in env.get_repos() {
if repo.name() == some_name {
print_repo(repo.repo());
found = true;
}
}
if !found {
exit_with!("no repository named {} found!", self.name_or_url);
}
}
}
}