1use crate::config::Config;
9use crate::git::MergeResult;
10use crate::spec::{Spec, SpecStatus};
11use crate::spec_group::{extract_member_number, is_member_of};
12use anyhow::Result;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum BranchStatus {
17 Ready,
19 NeedsRebase,
21 HasConflicts,
23 Incomplete,
25 NoCommits,
27 NoBranch,
29}
30
31#[derive(Debug, Clone)]
33pub struct BranchInfo {
34 pub spec_id: String,
35 pub spec_title: Option<String>,
36 pub status: BranchStatus,
37 pub commit_count: usize,
38 pub criteria_checked: usize,
39 pub criteria_total: usize,
40}
41
42pub fn load_main_branch(config: &Config) -> String {
44 config.defaults.main_branch.clone()
45}
46
47pub fn count_criteria(spec: &Spec) -> (usize, usize) {
49 let body = &spec.body;
50 let mut in_criteria_section = false;
51 let mut in_code_fence = false;
52 let mut checked = 0;
53 let mut total = 0;
54
55 for line in body.lines() {
56 let trimmed = line.trim();
57
58 if trimmed.starts_with("```") {
60 in_code_fence = !in_code_fence;
61 continue;
62 }
63
64 if in_code_fence {
66 continue;
67 }
68
69 if trimmed.starts_with("## Acceptance Criteria") {
71 in_criteria_section = true;
72 continue;
73 }
74
75 if in_criteria_section && trimmed.starts_with("## ") {
77 break;
78 }
79
80 if in_criteria_section {
82 if trimmed.starts_with("- [x]") || trimmed.starts_with("- [X]") {
83 checked += 1;
84 total += 1;
85 } else if trimmed.starts_with("- [ ]") {
86 total += 1;
87 }
88 }
89 }
90
91 (checked, total)
92}
93
94pub fn detect_branch_status(
96 spec: &Spec,
97 branch_name: &str,
98 main_branch: &str,
99) -> Result<BranchStatus> {
100 use crate::git;
101
102 let exists = git::branch_exists(branch_name)?;
104 if !exists {
105 return Ok(BranchStatus::NoBranch);
106 }
107
108 let commit_count = git::count_commits(branch_name).unwrap_or(0);
110 if commit_count == 0 {
111 return Ok(BranchStatus::NoCommits);
112 }
113
114 let (checked, total) = count_criteria(spec);
116 if total > 0 && checked < total {
117 return Ok(BranchStatus::Incomplete);
118 }
119
120 let can_ff = git::can_fast_forward_merge(branch_name, main_branch)?;
122 if can_ff {
123 return Ok(BranchStatus::Ready);
124 }
125
126 let is_behind = git::is_branch_behind(branch_name, main_branch)?;
128 if is_behind {
129 return Ok(BranchStatus::NeedsRebase);
131 }
132
133 Ok(BranchStatus::HasConflicts)
135}
136
137pub fn get_branch_info_for_specs(
139 specs: &[Spec],
140 branch_prefix: &str,
141 main_branch: &str,
142) -> Result<Vec<BranchInfo>> {
143 let mut infos = Vec::new();
144
145 for spec in specs {
146 if spec.frontmatter.status != SpecStatus::Completed {
147 continue;
148 }
149
150 let branch_name = format!("{}{}", branch_prefix, spec.id);
151 let status = detect_branch_status(spec, &branch_name, main_branch)?;
152
153 if status == BranchStatus::NoBranch {
155 continue;
156 }
157
158 let (checked, total) = count_criteria(spec);
159 let commit_count = crate::git::count_commits(&branch_name).unwrap_or(0);
160
161 infos.push(BranchInfo {
162 spec_id: spec.id.clone(),
163 spec_title: spec.title.clone(),
164 status,
165 commit_count,
166 criteria_checked: checked,
167 criteria_total: total,
168 });
169 }
170
171 Ok(infos)
172}
173
174pub fn get_specs_to_merge(
177 args: &[String],
178 all: bool,
179 all_specs: &[Spec],
180) -> Result<Vec<(String, Spec)>> {
181 let mut result = Vec::new();
182
183 if all {
184 for spec in all_specs {
186 if spec.frontmatter.status == SpecStatus::Completed {
187 result.push((spec.id.clone(), spec.clone()));
188 }
189 }
190 } else {
191 for partial_id in args {
196 if let Some(spec) = all_specs.iter().find(|s| s.id == *partial_id) {
198 result.push((spec.id.clone(), spec.clone()));
199 continue;
200 }
201
202 let suffix_matches: Vec<_> = all_specs
204 .iter()
205 .filter(|s| s.id.ends_with(partial_id))
206 .collect();
207 if suffix_matches.len() == 1 {
208 result.push((suffix_matches[0].id.clone(), suffix_matches[0].clone()));
209 continue;
210 }
211
212 let contains_matches: Vec<_> = all_specs
214 .iter()
215 .filter(|s| s.id.contains(partial_id))
216 .collect();
217 if contains_matches.len() == 1 {
218 result.push((contains_matches[0].id.clone(), contains_matches[0].clone()));
219 continue;
220 }
221
222 if contains_matches.len() > 1 {
223 anyhow::bail!(
224 "Ambiguous spec ID '{}'. Matches: {}",
225 partial_id,
226 contains_matches
227 .iter()
228 .map(|s| s.id.as_str())
229 .collect::<Vec<_>>()
230 .join(", ")
231 );
232 }
233
234 anyhow::bail!("Spec not found: {}", partial_id);
235 }
236 }
237
238 Ok(result)
239}
240
241pub fn validate_spec_can_merge(spec: &Spec, branch_exists: bool) -> Result<()> {
243 match &spec.frontmatter.status {
245 SpecStatus::Completed => {}
246 other => {
247 let status_str = format!("{:?}", other);
248 anyhow::bail!(
249 "{}",
250 crate::merge_errors::spec_status_not_mergeable(&spec.id, &status_str)
251 );
252 }
253 }
254
255 if !branch_exists {
257 anyhow::bail!("{}", crate::merge_errors::no_branch_for_spec(&spec.id));
258 }
259
260 Ok(())
261}
262
263pub fn is_driver_spec(spec: &Spec, all_specs: &[Spec]) -> bool {
265 let members = collect_member_specs(spec, all_specs);
266 !members.is_empty()
267}
268
269fn collect_member_specs(driver_spec: &Spec, all_specs: &[Spec]) -> Vec<Spec> {
271 let driver_id = &driver_spec.id;
272 let mut members: Vec<(u32, Spec)> = Vec::new();
273
274 for spec in all_specs {
275 if is_member_of(&spec.id, driver_id) {
276 if let Some(seq_num) = extract_member_number(&spec.id) {
278 members.push((seq_num, spec.clone()));
279 }
280 }
281 }
282
283 members.sort_by_key(|m| m.0);
285
286 members.into_iter().map(|(_, spec)| spec).collect()
288}
289
290pub fn merge_driver_spec(
302 driver_spec: &Spec,
303 all_specs: &[Spec],
304 branch_prefix: &str,
305 main_branch: &str,
306 should_delete_branch: bool,
307 dry_run: bool,
308) -> Result<Vec<MergeResult>> {
309 use crate::git;
310
311 let members = collect_member_specs(driver_spec, all_specs);
313
314 let mut incomplete_members = Vec::new();
316 for member in &members {
317 if member.frontmatter.status != SpecStatus::Completed {
319 incomplete_members.push(format!(
320 "{} (status: {:?})",
321 member.id, member.frontmatter.status
322 ));
323 }
324 }
325
326 if !dry_run {
328 for member in &members {
329 let branch_name = format!("{}{}", branch_prefix, member.id);
330 match git::branch_exists(&branch_name) {
331 Ok(exists) => {
332 if !exists {
333 incomplete_members.push(format!("{} (branch not found)", member.id));
334 }
335 }
336 Err(e) => {
337 anyhow::bail!("Failed to check branch for {}: {}", member.id, e);
338 }
339 }
340 }
341 }
342
343 if !incomplete_members.is_empty() {
345 anyhow::bail!(
346 "{}",
347 crate::merge_errors::driver_members_incomplete(&driver_spec.id, &incomplete_members)
348 );
349 }
350
351 let mut all_results = Vec::new();
353 for member in &members {
354 let branch_name = format!("{}{}", branch_prefix, member.id);
355 match git::merge_single_spec(
356 &member.id,
357 &branch_name,
358 main_branch,
359 should_delete_branch,
360 dry_run,
361 ) {
362 Ok(result) => {
363 if !result.success {
364 anyhow::bail!(
365 "{}",
366 crate::merge_errors::member_merge_failed(
367 &driver_spec.id,
368 &member.id,
369 &format!("Merge returned unsuccessful for {}", result.spec_id)
370 )
371 );
372 }
373 all_results.push(result);
374 }
375 Err(e) => {
376 anyhow::bail!(
377 "{}",
378 crate::merge_errors::member_merge_failed(
379 &driver_spec.id,
380 &member.id,
381 &e.to_string()
382 )
383 );
384 }
385 }
386 }
387
388 let driver_branch = format!("{}{}", branch_prefix, driver_spec.id);
390 match git::merge_single_spec(
391 &driver_spec.id,
392 &driver_branch,
393 main_branch,
394 should_delete_branch,
395 dry_run,
396 ) {
397 Ok(result) => {
398 all_results.push(result);
399 Ok(all_results)
400 }
401 Err(e) => {
402 anyhow::bail!(
403 "{}",
404 crate::merge_errors::member_merge_failed(
405 &driver_spec.id,
406 &driver_spec.id,
407 &e.to_string()
408 )
409 );
410 }
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use crate::config::DefaultsConfig;
418
419 fn make_config(defaults: DefaultsConfig) -> Config {
420 Config {
421 project: crate::config::ProjectConfig {
422 name: "test".to_string(),
423 prefix: None,
424 silent: false,
425 },
426 defaults,
427 providers: crate::provider::ProviderConfig::default(),
428 parallel: crate::config::ParallelConfig::default(),
429 repos: vec![],
430 enterprise: crate::config::EnterpriseConfig::default(),
431 approval: crate::config::ApprovalConfig::default(),
432 validation: crate::config::OutputValidationConfig::default(),
433 site: crate::config::SiteConfig::default(),
434 lint: crate::config::LintConfig::default(),
435 watch: crate::config::WatchConfig::default(),
436 }
437 }
438
439 fn make_spec(id: &str, status: SpecStatus) -> Spec {
440 Spec {
441 id: id.to_string(),
442 frontmatter: crate::spec::SpecFrontmatter {
443 status,
444 ..Default::default()
445 },
446 title: Some(format!("Spec {}", id)),
447 body: format!("Body {}", id),
448 }
449 }
450
451 #[test]
452 fn test_count_criteria_no_criteria_section() {
453 let spec = make_spec("001", SpecStatus::Pending);
454 let (checked, total) = count_criteria(&spec);
455 assert_eq!(checked, 0);
456 assert_eq!(total, 0);
457 }
458
459 #[test]
460 fn test_count_criteria_all_unchecked() {
461 let spec = Spec {
462 id: "001".to_string(),
463 frontmatter: crate::spec::SpecFrontmatter {
464 status: SpecStatus::Pending,
465 ..Default::default()
466 },
467 title: Some("Test".to_string()),
468 body: r#"# Test
469
470## Acceptance Criteria
471
472- [ ] First criterion
473- [ ] Second criterion
474- [ ] Third criterion
475
476## Next Section
477"#
478 .to_string(),
479 };
480 let (checked, total) = count_criteria(&spec);
481 assert_eq!(checked, 0);
482 assert_eq!(total, 3);
483 }
484
485 #[test]
486 fn test_count_criteria_partially_checked() {
487 let spec = Spec {
488 id: "001".to_string(),
489 frontmatter: crate::spec::SpecFrontmatter::default(),
490 title: None,
491 body: r#"## Acceptance Criteria
492
493- [x] First criterion
494- [ ] Second criterion
495- [x] Third criterion
496"#
497 .to_string(),
498 };
499 let (checked, total) = count_criteria(&spec);
500 assert_eq!(checked, 2);
501 assert_eq!(total, 3);
502 }
503
504 #[test]
505 fn test_count_criteria_all_checked() {
506 let spec = Spec {
507 id: "001".to_string(),
508 frontmatter: crate::spec::SpecFrontmatter::default(),
509 title: None,
510 body: r#"## Acceptance Criteria
511
512- [x] First criterion
513- [x] Second criterion
514- [X] Third criterion (uppercase X)
515"#
516 .to_string(),
517 };
518 let (checked, total) = count_criteria(&spec);
519 assert_eq!(checked, 3);
520 assert_eq!(total, 3);
521 }
522
523 #[test]
524 fn test_count_criteria_ignores_code_fence() {
525 let spec = Spec {
526 id: "001".to_string(),
527 frontmatter: crate::spec::SpecFrontmatter::default(),
528 title: None,
529 body: r#"## Acceptance Criteria
530
531- [x] Real criterion
532
533```markdown
534- [ ] This should be ignored (in code fence)
535- [x] This too
536```
537
538- [ ] Another real criterion
539"#
540 .to_string(),
541 };
542 let (checked, total) = count_criteria(&spec);
543 assert_eq!(checked, 1);
544 assert_eq!(total, 2);
545 }
546
547 #[test]
548 fn test_count_criteria_stops_at_next_section() {
549 let spec = Spec {
550 id: "001".to_string(),
551 frontmatter: crate::spec::SpecFrontmatter::default(),
552 title: None,
553 body: r#"## Acceptance Criteria
554
555- [x] First criterion
556- [ ] Second criterion
557
558## Implementation Notes
559
560- [ ] This should not be counted (different section)
561"#
562 .to_string(),
563 };
564 let (checked, total) = count_criteria(&spec);
565 assert_eq!(checked, 1);
566 assert_eq!(total, 2);
567 }
568
569 #[test]
570 fn test_load_main_branch_default() {
571 let config = make_config(DefaultsConfig::default());
572 let branch = load_main_branch(&config);
573 assert_eq!(branch, "main");
574 }
575
576 #[test]
577 fn test_load_main_branch_custom() {
578 let config = make_config(DefaultsConfig {
579 main_branch: "master".to_string(),
580 ..Default::default()
581 });
582 let branch = load_main_branch(&config);
583 assert_eq!(branch, "master");
584 }
585
586 #[test]
587 fn test_get_specs_to_merge_all() {
588 let specs = vec![
589 make_spec("spec1", SpecStatus::Completed),
590 make_spec("spec2", SpecStatus::Pending),
591 make_spec("spec3", SpecStatus::Completed),
592 ];
593
594 let result = get_specs_to_merge(&[], true, &specs).unwrap();
595 assert_eq!(result.len(), 2);
596 assert_eq!(result[0].0, "spec1");
597 assert_eq!(result[1].0, "spec3");
598 }
599
600 #[test]
601 fn test_get_specs_to_merge_specific() {
602 let specs = vec![
603 make_spec("spec1", SpecStatus::Completed),
604 make_spec("spec2", SpecStatus::Completed),
605 ];
606
607 let result = get_specs_to_merge(&["spec1".to_string()], false, &specs).unwrap();
608 assert_eq!(result.len(), 1);
609 assert_eq!(result[0].0, "spec1");
610 }
611
612 #[test]
613 fn test_get_specs_to_merge_not_found() {
614 let specs = vec![make_spec("spec1", SpecStatus::Pending)];
615
616 let result = get_specs_to_merge(&["nonexistent".to_string()], false, &specs);
617 assert!(result.is_err());
618 assert!(result.unwrap_err().to_string().contains("Spec not found"));
619 }
620
621 #[test]
622 fn test_validate_spec_can_merge_completed() {
623 let spec = make_spec("spec1", SpecStatus::Completed);
624 let result = validate_spec_can_merge(&spec, true);
625 assert!(result.is_ok());
626 }
627
628 #[test]
629 fn test_validate_spec_can_merge_pending_fails() {
630 let spec = make_spec("spec1", SpecStatus::Pending);
631 let result = validate_spec_can_merge(&spec, true);
632 assert!(result.is_err());
633 let err_msg = result.unwrap_err().to_string();
634 assert!(err_msg.contains("Cannot merge spec spec1"));
635 assert!(err_msg.contains("Pending"));
636 assert!(err_msg.contains("Next Steps"));
637 }
638
639 #[test]
640 fn test_validate_spec_can_merge_in_progress_fails() {
641 let spec = make_spec("spec1", SpecStatus::InProgress);
642 let result = validate_spec_can_merge(&spec, true);
643 assert!(result.is_err());
644 let err_msg = result.unwrap_err().to_string();
645 assert!(err_msg.contains("Cannot merge spec spec1"));
646 assert!(err_msg.contains("InProgress"));
647 assert!(err_msg.contains("Next Steps"));
648 }
649
650 #[test]
651 fn test_validate_spec_can_merge_failed_fails() {
652 let spec = make_spec("spec1", SpecStatus::Failed);
653 let result = validate_spec_can_merge(&spec, true);
654 assert!(result.is_err());
655 let err_msg = result.unwrap_err().to_string();
656 assert!(err_msg.contains("Cannot merge spec spec1"));
657 assert!(err_msg.contains("Failed"));
658 assert!(err_msg.contains("Next Steps"));
659 }
660
661 #[test]
662 fn test_validate_spec_can_merge_no_branch() {
663 let spec = make_spec("spec1", SpecStatus::Completed);
664 let result = validate_spec_can_merge(&spec, false);
665 assert!(result.is_err());
666 assert!(result.unwrap_err().to_string().contains("No branch found"));
667 }
668
669 #[test]
670 fn test_collect_member_specs() {
671 let driver = Spec {
672 id: "driver".to_string(),
673 frontmatter: crate::spec::SpecFrontmatter::default(),
674 title: Some("Driver".to_string()),
675 body: "Driver".to_string(),
676 };
677
678 let member1 = Spec {
679 id: "driver.1".to_string(),
680 frontmatter: crate::spec::SpecFrontmatter::default(),
681 title: Some("Member 1".to_string()),
682 body: "Member 1".to_string(),
683 };
684
685 let member2 = Spec {
686 id: "driver.2".to_string(),
687 frontmatter: crate::spec::SpecFrontmatter::default(),
688 title: Some("Member 2".to_string()),
689 body: "Member 2".to_string(),
690 };
691
692 let member3 = Spec {
693 id: "driver.3".to_string(),
694 frontmatter: crate::spec::SpecFrontmatter::default(),
695 title: Some("Member 3".to_string()),
696 body: "Member 3".to_string(),
697 };
698
699 let all_specs = vec![
700 driver.clone(),
701 member3.clone(),
702 member1.clone(),
703 member2.clone(),
704 ];
705
706 let result = collect_member_specs(&driver, &all_specs);
707 assert_eq!(result.len(), 3);
708 assert_eq!(result[0].id, "driver.1");
709 assert_eq!(result[1].id, "driver.2");
710 assert_eq!(result[2].id, "driver.3");
711 }
712
713 #[test]
714 fn test_collect_member_specs_with_nested() {
715 let driver = Spec {
716 id: "driver".to_string(),
717 frontmatter: crate::spec::SpecFrontmatter::default(),
718 title: Some("Driver".to_string()),
719 body: "Driver".to_string(),
720 };
721
722 let member1 = Spec {
723 id: "driver.1".to_string(),
724 frontmatter: crate::spec::SpecFrontmatter::default(),
725 title: Some("Member 1".to_string()),
726 body: "Member 1".to_string(),
727 };
728
729 let member2 = Spec {
730 id: "driver.2".to_string(),
731 frontmatter: crate::spec::SpecFrontmatter::default(),
732 title: Some("Member 2".to_string()),
733 body: "Member 2".to_string(),
734 };
735
736 let all_specs = vec![driver.clone(), member1.clone(), member2.clone()];
737
738 let result = collect_member_specs(&driver, &all_specs);
739 assert_eq!(result.len(), 2);
740 assert_eq!(result[0].id, "driver.1");
741 assert_eq!(result[1].id, "driver.2");
742 }
743
744 #[test]
745 fn test_collect_member_specs_empty() {
746 let driver = make_spec("driver", SpecStatus::Pending);
747 let other = make_spec("other", SpecStatus::Pending);
748 let all_specs = vec![driver.clone(), other];
749 let result = collect_member_specs(&driver, &all_specs);
750 assert_eq!(result.len(), 0);
751 }
752
753 #[test]
754 fn test_is_driver_spec_with_members() {
755 let driver = make_spec("driver", SpecStatus::Pending);
756 let member1 = make_spec("driver.1", SpecStatus::Pending);
757 let all_specs = vec![driver.clone(), member1];
758 let result = is_driver_spec(&driver, &all_specs);
759 assert!(result);
760 }
761
762 #[test]
763 fn test_is_driver_spec_without_members() {
764 let driver = make_spec("driver", SpecStatus::Pending);
765 let other = make_spec("other", SpecStatus::Pending);
766 let all_specs = vec![driver.clone(), other];
767 let result = is_driver_spec(&driver, &all_specs);
768 assert!(!result);
769 }
770
771 #[test]
772 #[serial_test::serial]
773 fn test_merge_driver_spec_all_members_completed() {
774 let driver = Spec {
775 id: "driver".to_string(),
776 frontmatter: crate::spec::SpecFrontmatter {
777 status: SpecStatus::Completed,
778 ..Default::default()
779 },
780 title: Some("Driver".to_string()),
781 body: "Driver".to_string(),
782 };
783
784 let member1 = Spec {
785 id: "driver.1".to_string(),
786 frontmatter: crate::spec::SpecFrontmatter {
787 status: SpecStatus::Completed,
788 ..Default::default()
789 },
790 title: Some("Member 1".to_string()),
791 body: "Member 1".to_string(),
792 };
793
794 let member2 = Spec {
795 id: "driver.2".to_string(),
796 frontmatter: crate::spec::SpecFrontmatter {
797 status: SpecStatus::Completed,
798 ..Default::default()
799 },
800 title: Some("Member 2".to_string()),
801 body: "Member 2".to_string(),
802 };
803
804 let member3 = Spec {
805 id: "driver.3".to_string(),
806 frontmatter: crate::spec::SpecFrontmatter {
807 status: SpecStatus::Completed,
808 ..Default::default()
809 },
810 title: Some("Member 3".to_string()),
811 body: "Member 3".to_string(),
812 };
813
814 let all_specs = vec![driver.clone(), member1, member2, member3];
815
816 let result = merge_driver_spec(&driver, &all_specs, "spec-", "main", false, true);
818 assert!(result.is_ok());
819 let results = result.unwrap();
820 assert_eq!(results.len(), 4);
822 assert!(results.iter().all(|r| r.dry_run));
824 }
825
826 #[test]
827 fn test_merge_driver_spec_member_pending() {
828 let driver = Spec {
829 id: "driver".to_string(),
830 frontmatter: crate::spec::SpecFrontmatter {
831 status: SpecStatus::Completed,
832 ..Default::default()
833 },
834 title: Some("Driver".to_string()),
835 body: "Driver".to_string(),
836 };
837
838 let member1 = Spec {
839 id: "driver.1".to_string(),
840 frontmatter: crate::spec::SpecFrontmatter {
841 status: SpecStatus::Completed,
842 ..Default::default()
843 },
844 title: Some("Member 1".to_string()),
845 body: "Member 1".to_string(),
846 };
847
848 let member2 = Spec {
849 id: "driver.2".to_string(),
850 frontmatter: crate::spec::SpecFrontmatter {
851 status: SpecStatus::Pending,
852 ..Default::default()
853 },
854 title: Some("Member 2".to_string()),
855 body: "Member 2".to_string(),
856 };
857
858 let all_specs = vec![driver.clone(), member1, member2];
859
860 let result = merge_driver_spec(&driver, &all_specs, "spec-", "main", false, true);
861 assert!(result.is_err());
862 let error = result.unwrap_err().to_string();
863 assert!(error.contains("Cannot merge driver spec"));
864 assert!(error.contains("driver.2"));
865 assert!(error.contains("incomplete"));
866 }
867
868 #[test]
869 #[serial_test::serial]
870 fn test_merge_driver_spec_multiple_members_in_order() {
871 let driver = Spec {
872 id: "2026-01-24-01y-73b".to_string(),
873 frontmatter: crate::spec::SpecFrontmatter {
874 status: SpecStatus::Completed,
875 ..Default::default()
876 },
877 title: Some("Driver".to_string()),
878 body: "Driver".to_string(),
879 };
880
881 let member1 = Spec {
882 id: "2026-01-24-01y-73b.1".to_string(),
883 frontmatter: crate::spec::SpecFrontmatter {
884 status: SpecStatus::Completed,
885 ..Default::default()
886 },
887 title: Some("Member 1".to_string()),
888 body: "Member 1".to_string(),
889 };
890
891 let member2 = Spec {
892 id: "2026-01-24-01y-73b.2".to_string(),
893 frontmatter: crate::spec::SpecFrontmatter {
894 status: SpecStatus::Completed,
895 ..Default::default()
896 },
897 title: Some("Member 2".to_string()),
898 body: "Member 2".to_string(),
899 };
900
901 let member3 = Spec {
902 id: "2026-01-24-01y-73b.3".to_string(),
903 frontmatter: crate::spec::SpecFrontmatter {
904 status: SpecStatus::Completed,
905 ..Default::default()
906 },
907 title: Some("Member 3".to_string()),
908 body: "Member 3".to_string(),
909 };
910
911 let all_specs = vec![driver.clone(), member3, member1, member2];
912
913 let result = merge_driver_spec(&driver, &all_specs, "spec-", "main", false, true);
914 assert!(result.is_ok());
915 let results = result.unwrap();
916 assert_eq!(results.len(), 4);
918 assert_eq!(results[0].spec_id, "2026-01-24-01y-73b.1");
919 assert_eq!(results[1].spec_id, "2026-01-24-01y-73b.2");
920 assert_eq!(results[2].spec_id, "2026-01-24-01y-73b.3");
921 assert_eq!(results[3].spec_id, "2026-01-24-01y-73b");
922 }
923
924 #[test]
925 #[serial_test::serial]
926 fn test_merge_driver_spec_dry_run_shows_all_merges() {
927 let driver = Spec {
928 id: "driver".to_string(),
929 frontmatter: crate::spec::SpecFrontmatter {
930 status: SpecStatus::Completed,
931 ..Default::default()
932 },
933 title: Some("Driver".to_string()),
934 body: "Driver".to_string(),
935 };
936
937 let member1 = Spec {
938 id: "driver.1".to_string(),
939 frontmatter: crate::spec::SpecFrontmatter {
940 status: SpecStatus::Completed,
941 ..Default::default()
942 },
943 title: Some("Member 1".to_string()),
944 body: "Member 1".to_string(),
945 };
946
947 let member2 = Spec {
948 id: "driver.2".to_string(),
949 frontmatter: crate::spec::SpecFrontmatter {
950 status: SpecStatus::Completed,
951 ..Default::default()
952 },
953 title: Some("Member 2".to_string()),
954 body: "Member 2".to_string(),
955 };
956
957 let all_specs = vec![driver.clone(), member1, member2];
958
959 let result = merge_driver_spec(&driver, &all_specs, "spec-", "main", false, true);
960 assert!(result.is_ok());
961 let results = result.unwrap();
962 assert_eq!(results.len(), 3);
964 assert!(results.iter().all(|r| r.dry_run));
966 assert!(results.iter().all(|r| r.success));
968 }
969}