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    .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  // Convert ArgMatches into Cli by using Cli::from_arg_matches
230  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;