Skip to main content

pact_plugin_cli/
lib.rs

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  /// Automatically answer Yes for all prompts
28  yes: bool,
29
30  #[clap(short, long)]
31  /// Enable debug level logs
32  pub debug: bool,
33
34  #[clap(short, long)]
35  /// Enable trace level logs
36  pub trace: bool,
37
38  #[clap(subcommand)]
39  command: Commands,
40
41  #[clap(short = 'v', long = "version", action = clap::ArgAction::Version)]
42  /// Print CLI version
43  cli_version: Option<bool>
44}
45
46#[derive(Subcommand, Debug)]
47enum Commands {
48  /// List installed or available plugins
49  #[command(subcommand)]
50  List(ListCommands),
51
52  /// Print out the Pact plugin environment config
53  Env,
54
55  /// Install a plugin
56  ///
57  /// A plugin can be either installed from a URL, or for a known plugin, by name (and optionally
58  /// version).
59  Install {
60    /// The type of source to fetch the plugin files from. Will default to Github releases.
61    ///
62    /// Valid values: github
63    #[clap(short = 't', long)]
64    source_type: Option<InstallationSource>,
65
66    #[clap(short, long)]
67    /// Automatically answer Yes for all prompts
68    yes: bool,
69
70    #[clap(short, long)]
71    /// Skip installing the plugin if the same version is already installed
72    skip_if_installed: bool,
73
74    /// Where to fetch the plugin files from. This should be a URL or the name of a known plugin.
75    source: String,
76
77    #[clap(short, long)]
78    /// The version to install. This is only used for known plugins.
79    version: Option<String>,
80
81    #[clap(long,env="PACT_PLUGIN_CLI_SKIP_LOAD")]
82    /// Skip auto-loading of plugin
83    skip_load: bool
84  },
85
86  /// Remove a plugin
87  Remove {
88    #[clap(short, long)]
89    /// Automatically answer Yes for all prompts
90    yes: bool,
91
92    /// Plugin name
93    name: String,
94
95    /// Plugin version. Not required if there is only one plugin version.
96    version: Option<String>
97  },
98
99  /// Enable a plugin version
100  Enable {
101    /// Plugin name
102    name: String,
103
104    /// Plugin version. Not required if there is only one plugin version.
105    version: Option<String>
106  },
107
108  /// Disable a plugin version
109  Disable {
110    /// Plugin name
111    name: String,
112
113    /// Plugin version. Not required if there is only one plugin version.
114    version: Option<String>
115  },
116
117  /// Sub-commands for dealing with a plugin repository
118  #[command(subcommand)]
119  Repository(RepositoryCommands)
120}
121
122#[derive(Subcommand, Debug)]
123pub enum ListCommands {
124  /// List installed plugins
125  Installed,
126
127  /// List known plugins
128  Known {
129    /// Display all versions of the known plugins
130    #[clap(short, long)]
131    show_all_versions: bool
132  }
133}
134
135#[derive(Subcommand, Debug)]
136enum RepositoryCommands {
137  /// Check the consistency of the repository index file
138  Validate {
139    /// Filename to validate
140    filename: String
141  },
142
143  /// Create a new blank repository index file
144  New {
145    /// Filename to use for the new file. By default will use repository.index
146    filename: Option<String>,
147
148    #[clap(short, long)]
149    /// Overwrite any existing file?
150    overwrite: bool
151  },
152
153  /// Add a plugin version to the index file (will update existing entry)
154  #[command(subcommand)]
155  AddPluginVersion(PluginVersionCommand),
156
157  /// Add all versions of a plugin to the index file (will update existing entries)
158  AddAllPluginVersions {
159    /// Repository index file to update
160    repository_file: String,
161
162    /// Repository owner to load versions from
163    owner: String,
164
165    /// Repository to load versions from
166    repository: String,
167
168    /// Base URL for GitHub APIs, will default to https://api.github.com/repos/
169    base_url: Option<String>
170  },
171
172  /// Remove a plugin version from the index file
173  YankVersion,
174
175  /// List all plugins found in the index file
176  List {
177    /// Filename to list entries from
178    filename: String
179  },
180
181  /// List all plugin versions found in the index file
182  ListVersions{
183    /// Filename to list versions from
184    filename: String,
185
186    /// Plugin entry to list versions for
187    name: String
188  }
189}
190
191#[derive(Subcommand, Debug)]
192enum PluginVersionCommand {
193  /// Add an entry for a local plugin manifest file to the repository file
194  File { repository_file: String, file: String },
195
196  /// Add an entry for a GitHub Release to the repository file
197  GitHub { repository_file: String, url: String }
198}
199
200/// Installation source to fetch plugins files from
201#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
202pub enum InstallationSource {
203  /// Install the plugin from a Github release page.
204  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    .pretty()
221    .with_max_level(log_level)
222    .with_ansi(true)
223    .finish();
224
225  if let Err(err) = tracing::subscriber::set_global_default(subscriber) {
226    eprintln!("WARN: Failed to initialise global tracing subscriber - {err}");
227  };
228}
229
230pub fn process_plugin_command(matches: &ArgMatches) -> Result<(), ExitCode> {
231  // Convert ArgMatches into Cli by using Cli::from_arg_matches
232  match Cli::from_arg_matches(matches) {
233    Ok(cli) => handle_matches(&cli),
234    Err(err) => {
235      error!("Failed to parse arguments: {}", err);
236      Err(ExitCode::FAILURE)
237    }
238  }
239}
240
241
242pub fn handle_matches(cli: &Cli) -> Result<(), ExitCode> {
243  let result = match &cli.command {
244    Commands::List(command) => list_plugins(command),
245    Commands::Env => print_env(),
246    Commands::Install { yes, skip_if_installed, source, source_type, version, skip_load } => {
247      install::install_plugin(source, source_type, *yes || cli.yes, *skip_if_installed, version, *skip_load)
248    },
249    Commands::Remove { yes, name, version } => remove_plugin(name, version, *yes || cli.yes),
250    Commands::Enable { name, version } => enable_plugin(name, version),
251    Commands::Disable { name, version } => disable_plugin(name, version),
252    Commands::Repository(command) => repository::handle_command(command)
253  };
254
255  result.map_err(|err| {
256    error!("error - {}", err);
257    ExitCode::FAILURE
258  })
259}
260
261
262
263fn remove_plugin(name: &String, version: &Option<String>, override_prompt: bool) -> anyhow::Result<()> {
264  let matches = find_plugin(name, version)?;
265  if matches.len() == 1 {
266    if let Some((manifest, _, _)) = matches.first() {
267      if override_prompt || prompt_delete(manifest) {
268        fs::remove_dir_all(manifest.plugin_dir.clone())?;
269        println!("Removed plugin with name '{}' and version '{}'", manifest.name, manifest.version);
270      } else {
271        println!("Aborting deletion of plugin.");
272      }
273      Ok(())
274    } else {
275      Err(anyhow!("Internal error, matches.len() == 1 but first() == None"))
276    }
277  } else if matches.len() > 1 {
278    Err(anyhow!("There is more than one plugin version for '{}', please also provide the version", name))
279  } else if let Some(version) = version {
280    Err(anyhow!("Did not find a plugin with name '{}' and version '{}'", name, version))
281  } else {
282    Err(anyhow!("Did not find a plugin with name '{}'", name))
283  }
284}
285
286fn prompt_delete(manifest: &PactPluginManifest) -> bool {
287  let question = requestty::Question::confirm("delete_plugin")
288    .message(format!("Are you sure you want to delete plugin with name '{}' and version '{}'?", manifest.name, manifest.version))
289    .default(false)
290    .on_esc(OnEsc::Terminate)
291    .build();
292  if let Ok(result) = requestty::prompt_one(question) {
293    if let Some(result) = result.as_bool() {
294      result
295    } else {
296      false
297    }
298  } else {
299    false
300  }
301}
302
303fn disable_plugin(name: &String, version: &Option<String>) -> anyhow::Result<()> {
304  let matches = find_plugin(name, version)?;
305  if matches.len() == 1 {
306    if let Some((manifest, file, status)) = matches.first() {
307      if !*status {
308        println!("Plugin '{}' with version '{}' is already disabled.", manifest.name, manifest.version);
309      } else {
310        fs::rename(file, file.with_file_name("pact-plugin.json.disabled"))?;
311        println!("Plugin '{}' with version '{}' is now disabled.", manifest.name, manifest.version);
312      }
313      Ok(())
314    } else {
315      Err(anyhow!("Internal error, matches.len() == 1 but first() == None"))
316    }
317  } else if matches.len() > 1 {
318    Err(anyhow!("There is more than one plugin version for '{}', please also provide the version", name))
319  } else if let Some(version) = version {
320    Err(anyhow!("Did not find a plugin with name '{}' and version '{}'", name, version))
321  } else {
322    Err(anyhow!("Did not find a plugin with name '{}'", name))
323  }
324}
325
326fn find_plugin(name: &String, version: &Option<String>) -> anyhow::Result<Vec<(PactPluginManifest, PathBuf, bool)>> {
327  let vec = plugin_list()?;
328  Ok(vec.iter()
329    .filter(|(manifest, _, _)| {
330      if let Some(version) = version {
331        manifest.name == *name && manifest.version == *version
332      } else {
333        manifest.name == *name
334      }
335    })
336    .map(|(m, p, s)| {
337      (m.clone(), p.clone(), *s)
338    })
339    .collect_vec())
340}
341
342fn enable_plugin(name: &String, version: &Option<String>) -> anyhow::Result<()> {
343  let matches = find_plugin(name, version)?;
344  if matches.len() == 1 {
345    if let Some((manifest, file, status)) = matches.first() {
346      if *status {
347        println!("Plugin '{}' with version '{}' is already enabled.", manifest.name, manifest.version);
348      } else {
349        fs::rename(file, file.with_file_name("pact-plugin.json"))?;
350        println!("Plugin '{}' with version '{}' is now enabled.", manifest.name, manifest.version);
351      }
352      Ok(())
353    } else {
354      Err(anyhow!("Internal error, matches.len() == 1 but first() == None"))
355    }
356  } else if matches.len() > 1 {
357    Err(anyhow!("There is more than one plugin version for '{}', please also provide the version", name))
358  } else if let Some(version) = version {
359    Err(anyhow!("Did not find a plugin with name '{}' and version '{}'", name, version))
360  } else {
361    Err(anyhow!("Did not find a plugin with name '{}'", name))
362  }
363}
364
365fn print_env() -> anyhow::Result<()> {
366  let mut table = Table::new();
367
368  let (plugin_src, plugin_dir) = resolve_plugin_dir();
369
370  table
371    .load_style(UTF8_FULL)
372    .set_header(vec!["Configuration", "Source", "Value"])
373    .add_row(vec!["Plugin Directory", plugin_src.as_str(), plugin_dir.as_str()]);
374
375  println!("{table}");
376
377  Ok(())
378}
379
380fn resolve_plugin_dir() -> (String, String) {
381  let home_dir = home::home_dir()
382    .map(|dir| dir.join(".pact/plugins"))
383    .unwrap_or_default();
384  match env::var_os("PACT_PLUGIN_DIR") {
385    None => ("$HOME/.pact/plugins".to_string(), home_dir.display().to_string()),
386    Some(dir) => {
387      let plugin_dir = dir.to_string_lossy();
388      if plugin_dir.is_empty() {
389        ("$HOME/.pact/plugins".to_string(), home_dir.display().to_string())
390      } else {
391        ("$PACT_PLUGIN_DIR".to_string(), plugin_dir.to_string())
392      }
393    }
394  }
395}
396
397#[cfg(test)]
398mod tests;