use std::{fs, io, path::PathBuf, process::Command};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::tracing::LogResult as _;
#[derive(Debug, Serialize, Deserialize)]
pub struct CommitCache {
pub version: String,
pub wizard_state: WizardState,
pub wizard_answers: WizardAnswers,
}
#[derive(
Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum WizardState {
#[default]
NotStarted,
Ongoing,
Completed,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct WizardAnswers {
pub r#type: Option<String>,
pub scope: Option<String>,
pub description: Option<String>,
pub breaking_change: Option<String>,
pub ticket: Option<String>,
}
#[derive(Debug, Error)]
pub enum LoadError {
#[error("Failed to get the path of the commit cache file")]
CommitCacheFile(#[from] CommitCacheFileError),
#[error("Failed to read the commit cache")]
Read(#[source] io::Error),
}
#[derive(Debug, Error)]
pub enum SaveError {
#[error("Failed to get the path of the git-z directory")]
GitZDir(#[from] GitZDirError),
#[error("Failed to get the path of the commit cache file")]
CommitCacheFile(#[from] CommitCacheFileError),
#[error("Failed to create the git-z directory")]
CreateDir(#[source] io::Error),
#[error("Failed to write the commit cache")]
Write(#[source] io::Error),
}
#[derive(Debug, Error)]
pub enum DiscardError {
#[error("Failed to get the path of the commit cache file")]
CommitCacheFile(#[from] CommitCacheFileError),
#[error("Failed to delete the commit cache file")]
Delete(#[source] io::Error),
}
#[derive(Debug, Error)]
pub enum FromTomlError {
#[error("Unsupported commit cache version {version}")]
UnsupportedVersion {
version: String,
},
#[error("Failed to parse the commit cache file")]
ParseError(#[source] toml::de::Error),
}
#[derive(Debug, Error)]
pub enum CommitCacheFileError {
#[error("Failed to get the path of the git-z directory")]
GitZDirError(#[from] GitZDirError),
}
#[derive(Debug, Error)]
pub enum GitZDirError {
#[error("Failed to get the path of the Git directory")]
GitDirError(#[from] GitDirError),
}
#[derive(Debug, Error)]
pub enum GitDirError {
#[error("Failed to run the git command")]
CannotRunGit(#[source] io::Error),
#[error("{0}")]
GitError(String),
#[error("The output of the git command is not proper UTF-8")]
EncodingError(#[source] std::string::FromUtf8Error),
}
#[derive(Debug, Serialize, Deserialize)]
struct MinimalCommitCache {
version: String,
}
const GITZ_DIR_NAME: &str = "git-z";
const COMMIT_CACHE_FILE_NAME: &str = "commit-cache.toml";
const VERSION: &str = "0.1";
impl Default for CommitCache {
fn default() -> Self {
Self {
version: String::from(VERSION),
wizard_state: WizardState::default(),
wizard_answers: WizardAnswers::default(),
}
}
}
impl CommitCache {
#[tracing::instrument(name = "load_cache", level = "trace")]
pub fn load() -> Result<Self, LoadError> {
let commit_cache_file = commit_cache_file()?;
match fs::read_to_string(&commit_cache_file) {
Ok(commit_cache) => {
tracing::debug!(?commit_cache_file, "loading the commit cache");
let commit_cache = Self::from_toml(&commit_cache)
.unwrap_or_else(|_| {
tracing::warn!(
?commit_cache_file,
"invalid commit cache, discarding it"
);
let _ = Self::discard().ok();
Self::default()
});
tracing::debug!(?commit_cache);
Ok(commit_cache)
}
Err(error) => {
if error.kind() == io::ErrorKind::NotFound {
tracing::debug!(
"no commit cache, starting from an empty one"
);
Ok(Self::default())
} else {
tracing::error!(
?error,
?commit_cache_file,
"cannot read the commit cache"
);
Err(LoadError::Read(error))
}
}
}
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn r#type(&self) -> Option<&str> {
let r#type = self.wizard_answers.r#type.as_deref();
tracing::trace!(?r#type);
r#type
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn scope(&self) -> Option<&str> {
let scope = self.wizard_answers.scope.as_deref();
tracing::trace!(?scope);
scope
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn description(&self) -> Option<&str> {
let description = self.wizard_answers.description.as_deref();
tracing::trace!(?description);
description
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn breaking_change(&self) -> Option<&str> {
let breaking_change = self.wizard_answers.breaking_change.as_deref();
tracing::trace!(?breaking_change);
breaking_change
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn ticket(&self) -> Option<&str> {
let ticket = self.wizard_answers.ticket.as_deref();
tracing::trace!(?ticket);
ticket
}
#[tracing::instrument(level = "trace", skip(self))]
pub fn reset(&mut self) -> Result<(), DiscardError> {
tracing::debug!("resetting the commit cache");
self.wizard_answers = WizardAnswers::default();
self.wizard_state = WizardState::default();
Self::discard()
}
#[tracing::instrument(level = "trace", skip(self))]
pub fn set_type(&mut self, r#type: &str) -> Result<(), SaveError> {
self.wizard_state = WizardState::Ongoing;
self.wizard_answers.r#type = Some(r#type.to_owned());
self.save()
}
#[tracing::instrument(level = "trace", skip(self))]
pub fn set_scope(&mut self, scope: Option<&str>) -> Result<(), SaveError> {
self.wizard_state = WizardState::Ongoing;
self.wizard_answers.scope = scope.map(ToOwned::to_owned);
self.save()
}
#[tracing::instrument(level = "trace", skip(self))]
pub fn set_description(
&mut self,
description: &str,
) -> Result<(), SaveError> {
self.wizard_state = WizardState::Ongoing;
self.wizard_answers.description = Some(description.to_owned());
self.save()
}
#[tracing::instrument(level = "trace", skip(self))]
pub fn set_breaking_change(
&mut self,
breaking_change: Option<&str>,
) -> Result<(), SaveError> {
self.wizard_state = WizardState::Ongoing;
self.wizard_answers.breaking_change =
breaking_change.map(ToOwned::to_owned);
self.save()
}
#[tracing::instrument(level = "trace", skip(self))]
pub fn set_ticket(
&mut self,
ticket: Option<&str>,
) -> Result<(), SaveError> {
self.wizard_state = WizardState::Ongoing;
self.wizard_answers.ticket = ticket.map(ToOwned::to_owned);
self.save()
}
#[tracing::instrument(level = "trace", skip(self))]
pub fn mark_wizard_as_ongoing(&mut self) -> Result<(), SaveError> {
tracing::trace!("marking the wizard as ongoing");
self.wizard_state = WizardState::Ongoing;
self.save()
}
#[tracing::instrument(level = "trace", skip(self))]
pub fn mark_wizard_as_completed(&mut self) -> Result<(), SaveError> {
tracing::debug!("marking the wizard as completed");
self.wizard_state = WizardState::Completed;
self.save()
}
#[tracing::instrument(level = "trace")]
pub fn discard() -> Result<(), DiscardError> {
tracing::debug!("discarding the commit cache");
fs::remove_file(commit_cache_file()?)
.map_err(DiscardError::Delete)
.log_err()?;
Ok(())
}
#[expect(
clippy::unwrap_in_result,
reason = "The expect in this function should not actually panic."
)]
#[tracing::instrument(level = "trace", skip_all)]
fn save(&self) -> Result<(), SaveError> {
tracing::trace!(?self, "saving the commit cache");
#[expect(
clippy::expect_used,
reason = "We control the format, so a serialisation error would be \
a bug in the code, not an error."
)]
let commit_cache = toml::to_string(self)
.expect("Failed to serialise the commit cache");
fs::create_dir_all(gitz_dir()?)
.map_err(SaveError::CreateDir)
.log_err()?;
fs::write(commit_cache_file()?, commit_cache)
.map_err(SaveError::Write)
.log_err()?;
Ok(())
}
#[tracing::instrument(level = "trace", skip_all)]
fn from_toml(toml: &str) -> Result<Self, FromTomlError> {
let minimal_cache: MinimalCommitCache = toml::from_str(toml)
.map_err(FromTomlError::ParseError)
.log_err()?;
if minimal_cache.version.as_str() == VERSION {
let cache = toml::from_str(toml)
.map_err(FromTomlError::ParseError)
.log_err()?;
Ok(cache)
} else {
Err(FromTomlError::UnsupportedVersion {
version: minimal_cache.version,
})
}
}
}
fn commit_cache_file() -> Result<PathBuf, CommitCacheFileError> {
Ok(gitz_dir()?.join(COMMIT_CACHE_FILE_NAME))
}
fn gitz_dir() -> Result<PathBuf, GitZDirError> {
Ok(git_dir()?.join(GITZ_DIR_NAME))
}
#[tracing::instrument(level = "trace")]
fn git_dir() -> Result<PathBuf, GitDirError> {
let git_rev_parse = Command::new("git")
.args(["rev-parse", "--git-dir"])
.output()
.map_err(GitDirError::CannotRunGit)
.log_err()?;
if git_rev_parse.status.success() {
Ok(String::from_utf8(git_rev_parse.stdout)
.map_err(GitDirError::EncodingError)
.log_err()?
.trim()
.into())
} else {
Err(GitDirError::GitError(
String::from_utf8(git_rev_parse.stderr)
.map_err(GitDirError::EncodingError)
.log_err()?
.trim()
.to_owned(),
))
.log_err()
}
}
#[cfg(test)]
mod test {
#![allow(clippy::pedantic, clippy::restriction)]
use indoc::formatdoc;
use super::*;
#[test]
fn toml_representation_for_default() {
let commit_cache = CommitCache::default();
assert_eq!(
toml::to_string(&commit_cache).unwrap(),
formatdoc! {r##"
version = "{VERSION}"
wizard_state = "not_started"
[wizard_answers]
"##}
);
}
#[test]
fn toml_representation_for_ongoing() {
let commit_cache = CommitCache {
version: String::from(VERSION),
wizard_state: WizardState::Ongoing,
wizard_answers: WizardAnswers {
r#type: Some(String::from("feat")),
scope: None,
description: Some(String::from("some description")),
breaking_change: None,
ticket: Some(String::from("#23")),
},
};
assert_eq!(
toml::to_string(&commit_cache).unwrap(),
formatdoc! {r##"
version = "{VERSION}"
wizard_state = "ongoing"
[wizard_answers]
type = "feat"
description = "some description"
ticket = "#23"
"##}
);
}
#[test]
fn toml_representation_for_completed() {
let commit_cache = CommitCache {
version: String::from(VERSION),
wizard_state: WizardState::Completed,
wizard_answers: WizardAnswers::default(),
};
assert_eq!(
toml::to_string(&commit_cache).unwrap(),
formatdoc! {r##"
version = "{VERSION}"
wizard_state = "completed"
[wizard_answers]
"##}
);
}
}