1use crate::candidates::Candidate;
4use crate::codemod::script_runner;
5use crate::proposal::DescriptionFormat;
6use crate::publish::{Error as PublishError, PublishResult};
7use crate::recipe::Recipe;
8use crate::vcs::{open_branch, BranchOpenError};
9use crate::workspace::Workspace;
10use crate::Mode;
11use breezyshim::branch::{Branch, GenericBranch};
12use breezyshim::error::Error as BrzError;
13use breezyshim::tree::MutableTree;
14use serde::Deserialize;
15use std::collections::HashMap;
16use std::io::Write;
17use std::path::{Path, PathBuf};
18use url::Url;
19
20pub const CURRENT_VERSION: u8 = 1;
22
23#[derive(Debug, serde::Serialize, serde::Deserialize)]
24pub struct Entry {
26 #[serde(skip)]
27 local_path: PathBuf,
28
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub subpath: Option<PathBuf>,
32
33 #[serde(rename = "url")]
35 pub target_branch_url: Option<Url>,
36
37 pub description: String,
39
40 #[serde(
41 rename = "commit-message",
42 default,
43 skip_serializing_if = "Option::is_none"
44 )]
45 pub commit_message: Option<String>,
47
48 #[serde(
49 rename = "auto-merge",
50 default,
51 skip_serializing_if = "Option::is_none"
52 )]
53 pub auto_merge: Option<bool>,
55
56 pub mode: Mode,
58
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub title: Option<String>,
62
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub owner: Option<String>,
66
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub labels: Option<Vec<String>>,
70
71 #[serde(default, skip_serializing_if = "serde_yaml::Value::is_null")]
73 pub context: serde_yaml::Value,
74
75 #[serde(
76 rename = "proposal-url",
77 default,
78 skip_serializing_if = "Option::is_none"
79 )]
80 pub proposal_url: Option<Url>,
82}
83
84#[derive(Debug, serde::Deserialize, serde::Serialize)]
85pub struct Batch {
87 #[serde(default)]
89 pub version: u8,
90
91 #[serde(deserialize_with = "deserialize_recipe")]
93 pub recipe: Recipe,
94
95 pub name: String,
97
98 pub work: HashMap<String, Entry>,
100
101 #[serde(skip)]
102 pub basepath: PathBuf,
104}
105
106fn deserialize_recipe<'de, D>(deserializer: D) -> Result<Recipe, D::Error>
107where
108 D: serde::Deserializer<'de>,
109{
110 #[derive(serde::Deserialize)]
112 #[serde(untagged)]
113 enum RecipeOrPathBuf {
114 Recipe(Recipe),
115 PathBuf(PathBuf),
116 }
117
118 let value = RecipeOrPathBuf::deserialize(deserializer)?;
119
120 match value {
121 RecipeOrPathBuf::Recipe(recipe) => Ok(recipe),
122 RecipeOrPathBuf::PathBuf(path) => {
123 let file = std::fs::File::open(&path).map_err(serde::de::Error::custom)?;
124 let recipe: Recipe = serde_yaml::from_reader(file).map_err(serde::de::Error::custom)?;
125 Ok(recipe)
126 }
127 }
128}
129
130#[derive(Debug)]
131pub enum Error {
133 Script(crate::codemod::Error),
135
136 Vcs(crate::vcs::BranchOpenError),
138
139 Io(std::io::Error),
141
142 Yaml(serde_yaml::Error),
144
145 Tera(tera::Error),
147
148 Workspace(crate::workspace::Error),
150}
151
152impl From<crate::workspace::Error> for Error {
153 fn from(e: crate::workspace::Error) -> Self {
154 Error::Workspace(e)
155 }
156}
157
158impl From<crate::codemod::Error> for Error {
159 fn from(e: crate::codemod::Error) -> Self {
160 Error::Script(e)
161 }
162}
163
164impl From<crate::vcs::BranchOpenError> for Error {
165 fn from(e: crate::vcs::BranchOpenError) -> Self {
166 Error::Vcs(e)
167 }
168}
169
170impl From<std::io::Error> for Error {
171 fn from(e: std::io::Error) -> Self {
172 Error::Io(e)
173 }
174}
175
176impl From<tera::Error> for Error {
177 fn from(e: tera::Error) -> Self {
178 Error::Tera(e)
179 }
180}
181
182impl From<serde_yaml::Error> for Error {
183 fn from(e: serde_yaml::Error) -> Self {
184 Error::Yaml(e)
185 }
186}
187
188impl std::fmt::Display for Error {
189 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
190 match self {
191 Error::Vcs(e) => write!(f, "VCS error: {}", e),
192 Error::Script(e) => write!(f, "Script error: {}", e),
193 Error::Io(e) => write!(f, "I/O error: {}", e),
194 Error::Yaml(e) => write!(f, "YAML error: {}", e),
195 Error::Tera(e) => write!(f, "Tera error: {}", e),
196 Error::Workspace(e) => write!(f, "Workspace error: {}", e),
197 }
198 }
199}
200
201impl Entry {
202 pub fn from_recipe(
204 recipe: &Recipe,
205 basepath: &Path,
206 url: &Url,
207 subpath: &Path,
208 default_mode: Option<Mode>,
209 extra_env: Option<HashMap<String, String>>,
210 ) -> Result<Self, Error> {
211 if !basepath.exists() {
212 std::fs::create_dir_all(basepath)?;
213 }
214 let basepath = basepath.canonicalize().unwrap();
215 let main_branch = match open_branch(url, None, None, None) {
216 Ok(branch) => branch,
217 Err(e) => return Err(Error::Vcs(e)),
218 };
219
220 let ws = Workspace::builder()
221 .main_branch(main_branch)
222 .path(basepath.to_path_buf())
223 .build()?;
224
225 log::info!(
226 "Making changes to {}",
227 ws.main_branch().unwrap().get_user_url()
228 );
229
230 let result = match script_runner(
231 ws.local_tree(),
232 recipe
233 .command
234 .as_ref()
235 .unwrap()
236 .argv()
237 .iter()
238 .map(|s| s.as_str())
239 .collect::<Vec<_>>()
240 .as_slice(),
241 subpath,
242 recipe.commit_pending,
243 None,
244 None,
245 extra_env,
246 std::process::Stdio::inherit(),
247 ) {
248 Ok(result) => result,
249 Err(e) => return Err(Error::Script(e)),
250 };
251
252 let tera_context: tera::Context = tera::Context::from_value(
253 result
254 .context
255 .clone()
256 .unwrap_or_else(|| serde_json::json!({})),
257 )
258 .unwrap();
259
260 let target_branch_url = match result.target_branch_url {
261 Some(url) => Some(url),
262 None => Some(url.clone()),
263 };
264 let description = if let Some(description) = result.description {
265 description
266 } else if let Some(ref mr) = recipe.merge_request {
267 mr.render_description(DescriptionFormat::Markdown, &tera_context)?
268 .unwrap()
269 } else {
270 panic!("No description provided");
271 };
272 let commit_message = if let Some(commit_message) = result.commit_message {
273 Some(commit_message)
274 } else if let Some(ref mr) = recipe.merge_request {
275 mr.render_commit_message(&tera_context)?
276 } else {
277 None
278 };
279 let title = if let Some(title) = result.title {
280 Some(title)
281 } else if let Some(ref mr) = recipe.merge_request {
282 mr.render_title(&tera_context)?
283 } else {
284 None
285 };
286 let mode = recipe.mode.or(default_mode).unwrap_or_default();
287 let labels = recipe.labels.clone();
288 let context = result.context;
289
290 let auto_merge = recipe.merge_request.as_ref().and_then(|mr| mr.auto_merge);
291
292 let owner = None;
293
294 Ok(Entry {
295 local_path: basepath.to_path_buf(),
296 subpath: Some(subpath.to_owned()),
297 target_branch_url,
298 description,
299 commit_message,
300 mode,
301 owner,
302 title,
303 labels,
304 auto_merge,
305 proposal_url: None,
306 context: serde_yaml::from_str(
307 context
308 .unwrap_or(serde_json::Value::Null)
309 .to_string()
310 .as_str(),
311 )
312 .unwrap(),
313 })
314 }
315
316 pub fn status(&self) -> Status {
318 if let Some(proposal_url) = self.proposal_url.as_ref() {
319 let proposal = breezyshim::forge::get_proposal_by_url(proposal_url).unwrap();
320 if proposal.is_merged().unwrap() {
321 Status::Merged(proposal_url.clone())
322 } else if proposal.is_closed().unwrap() {
323 Status::Closed(proposal_url.clone())
324 } else {
325 Status::Open(proposal_url.clone())
326 }
327 } else {
328 Status::NotPublished()
329 }
330 }
331
332 pub fn working_tree(&self) -> Result<Box<dyn breezyshim::tree::WorkingTree>, BrzError> {
334 breezyshim::workingtree::open(&self.local_path)
335 .map(|wt| Box::new(wt) as Box<dyn breezyshim::tree::WorkingTree>)
336 }
337
338 pub fn target_branch(&self) -> Result<GenericBranch, BranchOpenError> {
340 open_branch(self.target_branch_url.as_ref().unwrap(), None, None, None)
341 }
342
343 pub fn local_branch(&self) -> Result<GenericBranch, BranchOpenError> {
345 let url = match url::Url::from_directory_path(&self.local_path) {
346 Ok(url) => url,
347 Err(_) => {
348 return Err(BranchOpenError::Other(format!(
349 "Invalid URL: {}",
350 self.local_path.display()
351 )));
352 }
353 };
354 open_branch(&url, None, None, None)
355 }
356
357 pub fn refresh(
359 &mut self,
360 recipe: &Recipe,
361 extra_env: Option<HashMap<String, String>>,
362 ) -> Result<(), Error> {
363 let url = self.target_branch_url.as_ref().unwrap();
364 let main_branch = match open_branch(url, None, None, None) {
365 Ok(branch) => branch,
366 Err(e) => return Err(Error::Vcs(e)),
367 };
368
369 let ws = Workspace::builder()
370 .main_branch(main_branch)
371 .path(self.local_path.clone())
372 .build()?;
373
374 log::info!(
375 "Making changes to {}",
376 ws.main_branch().unwrap().get_user_url()
377 );
378
379 assert_eq!(
380 ws.main_branch().unwrap().last_revision(),
381 ws.local_tree().last_revision().unwrap()
382 );
383
384 let result = match script_runner(
385 ws.local_tree(),
386 recipe
387 .command
388 .as_ref()
389 .unwrap()
390 .argv()
391 .iter()
392 .map(|s| s.as_str())
393 .collect::<Vec<_>>()
394 .as_slice(),
395 self.subpath.as_deref().unwrap_or_else(|| Path::new("")),
396 recipe.commit_pending,
397 None,
398 None,
399 extra_env,
400 std::process::Stdio::inherit(),
401 ) {
402 Ok(result) => result,
403 Err(e) => return Err(Error::Script(e)),
404 };
405
406 let tera_context: tera::Context = tera::Context::from_value(
407 result
408 .context
409 .clone()
410 .unwrap_or_else(|| serde_json::json!({})),
411 )
412 .unwrap();
413
414 let target_branch_url = match result.target_branch_url {
415 Some(url) => Some(url),
416 None => Some(url.clone()),
417 };
418 let description = if let Some(description) = result.description {
419 description
420 } else if let Some(ref mr) = recipe.merge_request {
421 mr.render_description(DescriptionFormat::Markdown, &tera_context)?
422 .unwrap()
423 } else {
424 panic!("No description provided");
425 };
426 let commit_message = if let Some(commit_message) = result.commit_message {
427 Some(commit_message)
428 } else if let Some(ref mr) = recipe.merge_request {
429 mr.render_commit_message(&tera_context)?
430 } else {
431 None
432 };
433 let title = if let Some(title) = result.title {
434 Some(title)
435 } else if let Some(ref mr) = recipe.merge_request {
436 mr.render_title(&tera_context)?
437 } else {
438 None
439 };
440 let mode = recipe.mode.unwrap_or_default();
441 let labels = recipe.labels.clone();
442 let context = result.context;
443
444 let auto_merge = recipe.merge_request.as_ref().and_then(|mr| mr.auto_merge);
445
446 let owner = None;
447
448 self.target_branch_url = target_branch_url;
449 self.description = description;
450 self.commit_message = commit_message;
451 self.mode = mode;
452 self.owner = owner;
453 self.title = title;
454 self.labels = labels;
455 self.auto_merge = auto_merge;
456 self.context = serde_yaml::from_str(
457 context
458 .unwrap_or(serde_json::Value::Null)
459 .to_string()
460 .as_str(),
461 )
462 .unwrap();
463
464 Ok(())
465 }
466
467 pub fn publish(
469 &mut self,
470 batch_name: &str,
471 refresh: bool,
472 overwrite: Option<bool>,
473 ) -> Result<PublishResult, PublishError> {
474 let target_branch_url = match self.target_branch_url.as_ref() {
475 Some(url) => url,
476 None => {
477 return Err(PublishError::NoTargetBranch);
478 }
479 };
480
481 let result = publish_one(
482 target_branch_url,
483 self.working_tree().unwrap().as_ref(),
484 batch_name,
485 self.mode,
486 self.proposal_url.as_ref(),
487 self.labels.clone(),
488 self.owner.as_deref(),
489 refresh,
490 self.commit_message.as_deref(),
491 self.title.as_deref(),
492 Some(self.description.as_str()),
493 overwrite.or_else(|| {
494 if self.proposal_url.is_some() {
495 Some(true)
496 } else {
497 None
498 }
499 }),
500 self.auto_merge,
501 )?;
502
503 if let Some(ref proposal) = result.proposal {
504 self.proposal_url = Some(proposal.url().unwrap());
505 }
506 Ok(result)
507 }
508}
509
510pub enum Status {
512 Merged(Url),
514
515 Closed(Url),
517
518 Open(Url),
520
521 NotPublished(),
523}
524
525impl std::fmt::Display for Status {
526 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
527 match self {
528 Status::Merged(url) => write!(f, "Merged: {}", url),
529 Status::Closed(url) => write!(f, "{} was closed without being merged", url),
530 Status::Open(url) => write!(f, "{} is still open", url),
531 Status::NotPublished() => write!(f, "Not published yet"),
532 }
533 }
534}
535
536impl Batch {
537 pub fn from_recipe<'a>(
539 recipe: &Recipe,
540 candidates: impl Iterator<Item = &'a Candidate>,
541 directory: &Path,
542 extra_env: Option<HashMap<String, String>>,
543 ) -> Result<Batch, Error> {
544 if directory.exists() {
546 if !directory.is_dir() {
547 return Err(Error::Io(std::io::Error::new(
548 std::io::ErrorKind::AlreadyExists,
549 "Not a directory",
550 )));
551 }
552 if let Ok(entries) = std::fs::read_dir(directory) {
553 if entries.count() > 0 {
554 return Err(Error::Io(std::io::Error::new(
555 std::io::ErrorKind::AlreadyExists,
556 "Directory not empty",
557 )));
558 }
559 }
560 } else {
561 std::fs::create_dir_all(directory)?;
562 }
563
564 let directory = directory.canonicalize().unwrap();
566
567 let mut batch = match load_batch_metadata(&directory) {
568 Ok(Some(batch)) => batch,
569 Ok(None) => Batch {
570 version: CURRENT_VERSION,
571 recipe: recipe.clone(),
572 name: recipe.name.clone().unwrap(),
573 work: HashMap::new(),
574 basepath: directory.to_path_buf(),
575 },
576 Err(e) => return Err(e),
577 };
578
579 for candidate in candidates {
580 let basename = match candidate.shortname() {
581 Ok(name) => name,
582 Err(e) => {
583 log::warn!("Skipping candidate {}: {:?}", candidate.url, e);
584 continue;
585 }
586 };
587
588 let mut name = basename.to_string();
589
590 if let Some(entry) = batch.work.get(name.as_str()) {
592 if entry.target_branch_url.as_ref() == Some(&candidate.url) {
593 log::info!("Skipping {} ({}) (already in batch)", name, candidate.url);
594 continue;
595 }
596 }
597
598 let mut work_path = directory.join(&name);
599 let mut i = 0;
600 while std::fs::metadata(&work_path).is_ok() {
601 i += 1;
602 name = format!("{}.{}", basename, i);
603 work_path = directory.join(&name);
604 }
605
606 match Entry::from_recipe(
607 recipe,
608 work_path.as_ref(),
609 &candidate.url,
610 candidate
611 .subpath
612 .as_deref()
613 .unwrap_or_else(|| Path::new("")),
614 candidate.default_mode,
615 extra_env.clone(),
616 ) {
617 Ok(entry) => {
618 batch.work.insert(name, entry);
619 save_batch_metadata(&directory, &batch)?;
620 }
621 Err(e) => {
622 log::error!("Failed to generate batch entry for {}: {}", name, e);
623 std::fs::remove_dir_all(work_path)?;
625 continue;
626 }
627 }
628 }
629 save_batch_metadata(&directory, &batch)?;
630 Ok(batch)
631 }
632
633 pub fn get(&self, name: &str) -> Option<&Entry> {
635 self.work.get(name)
636 }
637
638 pub fn get_mut(&mut self, name: &str) -> Option<&mut Entry> {
640 self.work.get_mut(name)
641 }
642
643 pub fn status(&self) -> HashMap<&str, Status> {
645 let mut status = HashMap::new();
646 for (name, entry) in self.work.iter() {
647 status.insert(name.as_str(), entry.status());
648 }
649 status
650 }
651
652 pub fn remove(&mut self, name: &str) -> Result<(), Error> {
654 self.work.remove(name);
655 let path = self.basepath.join(name);
656 match std::fs::remove_dir_all(&path) {
657 Ok(()) => Ok(()),
658 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
659 log::warn!("{} ({}): already removed - {}", name, path.display(), e);
660 Ok(())
661 }
662 Err(e) => Err(Error::Io(e)),
663 }
664 }
665}
666
667pub fn drop_batch_entry(directory: &Path, name: &str) -> Result<(), Error> {
669 let mut batch = match load_batch_metadata(directory)? {
670 Some(batch) => batch,
671 None => return Ok(()),
672 };
673 batch.work.remove(name);
674 match std::fs::remove_dir_all(directory.join(name)) {
675 Ok(()) => {}
676 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
677 log::warn!(
678 "{} ({}): already removed - {}",
679 name,
680 directory.join(name).display(),
681 e
682 );
683 }
684 Err(e) => {
685 return Err(Error::Io(e));
686 }
687 }
688 save_batch_metadata(directory, &batch)?;
689 Ok(())
690}
691
692pub fn save_batch_metadata(directory: &Path, batch: &Batch) -> Result<(), Error> {
694 let mut file = std::fs::File::create(directory.join("batch.yaml"))?;
695 serde_yaml::to_writer(&mut file, &batch)?;
696 file.flush()?;
697 Ok(())
698}
699
700pub fn load_batch_metadata(directory: &Path) -> Result<Option<Batch>, Error> {
702 assert!(directory.is_absolute());
703 let file = match std::fs::File::open(directory.join("batch.yaml")) {
704 Ok(f) => f,
705 Err(e) => {
706 if e.kind() == std::io::ErrorKind::NotFound {
707 return Ok(None);
708 }
709 return Err(Error::Io(e));
710 }
711 };
712
713 let mut batch: Batch = serde_yaml::from_reader(file)?;
714
715 batch.basepath = directory.to_path_buf();
716
717 for (key, entry) in batch.work.iter_mut() {
719 entry.local_path = directory.join(key);
720 }
721
722 Ok(Some(batch))
723}
724
725fn publish_one(
727 url: &url::Url,
728 local_tree: &dyn breezyshim::tree::WorkingTree,
729 batch_name: &str,
730 mode: Mode,
731 existing_proposal_url: Option<&url::Url>,
732 labels: Option<Vec<String>>,
733 derived_owner: Option<&str>,
734 refresh: bool,
735 commit_message: Option<&str>,
736 title: Option<&str>,
737 description: Option<&str>,
738 mut overwrite: Option<bool>,
739 auto_merge: Option<bool>,
740) -> Result<PublishResult, PublishError> {
741 let main_branch = match crate::vcs::open_branch(url, None, None, None) {
742 Ok(b) => b,
743 Err(e) => {
744 log::error!("{}: {}", url, e);
745 return Err(e.into());
746 }
747 };
748
749 let (forge, existing_proposal, mut resume_branch) =
750 match breezyshim::forge::get_forge(&main_branch) {
751 Ok(f) => {
752 let (existing_proposal, resume_branch) = if let Some(existing_proposal_url) =
753 existing_proposal_url
754 {
755 let existing_proposal = f.get_proposal_by_url(existing_proposal_url).unwrap();
756 let resume_branch_url =
757 existing_proposal.get_source_branch_url().unwrap().unwrap();
758 let (resume_branch_url, params) =
759 breezyshim::urlutils::split_segment_parameters(&resume_branch_url);
760 let resume_branch_name = params.get("branch");
761 let resume_branch = match crate::vcs::open_branch(
762 &resume_branch_url,
763 None,
764 None,
765 resume_branch_name.map(|x| x.as_str()),
766 ) {
767 Ok(b) => b,
768 Err(e) => {
769 log::error!("{} {:?}: {}", resume_branch_url, resume_branch_name, e);
770 return Err(e.into());
771 }
772 };
773 (Some(existing_proposal), Some(resume_branch))
774 } else {
775 (None, None)
776 };
777 (Some(f), existing_proposal, resume_branch)
778 }
779 Err(BrzError::UnsupportedForge(e)) => {
780 if mode != Mode::Push {
781 return Err(BrzError::UnsupportedForge(e).into());
782 }
783
784 log::warn!(
787 "Unsupported forge ({}), will attempt to push to {}",
788 e,
789 crate::vcs::full_branch_url(&main_branch),
790 );
791 (None, None, None)
792 }
793 Err(e) => {
794 log::error!("{}: {}", url, e);
795 return Err(e.into());
796 }
797 };
798 if refresh {
799 if resume_branch.is_some() {
800 overwrite = Some(true);
801 }
802 resume_branch = None;
803 }
804 if let Some(ref existing_proposal) = existing_proposal {
805 log::info!("Updating {}", existing_proposal.url().unwrap());
806 }
807
808 let local_branch = local_tree.branch();
809
810 crate::publish::enable_tag_pushing(&local_branch).unwrap();
811
812 let publish_result = match crate::publish::publish_changes(
813 &local_branch,
814 &main_branch,
815 resume_branch.as_ref(),
816 mode,
817 batch_name,
818 |_df, _ep| description.unwrap().to_string(),
819 Some(|_ep: Option<&crate::proposal::MergeProposal>| commit_message.map(|s| s.to_string())),
820 Some(|_ep: Option<&crate::proposal::MergeProposal>| title.map(|s| s.to_string())),
821 forge.as_ref(),
822 Some(true),
823 None,
824 overwrite,
825 existing_proposal,
826 labels,
827 None,
828 derived_owner,
829 None,
830 None,
831 auto_merge,
832 None,
833 ) {
834 Ok(r) => r,
835 Err(e) => match e {
836 PublishError::UnsupportedForge(ref url) => {
837 log::error!("No known supported forge for {}. Run 'svp login'?", url);
838 return Err(e);
839 }
840 PublishError::InsufficientChangesForNewProposal => {
841 log::info!("Insufficient changes for a new merge proposal");
842 return Err(e);
843 }
844 PublishError::DivergedBranches() => {
845 if resume_branch.is_none() {
846 return Err(PublishError::UnrelatedBranchExists);
847 }
848 log::warn!("Branch exists that has diverged");
849 return Err(e);
850 }
851 PublishError::ForgeLoginRequired => {
852 log::error!(
853 "Credentials for hosting site at {} missing. Run 'svp login'?",
854 url
855 );
856 return Err(e);
857 }
858 _ => {
859 log::error!("Failed to publish: {}", e);
860 return Err(e);
861 }
862 },
863 };
864
865 if let Some(ref proposal) = publish_result.proposal {
866 if publish_result.is_new == Some(true) {
867 log::info!("Merge proposal created.");
868 } else {
869 log::info!("Merge proposal updated.")
870 }
871 log::info!("URL: {}", proposal.url().unwrap());
872 log::info!(
873 "Description: {}",
874 proposal.get_description().unwrap().unwrap()
875 );
876 }
877 Ok(publish_result)
878}
879
880#[cfg(test)]
881mod tests {
882 use super::*;
883 use crate::Mode;
884 use breezyshim::testing::TestEnv;
885 use serial_test::serial;
886 use std::collections::HashMap;
887
888 #[test]
889 #[serial]
890 fn test_entry_from_recipe() {
891 let _test_env = TestEnv::new();
892 let td = tempfile::tempdir().unwrap();
893 let remote = tempfile::tempdir().unwrap();
894 breezyshim::controldir::create_branch_convenience_as_generic(
895 &url::Url::from_directory_path(remote.path()).unwrap(),
896 None,
897 &breezyshim::controldir::ControlDirFormat::default(),
898 )
899 .unwrap();
900 let recipe = crate::recipe::RecipeBuilder::new();
901 let recipe = recipe
902 .shell("echo hello > hello.txt; echo hello".to_owned())
903 .build();
904 let entry = super::Entry::from_recipe(
905 &recipe,
906 td.path(),
907 &url::Url::from_directory_path(remote.path()).unwrap(),
908 std::path::Path::new(""),
909 None,
910 None,
911 )
912 .unwrap();
913 assert_eq!(entry.description, "hello\n");
914 }
915
916 #[test]
917 #[serial]
918 fn test_batch_from_recipe() {
919 let _test_env = TestEnv::new();
920 let td = tempfile::tempdir().unwrap();
921 let remote = tempfile::tempdir().unwrap();
922 breezyshim::controldir::create_branch_convenience_as_generic(
923 &url::Url::from_directory_path(remote.path()).unwrap(),
924 None,
925 &breezyshim::controldir::ControlDirFormat::default(),
926 )
927 .unwrap();
928 let recipe = crate::recipe::RecipeBuilder::new();
929 let recipe = recipe
930 .name("hello".to_owned())
931 .shell("echo hello > hello.txt; echo hello".to_owned())
932 .build();
933 let candidate = crate::candidates::Candidate {
934 url: url::Url::from_directory_path(remote.path()).unwrap(),
935 subpath: None,
936 paths: None,
937 default_mode: None,
938 branch: None,
939 name: Some("foo".to_owned()),
940 };
941 let batch =
942 super::Batch::from_recipe(&recipe, std::iter::once(&candidate), td.path(), None)
943 .unwrap();
944 assert_eq!(batch.work.len(), 1);
945 let entry = batch.work.get("foo").unwrap();
946 assert_eq!(entry.description, "hello\n");
947 }
948
949 #[test]
950 #[serial]
951 fn test_drop_batch_entry() {
952 let _test_env = TestEnv::new();
953 let td = tempfile::tempdir().unwrap();
954 let remote = tempfile::tempdir().unwrap();
955 breezyshim::controldir::create_branch_convenience_as_generic(
956 &url::Url::from_directory_path(remote.path()).unwrap(),
957 None,
958 &breezyshim::controldir::ControlDirFormat::default(),
959 )
960 .unwrap();
961 let recipe = crate::recipe::RecipeBuilder::new();
962 let recipe = recipe
963 .name("hello".to_owned())
964 .shell("echo hello > hello.txt; echo hello".to_owned())
965 .build();
966 let candidate = crate::candidates::Candidate {
967 url: url::Url::from_directory_path(remote.path()).unwrap(),
968 subpath: None,
969 paths: None,
970 default_mode: None,
971 branch: None,
972 name: Some("foo".to_owned()),
973 };
974
975 let batch =
977 super::Batch::from_recipe(&recipe, std::iter::once(&candidate), td.path(), None)
978 .unwrap();
979 assert_eq!(batch.work.len(), 1);
980 assert!(batch.work.contains_key("foo"));
981
982 assert!(td.path().join("foo").exists());
984 assert!(td.path().join("batch.yaml").exists());
985
986 super::drop_batch_entry(td.path(), "foo").unwrap();
988
989 let loaded_batch = super::load_batch_metadata(td.path()).unwrap().unwrap();
991 assert_eq!(loaded_batch.work.len(), 0);
992 assert!(!loaded_batch.work.contains_key("foo"));
993 assert!(!td.path().join("foo").exists());
994
995 super::drop_batch_entry(td.path(), "non-existent").unwrap();
997 }
998
999 #[test]
1000 fn test_load_save_batch_metadata() {
1001 let td = tempfile::tempdir().unwrap();
1002 let absolute_path = td.path().canonicalize().unwrap();
1003
1004 let recipe = crate::recipe::RecipeBuilder::new()
1006 .name("test-batch".to_owned())
1007 .shell("echo test".to_owned())
1008 .build();
1009
1010 let mut batch = Batch {
1012 version: CURRENT_VERSION,
1013 recipe: recipe.clone(),
1014 name: "test-batch".to_string(),
1015 work: HashMap::new(),
1016 basepath: absolute_path.clone(),
1017 };
1018
1019 let entry = Entry {
1021 local_path: absolute_path.join("test-entry"),
1022 subpath: Some(std::path::PathBuf::from("subdir")),
1023 target_branch_url: Some(url::Url::parse("https://example.com/repo").unwrap()),
1024 description: "Test description".to_string(),
1025 commit_message: Some("Test commit message".to_string()),
1026 auto_merge: Some(true),
1027 mode: Mode::Propose,
1028 title: Some("Test title".to_string()),
1029 owner: None,
1030 labels: Some(vec!["test-label".to_string()]),
1031 proposal_url: None,
1032 context: serde_yaml::Value::Null,
1033 };
1034
1035 batch.work.insert("test-entry".to_string(), entry);
1036
1037 super::save_batch_metadata(&absolute_path, &batch).unwrap();
1039
1040 assert!(absolute_path.join("batch.yaml").exists());
1042
1043 let loaded_batch = super::load_batch_metadata(&absolute_path).unwrap().unwrap();
1045
1046 assert_eq!(loaded_batch.version, batch.version);
1048 assert_eq!(loaded_batch.name, batch.name);
1049 assert_eq!(loaded_batch.basepath, batch.basepath);
1050 assert_eq!(loaded_batch.work.len(), 1);
1051
1052 let loaded_entry = loaded_batch.work.get("test-entry").unwrap();
1053 assert_eq!(loaded_entry.local_path, absolute_path.join("test-entry"));
1054 assert_eq!(
1055 loaded_entry.subpath,
1056 Some(std::path::PathBuf::from("subdir"))
1057 );
1058 assert_eq!(
1059 loaded_entry.target_branch_url,
1060 Some(url::Url::parse("https://example.com/repo").unwrap())
1061 );
1062 assert_eq!(loaded_entry.description, "Test description");
1063 assert_eq!(
1064 loaded_entry.commit_message,
1065 Some("Test commit message".to_string())
1066 );
1067 assert_eq!(loaded_entry.auto_merge, Some(true));
1068 assert_eq!(loaded_entry.mode, Mode::Propose);
1069 assert_eq!(loaded_entry.title, Some("Test title".to_string()));
1070 assert_eq!(loaded_entry.labels, Some(vec!["test-label".to_string()]));
1071
1072 let non_existent_dir = absolute_path.join("non-existent");
1074 std::fs::create_dir(&non_existent_dir).unwrap();
1075 let result = super::load_batch_metadata(&non_existent_dir).unwrap();
1076 assert!(result.is_none());
1077 }
1078}