ahiru-tpm 0.4.0

Drop-in replacement for the famous Tmux Plugin Manager (TPM), written in Rust. 🦆
Documentation
use anyhow::{Result, anyhow};
use getset::Getters;
use pest::Parser;
use pest_derive::Parser;

use crate::repo_url::RepoUrl;

#[derive(Debug, Getters, PartialEq, Clone)]
pub struct Spec {
    #[getset(get = "pub")]
    name: String,

    #[getset(get = "pub")]
    url: RepoUrl,

    branch: Option<String>,
}

impl Spec {
    pub fn try_from_url(value: &str) -> Result<Spec> {
        println!("try_from_url: {value}");
        if value.is_empty() {
            Err(anyhow!("Plugin URL must not be empty"))
        } else if value.contains(';') {
            println!("contains ';'");
            Err(anyhow!(
                "Attributes are not supported in legacy plugin definition using `@tpm_plugins`"
            )
            .context(format!("Failed to parse: {value}")))
        } else {
            parse_spec(value)
        }
    }

    pub fn branch(&self) -> Option<&str> {
        self.branch.as_deref()
    }
}

#[derive(Parser)]
#[grammar = "spec.pest"]
struct SpecParser;

impl TryFrom<&str> for Spec {
    type Error = anyhow::Error;

    fn try_from(value: &str) -> Result<Self> {
        if value.is_empty() {
            Err(anyhow!("Plugin spec must not be empty"))
        } else if value.contains(';') {
            Err(anyhow!("Attributes are not supported yet")
                .context(format!("Failed to parse: {value}")))
        } else {
            parse_spec(value)
        }
    }
}

impl TryFrom<String> for Spec {
    type Error = anyhow::Error;

    fn try_from(value: String) -> Result<Self> {
        Self::try_from(value.as_ref())
    }
}

fn parse_spec(value: &str) -> std::result::Result<Spec, anyhow::Error> {
    let mut name = None;
    let mut url = None;
    let mut branch = None;

    for pair in SpecParser::parse(Rule::spec, value)? {
        match pair.as_rule() {
            Rule::short_url => {
                url = Some(RepoUrl::Short(pair.as_str().to_string()));
                name = pair
                    .into_inner()
                    .find(|x| x.as_rule() == Rule::repo)
                    .map(|r| r.as_str().to_string());
            }
            Rule::branch => branch = Some(pair.as_str().to_string()),
            Rule::full_url => {
                url = Some(RepoUrl::Full(pair.as_str().to_string()));
                name = pair
                    .into_inner()
                    .find(|x| x.as_rule() == Rule::repo)
                    .map(|r| r.as_str().to_string());
            }
            Rule::EOI => break,
            Rule::spec | Rule::url | Rule::user | Rule::repo | Rule::ident => unreachable!(),
        };
    }

    Ok(Spec {
        name: name.ok_or_else(|| anyhow!("Failed to get plugin name from spec"))?,
        url: url.ok_or_else(|| anyhow!("Failed to get plugin url from spec"))?,
        branch,
    })
}

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

    #[test]
    fn test_parse_short_url() {
        let value = "user_name/repo-name";
        let expected_spec = Spec {
            name: "repo-name".into(),
            url: RepoUrl::Short("user_name/repo-name".into()),
            branch: None,
        };
        assert_eq!(Spec::try_from(value).unwrap(), expected_spec);
    }

    #[test]
    fn test_parse_short_url_with_branch() {
        let value = "user_name/repo-name#branch/name";
        let expected_spec = Spec {
            name: "repo-name".into(),
            url: RepoUrl::Short("user_name/repo-name".into()),
            branch: Some("branch/name".into()),
        };
        assert_eq!(Spec::try_from(value).unwrap(), expected_spec);
    }

    #[test]
    fn test_should_error_on_empty_value() {
        let value = "";
        let result = Spec::try_from(value);
        let err = result.unwrap_err();
        assert_eq!(format!("{err}"), "Plugin spec must not be empty");
    }
}