diff --git a/Cargo.lock b/Cargo.lock index 2bca5a00..9595abb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1038,13 +1038,14 @@ checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70" [[package]] name = "cliclack" -version = "0.2.5" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4febf49beeedc40528e4956995631f1bbdb4d8804ef940b44351f393a996c739" +checksum = "ac20862449f338d814085d6d025790896eb6ad8c7bb6f4e0633502796a16d49f" dependencies = [ "console", "indicatif", "once_cell", + "strsim 0.11.1", "textwrap", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index 8260c9d4..d34f63f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,8 +63,8 @@ git2_credentials = "0.13.0" # pop-cli clap = { version = "4.5", features = ["derive"] } -cliclack = "0.2" +cliclack = "0.3.1" console = "0.15" -strum = "0.26" -strum_macros = "0.26" os_info = { version = "3", default-features = false } +strum = "0.26" +strum_macros = "0.26" \ No newline at end of file diff --git a/crates/pop-cli/src/cli.rs b/crates/pop-cli/src/cli.rs new file mode 100644 index 00000000..088fe389 --- /dev/null +++ b/crates/pop-cli/src/cli.rs @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: GPL-3.0 + +use std::{fmt::Display, io::Result}; +#[cfg(test)] +pub(crate) use tests::MockCli; + +pub(crate) mod traits { + use std::{fmt::Display, io::Result}; + + /// A command line interface. + pub trait Cli { + /// Constructs a new [`Confirm`] prompt. + fn confirm(&mut self, prompt: impl Display) -> impl Confirm; + /// Prints an info message. + fn info(&mut self, text: impl Display) -> Result<()>; + /// Prints a header of the prompt sequence. + fn intro(&mut self, title: impl Display) -> Result<()>; + /// Constructs a new [`MultiSelect`] prompt. + fn multiselect(&mut self, prompt: impl Display) -> impl MultiSelect; + /// Prints a footer of the prompt sequence. + fn outro(&mut self, message: impl Display) -> Result<()>; + /// Prints a footer of the prompt sequence with a failure style. + fn outro_cancel(&mut self, message: impl Display) -> Result<()>; + } + + /// A confirmation prompt. + pub trait Confirm { + /// Starts the prompt interaction. + fn interact(&mut self) -> Result; + } + + /// A multi-select prompt. + pub trait MultiSelect { + /// Starts the prompt interaction. + fn interact(&mut self) -> Result>; + /// Adds an item to the list of options. + fn item(self, value: T, label: impl Display, hint: impl Display) -> Self; + /// Sets whether the input is required. + fn required(self, required: bool) -> Self; + } +} + +/// A command line interface using cliclack. +pub(crate) struct Cli; +impl traits::Cli for Cli { + /// Constructs a new [`Confirm`] prompt. + fn confirm(&mut self, prompt: impl Display) -> impl traits::Confirm { + Confirm(cliclack::confirm(prompt)) + } + + /// Prints an info message. + fn info(&mut self, text: impl Display) -> Result<()> { + cliclack::log::info(text) + } + + /// Prints a header of the prompt sequence. + fn intro(&mut self, title: impl Display) -> Result<()> { + cliclack::clear_screen()?; + cliclack::set_theme(crate::style::Theme); + cliclack::intro(format!("{}: {title}", console::style(" Pop CLI ").black().on_magenta())) + } + + /// Constructs a new [`MultiSelect`] prompt. + fn multiselect(&mut self, prompt: impl Display) -> impl traits::MultiSelect { + MultiSelect::(cliclack::multiselect(prompt)) + } + + /// Prints a footer of the prompt sequence. + fn outro(&mut self, message: impl Display) -> Result<()> { + cliclack::outro(message) + } + + /// Prints a footer of the prompt sequence with a failure style. + fn outro_cancel(&mut self, message: impl Display) -> Result<()> { + cliclack::outro_cancel(message) + } +} + +/// A confirmation prompt using cliclack. +struct Confirm(cliclack::Confirm); +impl traits::Confirm for Confirm { + /// Starts the prompt interaction. + fn interact(&mut self) -> Result { + self.0.interact() + } +} + +/// A multi-select prompt using cliclack. +struct MultiSelect(cliclack::MultiSelect); + +impl traits::MultiSelect for MultiSelect { + /// Starts the prompt interaction. + fn interact(&mut self) -> Result> { + self.0.interact() + } + + /// Adds an item to the list of options. + fn item(mut self, value: T, label: impl Display, hint: impl Display) -> Self { + self.0 = self.0.item(value, label, hint); + self + } + + /// Sets whether the input is required. + fn required(mut self, required: bool) -> Self { + self.0 = self.0.required(required); + self + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::traits::*; + use std::{fmt::Display, io::Result}; + + /// Mock Cli with optional expectations + #[derive(Default)] + pub(crate) struct MockCli { + confirm_expectation: Option<(String, bool)>, + info_expectations: Vec, + intro_expectation: Option, + outro_expectation: Option, + multiselect_expectation: + Option<(String, Option, bool, Option>)>, + outro_cancel_expectation: Option, + } + + impl MockCli { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn expect_confirm(mut self, prompt: impl Display, confirm: bool) -> Self { + self.confirm_expectation = Some((prompt.to_string(), confirm)); + self + } + + pub(crate) fn expect_info(mut self, text: impl Display) -> Self { + self.info_expectations.push(text.to_string()); + self + } + + pub(crate) fn expect_intro(mut self, title: impl Display) -> Self { + self.intro_expectation = Some(title.to_string()); + self + } + + pub(crate) fn expect_multiselect( + mut self, + prompt: impl Display, + required: Option, + collect: bool, + items: Option>, + ) -> Self { + self.multiselect_expectation = Some((prompt.to_string(), required, collect, items)); + self + } + + pub(crate) fn expect_outro(mut self, message: impl Display) -> Self { + self.outro_expectation = Some(message.to_string()); + self + } + + pub(crate) fn expect_outro_cancel(mut self, message: impl Display) -> Self { + self.outro_cancel_expectation = Some(message.to_string()); + self + } + + pub(crate) fn verify(self) -> anyhow::Result<()> { + if let Some((expectation, _)) = self.confirm_expectation { + panic!("`{expectation}` confirm expectation not satisfied") + } + if !self.info_expectations.is_empty() { + panic!("`{}` info log expectations not satisfied", self.info_expectations.join(",")) + } + if let Some(expectation) = self.intro_expectation { + panic!("`{expectation}` intro expectation not satisfied") + } + if let Some((prompt, _, _, _)) = self.multiselect_expectation { + panic!("`{prompt}` multiselect prompt expectation not satisfied") + } + if let Some(expectation) = self.outro_expectation { + panic!("`{expectation}` outro expectation not satisfied") + } + if let Some(expectation) = self.outro_cancel_expectation { + panic!("`{expectation}` outro cancel expectation not satisfied") + } + Ok(()) + } + } + + impl Cli for MockCli { + fn confirm(&mut self, prompt: impl Display) -> impl Confirm { + let prompt = prompt.to_string(); + if let Some((expectation, confirm)) = self.confirm_expectation.take() { + assert_eq!(expectation, prompt, "prompt does not satisfy expectation"); + return MockConfirm { confirm }; + } + MockConfirm::default() + } + + fn info(&mut self, text: impl Display) -> Result<()> { + let text = text.to_string(); + self.info_expectations.retain(|x| *x != text); + Ok(()) + } + + fn intro(&mut self, title: impl Display) -> Result<()> { + if let Some(expectation) = self.intro_expectation.take() { + assert_eq!(expectation, title.to_string(), "intro does not satisfy expectation"); + } + Ok(()) + } + + fn multiselect(&mut self, prompt: impl Display) -> impl MultiSelect { + let prompt = prompt.to_string(); + if let Some((expectation, required_expectation, collect, items_expectation)) = + self.multiselect_expectation.take() + { + assert_eq!(expectation, prompt, "prompt does not satisfy expectation"); + return MockMultiSelect { + required_expectation, + items_expectation, + collect, + items: vec![], + }; + } + + MockMultiSelect::default() + } + + fn outro(&mut self, message: impl Display) -> Result<()> { + if let Some(expectation) = self.outro_expectation.take() { + assert_eq!( + expectation, + message.to_string(), + "outro message does not satisfy expectation" + ); + } + Ok(()) + } + + fn outro_cancel(&mut self, message: impl Display) -> Result<()> { + if let Some(expectation) = self.outro_cancel_expectation.take() { + assert_eq!( + expectation, + message.to_string(), + "outro message does not satisfy expectation" + ); + } + Ok(()) + } + } + + /// Mock confirm prompt + #[derive(Default)] + struct MockConfirm { + confirm: bool, + } + + impl Confirm for MockConfirm { + fn interact(&mut self) -> Result { + Ok(self.confirm) + } + } + + /// Mock multi-select prompt + pub(crate) struct MockMultiSelect { + required_expectation: Option, + items_expectation: Option>, + collect: bool, + items: Vec, + } + + impl MockMultiSelect { + pub(crate) fn default() -> Self { + Self { + required_expectation: None, + items_expectation: None, + collect: false, + items: vec![], + } + } + } + + impl MultiSelect for MockMultiSelect { + fn interact(&mut self) -> Result> { + // Pass any collected items + Ok(self.items.clone()) + } + + fn item(mut self, value: T, label: impl Display, hint: impl Display) -> Self { + // Check expectations + if let Some(items) = self.items_expectation.as_mut() { + let item = (label.to_string(), hint.to_string()); + assert!(items.contains(&item), "`{item:?}` item does not satisfy any expectations"); + items.retain(|x| *x != item); + } + // Collect if specified + if self.collect { + self.items.push(value); + } + self + } + + fn required(mut self, required: bool) -> Self { + if let Some(expectation) = self.required_expectation.as_ref() { + assert_eq!(*expectation, required, "required does not satisfy expectation"); + self.required_expectation = None; + } + self + } + } +} diff --git a/crates/pop-cli/src/commands/clean.rs b/crates/pop-cli/src/commands/clean.rs new file mode 100644 index 00000000..3c599fad --- /dev/null +++ b/crates/pop-cli/src/commands/clean.rs @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: GPL-3.0 + +use crate::cli::traits::*; +use anyhow::Result; +use clap::{Args, Subcommand}; +use std::{ + fs::{read_dir, remove_file}, + path::PathBuf, +}; + +#[derive(Args)] +#[command(args_conflicts_with_subcommands = true)] +pub(crate) struct CleanArgs { + #[command(subcommand)] + pub(crate) command: Command, +} + +/// Remove generated/cached artifacts. +#[derive(Subcommand)] +pub(crate) enum Command { + /// Remove cached artifacts. + #[clap(alias = "c")] + Cache, +} + +/// Removes cached artifacts. +pub(crate) struct CleanCacheCommand<'a, CLI: Cli> { + /// The cli to be used. + pub(crate) cli: &'a mut CLI, + /// The cache to be used. + pub(crate) cache: PathBuf, +} + +impl<'a, CLI: Cli> CleanCacheCommand<'a, CLI> { + /// Executes the command. + pub(crate) fn execute(self) -> Result<()> { + self.cli.intro("Remove cached artifacts")?; + + // Get the cache contents + if !self.cache.exists() { + self.cli.outro_cancel("🚫 The cache does not exist.")?; + return Ok(()); + }; + let contents = contents(&self.cache)?; + if contents.is_empty() { + self.cli.outro(format!( + "ℹ️ The cache at {} is empty.", + self.cache.to_str().expect("expected local cache is invalid") + ))?; + return Ok(()); + } + self.cli.info(format!( + "ℹ️ The cache is located at {}", + self.cache.to_str().expect("expected local cache is invalid") + ))?; + + // Prompt for selection of artifacts to be removed + let selected = { + let mut prompt = + self.cli.multiselect("Select the artifacts you wish to remove:").required(false); + for (name, path, size) in &contents { + prompt = prompt.item(path, name, format!("{}MiB", size / 1_048_576)) + } + prompt.interact()? + }; + if selected.is_empty() { + self.cli.outro("ℹ️ No artifacts removed")?; + return Ok(()); + }; + + // Confirm removal + let prompt = match selected.len() { + 1 => "Are you sure you want to remove the selected artifact?".into(), + _ => format!( + "Are you sure you want to remove the {} selected artifacts?", + selected.len() + ), + }; + if !self.cli.confirm(prompt).interact()? { + self.cli.outro("ℹ️ No artifacts removed")?; + return Ok(()); + } + + // Finally remove selected artifacts + for file in &selected { + remove_file(file)? + } + self.cli.outro(format!("ℹ️ {} artifacts removed", selected.len()))?; + Ok(()) + } +} + +/// Returns the contents of the specified path. +fn contents(path: &PathBuf) -> Result> { + let mut contents: Vec<_> = read_dir(&path)? + .filter_map(|e| { + e.ok().and_then(|e| { + e.file_name() + .to_str() + .map(|f| (f.to_string(), e.path())) + .zip(e.metadata().ok()) + .map(|f| (f.0 .0, f.0 .1, f.1.len())) + }) + }) + .filter(|(name, _, _)| !name.starts_with(".")) + .collect(); + contents.sort_by(|(a, _, _), (b, _, _)| a.cmp(b)); + Ok(contents) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::MockCli; + use std::fs::File; + + #[test] + fn clean_cache_has_intro() -> Result<()> { + let cache = PathBuf::new(); + let mut cli = MockCli::new().expect_intro(&"Remove cached artifacts"); + + CleanCacheCommand { cli: &mut cli, cache }.execute()?; + + cli.verify() + } + + #[test] + fn clean_cache_handles_missing_cache() -> Result<()> { + let cache = PathBuf::new(); + let mut cli = MockCli::new().expect_outro_cancel(&"🚫 The cache does not exist."); + + CleanCacheCommand { cli: &mut cli, cache }.execute()?; + + cli.verify() + } + + #[test] + fn clean_cache_handles_empty_cache() -> Result<()> { + let temp = tempfile::tempdir()?; + let cache = temp.path().to_path_buf(); + let mut cli = MockCli::new() + .expect_outro(&format!("ℹ️ The cache at {} is empty.", cache.to_str().unwrap())); + + CleanCacheCommand { cli: &mut cli, cache }.execute()?; + + cli.verify() + } + + #[test] + fn clean_cache_outputs_cache_location() -> Result<()> { + let temp = tempfile::tempdir()?; + let cache = temp.path().to_path_buf(); + for artifact in ["polkadot"] { + File::create(cache.join(artifact))?; + } + let mut cli = MockCli::new() + .expect_info(format!("ℹ️ The cache is located at {}", cache.to_str().unwrap())); + + CleanCacheCommand { cli: &mut cli, cache }.execute()?; + + cli.verify() + } + + #[test] + fn clean_cache_prompts_for_selection() -> Result<()> { + let temp = tempfile::tempdir()?; + let cache = temp.path().to_path_buf(); + let mut items = vec![]; + for artifact in ["polkadot", "pop-node"] { + File::create(cache.join(artifact))?; + items.push((artifact.to_string(), "0MiB".to_string())) + } + let mut cli = MockCli::new().expect_multiselect::( + "Select the artifacts you wish to remove:", + Some(false), + true, + Some(items), + ); + + CleanCacheCommand { cli: &mut cli, cache }.execute()?; + + cli.verify() + } + + #[test] + fn clean_cache_removes_nothing_when_no_selection() -> Result<()> { + let temp = tempfile::tempdir()?; + let cache = temp.path().to_path_buf(); + let artifacts = ["polkadot", "polkadot-execute-worker", "polkadot-prepare-worker"] + .map(|a| cache.join(a)); + for artifact in &artifacts { + File::create(artifact)?; + } + let mut cli = MockCli::new() + .expect_multiselect::( + "Select the artifacts you wish to remove:", + Some(false), + false, + None, + ) + .expect_outro("ℹ️ No artifacts removed"); + + CleanCacheCommand { cli: &mut cli, cache }.execute()?; + + for artifact in artifacts { + assert!(artifact.exists()) + } + cli.verify() + } + + #[test] + fn clean_cache_confirms_removal() -> Result<()> { + let temp = tempfile::tempdir()?; + let cache = temp.path().to_path_buf(); + let artifacts = ["polkadot-parachain"]; + for artifact in artifacts { + File::create(cache.join(artifact))?; + } + let mut cli = MockCli::new() + .expect_multiselect::( + "Select the artifacts you wish to remove:", + None, + true, + None, + ) + .expect_confirm("Are you sure you want to remove the selected artifact?", false) + .expect_outro("ℹ️ No artifacts removed"); + + CleanCacheCommand { cli: &mut cli, cache }.execute()?; + + cli.verify() + } + + #[test] + fn clean_cache_removes_selection() -> Result<()> { + let temp = tempfile::tempdir()?; + let cache = temp.path().to_path_buf(); + let artifacts = ["polkadot", "polkadot-execute-worker", "polkadot-prepare-worker"] + .map(|a| cache.join(a)); + for artifact in &artifacts { + File::create(artifact)?; + } + let mut cli = MockCli::new() + .expect_multiselect::( + "Select the artifacts you wish to remove:", + None, + true, + None, + ) + .expect_confirm("Are you sure you want to remove the 3 selected artifacts?", true) + .expect_outro("ℹ️ 3 artifacts removed"); + + CleanCacheCommand { cli: &mut cli, cache }.execute()?; + + for artifact in artifacts { + assert!(!artifact.exists()) + } + cli.verify() + } + + #[test] + fn contents_works() -> Result<()> { + use std::fs::File; + let temp = tempfile::tempdir()?; + let cache = temp.path().to_path_buf(); + let mut files = vec!["a", "z", "1"]; + for file in &files { + File::create(cache.join(file))?; + } + files.sort(); + + let contents = contents(&cache)?; + assert_eq!( + contents, + files.iter().map(|f| (f.to_string(), cache.join(f), 0)).collect::>() + ); + Ok(()) + } +} diff --git a/crates/pop-cli/src/commands/mod.rs b/crates/pop-cli/src/commands/mod.rs index 8b1dd242..e8c459bc 100644 --- a/crates/pop-cli/src/commands/mod.rs +++ b/crates/pop-cli/src/commands/mod.rs @@ -1,10 +1,12 @@ // SPDX-License-Identifier: GPL-3.0 +use crate::{cache, cli::Cli}; use clap::Subcommand; use serde_json::{json, Value}; pub(crate) mod build; pub(crate) mod call; +pub(crate) mod clean; pub(crate) mod install; pub(crate) mod new; pub(crate) mod test; @@ -13,6 +15,9 @@ pub(crate) mod up; #[derive(Subcommand)] #[command(subcommand_required = true)] pub(crate) enum Command { + /// Set up the environment for development by installing required packages. + #[clap(alias = "i")] + Install(install::InstallArgs), /// Generate a new parachain, pallet or smart contract. #[clap(alias = "n")] #[cfg(any(feature = "parachain", feature = "contract"))] @@ -33,15 +38,17 @@ pub(crate) enum Command { #[clap(alias = "t")] #[cfg(feature = "contract")] Test(test::TestArgs), - /// Set up the environment for development by installing required packages. - #[clap(alias = "i")] - Install(install::InstallArgs), + /// Remove generated/cached artifacts. + #[clap(alias = "C")] + Clean(clean::CleanArgs), } impl Command { /// Executes the command. pub(crate) async fn execute(self) -> anyhow::Result { match self { + #[cfg(any(feature = "parachain", feature = "contract"))] + Self::Install(args) => install::Command.execute(args).await.map(|_| Value::Null), #[cfg(any(feature = "parachain", feature = "contract"))] Self::New(args) => match args.command { #[cfg(feature = "parachain")] @@ -90,8 +97,14 @@ impl Command { Err(e) => Err(e), }, }, - #[cfg(any(feature = "parachain", feature = "contract"))] - Self::Install(args) => install::Command.execute(args).await.map(|_| Value::Null), + Self::Clean(args) => match args.command { + clean::Command::Cache => { + // Initialize command and execute + clean::CleanCacheCommand { cli: &mut Cli, cache: cache()? } + .execute() + .map(|_| Value::Null) + }, + }, } } } diff --git a/crates/pop-cli/src/main.rs b/crates/pop-cli/src/main.rs index 29413d2a..73ddcd90 100644 --- a/crates/pop-cli/src/main.rs +++ b/crates/pop-cli/src/main.rs @@ -14,6 +14,7 @@ use { std::env::args, }; +mod cli; #[cfg(any(feature = "parachain", feature = "contract"))] mod commands; mod style; diff --git a/crates/pop-cli/src/style.rs b/crates/pop-cli/src/style.rs index ace4f4e7..6aed1ee6 100644 --- a/crates/pop-cli/src/style.rs +++ b/crates/pop-cli/src/style.rs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: GPL-3.0 + use cliclack::ThemeState; #[cfg(any(feature = "parachain", feature = "contract"))] pub(crate) use console::style;