use std::{env, fs};
use std::path::PathBuf;
use std::process::ExitCode;
use std::str::FromStr;
use anyhow::anyhow;
use clap::{ArgMatches, FromArgMatches, Parser, Subcommand};
use comfy_table::presets::UTF8_FULL;
use comfy_table::Table;
use itertools::Itertools;
use pact_plugin_driver::plugin_models::PactPluginManifest;
use requestty::OnEsc;
use tracing::{error, Level};
use tracing_subscriber::FmtSubscriber;
use crate::list::{list_plugins, plugin_list};
mod install;
mod repository;
mod list;
#[derive(Parser, Debug)]
#[clap(about, version)]
#[command(disable_version_flag(true))]
pub struct Cli {
#[clap(short, long)]
yes: bool,
#[clap(short, long)]
pub debug: bool,
#[clap(short, long)]
pub trace: bool,
#[clap(subcommand)]
command: Commands,
#[clap(short = 'v', long = "version", action = clap::ArgAction::Version)]
cli_version: Option<bool>
}
#[derive(Subcommand, Debug)]
enum Commands {
#[command(subcommand)]
List(ListCommands),
Env,
Install {
#[clap(short = 't', long)]
source_type: Option<InstallationSource>,
#[clap(short, long)]
yes: bool,
#[clap(short, long)]
skip_if_installed: bool,
source: String,
#[clap(short, long)]
version: Option<String>,
#[clap(long,env="PACT_PLUGIN_CLI_SKIP_LOAD")]
skip_load: bool
},
Remove {
#[clap(short, long)]
yes: bool,
name: String,
version: Option<String>
},
Enable {
name: String,
version: Option<String>
},
Disable {
name: String,
version: Option<String>
},
#[command(subcommand)]
Repository(RepositoryCommands)
}
#[derive(Subcommand, Debug)]
pub enum ListCommands {
Installed,
Known {
#[clap(short, long)]
show_all_versions: bool
}
}
#[derive(Subcommand, Debug)]
enum RepositoryCommands {
Validate {
filename: String
},
New {
filename: Option<String>,
#[clap(short, long)]
overwrite: bool
},
#[command(subcommand)]
AddPluginVersion(PluginVersionCommand),
AddAllPluginVersions {
repository_file: String,
owner: String,
repository: String,
base_url: Option<String>
},
YankVersion,
List {
filename: String
},
ListVersions{
filename: String,
name: String
}
}
#[derive(Subcommand, Debug)]
enum PluginVersionCommand {
File { repository_file: String, file: String },
GitHub { repository_file: String, url: String }
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub enum InstallationSource {
Github
}
impl FromStr for InstallationSource {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.to_lowercase() == "github" {
Ok(InstallationSource::Github)
} else {
Err(anyhow!("'{}' is not a valid installation source", s))
}
}
}
pub fn setup_logger(log_level: Level) {
let subscriber = FmtSubscriber::builder()
.with_max_level(log_level)
.finish();
if let Err(err) = tracing::subscriber::set_global_default(subscriber) {
eprintln!("WARN: Failed to initialise global tracing subscriber - {err}");
};
}
pub fn process_plugin_command(matches: &ArgMatches) -> Result<(), ExitCode> {
match Cli::from_arg_matches(matches) {
Ok(cli) => handle_matches(&cli),
Err(err) => {
error!("Failed to parse arguments: {}", err);
Err(ExitCode::FAILURE)
}
}
}
pub fn handle_matches(cli: &Cli) -> Result<(), ExitCode> {
let result = match &cli.command {
Commands::List(command) => list_plugins(command),
Commands::Env => print_env(),
Commands::Install { yes, skip_if_installed, source, source_type, version, skip_load } => {
install::install_plugin(source, source_type, *yes || cli.yes, *skip_if_installed, version, *skip_load)
},
Commands::Remove { yes, name, version } => remove_plugin(name, version, *yes || cli.yes),
Commands::Enable { name, version } => enable_plugin(name, version),
Commands::Disable { name, version } => disable_plugin(name, version),
Commands::Repository(command) => repository::handle_command(command)
};
result.map_err(|err| {
error!("error - {}", err);
ExitCode::FAILURE
})
}
fn remove_plugin(name: &String, version: &Option<String>, override_prompt: bool) -> anyhow::Result<()> {
let matches = find_plugin(name, version)?;
if matches.len() == 1 {
if let Some((manifest, _, _)) = matches.first() {
if override_prompt || prompt_delete(manifest) {
fs::remove_dir_all(manifest.plugin_dir.clone())?;
println!("Removed plugin with name '{}' and version '{}'", manifest.name, manifest.version);
} else {
println!("Aborting deletion of plugin.");
}
Ok(())
} else {
Err(anyhow!("Internal error, matches.len() == 1 but first() == None"))
}
} else if matches.len() > 1 {
Err(anyhow!("There is more than one plugin version for '{}', please also provide the version", name))
} else if let Some(version) = version {
Err(anyhow!("Did not find a plugin with name '{}' and version '{}'", name, version))
} else {
Err(anyhow!("Did not find a plugin with name '{}'", name))
}
}
fn prompt_delete(manifest: &PactPluginManifest) -> bool {
let question = requestty::Question::confirm("delete_plugin")
.message(format!("Are you sure you want to delete plugin with name '{}' and version '{}'?", manifest.name, manifest.version))
.default(false)
.on_esc(OnEsc::Terminate)
.build();
if let Ok(result) = requestty::prompt_one(question) {
if let Some(result) = result.as_bool() {
result
} else {
false
}
} else {
false
}
}
fn disable_plugin(name: &String, version: &Option<String>) -> anyhow::Result<()> {
let matches = find_plugin(name, version)?;
if matches.len() == 1 {
if let Some((manifest, file, status)) = matches.first() {
if !*status {
println!("Plugin '{}' with version '{}' is already disabled.", manifest.name, manifest.version);
} else {
fs::rename(file, file.with_file_name("pact-plugin.json.disabled"))?;
println!("Plugin '{}' with version '{}' is now disabled.", manifest.name, manifest.version);
}
Ok(())
} else {
Err(anyhow!("Internal error, matches.len() == 1 but first() == None"))
}
} else if matches.len() > 1 {
Err(anyhow!("There is more than one plugin version for '{}', please also provide the version", name))
} else if let Some(version) = version {
Err(anyhow!("Did not find a plugin with name '{}' and version '{}'", name, version))
} else {
Err(anyhow!("Did not find a plugin with name '{}'", name))
}
}
fn find_plugin(name: &String, version: &Option<String>) -> anyhow::Result<Vec<(PactPluginManifest, PathBuf, bool)>> {
let vec = plugin_list()?;
Ok(vec.iter()
.filter(|(manifest, _, _)| {
if let Some(version) = version {
manifest.name == *name && manifest.version == *version
} else {
manifest.name == *name
}
})
.map(|(m, p, s)| {
(m.clone(), p.clone(), *s)
})
.collect_vec())
}
fn enable_plugin(name: &String, version: &Option<String>) -> anyhow::Result<()> {
let matches = find_plugin(name, version)?;
if matches.len() == 1 {
if let Some((manifest, file, status)) = matches.first() {
if *status {
println!("Plugin '{}' with version '{}' is already enabled.", manifest.name, manifest.version);
} else {
fs::rename(file, file.with_file_name("pact-plugin.json"))?;
println!("Plugin '{}' with version '{}' is now enabled.", manifest.name, manifest.version);
}
Ok(())
} else {
Err(anyhow!("Internal error, matches.len() == 1 but first() == None"))
}
} else if matches.len() > 1 {
Err(anyhow!("There is more than one plugin version for '{}', please also provide the version", name))
} else if let Some(version) = version {
Err(anyhow!("Did not find a plugin with name '{}' and version '{}'", name, version))
} else {
Err(anyhow!("Did not find a plugin with name '{}'", name))
}
}
fn print_env() -> anyhow::Result<()> {
let mut table = Table::new();
let (plugin_src, plugin_dir) = resolve_plugin_dir();
table
.load_preset(UTF8_FULL)
.set_header(vec!["Configuration", "Source", "Value"])
.add_row(vec!["Plugin Directory", plugin_src.as_str(), plugin_dir.as_str()]);
println!("{table}");
Ok(())
}
fn resolve_plugin_dir() -> (String, String) {
let home_dir = home::home_dir()
.map(|dir| dir.join(".pact/plugins"))
.unwrap_or_default();
match env::var_os("PACT_PLUGIN_DIR") {
None => ("$HOME/.pact/plugins".to_string(), home_dir.display().to_string()),
Some(dir) => {
let plugin_dir = dir.to_string_lossy();
if plugin_dir.is_empty() {
("$HOME/.pact/plugins".to_string(), home_dir.display().to_string())
} else {
("$PACT_PLUGIN_DIR".to_string(), plugin_dir.to_string())
}
}
}
}
#[cfg(test)]
mod tests;