git-gamble 2.13.1

blend TDD + TCR to make sure to develop the right thing 😌, baby step by baby step πŸ‘ΆπŸ¦Ά
Documentation
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;

use crate::message::Message;

use super::gamble_result::GambleError;
use super::gamble_result::GambleResult;
use super::git_is_required::git_is_required;

enum CommandError {
	GambleError(GambleError),
	CommandNotFound(Message),
}

pub(crate) struct Repository {
	path: PathBuf,
	dry_run: bool,
	no_verify: bool,
}

impl Repository {
	pub(crate) fn new(path: &Path, dry_run: bool, no_verify: bool) -> Repository {
		Repository {
			path: path.to_path_buf(),
			dry_run,
			no_verify,
		}
	}

	fn path(&self) -> &Path {
		Path::new(&self.path)
	}

	fn executed_command(&self, program: &str, args: &[&str]) -> String {
		format!("`{program} {}` in {}", args.join(" "), self.path.display())
	}
}

/// commands
impl Repository {
	fn shell_command(&self, program: &str, args: &[&str]) -> Result<(), CommandError> {
		let executed_command = self.executed_command(program, args);

		if self.dry_run {
			log::info!("Would execute {executed_command}");

			return Ok(());
		}

		let command = Command::new(program)
			.current_dir(self.path())
			.args(args)
			.status();

		command
			.map_err(|error| {
				CommandError::CommandNotFound(Message::Error(format!(
					"Failed to execute {executed_command}: {error}"
				)))
			})
			.and_then(|status| match status.code() {
				Some(code) => {
					log::info!("{executed_command} exited with {code}");

					if status.success() {
						Ok(())
					} else {
						Err(CommandError::GambleError(GambleError {
							messages: vec![],
							code,
						}))
					}
				}
				None => Err(CommandError::GambleError(GambleError {
					messages: vec![Message::Error(format!(
						"{executed_command} has exited without a normal code"
					))],
					code: 1,
				})),
			})
	}

	pub(crate) fn command(&self, args: &[&str]) -> GambleResult<()> {
		self.shell_command("git", args)
			.map_err(|error| match error {
				CommandError::CommandNotFound(message) => git_is_required().add_message(message),
				CommandError::GambleError(gamble_error) => gamble_error,
			})
			.map_err(|error| {
				let executed_command =
					&format!("`git {}` in {}", args.join(" "), self.path().display());
				let help_message = format!(
					"Failed to execute {executed_command} returns code {}",
					error.code
				);

				error.add_message(Message::Error(help_message))
			})
	}

	fn allow_unknown_hook_name(&self) -> GambleResult<Option<&str>> {
		self.query(&["hook", "run", "--help"]).map(|output| {
			output
				.contains("allow-unknown-hook-name")
				.then_some("--allow-unknown-hook-name")
		})
	}

	pub(crate) fn run_hook(&self, hook_name: &str, args: &[&str]) -> GambleResult<()> {
		if !cfg!(feature = "with_custom_hooks") {
			return Ok(());
		}

		if self.no_verify {
			log::info!("Would execute {hook_name} hook"); // TODO: test this

			return Ok(());
		}

		self.allow_unknown_hook_name()
			.and_then(|allow_unknown_hook_name| {
				let hook_with_arguments = [
					vec!["hook", "run", "--ignore-missing"],
					allow_unknown_hook_name
						.map(|opt| vec![opt])
						.unwrap_or_default(),
					vec![hook_name, "--"],
					args.to_vec(),
				]
				.concat();
				self.command(&hook_with_arguments).map_err(|error| {
					error
						.add_message(Message::Error(format!(
							"The {hook_name} hook returns code {}, but it should be 0",
							error.code
						)))
						.add_message(Message::Info(format!(
							"You can test the hook by executing : git {}",
							hook_with_arguments.join(" "),
						)))
				})
			})
	}

	pub(crate) fn run_test_command(
		&self,
		test_command: Vec<String>,
	) -> Result<std::process::ExitStatus, GambleError> {
		let command = Command::new(&test_command[0])
			.args(&test_command[1..])
			.status();

		command.map_err(|error| {
			let executed_test_command = test_command.join(" ");
			GambleError {
				messages: vec![
					Message::Error(format!("`{executed_test_command}`: {error}")),
					Message::Error(format!(
						"Test command `{executed_test_command}` in {} has exited without a normal code",
						self.path().display()
					)),
				],
				code: 1,
			}
		})
	}
}

/// queries
impl Repository {
	fn query(&self, args: &[&str]) -> GambleResult<String> {
		let command = Command::new("git")
			.current_dir(self.path())
			.args(args)
			.output();

		command
			.map_err(|error| git_is_required().add_message(Message::Error(format!("{error}"))))
			.and_then(|output| {
				String::from_utf8(output.stdout).map_err(|error| GambleError {
					messages: vec![Message::Error(format!("Can't parse git output : {error}"))],
					code: 1,
				})
			})
			.map_err(|error| {
				let executed_command = self.executed_command("git", args);
				error.add_message(Message::Error(format!(
					"Failed to execute {executed_command}"
				)))
			})
	}

	fn get_commit_hash_for(&self, revision: &str) -> GambleResult<String> {
		self.query(&["rev-parse", revision])
	}

	pub(crate) fn head_is_failing_ref(&self) -> bool {
		let head_commit_hash = self.get_commit_hash_for("HEAD");
		let ref_is_failing_commit_hash = self.get_commit_hash_for("gamble-is-failing");

		match (head_commit_hash, ref_is_failing_commit_hash) {
			(Ok(head_commit_hash), Ok(ref_is_failing_commit_hash)) => {
				head_commit_hash == ref_is_failing_commit_hash
			}
			_ => false,
		}
	}

	pub(crate) fn is_clean(&self) -> GambleResult<bool> {
		self.query(&["status", "-z"])
			.map(|status| status.is_empty())
	}
}