Skip to main content

dolly_cli/
recipe.rs

1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use serde::Deserialize;
6use thiserror::Error;
7
8use crate::paths::{self, PathsError};
9
10#[derive(Debug, Error)]
11pub enum RecipeError {
12    #[error("no recipe at `{0}`")]
13    NotFound(PathBuf),
14
15    #[error("failed to read recipe `{path}`")]
16    Read {
17        path: PathBuf,
18        #[source]
19        source: io::Error,
20    },
21
22    #[error("failed to parse recipe `{path}`")]
23    Parse {
24        path: PathBuf,
25        #[source]
26        source: toml::de::Error,
27    },
28
29    #[error(transparent)]
30    Paths(#[from] PathsError),
31}
32
33#[derive(Debug, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct Recipe {
36    pub package: Package,
37    pub build: Build,
38}
39
40#[derive(Debug, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct Package {
43    pub owner: String,
44    pub repo: String,
45    pub description: Option<String>,
46}
47
48#[derive(Debug, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct Build {
51    pub steps: Vec<String>,
52    pub output: PathBuf,
53}
54
55impl Package {
56    pub fn slug(&self) -> String {
57        format!("{}/{}", self.owner, self.repo)
58    }
59}
60
61impl Recipe {
62    pub fn load(path: &Path) -> Result<Self, RecipeError> {
63        let contents = fs::read_to_string(path).map_err(|source| {
64            if source.kind() == io::ErrorKind::NotFound {
65                RecipeError::NotFound(path.to_path_buf())
66            } else {
67                RecipeError::Read {
68                    path: path.to_path_buf(),
69                    source,
70                }
71            }
72        })?;
73        toml::from_str(&contents).map_err(|source| RecipeError::Parse {
74            path: path.to_path_buf(),
75            source,
76        })
77    }
78
79    pub fn find(repo: &str) -> Result<Self, RecipeError> {
80        let path = paths::recipes_dir()?.join(format!("{repo}.toml"));
81        Self::load(&path)
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use std::io::Write;
89
90    fn write_recipe(content: &str) -> tempfile::NamedTempFile {
91        let mut file = tempfile::Builder::new().suffix(".toml").tempfile().unwrap();
92        file.write_all(content.as_bytes()).unwrap();
93        file
94    }
95
96    #[test]
97    fn parses_minimal_recipe() {
98        let file = write_recipe(
99            r#"
100[package]
101owner = "junegunn"
102repo = "fzf"
103
104[build]
105steps = ["go build"]
106output = "fzf"
107"#,
108        );
109
110        let recipe = Recipe::load(file.path()).unwrap();
111        assert_eq!(recipe.package.owner, "junegunn");
112        assert_eq!(recipe.package.repo, "fzf");
113        assert_eq!(recipe.package.description, None);
114        assert_eq!(recipe.build.steps, vec!["go build".to_string()]);
115        assert_eq!(recipe.build.output, PathBuf::from("fzf"));
116    }
117
118    #[test]
119    fn parses_recipe_with_description() {
120        let file = write_recipe(
121            r#"
122[package]
123owner = "junegunn"
124repo = "fzf"
125description = "A fuzzy finder"
126
127[build]
128steps = ["go build"]
129output = "fzf"
130"#,
131        );
132
133        let recipe = Recipe::load(file.path()).unwrap();
134        assert_eq!(
135            recipe.package.description.as_deref(),
136            Some("A fuzzy finder")
137        );
138    }
139
140    #[test]
141    fn parses_multiple_build_steps() {
142        let file = write_recipe(
143            r#"
144[package]
145owner = "tmux"
146repo = "tmux"
147
148[build]
149steps = ["sh autogen.sh", "./configure", "make"]
150output = "tmux"
151"#,
152        );
153
154        let recipe = Recipe::load(file.path()).unwrap();
155        assert_eq!(recipe.build.steps.len(), 3);
156        assert_eq!(recipe.build.steps[0], "sh autogen.sh");
157    }
158
159    #[test]
160    fn rejects_unknown_field_in_package() {
161        let file = write_recipe(
162            r#"
163[package]
164owner = "junegunn"
165repo = "fzf"
166tagline = "fuzzy"
167
168[build]
169steps = ["go build"]
170output = "fzf"
171"#,
172        );
173
174        let err = Recipe::load(file.path()).unwrap_err();
175        assert!(matches!(err, RecipeError::Parse { .. }));
176    }
177
178    #[test]
179    fn rejects_unknown_field_in_build() {
180        let file = write_recipe(
181            r#"
182[package]
183owner = "junegunn"
184repo = "fzf"
185
186[build]
187steps = ["go build"]
188output = "fzf"
189mode = "release"
190"#,
191        );
192
193        let err = Recipe::load(file.path()).unwrap_err();
194        assert!(matches!(err, RecipeError::Parse { .. }));
195    }
196
197    #[test]
198    fn rejects_missing_required_field() {
199        let file = write_recipe(
200            r#"
201[package]
202owner = "junegunn"
203
204[build]
205steps = ["go build"]
206output = "fzf"
207"#,
208        );
209
210        let err = Recipe::load(file.path()).unwrap_err();
211        assert!(matches!(err, RecipeError::Parse { .. }));
212    }
213
214    #[test]
215    fn rejects_malformed_toml() {
216        let file = write_recipe("[package\nthis is not valid toml");
217        let err = Recipe::load(file.path()).unwrap_err();
218        assert!(matches!(err, RecipeError::Parse { .. }));
219    }
220
221    #[test]
222    fn returns_not_found_for_missing_file() {
223        let dir = tempfile::tempdir().unwrap();
224        let path = dir.path().join("missing.toml");
225        let err = Recipe::load(&path).unwrap_err();
226        assert!(matches!(err, RecipeError::NotFound(p) if p == path));
227    }
228
229    #[test]
230    fn slug_formats_owner_slash_repo() {
231        let pkg = Package {
232            owner: "BurntSushi".to_string(),
233            repo: "ripgrep".to_string(),
234            description: None,
235        };
236        assert_eq!(pkg.slug(), "BurntSushi/ripgrep");
237    }
238}