1use crate::proposal::DescriptionFormat;
3use crate::Mode;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
8pub struct MergeRequest {
10 #[serde(rename = "commit-message")]
11 #[serde(default, skip_serializing_if = "Option::is_none")]
12 pub commit_message: Option<String>,
14
15 #[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 pub propose_threshold: Option<u32>,
23
24 #[serde(default, deserialize_with = "deserialize_description")]
26 pub description: HashMap<Option<DescriptionFormat>, String>,
27
28 #[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 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 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 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)]
102pub enum Command {
104 Shell(String),
106
107 Argv(Vec<String>),
109}
110
111impl Command {
112 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 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
132pub struct RecipeBuilder {
134 recipe: Recipe,
135}
136
137impl Default for RecipeBuilder {
138 fn default() -> Self {
139 Self::new()
140 }
141}
142
143impl RecipeBuilder {
144 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 pub fn name(mut self, name: String) -> Self {
161 self.recipe.name = Some(name);
162 self
163 }
164
165 pub fn merge_request(mut self, merge_request: MergeRequest) -> Self {
167 self.recipe.merge_request = Some(merge_request);
168 self
169 }
170
171 pub fn labels(mut self, labels: Vec<String>) -> Self {
173 self.recipe.labels = Some(labels);
174 self
175 }
176
177 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 pub fn command(mut self, command: Command) -> Self {
189 self.recipe.command = Some(command);
190 self
191 }
192
193 pub fn argv(mut self, argv: Vec<String>) -> Self {
195 self.recipe.command = Some(Command::Argv(argv));
196 self
197 }
198
199 pub fn shell(mut self, shell: String) -> Self {
201 self.recipe.command = Some(Command::Shell(shell));
202 self
203 }
204
205 pub fn mode(mut self, mode: Mode) -> Self {
207 self.recipe.mode = Some(mode);
208 self
209 }
210
211 pub fn resume(mut self, resume: bool) -> Self {
213 self.recipe.resume = Some(resume);
214 self
215 }
216
217 pub fn commit_pending(mut self, commit_pending: crate::CommitPending) -> Self {
219 self.recipe.commit_pending = commit_pending;
220 self
221 }
222
223 pub fn build(self) -> Recipe {
225 self.recipe
226 }
227}
228
229#[derive(Debug, Serialize, Deserialize, Clone)]
230pub struct Recipe {
232 pub name: Option<String>,
234
235 #[serde(rename = "merge-request")]
236 pub merge_request: Option<MergeRequest>,
238
239 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub labels: Option<Vec<String>>,
242
243 pub command: Option<Command>,
245
246 pub mode: Option<Mode>,
248
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub resume: Option<bool>,
252
253 #[serde(rename = "commit-pending")]
254 #[serde(default, skip_serializing_if = "crate::CommitPending::is_default")]
256 pub commit_pending: crate::CommitPending,
257}
258
259impl Recipe {
260 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 assert_eq!(shell_command.shell(), "echo hello");
371
372 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 assert_eq!(argv_command.shell(), "echo hello");
385
386 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 let commit_message = merge_request.render_commit_message(&context).unwrap();
420 assert_eq!(commit_message, Some("Commit: test-value".to_string()));
421
422 let title = merge_request.render_title(&context).unwrap();
424 assert_eq!(title, Some("Title: test-value".to_string()));
425
426 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 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 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 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 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 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 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 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 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 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}