From 4ecef316a9142e8108d129151d99ac541812fd08 Mon Sep 17 00:00:00 2001 From: Denis Gorbachev <829578+DenisGorbachev@users.noreply.github.com> Date: Wed, 24 Jul 2024 09:22:43 +0700 Subject: [PATCH] feat!: add --dry-run, add command output, add shell arguments, implement the support message --- Cargo.lock | 12 + Cargo.toml | 3 +- README.md | 67 ++++- src/bin/create-rust-github-private-bin.rs | 4 +- src/bin/create-rust-github-private-lib.rs | 4 +- src/bin/create-rust-github-public-bin.rs | 4 +- src/bin/create-rust-github-public-lib.rs | 4 +- src/bin/create-rust-keybase-private-bin.rs | 4 +- src/lib.rs | 319 +++++++++++++++------ src/main.rs | 4 +- 10 files changed, 328 insertions(+), 97 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0dc5009..a8aa99f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,6 +109,7 @@ version = "0.4.0" dependencies = [ "anyhow", "clap", + "derive-new", "derive_setters", "fs_extra", ] @@ -148,6 +149,17 @@ dependencies = [ "syn", ] +[[package]] +name = "derive-new" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d150dea618e920167e5973d70ae6ece4385b7164e0d799fe7c122dd0a5d912ad" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "derive_setters" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 8cca1e2..1856edf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ announcement = "" [dependencies] anyhow = "1.0.86" -clap = { version = "4.3.24", features = ["derive"] } +clap = { version = "4.3.24", features = ["derive", "env"] } derive_setters = "0.1.6" +derive-new = "0.6.0" fs_extra = "1.3.0" diff --git a/README.md b/README.md index c65d55b..8b49aee 100644 --- a/README.md +++ b/README.md @@ -53,34 +53,81 @@ Usage: create-rust-github-repo [OPTIONS] --name Options: -n, --name Repository name + -d, --dir Target directory for cloning the repository (must include the repo name) (defaults to "{current_dir}/{repo_name}") (see also: --workspace) + -w, --workspace Parent of the target directory for cloning the repository (must NOT include the repo name). If this option is specified, then the repo is cloned to "{workspace}/{repo_name}". The --dir option overrides this option + --shell-cmd - Shell to use for executing commands [default: /bin/sh] + Shell to use for executing commands + + [default: /bin/sh] + + --shell-args + Shell args to use for executing commands (note that '-c' is always passed as last arg) + -c, --copy-configs-from Source directory for config paths + --configs Config paths separated by comma (relative to `copy_configs_from`) (only applies if `copy_configs_from` is specified) (supports files and directories) + --repo-exists-cmd - Shell command to check if repo exists (supports substitutions - see help below) [default: "gh repo view --json nameWithOwner {{name}} 2>/dev/null"] + Shell command to check if repo exists (supports substitutions - see help below) + + [default: "gh repo view --json nameWithOwner {{name}} 2>/dev/null"] + --repo-create-cmd - Shell command to create a repo (supports substitutions - see help below) [default: "gh repo create --private {{name}}"] + Shell command to create a repo (supports substitutions - see help below) + + [default: "gh repo create --private {{name}}"] + --repo-clone-cmd - Shell command to clone a repo (supports substitutions - see help below) [default: "gh repo clone {{name}} {{dir}}"] + Shell command to clone a repo (supports substitutions - see help below) + + [default: "gh repo clone {{name}} {{dir}}"] + --project-init-cmd - Shell command to initialize a project (supports substitutions - see help below) [default: "cargo init"] + Shell command to initialize a project (supports substitutions - see help below) + + [default: "cargo init"] + --project-test-cmd - Shell command to test a project (supports substitutions - see help below) [default: "cargo test"] + Shell command to test a project (supports substitutions - see help below) + + [default: "cargo test"] + --repo-add-args - Shell command to add new files (supports substitutions - see help below) [default: "git add ."] + Shell command to add new files (supports substitutions - see help below) + + [default: "git add ."] + --repo-commit-args - Shell command to make a commit (supports substitutions - see help below) [default: "git commit -m \"Setup project\""] + Shell command to make a commit (supports substitutions - see help below) + + [default: "git commit -m \"Setup project\""] + --repo-push-args - Shell command to push the commit (supports substitutions - see help below) [default: "git push"] + Shell command to push the commit (supports substitutions - see help below) + + [default: "git push"] + + -s, --support-link-probability + The probability of seeing a support link in a single execution of the command is `1 / {{this-field-value}}`. + + Set it to 0 to disable the support link. + + [env: SUPPORT_LINK_PROBABILITY=] + [default: 1] + + --dry-run + Don't actually execute commands that modify the data, only print them (note that read-only commands will still be executed) + -h, --help - Print help + Print help (see a summary with '-h') + -V, --version Print version diff --git a/src/bin/create-rust-github-private-bin.rs b/src/bin/create-rust-github-private-bin.rs index d557e6e..da550dd 100644 --- a/src/bin/create-rust-github-private-bin.rs +++ b/src/bin/create-rust-github-private-bin.rs @@ -1,3 +1,5 @@ +use std::io::{stderr, stdout}; + use clap::Parser; use create_rust_github_repo::CreateRustGithubRepo; @@ -6,5 +8,5 @@ fn main() -> anyhow::Result<()> { CreateRustGithubRepo::parse() .repo_create_cmd("gh repo create --private {{name}}") .project_init_cmd("cargo init --bin") - .run() + .run(&mut stdout(), &mut stderr(), None) } diff --git a/src/bin/create-rust-github-private-lib.rs b/src/bin/create-rust-github-private-lib.rs index 3a6b0c2..069d21e 100644 --- a/src/bin/create-rust-github-private-lib.rs +++ b/src/bin/create-rust-github-private-lib.rs @@ -1,3 +1,5 @@ +use std::io::{stderr, stdout}; + use clap::Parser; use create_rust_github_repo::CreateRustGithubRepo; @@ -6,5 +8,5 @@ fn main() -> anyhow::Result<()> { CreateRustGithubRepo::parse() .repo_create_cmd("gh repo create --private {{name}}") .project_init_cmd("cargo init --lib") - .run() + .run(&mut stdout(), &mut stderr(), None) } diff --git a/src/bin/create-rust-github-public-bin.rs b/src/bin/create-rust-github-public-bin.rs index 43e4fce..ab1dbf3 100644 --- a/src/bin/create-rust-github-public-bin.rs +++ b/src/bin/create-rust-github-public-bin.rs @@ -1,3 +1,5 @@ +use std::io::{stderr, stdout}; + use clap::Parser; use create_rust_github_repo::CreateRustGithubRepo; @@ -6,5 +8,5 @@ fn main() -> anyhow::Result<()> { CreateRustGithubRepo::parse() .repo_create_cmd("gh repo create --public {{name}}") .project_init_cmd("cargo init --bin") - .run() + .run(&mut stdout(), &mut stderr(), None) } diff --git a/src/bin/create-rust-github-public-lib.rs b/src/bin/create-rust-github-public-lib.rs index 28f97ee..ed56938 100644 --- a/src/bin/create-rust-github-public-lib.rs +++ b/src/bin/create-rust-github-public-lib.rs @@ -1,3 +1,5 @@ +use std::io::{stderr, stdout}; + use clap::Parser; use create_rust_github_repo::CreateRustGithubRepo; @@ -6,5 +8,5 @@ fn main() -> anyhow::Result<()> { CreateRustGithubRepo::parse() .repo_create_cmd("gh repo create --public {{name}}") .project_init_cmd("cargo init --lib") - .run() + .run(&mut stdout(), &mut stderr(), None) } diff --git a/src/bin/create-rust-keybase-private-bin.rs b/src/bin/create-rust-keybase-private-bin.rs index 6ccbe3f..c89a266 100644 --- a/src/bin/create-rust-keybase-private-bin.rs +++ b/src/bin/create-rust-keybase-private-bin.rs @@ -1,3 +1,5 @@ +use std::io::{stderr, stdout}; + use clap::Parser; use create_rust_github_repo::CreateRustGithubRepo; @@ -8,5 +10,5 @@ fn main() -> anyhow::Result<()> { .repo_create_cmd("keybase git create {{name}}") .repo_clone_cmd("git clone $(keybase git list | grep \" {{name}} \" | awk '{print $2}') {{dir}}") .project_init_cmd("cargo init --bin") - .run() + .run(&mut stdout(), &mut stderr(), None) } diff --git a/src/lib.rs b/src/lib.rs index 6aea468..759a444 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,37 +29,22 @@ //! * [x] Can be used as a library use std::collections::HashMap; -use std::env::current_dir; -use std::ffi::OsStr; +use std::env::{current_dir, current_exe}; +use std::ffi::{OsStr, OsString}; use std::io; +use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus}; +use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Context; -use clap::{value_parser, Parser, ValueEnum}; +use clap::{value_parser, Parser}; +use derive_new::new; use derive_setters::Setters; use fs_extra::copy_items; use fs_extra::dir::CopyOptions; -#[derive(ValueEnum, Default, Eq, PartialEq, Hash, Clone, Copy, Debug)] -pub enum RepoVisibility { - Public, - #[default] - Private, - Internal, -} - -impl RepoVisibility { - pub fn to_gh_create_repo_flag(&self) -> &'static str { - match self { - RepoVisibility::Public => "--public", - RepoVisibility::Private => "--private", - RepoVisibility::Internal => "--internal", - } - } -} - -#[derive(Parser, Setters, Debug)] +#[derive(Parser, Setters, Default, Debug)] #[command(version, about, author, after_help = "All command arg options support the following substitutions:\n* {{name}} - substituted with --name arg\n* {{dir}} - substituted with resolved directory for repo (the resolved value of --dir)\n")] #[setters(into)] pub struct CreateRustGithubRepo { @@ -73,7 +58,10 @@ pub struct CreateRustGithubRepo { workspace: Option, #[arg(long, help = "Shell to use for executing commands", default_value = "/bin/sh")] - shell_cmd: String, + shell_cmd: OsString, + + #[arg(long, help = "Shell args to use for executing commands (note that '-c' is always passed as last arg)")] + shell_args: Vec, #[arg(long, short, help = "Source directory for config paths", value_parser = value_parser!(PathBuf))] copy_configs_from: Option, @@ -105,10 +93,20 @@ pub struct CreateRustGithubRepo { #[arg(long, help = "Shell command to push the commit (supports substitutions - see help below)", default_value = "git push")] repo_push_args: String, + + /// The probability of seeing a support link in a single execution of the command is `1 / {{this-field-value}}`. + /// + /// Set it to 0 to disable the support link. + #[arg(long, short = 's', env, default_value_t = 1)] + support_link_probability: u64, + + /// Don't actually execute commands that modify the data, only print them (note that read-only commands will still be executed) + #[arg(long)] + dry_run: bool, } impl CreateRustGithubRepo { - pub fn run(self) -> anyhow::Result<()> { + pub fn run(self, stdout: &mut impl Write, stderr: &mut impl Write, now: Option) -> anyhow::Result<()> { let current_dir = current_dir()?; let dir = self .dir @@ -121,111 +119,231 @@ impl CreateRustGithubRepo { ("{{dir}}", dir_string.as_str()), ]); - let repo_exists = success(&self.shell_cmd, ["-c"], [self.repo_exists_cmd], ¤t_dir, &substitutions)?; + let shell = Shell::new(self.shell_cmd, self.shell_args); + let executor = Executor::new(shell, self.dry_run); + + let repo_exists = executor + .is_success(replace_all(self.repo_exists_cmd, &substitutions), ¤t_dir, stderr) + .context("Failed to find out if repository exists")?; if !repo_exists { // Create a GitHub repo - exec(&self.shell_cmd, ["-c"], [self.repo_create_cmd], ¤t_dir, &substitutions).context("Failed to create repository")?; + executor + .exec(replace_all(self.repo_create_cmd, &substitutions), ¤t_dir, stderr) + .context("Failed to create repository")?; } if !dir.exists() { // Clone the repo - exec(&self.shell_cmd, ["-c"], [self.repo_clone_cmd], ¤t_dir, &substitutions).context("Failed to clone repository")?; + executor + .exec(replace_all(self.repo_clone_cmd, &substitutions), ¤t_dir, stderr) + .context("Failed to clone repository")?; } else { - println!("Directory \"{}\" exists, skipping clone command", dir.display()) + writeln!(stdout, "Directory \"{}\" exists, skipping clone command", dir.display())?; } let cargo_toml = dir.join("Cargo.toml"); if !cargo_toml.exists() { // Run cargo init - exec(&self.shell_cmd, ["-c"], [self.project_init_cmd], &dir, &substitutions).context("Failed to initialize the project")?; + executor + .exec(replace_all(self.project_init_cmd, &substitutions), &dir, stderr) + .context("Failed to initialize the project")?; } else { - println!("Cargo.toml exists in \"{}\", skipping `cargo init` command", dir.display()) + writeln!(stdout, "Cargo.toml exists in \"{}\", skipping `cargo init` command", dir.display())?; } if let Some(copy_configs_from) = self.copy_configs_from { let paths: Vec = self .configs .iter() + .filter(|s| !s.is_empty()) .map(|config| copy_configs_from.join(config)) .collect(); - let options = CopyOptions::new() - .skip_exist(true) - .copy_inside(true) - .buffer_size(MEGABYTE); - copy_items(&paths, &dir, &options).context("Failed to copy configuration files")?; + + for path in &paths { + writeln!(stderr, "[INFO] Copying {}", path.display())? + } + + if !self.dry_run { + let options = CopyOptions::new() + .skip_exist(true) + .copy_inside(true) + .buffer_size(MEGABYTE); + copy_items(&paths, &dir, &options).context("Failed to copy configuration files")?; + } } // test - exec(&self.shell_cmd, ["-c"], [self.project_test_cmd], &dir, &substitutions).context("Failed to test the project")?; + executor + .exec(replace_all(self.project_test_cmd, &substitutions), &dir, stderr) + .context("Failed to test the project")?; // add - exec(&self.shell_cmd, ["-c"], [self.repo_add_args], &dir, &substitutions).context("Failed to add files for commit")?; + executor + .exec(replace_all(self.repo_add_args, &substitutions), &dir, stderr) + .context("Failed to add files for commit")?; // commit - exec(&self.shell_cmd, ["-c"], [self.repo_commit_args], &dir, &substitutions).context("Failed to commit changes")?; + executor + .exec(replace_all(self.repo_commit_args, &substitutions), &dir, stderr) + .context("Failed to commit changes")?; // push - exec(&self.shell_cmd, ["-c"], [self.repo_push_args], &dir, &substitutions).context("Failed to push changes")?; + executor + .exec(replace_all(self.repo_push_args, &substitutions), &dir, stderr) + .context("Failed to push changes")?; + + let timestamp = now.unwrap_or_else(get_unix_timestamp_or_zero); + + if self.support_link_probability != 0 && timestamp % self.support_link_probability == 0 { + if let Some(new_issue_url) = get_new_issue_url(CARGO_PKG_REPOSITORY) { + let exe_name = get_current_exe_name() + .and_then(|name| name.into_string().ok()) + .unwrap_or_else(|| String::from("this program")); + let option_name = get_option_name_from_field_name(SUPPORT_LINK_FIELD_NAME); + let thank_you = format!("Thank you for using {exe_name}!"); + let can_we_make_it_better = "Can we make it better for you?"; + let open_issue = format!("Open an issue at {new_issue_url}"); + let newline = ""; + display_message_box( + &[ + newline, + &thank_you, + newline, + can_we_make_it_better, + &open_issue, + newline, + ], + stderr, + )?; + writeln!(stderr, "The message above can be disabled with {option_name} option")?; + } + } Ok(()) } } -pub fn replace_args(args: impl IntoIterator, substitutions: &HashMap<&str, &str>) -> Vec { - args.into_iter() - .map(|arg| replace_all(arg, substitutions)) - .collect() +fn display_message_box(lines: &[&str], writer: &mut impl Write) -> io::Result<()> { + if lines.is_empty() { + return Ok(()); + } + + let width = lines.iter().map(|s| s.len()).max().unwrap_or(0) + 4; + let border = "+".repeat(width); + + writeln!(writer, "{}", border)?; + + for message in lines { + let padding = width - message.len() - 4; + writeln!(writer, "+ {}{} +", message, " ".repeat(padding))?; + } + + writeln!(writer, "{}", border)?; + Ok(()) } -pub fn replace_all(mut input: String, substitutions: &HashMap<&str, &str>) -> String { - for (key, value) in substitutions { - input = input.replace(key, value); +/// This function may return 0 on error +fn get_unix_timestamp_or_zero() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +#[derive(new, Eq, PartialEq, Clone, Debug)] +pub struct Shell { + cmd: OsString, + args: Vec, +} + +impl Shell { + pub fn spawn_and_wait(&self, command: impl AsRef, current_dir: impl AsRef) -> io::Result { + Command::new(&self.cmd) + .args(&self.args) + .arg("-c") + .arg(command) + .current_dir(current_dir) + .spawn()? + .wait() + } + + pub fn exec(&self, command: impl AsRef, current_dir: impl AsRef) -> io::Result { + self.spawn_and_wait(command, current_dir) + .and_then(check_status) + } + + pub fn is_success(&self, command: impl AsRef, current_dir: impl AsRef) -> io::Result { + self.spawn_and_wait(command, current_dir) + .map(|status| status.success()) } - input } -pub fn exec(cmd: impl AsRef, args: impl IntoIterator> + Clone, extra_args: impl IntoIterator, current_dir: impl AsRef, substitutions: &HashMap<&str, &str>) -> io::Result { - let replacements = replace_args(extra_args, substitutions); - let extra_args = replacements.iter().map(AsRef::::as_ref); - exec_raw(cmd, args, extra_args, current_dir) +#[derive(new, Eq, PartialEq, Clone, Debug)] +pub struct Executor { + shell: Shell, + dry_run: bool, } -pub fn success(cmd: impl AsRef, args: impl IntoIterator> + Clone, extra_args: impl IntoIterator, current_dir: impl AsRef, substitutions: &HashMap<&str, &str>) -> io::Result { - let replacements = replace_args(extra_args, substitutions); - let extra_args = replacements.iter().map(AsRef::::as_ref); - success_raw(cmd, args, extra_args, current_dir) +impl Executor { + pub fn exec(&self, command: impl AsRef, current_dir: impl AsRef, stderr: &mut impl Write) -> io::Result> { + writeln!(stderr, "$ {}", command.as_ref().to_string_lossy())?; + if self.dry_run { + Ok(None) + } else { + self.shell.exec(command, current_dir).map(Some) + } + } + + pub fn is_success(&self, command: impl AsRef, current_dir: impl AsRef, stderr: &mut impl Write) -> io::Result { + writeln!(stderr, "$ {}", command.as_ref().to_string_lossy())?; + self.shell.is_success(command, current_dir) + } } -pub fn exec_raw(cmd: impl AsRef, args: impl IntoIterator> + Clone, extra_args: impl IntoIterator>, current_dir: impl AsRef) -> io::Result { - get_status_raw(cmd, args, extra_args, current_dir).and_then(check_status) +fn get_new_issue_url(repo_url: &str) -> Option { + if repo_url.starts_with("https://github.com/") { + Some(repo_url.to_string() + "/issues/new") + } else { + None + } } -pub fn success_raw(cmd: impl AsRef, args: impl IntoIterator> + Clone, extra_args: impl IntoIterator>, current_dir: impl AsRef) -> io::Result { - get_status_raw(cmd, args, extra_args, current_dir).map(|status| status.success()) +fn get_option_name_from_field_name(field_name: &str) -> String { + let field_name = field_name.replace('_', "-"); + format!("--{}", field_name) } -pub fn get_status_raw(cmd: impl AsRef, args: impl IntoIterator> + Clone, extra_args: impl IntoIterator>, current_dir: impl AsRef) -> io::Result { - eprintln!("$ {}", cmd_to_string(cmd.as_ref(), args.clone())); - Command::new(cmd) - .args(args) - .args(extra_args) - .current_dir(current_dir) - .spawn()? - .wait() +fn get_current_exe_name() -> Option { + current_exe() + .map(|exe| exe.file_name().map(OsStr::to_owned)) + .unwrap_or_default() } -fn cmd_to_string(cmd: impl AsRef, args: impl IntoIterator>) -> String { - let mut cmd_str = cmd.as_ref().to_string_lossy().to_string(); - for arg in args { - cmd_str.push(' '); - cmd_str.push_str(arg.as_ref().to_string_lossy().as_ref()); +pub fn replace_args(args: impl IntoIterator, substitutions: &HashMap<&str, &str>) -> Vec { + args.into_iter() + .map(|arg| replace_all(arg, substitutions)) + .collect() +} + +pub fn replace_all(mut input: String, substitutions: &HashMap<&str, &str>) -> String { + for (key, value) in substitutions { + input = input.replace(key, value); } - cmd_str + input } -pub fn check_status(status: ExitStatus) -> io::Result { +// fn cmd_to_string(cmd: impl AsRef, args: impl IntoIterator>) -> String { +// let mut cmd_str = cmd.as_ref().to_string_lossy().to_string(); +// for arg in args { +// cmd_str.push(' '); +// cmd_str.push_str(arg.as_ref().to_string_lossy().as_ref()); +// } +// cmd_str +// } + +fn check_status(status: ExitStatus) -> io::Result { if status.success() { Ok(status) } else { @@ -233,10 +351,51 @@ pub fn check_status(status: ExitStatus) -> io::Result { } } -#[test] -fn verify_cli() { - use clap::CommandFactory; - CreateRustGithubRepo::command().debug_assert(); -} - +const CARGO_PKG_REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY"); +const SUPPORT_LINK_FIELD_NAME: &str = "support_link_probability"; const MEGABYTE: usize = 1048576; + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use super::*; + + #[test] + fn verify_cli() { + use clap::CommandFactory; + CreateRustGithubRepo::command().debug_assert(); + } + + #[cfg(test)] + macro_rules! test_support_link_probability_name { + ($field:ident) => { + let cmd = CreateRustGithubRepo::default(); + cmd.$field(0u64); + assert_eq!(stringify!($field), SUPPORT_LINK_FIELD_NAME); + }; + } + + #[test] + fn test_support_link_probability_name() { + test_support_link_probability_name!(support_link_probability); + } + + #[test] + fn test_support_link() { + let mut stdout = Cursor::new(Vec::new()); + let mut stderr = Cursor::new(Vec::new()); + let cmd = get_dry_cmd().support_link_probability(1u64); + cmd.run(&mut stdout, &mut stderr, Some(0)).unwrap(); + let stderr_string = String::from_utf8(stderr.into_inner()).unwrap(); + assert!(stderr_string.contains("Open an issue")) + } + + fn get_dry_cmd() -> CreateRustGithubRepo { + CreateRustGithubRepo::default() + .name("test") + .shell_cmd("/bin/sh") + .repo_exists_cmd("echo") + .dry_run(true) + } +} diff --git a/src/main.rs b/src/main.rs index c795faa..f584d58 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,9 @@ +use std::io::{stderr, stdout}; + use clap::Parser; use create_rust_github_repo::CreateRustGithubRepo; fn main() -> anyhow::Result<()> { - CreateRustGithubRepo::parse().run() + CreateRustGithubRepo::parse().run(&mut stdout(), &mut stderr(), None) }