create_rust_github_repo/
lib.rs

1//! # Overview
2//!
3//! `create-rust-github-repo` is a CLI program that creates a new repository on GitHub, clones it locally, initializes a Rust project, copies the configs from a pre-existing directory.
4//!
5//! # Examples
6//!
7//! ```shell,ignore
8//! # Create a GitHub repo & init a Rust project
9//! create-rust-github-repo --name my-new-project
10//!
11//! # Copy configs from existing project
12//! create-rust-github-repo --name my-new-project --copy-configs-from ~/workspace/my-existing-project --configs .github,rustfmt.toml,clippy.toml
13//!
14//! # Clone to a specific directory
15//! create-rust-github-repo --name my-new-project --dir ~/workspace/my-new-project
16//!
17//! # Create a public repo
18//! create-rust-github-repo --name my-new-project --repo-create-cmd "gh repo create --public {{name}}"
19//!
20//! # Create a lib instead of bin
21//! create-rust-github-repo --name my-new-project --project-init-cmd "cargo init --lib"
22//! ```
23//!
24//! # Features
25//!
26//! * ✅ Uses existing `gh`, `git`, `cargo` commands
27//! * ✅ Supports overrides for all commands
28//! * ✅ Supports substitutions (see help below)
29//! * ✅ Can be used as a library
30
31use std::borrow::ToOwned;
32use std::collections::HashMap;
33use std::env::{current_dir, current_exe};
34use std::ffi::{OsStr, OsString};
35use std::fs::create_dir_all;
36use std::io;
37use std::io::Write;
38use std::path::{Path, PathBuf};
39use std::process::ExitStatus;
40use std::sync::LazyLock;
41use std::time::{SystemTime, UNIX_EPOCH};
42use tokio::process::Command;
43
44use anyhow::{anyhow, Context};
45use clap::{value_parser, Parser};
46use derive_new::new;
47use derive_setters::Setters;
48use fs_extra::{dir, file};
49
50#[derive(Parser, Setters, Default, Debug)]
51#[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")]
52#[setters(into)]
53pub struct CreateRustGithubRepo {
54    #[arg(long, short = 'n', help = "Repository name")]
55    name: String,
56
57    #[arg(long, short, help = "Target directory for cloning the repository (must include the repo name) (defaults to \"{current_dir}/{repo_name}\") (see also: --workspace)", value_parser = value_parser!(PathBuf))]
58    dir: Option<PathBuf>,
59
60    #[arg(long, short, help = "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", value_parser = value_parser!(PathBuf))]
61    workspace: Option<PathBuf>,
62
63    #[arg(long, help = "Shell to use for executing commands", default_value = "/bin/sh")]
64    shell_cmd: OsString,
65
66    #[arg(long, help = "Shell args to use for executing commands (note that '-c' is always passed as last arg)")]
67    shell_args: Vec<OsString>,
68
69    #[arg(long, short, help = "Source directory for config paths", value_parser = value_parser!(PathBuf))]
70    copy_configs_from: Option<PathBuf>,
71
72    /// Config paths separated by comma (relative to `copy_configs_from`) (only applies if `copy_configs_from` is specified) (supports files and directories)
73    #[arg(long, value_delimiter = ',')]
74    configs: Vec<String>,
75
76    #[arg(long, help = "Shell command to check if repo exists (supports substitutions - see help below)", default_value = "gh repo view --json nameWithOwner {{name}} 2>/dev/null")]
77    repo_exists_cmd: String,
78
79    #[arg(long, help = "Shell command to create a repo (supports substitutions - see help below)", default_value = "gh repo create --private {{name}}")]
80    repo_create_cmd: String,
81
82    #[arg(long, help = "Shell command to clone a repo (supports substitutions - see help below)", default_value = "gh repo clone {{name}} {{dir}}")]
83    repo_clone_cmd: String,
84
85    #[arg(long, help = "Shell command to initialize a project (supports substitutions - see help below)", default_value = "cargo init")]
86    project_init_cmd: String,
87
88    #[arg(long, help = "Shell command to test a project (supports substitutions - see help below)", default_value = "cargo test")]
89    project_test_cmd: String,
90
91    #[arg(long, help = "Shell command to add new files (supports substitutions - see help below)", default_value = "git add .")]
92    repo_add_cmd: String,
93
94    #[arg(long, help = "Shell command to make a commit (supports substitutions - see help below)", default_value = "git commit -m \"feat: setup project\"")]
95    repo_commit_cmd: String,
96
97    #[arg(long, help = "Shell command to push the commit (supports substitutions - see help below)", default_value = "git push")]
98    repo_push_cmd: String,
99
100    #[arg(long, help = "Shell command to execute after all other commands (supports substitutions - see help below)")]
101    after_all_cmd: Option<String>,
102
103    /// The probability of seeing a support link in a single execution of the command is `1 / {{this-field-value}}`.
104    ///
105    /// Set it to 0 to disable the support link.
106    #[arg(long, short = 's', env, default_value_t = 1)]
107    support_link_probability: u64,
108
109    /// Don't actually execute commands that modify the data, only print them (note that read-only commands will still be executed)
110    #[arg(long)]
111    dry_run: bool,
112}
113
114impl CreateRustGithubRepo {
115    pub async fn run(self, stdout: &mut impl Write, stderr: &mut impl Write, now: Option<u64>) -> anyhow::Result<()> {
116        // let client = posthog_rs::client(env!("phc_oVuia2IowZytcMTQn7lQVWgWYPu1ckdpj43DnJ7TamJ"));
117
118        let current_dir = current_dir()?;
119        let dir = self
120            .dir
121            .or_else(|| self.workspace.map(|workspace| workspace.join(&self.name)))
122            .unwrap_or(current_dir.join(&self.name));
123        let dir_string = dir.display().to_string();
124
125        let substitutions = HashMap::<&'static str, &str>::from([
126            ("{{name}}", self.name.as_str()),
127            ("{{dir}}", dir_string.as_str()),
128        ]);
129
130        let shell = Shell::new(self.shell_cmd, self.shell_args);
131        let executor = Executor::new(shell, self.dry_run);
132
133        let repo_exists = executor
134            .is_success(replace_all(self.repo_exists_cmd, &substitutions), &current_dir, stderr)
135            .await
136            .context("Failed to find out if repository exists")?;
137
138        if !repo_exists {
139            // Create a GitHub repo
140            executor
141                .exec(replace_all(self.repo_create_cmd, &substitutions), &current_dir, stderr)
142                .await
143                .context("Failed to create repository")?;
144        }
145
146        if !dir.exists() {
147            // Clone the repo
148            executor
149                .exec(replace_all(self.repo_clone_cmd, &substitutions), &current_dir, stderr)
150                .await
151                .context("Failed to clone repository")?;
152        } else {
153            writeln!(stdout, "Directory \"{}\" exists, skipping clone command", dir.display())?;
154        }
155
156        let cargo_toml = dir.join("Cargo.toml");
157
158        if !cargo_toml.exists() {
159            // Run cargo init
160            executor
161                .exec(replace_all(self.project_init_cmd, &substitutions), &dir, stderr)
162                .await
163                .context("Failed to initialize the project")?;
164        } else {
165            writeln!(stdout, "Cargo.toml exists in \"{}\", skipping `cargo init` command", dir.display())?;
166        }
167
168        if let Some(copy_configs_from) = self.copy_configs_from {
169            let non_empty_configs = self.configs.iter().filter(|s| !s.is_empty());
170
171            for config in non_empty_configs {
172                let source = copy_configs_from.join(config);
173                let target = dir.join(config);
174
175                if !self.dry_run {
176                    if source.exists() && !target.exists() {
177                        writeln!(stderr, "[INFO] Copying {} to {}", source.display(), target.display())?;
178                        let parent = target
179                            .parent()
180                            .ok_or(anyhow!("Could not find parent of {}", source.display()))?;
181                        create_dir_all(parent)?;
182                        if source.is_file() {
183                            let options = file::CopyOptions::new()
184                                .skip_exist(true)
185                                .buffer_size(MEGABYTE);
186                            file::copy(&source, &target, &options)?;
187                        } else {
188                            let options = dir::CopyOptions::new()
189                                .skip_exist(true)
190                                .copy_inside(true)
191                                .buffer_size(MEGABYTE);
192                            dir::copy(&source, &target, &options)?;
193                        }
194                    } else {
195                        writeln!(stderr, "[INFO] Skipping {} because {} exists", source.display(), target.display())?;
196                    }
197                } else {
198                    writeln!(stderr, "[INFO] Would copy {} to {}", source.display(), target.display())?;
199                }
200            }
201        }
202
203        // test
204        executor
205            .exec(replace_all(self.project_test_cmd, &substitutions), &dir, stderr)
206            .await
207            .context("Failed to test the project")?;
208
209        // add
210        executor
211            .exec(replace_all(self.repo_add_cmd, &substitutions), &dir, stderr)
212            .await
213            .context("Failed to add files for commit")?;
214
215        // commit
216        executor
217            .exec(replace_all(self.repo_commit_cmd, &substitutions), &dir, stderr)
218            .await
219            .context("Failed to commit changes")?;
220
221        // push
222        executor
223            .exec(replace_all(self.repo_push_cmd, &substitutions), &dir, stderr)
224            .await
225            .context("Failed to push changes")?;
226
227        // after all
228        if let Some(after_all_cmd) = self.after_all_cmd {
229            executor
230                .exec(replace_all(after_all_cmd, &substitutions), &dir, stderr)
231                .await
232                .context("Failed to run after_all_cmd")?;
233        }
234
235        let timestamp = now.unwrap_or_else(get_unix_timestamp_or_zero);
236
237        if self.support_link_probability != 0 && timestamp % self.support_link_probability == 0 {
238            if let Some(new_issue_url) = get_new_issue_url(CARGO_PKG_REPOSITORY) {
239                let exe_name = get_current_exe_name()
240                    .and_then(|name| name.into_string().ok())
241                    .unwrap_or_else(|| String::from("this program"));
242                let option_name = get_option_name_from_field_name(SUPPORT_LINK_FIELD_NAME);
243                let thank_you = format!("Thank you for using {exe_name}!");
244                let can_we_make_it_better = "Can we make it better for you?";
245                let open_issue = format!("Open an issue at {new_issue_url}");
246                let newline = "";
247                display_message_box(
248                    &[
249                        newline,
250                        &thank_you,
251                        newline,
252                        can_we_make_it_better,
253                        &open_issue,
254                        newline,
255                    ],
256                    stderr,
257                )?;
258                writeln!(stderr, "The message above can be disabled with {option_name} option")?;
259            }
260        }
261
262        Ok(())
263    }
264}
265
266fn display_message_box(lines: &[&str], writer: &mut impl Write) -> io::Result<()> {
267    if lines.is_empty() {
268        return Ok(());
269    }
270
271    let width = lines.iter().map(|s| s.len()).max().unwrap_or(0) + 4;
272    let border = "+".repeat(width);
273
274    writeln!(writer, "{}", border)?;
275
276    for message in lines {
277        let padding = width - message.len() - 4;
278        writeln!(writer, "+ {}{} +", message, " ".repeat(padding))?;
279    }
280
281    writeln!(writer, "{}", border)?;
282    Ok(())
283}
284
285/// This function may return 0 on error
286fn get_unix_timestamp_or_zero() -> u64 {
287    SystemTime::now()
288        .duration_since(UNIX_EPOCH)
289        .unwrap_or_default()
290        .as_secs()
291}
292
293#[derive(new, Eq, PartialEq, Clone, Debug)]
294pub struct Shell {
295    cmd: OsString,
296    args: Vec<OsString>,
297}
298
299impl Shell {
300    pub async fn spawn_and_wait(&self, command: impl AsRef<OsStr>, current_dir: impl AsRef<Path>) -> io::Result<ExitStatus> {
301        Command::new(&self.cmd)
302            .args(&self.args)
303            .arg("-c")
304            .arg(command)
305            .current_dir(current_dir)
306            .spawn()?
307            .wait()
308            .await
309    }
310
311    pub async fn exec(&self, command: impl AsRef<OsStr>, current_dir: impl AsRef<Path>) -> io::Result<ExitStatus> {
312        self.spawn_and_wait(command, current_dir)
313            .await
314            .and_then(check_status)
315    }
316
317    pub async fn is_success(&self, command: impl AsRef<OsStr>, current_dir: impl AsRef<Path>) -> io::Result<bool> {
318        self.spawn_and_wait(command, current_dir)
319            .await
320            .map(|status| status.success())
321    }
322}
323
324#[derive(new, Eq, PartialEq, Clone, Debug)]
325pub struct Executor {
326    shell: Shell,
327    dry_run: bool,
328}
329
330impl Executor {
331    pub async fn exec(&self, command: impl AsRef<OsStr>, current_dir: impl AsRef<Path>, stderr: &mut impl Write) -> io::Result<Option<ExitStatus>> {
332        writeln!(stderr, "$ {}", command.as_ref().to_string_lossy())?;
333        if self.dry_run {
334            Ok(None)
335        } else {
336            self.shell.exec(command, current_dir).await.map(Some)
337        }
338    }
339
340    pub async fn is_success(&self, command: impl AsRef<OsStr>, current_dir: impl AsRef<Path>, stderr: &mut impl Write) -> io::Result<bool> {
341        writeln!(stderr, "$ {}", command.as_ref().to_string_lossy())?;
342        self.shell.is_success(command, current_dir).await
343    }
344}
345
346fn get_new_issue_url(repo_url: &str) -> Option<String> {
347    if repo_url.starts_with("https://github.com/") {
348        Some(repo_url.to_string() + "/issues/new")
349    } else {
350        None
351    }
352}
353
354fn get_option_name_from_field_name(field_name: &str) -> String {
355    let field_name = field_name.replace('_', "-");
356    format!("--{}", field_name)
357}
358
359fn get_current_exe_name() -> Option<OsString> {
360    current_exe()
361        .map(|exe| exe.file_name().map(OsStr::to_owned))
362        .unwrap_or_default()
363}
364
365pub fn replace_args(args: impl IntoIterator<Item = String>, substitutions: &HashMap<&str, &str>) -> Vec<String> {
366    args.into_iter()
367        .map(|arg| replace_all(arg, substitutions))
368        .collect()
369}
370
371pub fn replace_all(mut input: String, substitutions: &HashMap<&str, &str>) -> String {
372    for (key, value) in substitutions {
373        input = input.replace(key, value);
374    }
375    input
376}
377
378// fn cmd_to_string(cmd: impl AsRef<OsStr>, args: impl IntoIterator<Item = impl AsRef<OsStr>>) -> String {
379//     let mut cmd_str = cmd.as_ref().to_string_lossy().to_string();
380//     for arg in args {
381//         cmd_str.push(' ');
382//         cmd_str.push_str(arg.as_ref().to_string_lossy().as_ref());
383//     }
384//     cmd_str
385// }
386
387fn check_status(status: ExitStatus) -> io::Result<ExitStatus> {
388    if status.success() {
389        Ok(status)
390    } else {
391        Err(io::Error::other(format!("Process exited with with status {}", status)))
392    }
393}
394
395pub fn set_keybase_defaults(create_repo: CreateRustGithubRepo) -> CreateRustGithubRepo {
396    create_repo
397        .repo_exists_cmd("keybase git list | grep \" {{name}} \"")
398        .repo_create_cmd("keybase git create {{name}}")
399        .repo_clone_cmd("git clone $(keybase git list | grep \" {{name}} \" | awk '{print $2}') {{dir}}")
400}
401
402const CARGO_PKG_REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
403const SUPPORT_LINK_FIELD_NAME: &str = "support_link_probability";
404const MEGABYTE: usize = 1048576;
405
406#[doc(hidden)]
407static _POSTHOG_API_KEY: LazyLock<String> = LazyLock::new(|| {
408    String::from_utf8(vec![
409        112, 104, 99, 95, 111, 86, 117, 105, 97, 50, 73, 111, 119, 90, 121, 116, 99, 77, 84, 81, 110, 55, 108, 81, 86, 87, 103, 87, 89, 80, 117, 49, 99, 107, 100, 112, 106, 52, 51, 68, 110, 74, 55, 84, 97, 109, 74,
410    ])
411    .unwrap()
412});
413
414#[cfg(test)]
415mod tests {
416    use std::io::Cursor;
417
418    use super::*;
419
420    #[test]
421    fn verify_cli() {
422        use clap::CommandFactory;
423        CreateRustGithubRepo::command().debug_assert();
424    }
425
426    #[cfg(test)]
427    macro_rules! test_support_link_probability_name {
428        ($field:ident) => {
429            let cmd = CreateRustGithubRepo::default();
430            cmd.$field(0u64);
431            assert_eq!(stringify!($field), SUPPORT_LINK_FIELD_NAME);
432        };
433    }
434
435    #[test]
436    fn test_support_link_probability_name() {
437        test_support_link_probability_name!(support_link_probability);
438    }
439
440    #[tokio::test]
441    async fn test_support_link() {
442        let mut stdout = Cursor::new(Vec::new());
443        let mut stderr = Cursor::new(Vec::new());
444        let cmd = get_dry_cmd().support_link_probability(1u64);
445        cmd.run(&mut stdout, &mut stderr, Some(0)).await.unwrap();
446        let stderr_string = String::from_utf8(stderr.into_inner()).unwrap();
447        assert!(stderr_string.contains("Open an issue"))
448    }
449
450    fn get_dry_cmd() -> CreateRustGithubRepo {
451        CreateRustGithubRepo::default()
452            .name("test")
453            .shell_cmd("/bin/sh")
454            .repo_exists_cmd("echo")
455            .dry_run(true)
456    }
457}