dolly-cli 0.1.3

Like apt, but for GitHub repositories — clone, build, install and update tools from source.
Documentation
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use serde::Deserialize;
use thiserror::Error;

use crate::paths::{self, PathsError};

#[derive(Debug, Error)]
pub enum RecipeError {
    #[error("no recipe at `{0}`")]
    NotFound(PathBuf),

    #[error("failed to read recipe `{path}`")]
    Read {
        path: PathBuf,
        #[source]
        source: io::Error,
    },

    #[error("failed to parse recipe `{path}`")]
    Parse {
        path: PathBuf,
        #[source]
        source: toml::de::Error,
    },

    #[error(transparent)]
    Paths(#[from] PathsError),
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Recipe {
    pub package: Package,
    pub build: Build,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Package {
    pub owner: String,
    pub repository: String,
    pub description: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Build {
    pub steps: Vec<String>,
    pub output: PathBuf,
}

impl Package {
    pub fn slug(&self) -> String {
        format!("{}/{}", self.owner, self.repository)
    }
}

impl Recipe {
    pub fn load(path: &Path) -> Result<Self, RecipeError> {
        let contents = fs::read_to_string(path).map_err(|source| {
            if source.kind() == io::ErrorKind::NotFound {
                RecipeError::NotFound(path.to_path_buf())
            } else {
                RecipeError::Read {
                    path: path.to_path_buf(),
                    source,
                }
            }
        })?;
        toml::from_str(&contents).map_err(|source| RecipeError::Parse {
            path: path.to_path_buf(),
            source,
        })
    }

    pub fn find(repo: &str) -> Result<Self, RecipeError> {
        let path = paths::recipes_dir()?.join(format!("{repo}.toml"));
        Self::load(&path)
    }
}

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

    fn write_recipe(content: &str) -> tempfile::NamedTempFile {
        let mut file = tempfile::Builder::new().suffix(".toml").tempfile().unwrap();
        file.write_all(content.as_bytes()).unwrap();
        file
    }

    #[test]
    fn parses_minimal_recipe() {
        let file = write_recipe(
            r#"
[package]
owner = "junegunn"
repository = "fzf"

[build]
steps = ["go build"]
output = "fzf"
"#,
        );

        let recipe = Recipe::load(file.path()).unwrap();
        assert_eq!(recipe.package.owner, "junegunn");
        assert_eq!(recipe.package.repository, "fzf");
        assert_eq!(recipe.package.description, None);
        assert_eq!(recipe.build.steps, vec!["go build".to_string()]);
        assert_eq!(recipe.build.output, PathBuf::from("fzf"));
    }

    #[test]
    fn parses_recipe_with_description() {
        let file = write_recipe(
            r#"
[package]
owner = "junegunn"
repository = "fzf"
description = "A fuzzy finder"

[build]
steps = ["go build"]
output = "fzf"
"#,
        );

        let recipe = Recipe::load(file.path()).unwrap();
        assert_eq!(
            recipe.package.description.as_deref(),
            Some("A fuzzy finder")
        );
    }

    #[test]
    fn parses_multiple_build_steps() {
        let file = write_recipe(
            r#"
[package]
owner = "tmux"
repository = "tmux"

[build]
steps = ["sh autogen.sh", "./configure", "make"]
output = "tmux"
"#,
        );

        let recipe = Recipe::load(file.path()).unwrap();
        assert_eq!(recipe.build.steps.len(), 3);
        assert_eq!(recipe.build.steps[0], "sh autogen.sh");
    }

    #[test]
    fn rejects_unknown_field_in_package() {
        let file = write_recipe(
            r#"
[package]
owner = "junegunn"
repository = "fzf"
tagline = "fuzzy"

[build]
steps = ["go build"]
output = "fzf"
"#,
        );

        let err = Recipe::load(file.path()).unwrap_err();
        assert!(matches!(err, RecipeError::Parse { .. }));
    }

    #[test]
    fn rejects_unknown_field_in_build() {
        let file = write_recipe(
            r#"
[package]
owner = "junegunn"
repository = "fzf"

[build]
steps = ["go build"]
output = "fzf"
mode = "release"
"#,
        );

        let err = Recipe::load(file.path()).unwrap_err();
        assert!(matches!(err, RecipeError::Parse { .. }));
    }

    #[test]
    fn rejects_missing_required_field() {
        let file = write_recipe(
            r#"
[package]
owner = "junegunn"

[build]
steps = ["go build"]
output = "fzf"
"#,
        );

        let err = Recipe::load(file.path()).unwrap_err();
        assert!(matches!(err, RecipeError::Parse { .. }));
    }

    #[test]
    fn rejects_malformed_toml() {
        let file = write_recipe("[package\nthis is not valid toml");
        let err = Recipe::load(file.path()).unwrap_err();
        assert!(matches!(err, RecipeError::Parse { .. }));
    }

    #[test]
    fn returns_not_found_for_missing_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("missing.toml");
        let err = Recipe::load(&path).unwrap_err();
        assert!(matches!(err, RecipeError::NotFound(p) if p == path));
    }

    #[test]
    fn slug_formats_owner_slash_repo() {
        let pkg = Package {
            owner: "BurntSushi".to_string(),
            repository: "ripgrep".to_string(),
            description: None,
        };
        assert_eq!(pkg.slug(), "BurntSushi/ripgrep");
    }
}