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 repo: 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.repo)
}
}
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"
repo = "fzf"
[build]
steps = ["go build"]
output = "fzf"
"#,
);
let recipe = Recipe::load(file.path()).unwrap();
assert_eq!(recipe.package.owner, "junegunn");
assert_eq!(recipe.package.repo, "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"
repo = "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"
repo = "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"
repo = "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"
repo = "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(),
repo: "ripgrep".to_string(),
description: None,
};
assert_eq!(pkg.slug(), "BurntSushi/ripgrep");
}
}