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 .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 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;