git-simple-encrypt 2.0.0

Encrypt/decrypt some files in your git repo using only one password
Documentation
use std::{
    path::{Path, PathBuf},
    sync::OnceLock,
};

use anyhow::{Result, anyhow};
use assert2::assert;
use config_file2::LoadConfigFile;
use log::{info, warn};

use crate::{
    config::{CONFIG_FILE_NAME, Config},
    utils::prompt_password,
};

pub const GIT_CONFIG_PREFIX: &str =
    const_str::replace!(concat!(env!("CARGO_CRATE_NAME"), "."), "_", "-");

#[derive(Debug, Clone, Default)]
pub struct Repo {
    /// The absolute path of the opened repo.
    pub path: PathBuf,
    pub conf: Config,
    pub key_sha: OnceLock<Box<[u8]>>,
}

impl Repo {
    /// open a repo. The [`path`] param must be absolute path.
    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
        debug_assert!(path.as_ref().is_absolute(), "given path must be absolute");
        let mut repo_path = path.as_ref().to_path_buf();
        assert!(
            repo_path.exists(),
            "Repo not found: {}",
            repo_path.display()
        );
        assert!(
            repo_path.is_dir(),
            "Not a directory: {}",
            repo_path.display()
        );
        if repo_path
            .file_name()
            .ok_or_else(|| anyhow!("Filename not found"))?
            == ".git"
        {
            repo_path.pop();
        }
        info!("Open repo: {}", repo_path.display());
        let config_file_path = repo_path.join(CONFIG_FILE_NAME);
        if !config_file_path.exists() {
            warn!(
                "Config file not found: `{}`, using default config instead...",
                config_file_path.display()
            );
        }
        let conf = Config::load_or_default(&config_file_path)?.with_repo_path(&repo_path);
        Ok(Self {
            path: repo_path,
            conf,
            key_sha: OnceLock::new(),
        })
    }

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

    pub fn to_absolute_path(&self, path: impl AsRef<Path>) -> PathBuf {
        self.path.join(path.as_ref())
    }

    pub fn get_key(&self) -> String {
        self.get_config("key")
            .expect("Key not found, please exec `git-se p` first.")
    }

    /// set the key interactively
    pub fn set_key_interactive(&self) -> Result<()> {
        let key = prompt_password("Please input your key: ")?;
        self.set_config("key", &key)?;
        info!("Set key: `{key}`");
        Ok(())
    }
}

pub trait GitCommand {
    fn run(&self, args: &[&str]) -> Result<()>;
    fn run_with_output(&self, args: &[&str]) -> Result<String>;
    fn set_config(&self, key: &str, value: &str) -> Result<()>;
    fn get_config(&self, key: &str) -> Result<String>;
}

impl GitCommand for Repo {
    fn run(&self, args: &[&str]) -> Result<()> {
        let output = std::process::Command::new("git")
            .current_dir(&self.path)
            .args(args)
            .output()?;
        if !output.status.success() {
            return Err(anyhow!(
                "Git command failed: {}",
                String::from_utf8_lossy(&output.stderr)
            ));
        }
        Ok(())
    }
    fn run_with_output(&self, args: &[&str]) -> Result<String> {
        let mut cmd = std::process::Command::new("git");

        // we need to check English output in test
        if cfg!(test) {
            cmd.env("LC_ALL", "C.UTF-8").env("LANGUAGE", "C.UTF-8");
        }

        let output = cmd.current_dir(&self.path).args(args).output()?;
        if !output.status.success() {
            return Err(anyhow!(
                "Git command failed: {}",
                String::from_utf8_lossy(&output.stderr)
            ));
        }
        Ok(String::from_utf8(output.stdout)?)
    }
    fn set_config(&self, key: &str, value: &str) -> Result<()> {
        let temp = String::from(GIT_CONFIG_PREFIX) + key;
        self.run(&["config", "--local", &temp, value.trim()])
    }
    fn get_config(&self, key: &str) -> Result<String> {
        let temp = String::from(GIT_CONFIG_PREFIX) + key;
        self.run_with_output(&["config", "--get", &temp])
            .map(|x| x.trim().to_string())
    }
}

#[cfg(test)]
mod tests {
    use path_absolutize::Absolutize;

    use super::*;

    #[test]
    fn test_repo_open() -> Result<()> {
        let repo = Repo::open(Path::new(".").absolutize()?)?;
        assert_eq!(repo.path().file_name().unwrap(), "git-simple-encrypt");
        let repo = Repo::open(Path::new("./.git").absolutize()?)?;
        assert_eq!(repo.path().file_name().unwrap(), "git-simple-encrypt");
        Ok(())
    }
}