git-simple-encrypt 3.0.0

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

use clap::{Parser, Subcommand};
use config_file2::Storable;
use log::{debug, info, warn};

use crate::repo::{GitCommand, Repo};

#[derive(Parser, Clone, Debug)]
#[command(author, version, about, long_about = None, after_help = r#"Examples:
git-se p                    # Set/update master password
git-se add file.txt  mydir  # Add files/folders to the encryption list
git-se e                    # Encrypt all files in the list
git-se d                    # Decrypt all files in the list
git-se e xxx.txt dir1 ...   # Encrypt specific files
git-se d xxx.txt dir1 ...   # Decrypt specific files
git-se i                    # Install a pre-commit hook to check encryption before committing
"#)]
#[clap(args_conflicts_with_subcommands = true)]
pub struct Cli {
    /// Encrypt, Decrypt and Add
    #[command(subcommand)]
    pub command: SubCommand,
    /// Repository path, allow both relative and absolute path.
    #[arg(short, long, global = true)]
    #[clap(value_parser = repo_path_parser, default_value = ".")]
    pub repo: PathBuf,
}

fn repo_path_parser(path: &str) -> Result<PathBuf, String> {
    match path_absolutize::Absolutize::absolutize(Path::new(path)) {
        Ok(p) => Ok(p.into_owned()),
        Err(e) => Err(format!("{e}")),
    }
}

impl Default for Cli {
    fn default() -> Self {
        Self {
            command: SubCommand::Pwd,
            repo: PathBuf::from("."),
        }
    }
}

#[derive(Subcommand, Debug, Clone)]
pub enum SubCommand {
    /// Encrypt all files with crypt attr.
    #[clap(alias("e"))]
    Encrypt {
        /// The files or folders to be encrypted.
        paths: Vec<PathBuf>,
    },
    /// Decrypt all files with crypt attr and `.enc` extension.
    #[clap(alias("d"))]
    Decrypt {
        /// The files or folders to be decrypted.
        paths: Vec<PathBuf>,
    },
    /// Mark files or folders as need-to-be-crypted.
    Add { paths: Vec<PathBuf> },
    /// Set key or other config items.
    Set {
        #[clap(subcommand)]
        field: SetField,
    },
    /// Set password interactively.
    #[clap(alias("p"))]
    Pwd,
    /// Check if all files in the crypt list are encrypted.
    #[clap(alias("c"))]
    Check {
        /// The files or folders to check. If empty, checks all files in the
        /// crypt list.
        paths: Vec<PathBuf>,
    },
    /// Install a pre-commit hook to check encryption before committing.
    #[clap(alias("i"))]
    Install,
}

#[derive(Debug, Subcommand, Clone)]
pub enum SetField {
    /// Set key
    Key { value: String },
    /// Set zstd compression level
    ZstdLevel {
        #[clap(value_parser = validate_zstd_level)]
        value: u8,
    },
    /// Set zstd compression enable or not
    EnableZstd {
        #[clap(value_parser = validate_bool)]
        value: bool,
    },
}

impl SetField {
    /// Set a field.
    ///
    /// # Errors
    ///
    /// Returns an error if fail to exec git command or fail to write to config
    /// file.
    pub fn set(&self, repo: &mut Repo) -> anyhow::Result<()> {
        match self {
            Self::Key { value } => {
                warn!("`set key` is deprecated, please use `pwd` or `p` instead.");
                repo.set_config("key", value)?;
                info!("key set to `{value}`");
            }
            Self::EnableZstd { value } => {
                repo.conf.use_zstd = *value;
                info!("zstd compression enabled: {value}");
            }
            Self::ZstdLevel { value } => {
                repo.conf.zstd_level = *value;
                info!("zstd compression level set to {value}");
            }
        }
        debug!("store config to {}", repo.conf.config_path.display());
        repo.conf.store()?;

        Ok(())
    }
}

fn validate_zstd_level(value: &str) -> Result<u8, String> {
    let value = value
        .parse::<u8>()
        .map_err(|_| "value should be a number")?;
    if (1..=22_u8).contains(&value) {
        Ok(value)
    } else {
        Err("value should be 1-22".to_string())
    }
}

fn validate_bool(value: &str) -> Result<bool, String> {
    match value {
        "true" | "1" => Ok(true),
        "false" | "0" => Ok(false),
        _ => Err("value should be `true`, `false`, `1` or `0`".into()),
    }
}