1use 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 #[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 #[arg(long, short = 's', env, default_value_t = 1)]
107 support_link_probability: u64,
108
109 #[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 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), ¤t_dir, stderr)
135 .await
136 .context("Failed to find out if repository exists")?;
137
138 if !repo_exists {
139 executor
141 .exec(replace_all(self.repo_create_cmd, &substitutions), ¤t_dir, stderr)
142 .await
143 .context("Failed to create repository")?;
144 }
145
146 if !dir.exists() {
147 executor
149 .exec(replace_all(self.repo_clone_cmd, &substitutions), ¤t_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 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 executor
205 .exec(replace_all(self.project_test_cmd, &substitutions), &dir, stderr)
206 .await
207 .context("Failed to test the project")?;
208
209 executor
211 .exec(replace_all(self.repo_add_cmd, &substitutions), &dir, stderr)
212 .await
213 .context("Failed to add files for commit")?;
214
215 executor
217 .exec(replace_all(self.repo_commit_cmd, &substitutions), &dir, stderr)
218 .await
219 .context("Failed to commit changes")?;
220
221 executor
223 .exec(replace_all(self.repo_push_cmd, &substitutions), &dir, stderr)
224 .await
225 .context("Failed to push changes")?;
226
227 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
285fn 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
378fn 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}