use anyhow::Result; use assert_cmd::{cargo::cargo_bin, Command}; use rexpect::session::{spawn_command, PtySession}; use shackle_shell::user_info::{get_user_groups, get_username}; use std::path::{Path, PathBuf}; use tempfile::TempDir; struct TestContext { p: PtySession, workdir: TempDir, } impl TestContext { fn personal_repo_dir(&self, repo_name: &str) -> PathBuf { let username = get_username().unwrap(); self.workdir .as_ref() .join("git") .join(username) .join(&format!("{}.git", repo_name)) } fn group_repo_dir(&self, group: &str, repo_name: &str) -> PathBuf { self.workdir .as_ref() .join("git") .join(group) .join(&format!("{}.git", repo_name)) } } fn personal_repo_path(repo_name: &str) -> String { let username = get_username().unwrap(); format!("git/{username}/{repo_name}.git") } fn arbitrary_user_group() -> String { get_user_groups().into_iter().next().unwrap() } fn group_repo_path(group: &str, repo_name: &str) -> String { format!("git/{group}/{repo_name}.git") } fn spawn_interactive_process() -> Result { let workdir = tempfile::tempdir()?; let path = cargo_bin(env!("CARGO_PKG_NAME")); let mut command = std::process::Command::new(&path); command.current_dir(&workdir); let mut p = spawn_command(command, Some(3000))?; expect_prompt(&mut p)?; Ok(TestContext { p, workdir }) } fn run_batch_command(batch_command: &str) -> Result { let workdir = tempfile::tempdir()?; let path = cargo_bin(env!("CARGO_PKG_NAME")); let mut command = std::process::Command::new(&path); command.current_dir(&workdir); command.args(["-c", batch_command]); let p = spawn_command(command, Some(3000))?; Ok(TestContext { p, workdir }) } fn expect_prompt(p: &mut PtySession) -> Result<()> { p.exp_string("> ")?; Ok(()) } #[test] fn shows_a_prompt() -> Result<()> { spawn_interactive_process()?; Ok(()) } #[test] fn does_nothing_after_receiving_whitespace_input() -> Result<()> { let mut c = spawn_interactive_process()?; c.p.send_line("")?; expect_prompt(&mut c.p)?; c.p.send_line(" ")?; expect_prompt(&mut c.p)?; Ok(()) } #[test] fn quits_when_eof_is_sent() -> Result<()> { let mut c = spawn_interactive_process()?; c.p.send_control('d')?; c.p.exp_eof()?; Ok(()) } #[test] fn quits_when_exit_command_is_sent() -> Result<()> { let mut c = spawn_interactive_process()?; c.p.send_line("exit")?; c.p.exp_eof()?; Ok(()) } #[test] fn reports_error_with_unsupported_shell_commands() -> Result<()> { let mut c = spawn_interactive_process()?; c.p.send_line("ls")?; c.p.exp_string("error: unrecognized subcommand 'ls'")?; expect_prompt(&mut c.p)?; Ok(()) } #[test] fn reports_error_with_nonsense_input() -> Result<()> { let mut c = spawn_interactive_process()?; c.p.send_line(" asd fg ")?; c.p.exp_string("error: unrecognized subcommand 'asd'")?; expect_prompt(&mut c.p)?; Ok(()) } fn verify_repo_exists(repo_dir: &Path) { Command::new("git") .arg("rev-list") .arg("--all") .current_dir(repo_dir) .assert() .success() .stdout(""); } fn verify_repo_does_not_exist(repo_dir: &Path) { assert!(!repo_dir.exists()); } fn verify_current_branch(repo_dir: &Path, expected_ref: &str) { Command::new("git") .arg("symbolic-ref") .arg("HEAD") .current_dir(repo_dir) .assert() .success() .stdout(format!("{expected_ref}\n")); } fn verify_repo_config_value(repo_dir: &Path, config_key: &str, config_value: Option<&str>) { let assert = Command::new("git") .args(["config", "--local", config_key]) .current_dir(repo_dir) .assert(); match config_value { Some(value) => { assert.success().stdout(format!("{value}\n")); } None => { assert.failure().code(1); } } } #[test] fn can_init_a_new_git_repo() -> Result<()> { let mut c = spawn_interactive_process()?; let repo_name = "my-new-repo"; c.p.send_line(&format!("init {}", repo_name))?; c.p.exp_string(&format!( "Successfully created \"{}\"", personal_repo_path(repo_name) ))?; expect_prompt(&mut c.p)?; let repo_dir = c.personal_repo_dir(repo_name); verify_repo_exists(&repo_dir); verify_current_branch(&repo_dir, "refs/heads/main"); verify_repo_config_value(&repo_dir, "core.sharedrepository", None); Ok(()) } #[test] fn can_init_a_new_shared_git_repo() -> Result<()> { let mut c = spawn_interactive_process()?; let group = arbitrary_user_group(); let repo_name = "my-new-shared-repo"; c.p.send_line(&format!("init --group {} {}", group, repo_name))?; c.p.exp_string(&format!( "Successfully created \"{}\"", group_repo_path(&group, repo_name) ))?; expect_prompt(&mut c.p)?; let repo_dir = c.group_repo_dir(&group, repo_name); verify_repo_exists(&repo_dir); verify_repo_config_value(&repo_dir, "core.sharedrepository", Some("1")); Ok(()) } #[test] fn does_not_init_shared_repo_if_the_user_isnt_in_the_group() -> Result<()> { let mut c = spawn_interactive_process()?; let group = "not-a-real-group"; let repo_name = "my-new-shared-repo"; c.p.send_line(&format!("init --group {} {}", group, repo_name))?; c.p.exp_string("Unknown group")?; Ok(()) } #[test] fn runs_a_single_command_and_exit_with_cli_flag() -> Result<()> { let repo_name = "another-new-repo"; let mut c = run_batch_command(&format!("init {}", repo_name))?; c.p.exp_string(&format!( "Successfully created \"{}\"", personal_repo_path(repo_name) ))?; c.p.exp_eof()?; Ok(()) } #[test] fn allows_quotes_arguments() -> Result<()> { let repo_name = "another-new-repo"; let mut c = spawn_interactive_process()?; c.p.send_line(&format!("\"init\" '{repo_name}'"))?; c.p.exp_string(&format!( "Successfully created \"{}\"", personal_repo_path(repo_name) ))?; Ok(()) } #[test] fn errors_with_an_open_double_quote() -> Result<()> { let mut c = spawn_interactive_process()?; c.p.send_line("\"init 'another-new-repo'")?; c.p.exp_string("Incomplete input")?; Ok(()) } #[test] fn errors_with_an_open_single_quote() -> Result<()> { let mut c = spawn_interactive_process()?; c.p.send_line("'init 'another-new-repo'")?; c.p.exp_string("Incomplete input")?; Ok(()) } #[test] fn allows_single_quotes_and_spaces_inside_double_quotes() -> Result<()> { let repo_name = "shukkie's new repo"; let mut c = spawn_interactive_process()?; c.p.send_line(&format!("init \"{repo_name}\""))?; c.p.exp_string(&format!( "Successfully created \"{}\"", personal_repo_path(repo_name) ))?; Ok(()) } const DEFAULT_DESCRIPTION: &str = "Unnamed repository; edit this file 'description' to name the repository."; fn expect_list_table(c: &mut TestContext, repos: &[(String, String)]) -> Result<()> { c.p.send_line("list")?; c.p.exp_regex(r"\+-+\+-+\+")?; c.p.exp_regex(r"\| path +\| description +\|")?; c.p.exp_regex(r"\+=+\+")?; for (path, description) in repos { c.p.exp_string("| ")?; c.p.exp_string(path)?; c.p.exp_regex(r" +\| ")?; c.p.exp_string(&description)?; c.p.exp_regex(r" +\|")?; } c.p.exp_regex(r"\+-+\+-+\+")?; expect_prompt(&mut c.p)?; Ok(()) } #[test] fn list_can_print_an_empty_list() -> Result<()> { let mut c = spawn_interactive_process()?; expect_list_table(&mut c, &[])?; Ok(()) } #[test] fn list_can_print_a_list_of_personal_repos_with_descriptions() -> Result<()> { let mut c = spawn_interactive_process()?; let repo_name = "my-personal-repo"; c.p.send_line(&format!("init {}", repo_name))?; c.p.exp_string(&format!( "Successfully created \"{}\"", personal_repo_path(repo_name) ))?; expect_prompt(&mut c.p)?; expect_list_table( &mut c, &[( personal_repo_path(repo_name), DEFAULT_DESCRIPTION.to_owned(), )], )?; Ok(()) } #[test] fn list_can_print_a_list_of_all_repos_with_descriptions() -> Result<()> { let mut c = spawn_interactive_process()?; let personal_repo_name = "my-personal-repo"; c.p.send_line(&format!("init {}", personal_repo_name))?; c.p.exp_string(&format!( "Successfully created \"{}\"", personal_repo_path(personal_repo_name) ))?; expect_prompt(&mut c.p)?; let group = arbitrary_user_group(); let shared_repo_name = "my-shared-repo"; c.p.send_line(&format!("init --group {} {}", group, shared_repo_name))?; c.p.exp_string(&format!( "Successfully created \"{}\"", group_repo_path(&group, shared_repo_name) ))?; expect_prompt(&mut c.p)?; expect_list_table( &mut c, &[ ( personal_repo_path(personal_repo_name), DEFAULT_DESCRIPTION.to_owned(), ), ( group_repo_path(&group, shared_repo_name), DEFAULT_DESCRIPTION.to_owned(), ), ], )?; Ok(()) } #[test] fn can_set_the_description_on_a_repo_during_init() -> Result<()> { let mut c = spawn_interactive_process()?; let repo_name = "my-personal-repo"; let description = "A cool repo that does cool things"; c.p.send_line(&format!("init --description \"{description}\" {repo_name}"))?; c.p.exp_string(&format!( "Successfully created \"{}\"", personal_repo_path(repo_name) ))?; expect_list_table( &mut c, &[(personal_repo_path(repo_name), description.to_owned())], )?; Ok(()) } #[test] fn can_change_the_description_on_a_repo() -> Result<()> { let mut c = spawn_interactive_process()?; let repo_name = "my-personal-repo"; let description = "A cool repo that does cool things"; let repo_path = personal_repo_path(repo_name); c.p.send_line(&format!("init {repo_name}"))?; c.p.exp_string(&format!("Successfully created \"{repo_path}\"",))?; c.p.send_line(&format!( "set-description \"{repo_path}\" \"{description}\"" ))?; c.p.exp_string("Successfully updated description")?; expect_prompt(&mut c.p)?; expect_list_table(&mut c, &[(repo_path, description.to_owned())])?; Ok(()) } #[test] fn can_set_the_main_branch_of_a_new_git_repo() -> Result<()> { let mut c = spawn_interactive_process()?; let repo_name = "my-new-repo"; let main_branch = "foobar"; c.p.send_line(&format!("init --branch {} {}", main_branch, repo_name))?; c.p.exp_string(&format!( "Successfully created \"{}\"", personal_repo_path(repo_name) ))?; let repo_dir = c.personal_repo_dir(repo_name); verify_current_branch(&repo_dir, &format!("refs/heads/{main_branch}")); Ok(()) } #[test] fn can_change_the_main_branch_on_a_repo() -> Result<()> { let mut c = spawn_interactive_process()?; let repo_name = "my-new-repo"; let main_branch = "foobar"; let repo_path = personal_repo_path(repo_name); c.p.send_line(&format!("init {}", repo_name))?; c.p.exp_string(&format!("Successfully created \"{repo_path}\""))?; c.p.send_line(&format!("set-branch \"{repo_path}\" \"{main_branch}\""))?; c.p.exp_string("Successfully updated branch")?; let repo_dir = c.personal_repo_dir(repo_name); verify_current_branch(&repo_dir, &format!("refs/heads/{main_branch}")); Ok(()) } #[test] fn can_delete_a_repo() -> Result<()> { let mut c = spawn_interactive_process()?; let repo_name = "an-old-repo"; let repo_path = personal_repo_path(repo_name); let repo_dir = c.personal_repo_dir(repo_name); c.p.send_line(&format!("init {}", repo_name))?; c.p.exp_string(&format!("Successfully created \"{repo_path}\""))?; verify_repo_exists(&repo_dir); c.p.send_line(&format!("delete \"{repo_path}\""))?; c.p.exp_string(&format!("Successfully deleted \"{repo_path}\""))?; verify_repo_does_not_exist(&repo_dir); Ok(()) }