diff --git a/Cargo.lock b/Cargo.lock index e950a46..a66b29a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -385,6 +385,7 @@ dependencies = [ "encode_unicode", "lazy_static", "libc", + "unicode-width", "windows-sys 0.45.0", ] @@ -479,6 +480,18 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "fuzzy-matcher", + "shell-words", + "thiserror", +] + [[package]] name = "digest" version = "0.10.7" @@ -683,6 +696,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1502,6 +1524,7 @@ dependencies = [ "anyhow", "chrono", "clap", + "dialoguer", "dircpy", "directories", "fs-err", @@ -1605,6 +1628,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -1822,6 +1851,16 @@ dependencies = [ "syn 2.0.37", ] +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "time" version = "0.3.34" diff --git a/Cargo.toml b/Cargo.toml index 65e92ef..68db85b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,9 @@ path = "src/main.rs" anyhow = "1.0.70" chrono = "0.4.24" clap = { version = "4.1.13", features = ["derive"] } +dialoguer = { version = "0.11.0", default-features = false, features = [ + "fuzzy-select", +] } dircpy = { version = "0.3.15", default-features = false } directories = "5.0.1" fs-err = "2.9.0" diff --git a/src/cli.rs b/src/cli.rs index 878b305..806001a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -22,6 +22,10 @@ pub struct Cli { /// Display of scafalra's data storage location #[arg(long)] pub proj_dir: bool, + + /// Interactive mode + #[arg(short, long, global = true)] + pub interactive: bool, } #[derive(Subcommand)] @@ -54,13 +58,17 @@ pub struct ListArgs { #[derive(Args, Debug)] pub struct RemoveArgs { - pub names: Vec, + /// Template name List + pub names: Option>, } #[derive(Args, Debug)] pub struct RenameArgs { - pub name: String, - pub new_name: String, + /// Template name + pub name: Option, + + /// New Template name + pub new_name: Option, } #[derive(Args, Debug)] @@ -104,7 +112,7 @@ pub struct AddArgs { #[derive(Args, Debug)] pub struct CreateArgs { /// Template name - pub name: String, + pub name: Option, /// Specified directory(defaults to the current directory) pub directory: Option, diff --git a/src/interactive.rs b/src/interactive.rs new file mode 100644 index 0000000..df96516 --- /dev/null +++ b/src/interactive.rs @@ -0,0 +1,33 @@ +use anyhow::Result; +use dialoguer::{theme::ColorfulTheme, FuzzySelect, Input, MultiSelect}; + +pub fn fuzzy_select(itmes: Vec<&String>) -> Result> { + let idx = FuzzySelect::with_theme(&ColorfulTheme::default()) + .with_prompt( + "Typing to search, use ↑↓ to pick, hit 'Enter' to confirm, or hit 'Esc' to exit", + ) + .items(&itmes) + .highlight_matches(true) + .interact_opt()?; + + Ok(idx.map(|i| itmes[i])) +} + +pub fn multi_select(itmes: Vec<&String>) -> Result>> { + let vi = MultiSelect::with_theme(&ColorfulTheme::default()) + .with_prompt( + "Use ↑↓ to pick, hit 'Enter' to confirm, hit 'Space' to select, or hit 'Esc' to exit", + ) + .items(&itmes) + .interact_opt()?; + + Ok(vi.map(|vi| vi.into_iter().map(|i| itmes[i]).collect())) +} + +pub fn input(prompt: &str) -> Result { + let ret = Input::::with_theme(&ColorfulTheme::default()) + .with_prompt(prompt) + .interact_text()?; + + Ok(ret) +} diff --git a/src/main.rs b/src/main.rs index ca02375..2a83607 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod cli; mod colorize; mod config; mod debug; +mod interactive; mod json; mod path_ext; mod repository; @@ -46,6 +47,10 @@ fn run() -> Result<()> { return Ok(()); } + if cli.interactive { + scafalra.interactive_mode = true; + } + if let Some(command) = cli.command { match command { Command::List(args) => scafalra.list(args), diff --git a/src/scafalra.rs b/src/scafalra.rs index a28e5c1..c6244c2 100644 --- a/src/scafalra.rs +++ b/src/scafalra.rs @@ -12,6 +12,7 @@ use crate::{ cli::{AddArgs, CreateArgs, ListArgs, RemoveArgs, RenameArgs, TokenArgs}, config::Config, debug, + interactive::{fuzzy_select, input, multi_select}, path_ext::*, repository::Repository, repository_config::RepositoryConfig, @@ -24,6 +25,7 @@ pub struct Scafalra { config: Config, store: Store, github_api: GitHubApi, + pub interactive_mode: bool, } impl Scafalra { @@ -55,6 +57,7 @@ impl Scafalra { config, store, github_api, + interactive_mode: false, }) } @@ -223,8 +226,22 @@ impl Scafalra { pub fn create(&self, args: CreateArgs) -> Result<()> { debug!("args: {:#?}", args); - let Some(template) = self.store.get(&args.name) else { - let suggestion = self.store.similar_name_suggestion(&args.name); + let tpl_name = match (&args.name, self.interactive_mode) { + (Some(arg_name), false) => Some(arg_name), + (_, true) => fuzzy_select(self.store.all_templates_name())?, + _ => { + anyhow::bail!( + "Provide a name or opt for interactive mode with the `-i` argument" + ) + } + }; + + let Some(tpl_name) = tpl_name else { + return Ok(()); + }; + + let Some(template) = self.store.get(tpl_name) else { + let suggestion = self.store.similar_name_suggestion(tpl_name); anyhow::bail!("{}", suggestion); }; @@ -239,7 +256,7 @@ impl Scafalra { cwd.join(arg_dir) } } else { - cwd.join(args.name) + cwd.join(tpl_name) }; debug!("dest: {:?}", dest); @@ -285,10 +302,37 @@ impl Scafalra { pub fn rename(&mut self, args: RenameArgs) -> Result<()> { debug!("args: {:#?}", args); - let renamed = self.store.rename(&args.name, &args.new_name); + let (name, new_name) = match ( + args.name, + args.new_name, + self.interactive_mode, + ) { + (Some(name), Some(new_name), false) => (name, new_name), + (_, _, true) => { + let name = fuzzy_select(self.store.all_templates_name())?; + let Some(name) = name else { + return Ok(()); + }; + let new_name = input("New name?")?; + (name.clone(), new_name) + } + (Some(_), None, false) => { + anyhow::bail!("Please provide a new name") + } + (_, _, _) => { + anyhow::bail!( + "Provide both the target and new names, or opt for interactive mode with the `-i` argument" + ) + } + }; + + println!("{} {}", name, new_name); + + let renamed = self.store.rename(&name, &new_name); if renamed { self.store.save()?; + println!("{} -> {}", name, new_name); } Ok(()) @@ -297,7 +341,24 @@ impl Scafalra { pub fn remove(&mut self, args: RemoveArgs) -> Result<()> { debug!("args: {:#?}", args); - for name in args.names { + let names = match (args.names, self.interactive_mode) { + (Some(names), false) => Some(names), + (_, true) => { + multi_select(self.store.all_templates_name())? + .map(|vs| vs.into_iter().cloned().collect()) + } + _ => { + anyhow::bail!( + "Provide names or opt for interactive mode with the `-i` argument" + ) + } + }; + + let Some(names) = names else { + return Ok(()); + }; + + for name in names { self.store.remove(&name)?; } @@ -373,6 +434,7 @@ mod test_utils { } } + /// create a template that name is `bar` pub fn with_content(self) -> Self { use crate::path_ext::*; @@ -437,7 +499,7 @@ mod tests { use super::test_utils::{ScafalraMock, ServerMock}; use crate::{ - cli::{test_utils::AddArgsMock, CreateArgs}, + cli::{test_utils::AddArgsMock, CreateArgs, RemoveArgs, RenameArgs}, path_ext::*, store::test_utils::StoreJsonMock, }; @@ -617,7 +679,7 @@ mod tests { let tmp_dir_path = tmp_dir.path(); scafalra.create(CreateArgs { - name: "bar".to_string(), + name: Some("bar".to_string()), // Due to chroot restrictions, a directory is specified here to // simulate the current working directory directory: Some(tmp_dir_path.join("bar")), @@ -629,6 +691,25 @@ mod tests { Ok(()) } + #[test] + fn test_scafalra_create_err() -> Result<()> { + let ScafalraMock { + tmp_dir: _tmp_dir, + scafalra, + .. + } = ScafalraMock::new(); + + let ret = scafalra.create(CreateArgs { + name: None, + directory: None, + with: None, + }); + + assert!(ret.is_err()); + + Ok(()) + } + #[test] fn test_scafalra_create_not_found() -> Result<()> { let ScafalraMock { @@ -638,7 +719,7 @@ mod tests { } = ScafalraMock::new(); let ret = scafalra.create(CreateArgs { - name: "bar".to_string(), + name: Some("bar".to_string()), directory: None, with: None, }); @@ -705,7 +786,7 @@ mod tests { let dest = tmp_dir.path().join("dest"); scafalra.create(CreateArgs { - name: "b".to_string(), + name: Some("b".to_string()), directory: Some(dest.clone()), with: Some("common.txt,copy-dir,copy-all-in-dir/**".to_string()), })?; @@ -720,4 +801,39 @@ mod tests { Ok(()) } + + #[test] + fn test_scafalra_remove_err() -> Result<()> { + let ScafalraMock { + tmp_dir: _tmp_dir, + mut scafalra, + .. + } = ScafalraMock::new(); + + let ret = scafalra.remove(RemoveArgs { + names: None, + }); + + assert!(ret.is_err()); + + Ok(()) + } + + #[test] + fn test_scafalra_rename_err() -> Result<()> { + let ScafalraMock { + tmp_dir: _tmp_dir, + mut scafalra, + .. + } = ScafalraMock::new(); + + let ret = scafalra.rename(RenameArgs { + name: None, + new_name: None, + }); + + assert!(ret.is_err()); + + Ok(()) + } } diff --git a/src/store.rs b/src/store.rs index e0b9de3..23aed6b 100644 --- a/src/store.rs +++ b/src/store.rs @@ -282,6 +282,10 @@ impl Store { similar, } } + + pub fn all_templates_name(&self) -> Vec<&String> { + self.templates.values().map(|v| &v.name).collect() + } } pub struct Suggestion<'a> {