1use std::{env, fs};
2use std::path::PathBuf;
3use std::process::ExitCode;
4use std::str::FromStr;
5
6use anyhow::anyhow;
7use clap::{ArgMatches, FromArgMatches, Parser, Subcommand};
8use comfy_table::presets::UTF8_FULL;
9use comfy_table::Table;
10use itertools::Itertools;
11use pact_plugin_driver::plugin_models::PactPluginManifest;
12use requestty::OnEsc;
13use tracing::{error, Level};
14use tracing_subscriber::FmtSubscriber;
15
16use crate::list::{list_plugins, plugin_list};
17
18mod install;
19mod repository;
20mod list;
21
22#[derive(Parser, Debug)]
23#[clap(about, version)]
24#[command(disable_version_flag(true))]
25pub struct Cli {
26 #[clap(short, long)]
27 yes: bool,
29
30 #[clap(short, long)]
31 pub debug: bool,
33
34 #[clap(short, long)]
35 pub trace: bool,
37
38 #[clap(subcommand)]
39 command: Commands,
40
41 #[clap(short = 'v', long = "version", action = clap::ArgAction::Version)]
42 cli_version: Option<bool>
44}
45
46#[derive(Subcommand, Debug)]
47enum Commands {
48 #[command(subcommand)]
50 List(ListCommands),
51
52 Env,
54
55 Install {
60 #[clap(short = 't', long)]
64 source_type: Option<InstallationSource>,
65
66 #[clap(short, long)]
67 yes: bool,
69
70 #[clap(short, long)]
71 skip_if_installed: bool,
73
74 source: String,
76
77 #[clap(short, long)]
78 version: Option<String>,
80
81 #[clap(long,env="PACT_PLUGIN_CLI_SKIP_LOAD")]
82 skip_load: bool
84 },
85
86 Remove {
88 #[clap(short, long)]
89 yes: bool,
91
92 name: String,
94
95 version: Option<String>
97 },
98
99 Enable {
101 name: String,
103
104 version: Option<String>
106 },
107
108 Disable {
110 name: String,
112
113 version: Option<String>
115 },
116
117 #[command(subcommand)]
119 Repository(RepositoryCommands)
120}
121
122#[derive(Subcommand, Debug)]
123pub enum ListCommands {
124 Installed,
126
127 Known {
129 #[clap(short, long)]
131 show_all_versions: bool
132 }
133}
134
135#[derive(Subcommand, Debug)]
136enum RepositoryCommands {
137 Validate {
139 filename: String
141 },
142
143 New {
145 filename: Option<String>,
147
148 #[clap(short, long)]
149 overwrite: bool
151 },
152
153 #[command(subcommand)]
155 AddPluginVersion(PluginVersionCommand),
156
157 AddAllPluginVersions {
159 repository_file: String,
161
162 owner: String,
164
165 repository: String,
167
168 base_url: Option<String>
170 },
171
172 YankVersion,
174
175 List {
177 filename: String
179 },
180
181 ListVersions{
183 filename: String,
185
186 name: String
188 }
189}
190
191#[derive(Subcommand, Debug)]
192enum PluginVersionCommand {
193 File { repository_file: String, file: String },
195
196 GitHub { repository_file: String, url: String }
198}
199
200#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
202pub enum InstallationSource {
203 Github
205}
206
207impl FromStr for InstallationSource {
208 type Err = anyhow::Error;
209 fn from_str(s: &str) -> Result<Self, Self::Err> {
210 if s.to_lowercase() == "github" {
211 Ok(InstallationSource::Github)
212 } else {
213 Err(anyhow!("'{}' is not a valid installation source", s))
214 }
215 }
216}
217
218pub fn setup_logger(log_level: Level) {
219 let subscriber = FmtSubscriber::builder()
220 .with_max_level(log_level)
221 .finish();
222
223 if let Err(err) = tracing::subscriber::set_global_default(subscriber) {
224 eprintln!("WARN: Failed to initialise global tracing subscriber - {err}");
225 };
226}
227
228pub fn process_plugin_command(matches: &ArgMatches) -> Result<(), ExitCode> {
229 match Cli::from_arg_matches(matches) {
231 Ok(cli) => handle_matches(&cli),
232 Err(err) => {
233 error!("Failed to parse arguments: {}", err);
234 Err(ExitCode::FAILURE)
235 }
236 }
237}
238
239
240pub fn handle_matches(cli: &Cli) -> Result<(), ExitCode> {
241 let result = match &cli.command {
242 Commands::List(command) => list_plugins(command),
243 Commands::Env => print_env(),
244 Commands::Install { yes, skip_if_installed, source, source_type, version, skip_load } => {
245 install::install_plugin(source, source_type, *yes || cli.yes, *skip_if_installed, version, *skip_load)
246 },
247 Commands::Remove { yes, name, version } => remove_plugin(name, version, *yes || cli.yes),
248 Commands::Enable { name, version } => enable_plugin(name, version),
249 Commands::Disable { name, version } => disable_plugin(name, version),
250 Commands::Repository(command) => repository::handle_command(command)
251 };
252
253 result.map_err(|err| {
254 error!("error - {}", err);
255 ExitCode::FAILURE
256 })
257}
258
259
260
261fn remove_plugin(name: &String, version: &Option<String>, override_prompt: bool) -> anyhow::Result<()> {
262 let matches = find_plugin(name, version)?;
263 if matches.len() == 1 {
264 if let Some((manifest, _, _)) = matches.first() {
265 if override_prompt || prompt_delete(manifest) {
266 fs::remove_dir_all(manifest.plugin_dir.clone())?;
267 println!("Removed plugin with name '{}' and version '{}'", manifest.name, manifest.version);
268 } else {
269 println!("Aborting deletion of plugin.");
270 }
271 Ok(())
272 } else {
273 Err(anyhow!("Internal error, matches.len() == 1 but first() == None"))
274 }
275 } else if matches.len() > 1 {
276 Err(anyhow!("There is more than one plugin version for '{}', please also provide the version", name))
277 } else if let Some(version) = version {
278 Err(anyhow!("Did not find a plugin with name '{}' and version '{}'", name, version))
279 } else {
280 Err(anyhow!("Did not find a plugin with name '{}'", name))
281 }
282}
283
284fn prompt_delete(manifest: &PactPluginManifest) -> bool {
285 let question = requestty::Question::confirm("delete_plugin")
286 .message(format!("Are you sure you want to delete plugin with name '{}' and version '{}'?", manifest.name, manifest.version))
287 .default(false)
288 .on_esc(OnEsc::Terminate)
289 .build();
290 if let Ok(result) = requestty::prompt_one(question) {
291 if let Some(result) = result.as_bool() {
292 result
293 } else {
294 false
295 }
296 } else {
297 false
298 }
299}
300
301fn disable_plugin(name: &String, version: &Option<String>) -> anyhow::Result<()> {
302 let matches = find_plugin(name, version)?;
303 if matches.len() == 1 {
304 if let Some((manifest, file, status)) = matches.first() {
305 if !*status {
306 println!("Plugin '{}' with version '{}' is already disabled.", manifest.name, manifest.version);
307 } else {
308 fs::rename(file, file.with_file_name("pact-plugin.json.disabled"))?;
309 println!("Plugin '{}' with version '{}' is now disabled.", manifest.name, manifest.version);
310 }
311 Ok(())
312 } else {
313 Err(anyhow!("Internal error, matches.len() == 1 but first() == None"))
314 }
315 } else if matches.len() > 1 {
316 Err(anyhow!("There is more than one plugin version for '{}', please also provide the version", name))
317 } else if let Some(version) = version {
318 Err(anyhow!("Did not find a plugin with name '{}' and version '{}'", name, version))
319 } else {
320 Err(anyhow!("Did not find a plugin with name '{}'", name))
321 }
322}
323
324fn find_plugin(name: &String, version: &Option<String>) -> anyhow::Result<Vec<(PactPluginManifest, PathBuf, bool)>> {
325 let vec = plugin_list()?;
326 Ok(vec.iter()
327 .filter(|(manifest, _, _)| {
328 if let Some(version) = version {
329 manifest.name == *name && manifest.version == *version
330 } else {
331 manifest.name == *name
332 }
333 })
334 .map(|(m, p, s)| {
335 (m.clone(), p.clone(), *s)
336 })
337 .collect_vec())
338}
339
340fn enable_plugin(name: &String, version: &Option<String>) -> anyhow::Result<()> {
341 let matches = find_plugin(name, version)?;
342 if matches.len() == 1 {
343 if let Some((manifest, file, status)) = matches.first() {
344 if *status {
345 println!("Plugin '{}' with version '{}' is already enabled.", manifest.name, manifest.version);
346 } else {
347 fs::rename(file, file.with_file_name("pact-plugin.json"))?;
348 println!("Plugin '{}' with version '{}' is now enabled.", manifest.name, manifest.version);
349 }
350 Ok(())
351 } else {
352 Err(anyhow!("Internal error, matches.len() == 1 but first() == None"))
353 }
354 } else if matches.len() > 1 {
355 Err(anyhow!("There is more than one plugin version for '{}', please also provide the version", name))
356 } else if let Some(version) = version {
357 Err(anyhow!("Did not find a plugin with name '{}' and version '{}'", name, version))
358 } else {
359 Err(anyhow!("Did not find a plugin with name '{}'", name))
360 }
361}
362
363fn print_env() -> anyhow::Result<()> {
364 let mut table = Table::new();
365
366 let (plugin_src, plugin_dir) = resolve_plugin_dir();
367
368 table
369 .load_preset(UTF8_FULL)
370 .set_header(vec!["Configuration", "Source", "Value"])
371 .add_row(vec!["Plugin Directory", plugin_src.as_str(), plugin_dir.as_str()]);
372
373 println!("{table}");
374
375 Ok(())
376}
377
378fn resolve_plugin_dir() -> (String, String) {
379 let home_dir = home::home_dir()
380 .map(|dir| dir.join(".pact/plugins"))
381 .unwrap_or_default();
382 match env::var_os("PACT_PLUGIN_DIR") {
383 None => ("$HOME/.pact/plugins".to_string(), home_dir.display().to_string()),
384 Some(dir) => {
385 let plugin_dir = dir.to_string_lossy();
386 if plugin_dir.is_empty() {
387 ("$HOME/.pact/plugins".to_string(), home_dir.display().to_string())
388 } else {
389 ("$PACT_PLUGIN_DIR".to_string(), plugin_dir.to_string())
390 }
391 }
392 }
393}
394
395#[cfg(test)]
396mod tests;