Skip to main content

silver_platter/
recipe.rs

1//! Recipes
2use crate::proposal::DescriptionFormat;
3use crate::Mode;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
8/// Merge request configuration
9pub struct MergeRequest {
10    #[serde(rename = "commit-message")]
11    #[serde(default, skip_serializing_if = "Option::is_none")]
12    /// Commit message template
13    pub commit_message: Option<String>,
14
15    /// Title template
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub title: Option<String>,
18
19    #[serde(rename = "propose-threshold")]
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    /// Value threshold for proposing the merge request
22    pub propose_threshold: Option<u32>,
23
24    /// Description templates
25    #[serde(default, deserialize_with = "deserialize_description")]
26    pub description: HashMap<Option<DescriptionFormat>, String>,
27
28    /// Whether to enable automatic merge
29    #[serde(
30        rename = "auto-merge",
31        default,
32        skip_serializing_if = "Option::is_none"
33    )]
34    pub auto_merge: Option<bool>,
35}
36
37fn deserialize_description<'de, D>(
38    deserializer: D,
39) -> Result<HashMap<Option<DescriptionFormat>, String>, D::Error>
40where
41    D: serde::Deserializer<'de>,
42{
43    #[derive(Deserialize)]
44    #[serde(untagged)]
45    enum StringOrMap {
46        String(String),
47        Map(HashMap<Option<DescriptionFormat>, String>),
48    }
49
50    let helper = StringOrMap::deserialize(deserializer)?;
51    let mut result = HashMap::new();
52    match helper {
53        StringOrMap::String(s) => {
54            result.insert(None, s);
55        }
56        StringOrMap::Map(m) => {
57            result = m;
58        }
59    }
60    Ok(result)
61}
62
63impl MergeRequest {
64    /// Render a commit message
65    pub fn render_commit_message(&self, context: &tera::Context) -> tera::Result<Option<String>> {
66        let mut tera = tera::Tera::default();
67        self.commit_message
68            .as_ref()
69            .map(|m| tera.render_str(m, context))
70            .transpose()
71    }
72
73    /// Render the title of the merge request
74    pub fn render_title(&self, context: &tera::Context) -> tera::Result<Option<String>> {
75        let mut tera = tera::Tera::default();
76        self.title
77            .as_ref()
78            .map(|m| tera.render_str(m, context))
79            .transpose()
80    }
81
82    /// Render the description of the merge request
83    pub fn render_description(
84        &self,
85        description_format: DescriptionFormat,
86        context: &tera::Context,
87    ) -> tera::Result<Option<String>> {
88        let mut tera = tera::Tera::default();
89        let template = if let Some(template) = self.description.get(&Some(description_format)) {
90            template
91        } else if let Some(template) = self.description.get(&None) {
92            template
93        } else {
94            return Ok(None);
95        };
96        Ok(Some(tera.render_str(template.as_str(), context)?))
97    }
98}
99
100#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
101#[serde(untagged)]
102/// Command as either a shell string or a vector of arguments
103pub enum Command {
104    /// Command as a shell string
105    Shell(String),
106
107    /// Command as a vector of arguments
108    Argv(Vec<String>),
109}
110
111impl Command {
112    /// Get the command as a shell string
113    pub fn shell(&self) -> String {
114        match self {
115            Command::Shell(s) => s.clone(),
116            Command::Argv(v) => {
117                let args = v.iter().map(|x| x.as_str()).collect::<Vec<_>>();
118                shlex::try_join(args).unwrap()
119            }
120        }
121    }
122
123    /// Get the command as a vector of arguments
124    pub fn argv(&self) -> Vec<String> {
125        match self {
126            Command::Shell(s) => vec!["sh".to_string(), "-c".to_string(), s.clone()],
127            Command::Argv(v) => v.clone(),
128        }
129    }
130}
131
132/// A recipe builder
133pub struct RecipeBuilder {
134    recipe: Recipe,
135}
136
137impl Default for RecipeBuilder {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143impl RecipeBuilder {
144    /// Create a new recipe builder
145    pub fn new() -> Self {
146        Self {
147            recipe: Recipe {
148                name: None,
149                merge_request: None,
150                labels: None,
151                command: None,
152                mode: None,
153                resume: None,
154                commit_pending: crate::CommitPending::default(),
155            },
156        }
157    }
158
159    /// Set the name of the recipe
160    pub fn name(mut self, name: String) -> Self {
161        self.recipe.name = Some(name);
162        self
163    }
164
165    /// Set the merge request configuration
166    pub fn merge_request(mut self, merge_request: MergeRequest) -> Self {
167        self.recipe.merge_request = Some(merge_request);
168        self
169    }
170
171    /// Set the labels to apply to the merge request
172    pub fn labels(mut self, labels: Vec<String>) -> Self {
173        self.recipe.labels = Some(labels);
174        self
175    }
176
177    /// Set a label to apply to the merge request
178    pub fn label(mut self, label: String) -> Self {
179        if let Some(labels) = &mut self.recipe.labels {
180            labels.push(label);
181        } else {
182            self.recipe.labels = Some(vec![label]);
183        }
184        self
185    }
186
187    /// Set the command to run
188    pub fn command(mut self, command: Command) -> Self {
189        self.recipe.command = Some(command);
190        self
191    }
192
193    /// Set the command to run as an argv
194    pub fn argv(mut self, argv: Vec<String>) -> Self {
195        self.recipe.command = Some(Command::Argv(argv));
196        self
197    }
198
199    /// Set the command to run as a shell string
200    pub fn shell(mut self, shell: String) -> Self {
201        self.recipe.command = Some(Command::Shell(shell));
202        self
203    }
204
205    /// Set the mode to run the recipe in
206    pub fn mode(mut self, mode: Mode) -> Self {
207        self.recipe.mode = Some(mode);
208        self
209    }
210
211    /// Set whether to resume a previous run
212    pub fn resume(mut self, resume: bool) -> Self {
213        self.recipe.resume = Some(resume);
214        self
215    }
216
217    /// Set whether to commit pending changes
218    pub fn commit_pending(mut self, commit_pending: crate::CommitPending) -> Self {
219        self.recipe.commit_pending = commit_pending;
220        self
221    }
222
223    /// Build the recipe
224    pub fn build(self) -> Recipe {
225        self.recipe
226    }
227}
228
229#[derive(Debug, Serialize, Deserialize, Clone)]
230/// A recipe
231pub struct Recipe {
232    /// Name of the recipe
233    pub name: Option<String>,
234
235    #[serde(rename = "merge-request")]
236    /// Merge request configuration
237    pub merge_request: Option<MergeRequest>,
238
239    /// Labels to apply to the merge request
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub labels: Option<Vec<String>>,
242
243    /// Command to run
244    pub command: Option<Command>,
245
246    /// Mode to run the recipe in
247    pub mode: Option<Mode>,
248
249    /// Whether to resume a previous run
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub resume: Option<bool>,
252
253    #[serde(rename = "commit-pending")]
254    /// Whether to commit pending changes
255    #[serde(default, skip_serializing_if = "crate::CommitPending::is_default")]
256    pub commit_pending: crate::CommitPending,
257}
258
259impl Recipe {
260    /// Load a recipe from a file
261    pub fn from_path(path: &std::path::Path) -> std::io::Result<Self> {
262        let file = std::fs::File::open(path)?;
263        let mut recipe: Recipe = serde_yaml::from_reader(file).unwrap();
264        if recipe.name.is_none() {
265            recipe.name = Some(path.file_stem().unwrap().to_str().unwrap().to_string());
266        }
267        Ok(recipe)
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn test_simple() {
277        let td = tempfile::tempdir().unwrap();
278        let path = td.path().join("test.yaml");
279        std::fs::write(
280            &path,
281            r#"---
282name: test
283command: ["echo", "hello"]
284mode: propose
285merge-request:
286  commit-message: "test commit message"
287  title: "test title"
288  description:
289    plain: "test description"
290"#,
291        )
292        .unwrap();
293
294        let recipe = Recipe::from_path(&path).unwrap();
295        assert_eq!(recipe.name, Some("test".to_string()));
296        assert_eq!(
297            recipe.command.unwrap().argv(),
298            vec!["echo".to_string(), "hello".to_string()]
299        );
300        assert_eq!(recipe.mode, Some(Mode::Propose));
301        assert_eq!(
302            recipe.merge_request,
303            Some(MergeRequest {
304                commit_message: Some("test commit message".to_string()),
305                title: Some("test title".to_string()),
306                propose_threshold: None,
307                auto_merge: None,
308                description: vec![(
309                    Some(DescriptionFormat::Plain),
310                    "test description".to_string()
311                )]
312                .into_iter()
313                .collect(),
314            })
315        );
316    }
317
318    #[test]
319    fn test_builder() {
320        let recipe = RecipeBuilder::new()
321            .name("test".to_string())
322            .command(Command::Argv(vec!["echo".to_string(), "hello".to_string()]))
323            .mode(Mode::Propose)
324            .merge_request(MergeRequest {
325                commit_message: Some("test commit message".to_string()),
326                title: Some("test title".to_string()),
327                propose_threshold: None,
328                auto_merge: None,
329                description: vec![(
330                    Some(DescriptionFormat::Plain),
331                    "test description".to_string(),
332                )]
333                .into_iter()
334                .collect(),
335            })
336            .build();
337        assert_eq!(recipe.name, Some("test".to_string()));
338        assert_eq!(
339            recipe.command.unwrap().argv(),
340            vec!["echo".to_string(), "hello".to_string()]
341        );
342    }
343
344    #[test]
345    fn test_builder_with_optional_fields() {
346        let recipe = RecipeBuilder::new()
347            .name("test".to_string())
348            .command(Command::Argv(vec!["echo".to_string(), "hello".to_string()]))
349            .mode(Mode::Propose)
350            .label("test-label".to_string())
351            .label("another-label".to_string())
352            .resume(true)
353            .commit_pending(crate::CommitPending::Yes)
354            .build();
355
356        assert_eq!(recipe.name, Some("test".to_string()));
357        assert_eq!(
358            recipe.labels,
359            Some(vec!["test-label".to_string(), "another-label".to_string()])
360        );
361        assert_eq!(recipe.resume, Some(true));
362        assert_eq!(recipe.commit_pending, crate::CommitPending::Yes);
363    }
364
365    #[test]
366    fn test_command_shell() {
367        let shell_command = Command::Shell("echo hello".to_string());
368
369        // Test shell() method
370        assert_eq!(shell_command.shell(), "echo hello");
371
372        // Test argv() method for shell command
373        assert_eq!(
374            shell_command.argv(),
375            vec!["sh".to_string(), "-c".to_string(), "echo hello".to_string()]
376        );
377    }
378
379    #[test]
380    fn test_command_argv() {
381        let argv_command = Command::Argv(vec!["echo".to_string(), "hello".to_string()]);
382
383        // Test shell() method for argv command
384        assert_eq!(argv_command.shell(), "echo hello");
385
386        // Test argv() method
387        assert_eq!(
388            argv_command.argv(),
389            vec!["echo".to_string(), "hello".to_string()]
390        );
391    }
392
393    #[test]
394    fn test_merge_request_render() {
395        let merge_request = MergeRequest {
396            commit_message: Some("Commit: {{ var }}".to_string()),
397            title: Some("Title: {{ var }}".to_string()),
398            propose_threshold: None,
399            auto_merge: None,
400            description: [
401                (
402                    Some(DescriptionFormat::Markdown),
403                    "Markdown: {{ var }}".to_string(),
404                ),
405                (
406                    Some(DescriptionFormat::Plain),
407                    "Plain: {{ var }}".to_string(),
408                ),
409                (None, "Default: {{ var }}".to_string()),
410            ]
411            .into_iter()
412            .collect(),
413        };
414
415        let mut context = tera::Context::new();
416        context.insert("var", "test-value");
417
418        // Test rendering commit message
419        let commit_message = merge_request.render_commit_message(&context).unwrap();
420        assert_eq!(commit_message, Some("Commit: test-value".to_string()));
421
422        // Test rendering title
423        let title = merge_request.render_title(&context).unwrap();
424        assert_eq!(title, Some("Title: test-value".to_string()));
425
426        // Test rendering description with specific format
427        let markdown_desc = merge_request
428            .render_description(DescriptionFormat::Markdown, &context)
429            .unwrap();
430        assert_eq!(markdown_desc, Some("Markdown: test-value".to_string()));
431
432        // Test rendering description with another format
433        let plain_desc = merge_request
434            .render_description(DescriptionFormat::Plain, &context)
435            .unwrap();
436        assert_eq!(plain_desc, Some("Plain: test-value".to_string()));
437
438        // Test rendering description with format not defined (should fall back to default)
439        let html_desc = merge_request
440            .render_description(DescriptionFormat::Html, &context)
441            .unwrap();
442        assert_eq!(html_desc, Some("Default: test-value".to_string()));
443    }
444
445    #[test]
446    fn test_merge_request_no_templates() {
447        let merge_request = MergeRequest {
448            commit_message: None,
449            title: None,
450            propose_threshold: None,
451            auto_merge: None,
452            description: HashMap::new(),
453        };
454
455        let context = tera::Context::new();
456
457        // Test rendering with no templates
458        let commit_message = merge_request.render_commit_message(&context).unwrap();
459        assert_eq!(commit_message, None);
460
461        let title = merge_request.render_title(&context).unwrap();
462        assert_eq!(title, None);
463
464        let desc = merge_request
465            .render_description(DescriptionFormat::Markdown, &context)
466            .unwrap();
467        assert_eq!(desc, None);
468    }
469
470    #[test]
471    fn test_merge_request_auto_merge() {
472        // Test default value
473        let merge_request = MergeRequest {
474            commit_message: None,
475            title: None,
476            propose_threshold: None,
477            description: std::collections::HashMap::new(),
478            auto_merge: None,
479        };
480        assert_eq!(merge_request.auto_merge, None);
481
482        // Test explicit true value
483        let merge_request = MergeRequest {
484            commit_message: None,
485            title: None,
486            propose_threshold: None,
487            description: std::collections::HashMap::new(),
488            auto_merge: Some(true),
489        };
490        assert_eq!(merge_request.auto_merge, Some(true));
491
492        // Test explicit false value
493        let merge_request = MergeRequest {
494            commit_message: None,
495            title: None,
496            propose_threshold: None,
497            description: std::collections::HashMap::new(),
498            auto_merge: Some(false),
499        };
500        assert_eq!(merge_request.auto_merge, Some(false));
501    }
502
503    #[test]
504    fn test_merge_request_auto_merge_serialization() {
505        use serde_yaml;
506
507        // Test serialization with auto_merge: true
508        let merge_request = MergeRequest {
509            commit_message: None,
510            title: None,
511            propose_threshold: None,
512            description: std::collections::HashMap::new(),
513            auto_merge: Some(true),
514        };
515        let yaml = serde_yaml::to_string(&merge_request).unwrap();
516        assert!(yaml.contains("auto-merge: true"));
517
518        // Test deserialization
519        let yaml_content = r#"
520auto-merge: true
521"#;
522        let merge_request: MergeRequest = serde_yaml::from_str(yaml_content).unwrap();
523        assert_eq!(merge_request.auto_merge, Some(true));
524
525        // Test deserialization with false
526        let yaml_content = r#"
527auto-merge: false
528"#;
529        let merge_request: MergeRequest = serde_yaml::from_str(yaml_content).unwrap();
530        assert_eq!(merge_request.auto_merge, Some(false));
531    }
532}