shackle-shell 0.4.1

A shell for restricting access on a version control server
Documentation
use clap::Parser;
use std::{
    path::{Path, PathBuf},
    str::FromStr,
};
use thiserror::Error;

#[derive(Parser, Clone, Debug, PartialEq, Eq)]
#[command(name = "")]
pub enum ShackleCommand {
    /// Create a new repository
    Init(InitArgs),
    /// List all repositories available
    List(ListArgs),
    /// Sets the description of a repository, as shown in the CLI listing and web interfaces
    SetDescription(SetDescriptionArgs),
    /// Sets the main branch of the repository
    SetBranch(SetBranchArgs),
    /// Deletes a repository
    Delete(DeleteArgs),
    /// Does any housekeeping, like deleting unreachable objects and repacking more efficiently
    Housekeeping(HousekeepingArgs),
    /// Quit the shell
    Exit,
    /// Server side command required to git fetch from the server
    #[command(hide = true)]
    GitUploadPack(GitUploadPackArgs),
    /// Server side command required by git push to the server
    #[command(hide = true)]
    GitReceivePack(GitReceivePackArgs),
}

#[derive(Parser, Clone, Debug, PartialEq, Eq)]
pub struct InitArgs {
    /// Share repository ownership with the specified group (user must be a member of the group)
    #[arg(long)]
    pub group: Option<String>,
    /// Sets the description of the repository, as shown in the CLI listing and web interfaces
    #[arg(long)]
    pub description: Option<String>,
    /// Sets the main branch of the repository
    #[arg(long, default_value = "main")]
    pub branch: String,
    /// Sets up the new repo to mirror some other upstream repo using the provided git fetch url
    #[arg(long)]
    pub mirror: Option<String>,
    /// Name of the new repository
    pub repo_name: String,
}

#[derive(Parser, Clone, Debug, PartialEq, Eq)]
pub struct ListArgs {
    /// List extra metadata, like the repo's size on disk
    #[arg(short, long)]
    pub verbose: bool,
}

#[derive(Parser, Clone, Debug, PartialEq, Eq)]
pub struct SetDescriptionArgs {
    /// The full relative path of the repository, for example git/shuckie/repo.git
    #[arg(value_parser = RelativePathParser)]
    pub directory: PathBuf,
    /// The new description
    pub description: String,
}

#[derive(Parser, Clone, Debug, PartialEq, Eq)]
pub struct SetBranchArgs {
    /// The full relative path of the repository, for example git/shuckie/repo.git
    #[arg(value_parser = RelativePathParser)]
    pub directory: PathBuf,
    /// The new branch name
    pub branch: String,
}

#[derive(Parser, Clone, Debug, PartialEq, Eq)]
pub struct DeleteArgs {
    /// The full relative path of the repository, for example git/shuckie/repo.git
    #[arg(value_parser = RelativePathParser)]
    pub directory: PathBuf,
}

#[derive(Parser, Clone, Debug, PartialEq, Eq)]
pub struct HousekeepingArgs {
    /// The full relative path of the repository, for example
    /// git/shuckie/repo.git. If omitted, all repos will be checked.
    pub directory: Option<PathBuf>,
}

#[derive(Parser, Clone, Debug, PartialEq, Eq)]
pub struct GitUploadPackArgs {
    /// Do not try <directory>/.git/ if <directory> is no Git directory
    #[arg(long, default_value_t = true)]
    pub strict: bool,
    /// Always try <directory>/.git/ if <directory> is no Git directory - this argument is accepted for compatability with git, but is ignored
    #[arg(long)]
    pub no_strict: bool,
    /// Interrupt transfer after <TIMEOUT> seconds of inactivity
    #[arg(long)]
    pub timeout: Option<u32>,
    /// Perform only a single read-write cycle with stdin and stdout
    #[arg(long)]
    pub stateless_rpc: bool,
    /// Only the initial ref advertisement is output, and the program exits immediately
    #[arg(long)]
    pub advertise_refs: bool,
    /// The full relative path of the repository for example git/shuckie/repo.git
    #[arg(value_parser = RelativePathParser)]
    pub directory: PathBuf,
}

#[derive(Parser, Clone, Debug, PartialEq, Eq)]
pub struct GitReceivePackArgs {
    /// The full relative path of the repository for example git/shuckie/repo.git
    #[arg(value_parser = RelativePathParser)]
    pub directory: PathBuf,
}

#[derive(Error, Debug)]
pub enum ParserError {
    #[error(transparent)]
    ClapError(#[from] clap::error::Error),
    #[error("`{0}`")]
    LexerError(String),
}

impl FromStr for ShackleCommand {
    type Err = ParserError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let trimmed = s.trim();
        let lexed = shlex::split(trimmed);
        match lexed {
            None => Err(ParserError::LexerError("Incomplete input".to_string())),
            Some(lexed) => {
                let parsed =
                    ShackleCommand::try_parse_from(["".to_owned()].into_iter().chain(lexed))?;
                Ok(parsed)
            }
        }
    }
}

#[derive(Clone)]
struct RelativePathParser;

impl clap::builder::TypedValueParser for RelativePathParser {
    type Value = std::path::PathBuf;

    fn parse_ref(
        &self,
        cmd: &clap::Command,
        arg: Option<&clap::Arg>,
        value: &std::ffi::OsStr,
    ) -> Result<Self::Value, clap::Error> {
        clap::builder::TypedValueParser::parse(self, cmd, arg, value.to_owned())
    }

    fn parse(
        &self,
        cmd: &clap::Command,
        arg: Option<&clap::Arg>,
        value: std::ffi::OsString,
    ) -> Result<Self::Value, clap::Error> {
        let raw = clap::builder::PathBufValueParser::default().parse(cmd, arg, value)?;
        Ok(raw
            .strip_prefix(Path::new("/~"))
            .or_else(|_| raw.strip_prefix(Path::new("~")))
            .map(|m| m.to_owned())
            .unwrap_or(raw))
    }
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn it_parses_exit_correctly() {
        assert_eq!(
            "exit".parse::<ShackleCommand>().unwrap(),
            ShackleCommand::Exit
        );
    }

    #[test]
    fn it_parses_git_upload_pack_correctly() {
        assert_eq!(
            "git-upload-pack --stateless-rpc foobar.git"
                .parse::<ShackleCommand>()
                .unwrap(),
            ShackleCommand::GitUploadPack(GitUploadPackArgs {
                strict: true,
                no_strict: false,
                timeout: None,
                stateless_rpc: true,
                advertise_refs: false,
                directory: PathBuf::from("foobar.git"),
            })
        );
    }
}