Skip to main content

jj_ryu/submit/
plan.rs

1//! Phase 2: Submission planning
2//!
3//! Determines what operations need to be performed to submit a stack.
4
5use crate::error::{Error, Result};
6use crate::platform::PlatformService;
7use crate::submit::SubmissionAnalysis;
8use crate::submit::analysis::{generate_pr_title, get_base_branch};
9use crate::types::{Bookmark, NarrowedBookmarkSegment, PullRequest};
10use std::cmp::Reverse;
11use std::collections::{BinaryHeap, HashMap, HashSet};
12
13/// Information about a PR that needs to be created
14#[derive(Debug, Clone)]
15pub struct PrToCreate {
16    /// Bookmark for this PR
17    pub bookmark: Bookmark,
18    /// Base branch (previous bookmark or default branch)
19    pub base_branch: String,
20    /// Generated PR title
21    pub title: String,
22    /// Whether to create as draft
23    pub draft: bool,
24}
25
26/// Information about a PR that needs its base updated
27#[derive(Debug, Clone)]
28pub struct PrBaseUpdate {
29    /// Bookmark for this PR
30    pub bookmark: Bookmark,
31    /// Current base branch
32    pub current_base: String,
33    /// Expected base branch
34    pub expected_base: String,
35    /// Existing PR
36    pub pr: PullRequest,
37}
38
39/// Ordered execution step for a submission plan
40#[derive(Debug, Clone)]
41pub enum ExecutionStep {
42    /// Push bookmark to remote
43    Push(Bookmark),
44    /// Update PR base branch
45    UpdateBase(PrBaseUpdate),
46    /// Create a new PR
47    CreatePr(PrToCreate),
48    /// Publish a draft PR
49    PublishPr(PullRequest),
50}
51
52impl ExecutionStep {
53    /// Get the bookmark name for this step
54    pub fn bookmark_name(&self) -> &str {
55        match self {
56            Self::Push(bm) => &bm.name,
57            Self::UpdateBase(update) => &update.bookmark.name,
58            Self::CreatePr(create) => &create.bookmark.name,
59            Self::PublishPr(pr) => &pr.head_ref,
60        }
61    }
62}
63
64impl std::fmt::Display for ExecutionStep {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            Self::Push(bm) => write!(f, "push {}", bm.name),
68            Self::UpdateBase(update) => write!(
69                f,
70                "update {} (PR #{}) {} → {}",
71                update.bookmark.name, update.pr.number, update.current_base, update.expected_base
72            ),
73            Self::CreatePr(create) => {
74                write!(
75                    f,
76                    "create PR {} → {} ({})",
77                    create.bookmark.name, create.base_branch, create.title
78                )?;
79                if create.draft {
80                    write!(f, " [draft]")?;
81                }
82                Ok(())
83            }
84            Self::PublishPr(pr) => write!(f, "publish PR #{} ({})", pr.number, pr.head_ref),
85        }
86    }
87}
88
89// ═══════════════════════════════════════════════════════════════════════════
90// Typed constraint system for dependency-aware scheduling
91// ═══════════════════════════════════════════════════════════════════════════
92
93/// Typed reference to a Push operation by bookmark name.
94/// Distinct from [`UpdateRef`]/[`CreateRef`] to prevent mixing constraint endpoints.
95#[derive(Debug, Clone, PartialEq, Eq, Hash)]
96pub struct PushRef(pub String);
97
98/// Typed reference to an `UpdateBase` operation by bookmark name.
99#[derive(Debug, Clone, PartialEq, Eq, Hash)]
100pub struct UpdateRef(pub String);
101
102/// Typed reference to a `CreatePr` operation by bookmark name.
103#[derive(Debug, Clone, PartialEq, Eq, Hash)]
104pub struct CreateRef(pub String);
105
106/// Dependency constraint between execution operations.
107///
108/// Each variant encodes a semantic relationship between operations.
109/// Invalid pairings (e.g., `CreatePr` → `Push`) are unrepresentable at the type level.
110///
111/// Constraints may reference operations that don't exist in the current plan
112/// (e.g., a bookmark that's already synced has no `Push` node). Resolution
113/// returns `None` for such constraints, which is expected behavior.
114#[derive(Debug, Clone)]
115pub enum ExecutionConstraint {
116    /// Push parent branch before child branch.
117    /// Ensures commits are pushed in stack order (ancestors before descendants).
118    PushOrder {
119        /// Parent bookmark (pushed first)
120        parent: PushRef,
121        /// Child bookmark (pushed second)
122        child: PushRef,
123    },
124
125    /// Push new base branch before retargeting PR to it.
126    /// Can't retarget a PR to a branch that doesn't exist on remote yet.
127    PushBeforeRetarget {
128        /// Base branch to push
129        base: PushRef,
130        /// PR to retarget
131        pr: UpdateRef,
132    },
133
134    /// Retarget PR before pushing its old base (swap scenario).
135    /// When stack order changes and a PR's current base moves "below" it,
136    /// must retarget first to avoid platform rejection.
137    RetargetBeforePush {
138        /// PR to retarget first
139        pr: UpdateRef,
140        /// Old base to push after
141        old_base: PushRef,
142    },
143
144    /// Push branch before creating PR for it.
145    /// Branch must exist on remote before PR creation.
146    PushBeforeCreate {
147        /// Branch to push
148        push: PushRef,
149        /// PR to create
150        create: CreateRef,
151    },
152
153    /// Create parent PR before child PR.
154    /// Parent PR must exist so stack comments can reference its number/URL.
155    CreateOrder {
156        /// Parent PR (created first)
157        parent: CreateRef,
158        /// Child PR (created second)
159        child: CreateRef,
160    },
161}
162
163impl std::fmt::Display for ExecutionConstraint {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        match self {
166            Self::PushOrder { parent, child } => {
167                write!(f, "Push({}) → Push({})", parent.0, child.0)
168            }
169            Self::PushBeforeRetarget { base, pr } => {
170                write!(f, "Push({}) → UpdateBase({})", base.0, pr.0)
171            }
172            Self::RetargetBeforePush { pr, old_base } => {
173                write!(f, "UpdateBase({}) → Push({})", pr.0, old_base.0)
174            }
175            Self::PushBeforeCreate { push, create } => {
176                write!(f, "Push({}) → CreatePr({})", push.0, create.0)
177            }
178            Self::CreateOrder { parent, child } => {
179                write!(f, "CreatePr({}) → CreatePr({})", parent.0, child.0)
180            }
181        }
182    }
183}
184
185/// Opaque node index, only obtainable via [`NodeRegistry`].
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
187struct NodeIdx(usize);
188
189/// Registry mapping typed refs to node indices.
190/// Built during node creation, consumed during constraint resolution.
191#[derive(Debug, Default)]
192struct NodeRegistry {
193    push: HashMap<String, NodeIdx>,
194    update: HashMap<String, NodeIdx>,
195    create: HashMap<String, NodeIdx>,
196    publish: HashMap<String, NodeIdx>,
197}
198
199impl NodeRegistry {
200    fn register_push(&mut self, name: &str, idx: usize) {
201        self.push.insert(name.to_string(), NodeIdx(idx));
202    }
203
204    fn register_update(&mut self, name: &str, idx: usize) {
205        self.update.insert(name.to_string(), NodeIdx(idx));
206    }
207
208    fn register_create(&mut self, name: &str, idx: usize) {
209        self.create.insert(name.to_string(), NodeIdx(idx));
210    }
211
212    fn register_publish(&mut self, name: &str, idx: usize) {
213        self.publish.insert(name.to_string(), NodeIdx(idx));
214    }
215
216    fn len(&self) -> usize {
217        self.push.len() + self.update.len() + self.create.len() + self.publish.len()
218    }
219}
220
221impl ExecutionConstraint {
222    /// Resolve constraint to concrete `(from, to)` indices.
223    ///
224    /// Returns `None` if either endpoint doesn't exist in the registry.
225    /// This is expected when an operation isn't needed (e.g., already-synced bookmark).
226    fn resolve(&self, registry: &NodeRegistry) -> Option<(usize, usize)> {
227        match self {
228            Self::PushOrder { parent, child } => {
229                let from = registry.push.get(&parent.0)?;
230                let to = registry.push.get(&child.0)?;
231                Some((from.0, to.0))
232            }
233            Self::PushBeforeRetarget { base, pr } => {
234                let from = registry.push.get(&base.0)?;
235                let to = registry.update.get(&pr.0)?;
236                Some((from.0, to.0))
237            }
238            Self::RetargetBeforePush { pr, old_base } => {
239                let from = registry.update.get(&pr.0)?;
240                let to = registry.push.get(&old_base.0)?;
241                Some((from.0, to.0))
242            }
243            Self::PushBeforeCreate { push, create } => {
244                let from = registry.push.get(&push.0)?;
245                let to = registry.create.get(&create.0)?;
246                Some((from.0, to.0))
247            }
248            Self::CreateOrder { parent, child } => {
249                let from = registry.create.get(&parent.0)?;
250                let to = registry.create.get(&child.0)?;
251                Some((from.0, to.0))
252            }
253        }
254    }
255}
256
257/// Internal node for dependency-aware scheduling
258#[derive(Debug, Clone)]
259struct ExecutionNode {
260    step: ExecutionStep,
261    order: usize,
262}
263
264/// Submission plan
265#[derive(Debug, Clone)]
266pub struct SubmissionPlan {
267    /// Segments to submit (used for stack comment generation)
268    pub segments: Vec<NarrowedBookmarkSegment>,
269    /// Dependency constraints between operations (for debugging/dry-run display)
270    pub constraints: Vec<ExecutionConstraint>,
271    /// Ordered execution steps
272    pub execution_steps: Vec<ExecutionStep>,
273    /// Existing PRs by bookmark name
274    pub existing_prs: HashMap<String, PullRequest>,
275    /// Remote name to push to
276    pub remote: String,
277    /// Default branch name (main/master)
278    pub default_branch: String,
279}
280
281impl SubmissionPlan {
282    /// Check if there's nothing to do
283    pub const fn is_empty(&self) -> bool {
284        self.execution_steps.is_empty()
285    }
286
287    /// Count push steps
288    pub fn count_pushes(&self) -> usize {
289        self.execution_steps
290            .iter()
291            .filter(|s| matches!(s, ExecutionStep::Push(_)))
292            .count()
293    }
294
295    /// Count create PR steps
296    pub fn count_creates(&self) -> usize {
297        self.execution_steps
298            .iter()
299            .filter(|s| matches!(s, ExecutionStep::CreatePr(_)))
300            .count()
301    }
302
303    /// Count update base steps
304    pub fn count_updates(&self) -> usize {
305        self.execution_steps
306            .iter()
307            .filter(|s| matches!(s, ExecutionStep::UpdateBase(_)))
308            .count()
309    }
310
311    /// Count publish steps
312    pub fn count_publishes(&self) -> usize {
313        self.execution_steps
314            .iter()
315            .filter(|s| matches!(s, ExecutionStep::PublishPr(_)))
316            .count()
317    }
318}
319
320/// Create a submission plan
321///
322/// This determines what operations need to be performed:
323/// - Which bookmarks need pushing
324/// - Which PRs need to be created
325/// - Which PR bases need updating
326pub async fn create_submission_plan(
327    analysis: &SubmissionAnalysis,
328    platform: &dyn PlatformService,
329    remote: &str,
330    default_branch: &str,
331) -> Result<SubmissionPlan> {
332    let segments = &analysis.segments;
333    let bookmarks: Vec<&Bookmark> = segments.iter().map(|s| &s.bookmark).collect();
334
335    // Check for existing PRs
336    let mut existing_prs = HashMap::new();
337    for bookmark in &bookmarks {
338        if let Some(pr) = platform.find_existing_pr(&bookmark.name).await? {
339            existing_prs.insert(bookmark.name.clone(), pr);
340        }
341    }
342
343    // Collect raw operations (unordered)
344    let mut bookmarks_needing_push = Vec::new();
345    let mut prs_to_create = Vec::new();
346    let mut prs_to_update_base = Vec::new();
347
348    for bookmark in &bookmarks {
349        // Check if needs push
350        if !bookmark.has_remote || !bookmark.is_synced {
351            bookmarks_needing_push.push((*bookmark).clone());
352        }
353
354        // Check if needs PR creation
355        if let Some(pr) = existing_prs.get(&bookmark.name) {
356            // PR exists - check if base needs updating
357            let expected_base = get_base_branch(&bookmark.name, segments, default_branch)?;
358
359            if pr.base_ref != expected_base {
360                prs_to_update_base.push(PrBaseUpdate {
361                    bookmark: (*bookmark).clone(),
362                    current_base: pr.base_ref.clone(),
363                    expected_base,
364                    pr: pr.clone(),
365                });
366            }
367        } else {
368            // PR doesn't exist - needs creation
369            let base_branch = get_base_branch(&bookmark.name, segments, default_branch)?;
370            let title = generate_pr_title(&bookmark.name, segments)?;
371
372            prs_to_create.push(PrToCreate {
373                bookmark: (*bookmark).clone(),
374                base_branch,
375                title,
376                draft: false,
377            });
378        }
379    }
380
381    // Build ordered execution steps
382    let (constraints, execution_steps) = build_execution_steps(
383        segments,
384        &bookmarks_needing_push,
385        &prs_to_update_base,
386        &prs_to_create,
387        &[], // prs_to_publish populated by CLI layer via apply_plan_options
388    )?;
389
390    Ok(SubmissionPlan {
391        segments: segments.clone(),
392        constraints,
393        execution_steps,
394        existing_prs,
395        remote: remote.to_string(),
396        default_branch: default_branch.to_string(),
397    })
398}
399
400/// Build dependency-ordered execution steps.
401///
402/// Returns both the constraints (for debugging/display) and the sorted execution steps.
403fn build_execution_steps(
404    segments: &[NarrowedBookmarkSegment],
405    bookmarks_needing_push: &[Bookmark],
406    prs_to_update_base: &[PrBaseUpdate],
407    prs_to_create: &[PrToCreate],
408    prs_to_publish: &[PullRequest],
409) -> Result<(Vec<ExecutionConstraint>, Vec<ExecutionStep>)> {
410    let stack_index = build_stack_index(segments);
411
412    // Phase 1: Collect semantic constraints (declarative, no indices)
413    let constraints =
414        collect_constraints(segments, prs_to_update_base, prs_to_create, &stack_index);
415
416    tracing::debug!(
417        constraint_count = constraints.len(),
418        "Collected execution constraints"
419    );
420
421    // Phase 2: Build nodes and registry
422    let (nodes, registry) = build_execution_nodes(
423        segments,
424        bookmarks_needing_push,
425        prs_to_update_base,
426        prs_to_create,
427        prs_to_publish,
428    );
429
430    // Phase 3: Resolve constraints to edges
431    let edges = resolve_constraints(&constraints, &registry);
432
433    // Phase 4: Topological sort
434    let steps = topo_sort_steps(&nodes, &edges)?;
435
436    Ok((constraints, steps))
437}
438
439/// Map bookmark name to stack position for relative ordering
440fn build_stack_index(segments: &[NarrowedBookmarkSegment]) -> HashMap<String, usize> {
441    segments
442        .iter()
443        .enumerate()
444        .map(|(idx, seg)| (seg.bookmark.name.clone(), idx))
445        .collect()
446}
447
448/// Collect all dependency constraints declaratively.
449///
450/// This phase creates typed constraints without resolving them to indices.
451/// Constraints may reference operations that won't exist in the final plan
452/// (e.g., already-synced bookmarks have no Push node); resolution handles this.
453fn collect_constraints(
454    segments: &[NarrowedBookmarkSegment],
455    prs_to_update_base: &[PrBaseUpdate],
456    prs_to_create: &[PrToCreate],
457    stack_index: &HashMap<String, usize>,
458) -> Vec<ExecutionConstraint> {
459    let mut constraints = Vec::new();
460
461    // Constraint: Push(parent) → Push(child) for stack order
462    for window in segments.windows(2) {
463        constraints.push(ExecutionConstraint::PushOrder {
464            parent: PushRef(window[0].bookmark.name.clone()),
465            child: PushRef(window[1].bookmark.name.clone()),
466        });
467    }
468
469    // Constraint: Push(expected_base) → UpdateBase(PR)
470    for update in prs_to_update_base {
471        constraints.push(ExecutionConstraint::PushBeforeRetarget {
472            base: PushRef(update.expected_base.clone()),
473            pr: UpdateRef(update.bookmark.name.clone()),
474        });
475    }
476
477    // Constraint: UpdateBase(PR) → Push(current_base) when swapping
478    for update in prs_to_update_base {
479        if update.expected_base != update.current_base {
480            let current_pos = stack_index.get(&update.current_base);
481            let bookmark_pos = stack_index.get(&update.bookmark.name);
482            if let (Some(&current_pos), Some(&bookmark_pos)) = (current_pos, bookmark_pos)
483                && current_pos > bookmark_pos
484            {
485                // Current base is now below this bookmark - swap scenario
486                constraints.push(ExecutionConstraint::RetargetBeforePush {
487                    pr: UpdateRef(update.bookmark.name.clone()),
488                    old_base: PushRef(update.current_base.clone()),
489                });
490            }
491        }
492    }
493
494    // Constraint: Push(bookmark) → CreatePr(bookmark)
495    for create in prs_to_create {
496        constraints.push(ExecutionConstraint::PushBeforeCreate {
497            push: PushRef(create.bookmark.name.clone()),
498            create: CreateRef(create.bookmark.name.clone()),
499        });
500    }
501
502    // Constraint: CreatePr(parent) → CreatePr(child)
503    for window in segments.windows(2) {
504        constraints.push(ExecutionConstraint::CreateOrder {
505            parent: CreateRef(window[0].bookmark.name.clone()),
506            child: CreateRef(window[1].bookmark.name.clone()),
507        });
508    }
509
510    constraints
511}
512
513/// Build execution nodes for all operations
514fn build_execution_nodes(
515    segments: &[NarrowedBookmarkSegment],
516    bookmarks_needing_push: &[Bookmark],
517    prs_to_update_base: &[PrBaseUpdate],
518    prs_to_create: &[PrToCreate],
519    prs_to_publish: &[PullRequest],
520) -> (Vec<ExecutionNode>, NodeRegistry) {
521    let mut nodes = Vec::new();
522    let mut order = 0usize;
523    let mut registry = NodeRegistry::default();
524
525    // Build push set for O(1) lookup
526    let push_set: HashSet<_> = bookmarks_needing_push.iter().map(|b| &b.name).collect();
527
528    // Add push nodes in stack order
529    for seg in segments {
530        if push_set.contains(&seg.bookmark.name) {
531            let bookmark = bookmarks_needing_push
532                .iter()
533                .find(|b| b.name == seg.bookmark.name)
534                .expect("bookmark in push_set verified above")
535                .clone();
536            registry.register_push(&seg.bookmark.name, nodes.len());
537            nodes.push(ExecutionNode {
538                step: ExecutionStep::Push(bookmark),
539                order,
540            });
541            order += 1;
542        }
543    }
544
545    // Add any pushes not in segments (shouldn't happen, but be safe)
546    for bookmark in bookmarks_needing_push {
547        if !registry.push.contains_key(&bookmark.name) {
548            registry.register_push(&bookmark.name, nodes.len());
549            nodes.push(ExecutionNode {
550                step: ExecutionStep::Push(bookmark.clone()),
551                order,
552            });
553            order += 1;
554        }
555    }
556
557    // Add update base nodes
558    for update in prs_to_update_base {
559        registry.register_update(&update.bookmark.name, nodes.len());
560        nodes.push(ExecutionNode {
561            step: ExecutionStep::UpdateBase(update.clone()),
562            order,
563        });
564        order += 1;
565    }
566
567    // Add create PR nodes (in stack order for proper base dependencies)
568    let create_set: HashSet<_> = prs_to_create.iter().map(|c| &c.bookmark.name).collect();
569    for seg in segments {
570        if create_set.contains(&seg.bookmark.name) {
571            let create = prs_to_create
572                .iter()
573                .find(|c| c.bookmark.name == seg.bookmark.name)
574                .expect("bookmark in create_set verified above")
575                .clone();
576            registry.register_create(&seg.bookmark.name, nodes.len());
577            nodes.push(ExecutionNode {
578                step: ExecutionStep::CreatePr(create),
579                order,
580            });
581            order += 1;
582        }
583    }
584
585    // Add publish nodes
586    for pr in prs_to_publish {
587        registry.register_publish(&pr.head_ref, nodes.len());
588        nodes.push(ExecutionNode {
589            step: ExecutionStep::PublishPr(pr.clone()),
590            order,
591        });
592        order += 1;
593    }
594
595    (nodes, registry)
596}
597
598/// Resolve constraints to adjacency list edges.
599///
600/// Constraints that reference non-existent nodes are silently skipped.
601/// This is expected: e.g., a Push constraint for an already-synced bookmark.
602fn resolve_constraints(
603    constraints: &[ExecutionConstraint],
604    registry: &NodeRegistry,
605) -> Vec<Vec<usize>> {
606    let mut edges = vec![Vec::new(); registry.len()];
607
608    for constraint in constraints {
609        if let Some((from, to)) = constraint.resolve(registry) {
610            if !edges[from].contains(&to) {
611                edges[from].push(to);
612                tracing::trace!(%constraint, from, to, "Resolved constraint to edge");
613            }
614        } else {
615            tracing::trace!(%constraint, "Constraint skipped (endpoint not in plan)");
616        }
617    }
618
619    edges
620}
621
622/// Topologically sort nodes respecting dependencies
623fn topo_sort_steps(nodes: &[ExecutionNode], edges: &[Vec<usize>]) -> Result<Vec<ExecutionStep>> {
624    // Kahn's algorithm with heap for stable ordering
625    let mut indegree = vec![0usize; nodes.len()];
626    for edge_list in edges {
627        for &to in edge_list {
628            indegree[to] += 1;
629        }
630    }
631
632    // Use min-heap by (order, idx) for deterministic output
633    let mut ready = BinaryHeap::new();
634    for (idx, node) in nodes.iter().enumerate() {
635        if indegree[idx] == 0 {
636            ready.push(Reverse((node.order, idx)));
637        }
638    }
639
640    let mut sorted = Vec::with_capacity(nodes.len());
641    while let Some(Reverse((_order, idx))) = ready.pop() {
642        sorted.push(idx);
643        for &to in &edges[idx] {
644            indegree[to] -= 1;
645            if indegree[to] == 0 {
646                ready.push(Reverse((nodes[to].order, to)));
647            }
648        }
649    }
650
651    if sorted.len() != nodes.len() {
652        // Collect nodes stuck in the cycle (indegree > 0 means couldn't be scheduled)
653        let cycle_nodes: Vec<String> = nodes
654            .iter()
655            .enumerate()
656            .filter(|(idx, _)| indegree[*idx] > 0)
657            .map(|(_, node)| format!("{}", node.step))
658            .collect();
659
660        tracing::error!(
661            cycle_nodes = ?cycle_nodes,
662            "Scheduler cycle detected - this is a bug in jj-ryu"
663        );
664
665        return Err(Error::SchedulerCycle {
666            message:
667                "Dependency cycle in execution plan - this is a bug in jj-ryu, please report it"
668                    .to_string(),
669            cycle_nodes,
670        });
671    }
672
673    Ok(sorted
674        .into_iter()
675        .map(|idx| nodes[idx].step.clone())
676        .collect())
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682
683    fn make_bookmark(name: &str, has_remote: bool, is_synced: bool) -> Bookmark {
684        Bookmark {
685            name: name.to_string(),
686            commit_id: format!("{name}_commit"),
687            change_id: format!("{name}_change"),
688            has_remote,
689            is_synced,
690        }
691    }
692
693    fn make_segment(name: &str) -> NarrowedBookmarkSegment {
694        NarrowedBookmarkSegment {
695            bookmark: make_bookmark(name, false, false),
696            changes: vec![],
697        }
698    }
699
700    fn make_pr(number: u64, bookmark: &str, base: &str) -> PullRequest {
701        PullRequest {
702            number,
703            html_url: format!("https://github.com/test/test/pull/{number}"),
704            base_ref: base.to_string(),
705            head_ref: bookmark.to_string(),
706            title: format!("PR for {bookmark}"),
707            node_id: Some(format!("PR_node_{number}")),
708            is_draft: false,
709        }
710    }
711
712    fn make_update(
713        bookmark: &Bookmark,
714        current_base: &str,
715        expected_base: &str,
716        pr_number: u64,
717    ) -> PrBaseUpdate {
718        PrBaseUpdate {
719            bookmark: bookmark.clone(),
720            current_base: current_base.to_string(),
721            expected_base: expected_base.to_string(),
722            pr: make_pr(pr_number, &bookmark.name, current_base),
723        }
724    }
725
726    fn make_create(bookmark: &Bookmark, base_branch: &str) -> PrToCreate {
727        PrToCreate {
728            bookmark: bookmark.clone(),
729            base_branch: base_branch.to_string(),
730            title: format!("Add {}", bookmark.name),
731            draft: false,
732        }
733    }
734
735    fn find_step_index(
736        steps: &[ExecutionStep],
737        predicate: impl Fn(&ExecutionStep) -> bool,
738    ) -> Option<usize> {
739        steps.iter().position(predicate)
740    }
741
742    #[test]
743    fn test_bookmark_needs_push() {
744        let bm1 = make_bookmark("feat-a", false, false);
745        assert!(!bm1.has_remote || !bm1.is_synced);
746
747        let bm2 = make_bookmark("feat-b", true, false);
748        assert!(!bm2.has_remote || !bm2.is_synced);
749
750        let bm3 = make_bookmark("feat-c", true, true);
751        assert!(bm3.has_remote && bm3.is_synced);
752    }
753
754    #[test]
755    fn test_pr_to_create_structure() {
756        let pr_create = PrToCreate {
757            bookmark: make_bookmark("feat-a", false, false),
758            base_branch: "main".to_string(),
759            title: "Add feature A".to_string(),
760            draft: false,
761        };
762
763        assert_eq!(pr_create.bookmark.name, "feat-a");
764        assert_eq!(pr_create.base_branch, "main");
765        assert_eq!(pr_create.title, "Add feature A");
766        assert!(!pr_create.draft);
767    }
768
769    #[test]
770    fn test_execution_steps_simple_push_order() {
771        let segments = vec![make_segment("a"), make_segment("b")];
772        let pushes = vec![
773            make_bookmark("a", false, false),
774            make_bookmark("b", false, false),
775        ];
776
777        let (_constraints, steps) =
778            build_execution_steps(&segments, &pushes, &[], &[], &[]).unwrap();
779
780        let push_a = find_step_index(
781            &steps,
782            |s| matches!(s, ExecutionStep::Push(b) if b.name == "a"),
783        );
784        let push_b = find_step_index(
785            &steps,
786            |s| matches!(s, ExecutionStep::Push(b) if b.name == "b"),
787        );
788
789        assert!(
790            push_a.unwrap() < push_b.unwrap(),
791            "pushes should follow stack order"
792        );
793    }
794
795    #[test]
796    fn test_execution_steps_push_before_create() {
797        let bm_a = make_bookmark("a", false, false);
798        let segments = vec![make_segment("a")];
799        let pushes = vec![bm_a.clone()];
800        let creates = vec![make_create(&bm_a, "main")];
801
802        let (_constraints, steps) =
803            build_execution_steps(&segments, &pushes, &[], &creates, &[]).unwrap();
804
805        let push_a = find_step_index(
806            &steps,
807            |s| matches!(s, ExecutionStep::Push(b) if b.name == "a"),
808        )
809        .unwrap();
810        let create_a = find_step_index(
811            &steps,
812            |s| matches!(s, ExecutionStep::CreatePr(c) if c.bookmark.name == "a"),
813        )
814        .unwrap();
815
816        assert!(push_a < create_a, "push must happen before create");
817    }
818
819    #[test]
820    fn test_execution_steps_create_order_follows_stack() {
821        let bm_a = make_bookmark("a", false, false);
822        let bm_b = make_bookmark("b", false, false);
823        let segments = vec![make_segment("a"), make_segment("b")];
824        let pushes = vec![bm_a.clone(), bm_b.clone()];
825        let creates = vec![make_create(&bm_a, "main"), make_create(&bm_b, "a")];
826
827        let (_constraints, steps) =
828            build_execution_steps(&segments, &pushes, &[], &creates, &[]).unwrap();
829
830        let create_a = find_step_index(
831            &steps,
832            |s| matches!(s, ExecutionStep::CreatePr(c) if c.bookmark.name == "a"),
833        )
834        .unwrap();
835        let create_b = find_step_index(
836            &steps,
837            |s| matches!(s, ExecutionStep::CreatePr(c) if c.bookmark.name == "b"),
838        )
839        .unwrap();
840
841        assert!(create_a < create_b, "creates should follow stack order");
842    }
843
844    #[test]
845    fn test_execution_steps_swap_order() {
846        // Scenario: Stack was A -> B, now B -> A (swapped)
847        let bm_a = make_bookmark("a", false, false);
848        let bm_b = make_bookmark("b", false, false);
849
850        // New stack order: B is root, A is leaf
851        let segments = vec![make_segment("b"), make_segment("a")];
852        let pushes = vec![bm_a.clone(), bm_b.clone()];
853        let updates = vec![
854            make_update(&bm_b, "a", "main", 2), // B was on A, now on main
855            make_update(&bm_a, "main", "b", 1), // A was on main, now on B
856        ];
857
858        let (_constraints, steps) =
859            build_execution_steps(&segments, &pushes, &updates, &[], &[]).unwrap();
860
861        let retarget_b = find_step_index(
862            &steps,
863            |s| matches!(s, ExecutionStep::UpdateBase(u) if u.bookmark.name == "b"),
864        )
865        .unwrap();
866        let push_a = find_step_index(
867            &steps,
868            |s| matches!(s, ExecutionStep::Push(b) if b.name == "a"),
869        )
870        .unwrap();
871        let push_b = find_step_index(
872            &steps,
873            |s| matches!(s, ExecutionStep::Push(b) if b.name == "b"),
874        )
875        .unwrap();
876
877        assert!(retarget_b < push_a, "b must move off a before pushing a");
878        assert!(
879            push_b < push_a,
880            "push order should follow new stack (b before a)"
881        );
882    }
883
884    #[test]
885    fn test_plan_is_empty() {
886        let plan = SubmissionPlan {
887            segments: vec![],
888            constraints: vec![],
889            execution_steps: vec![],
890            existing_prs: HashMap::new(),
891            remote: "origin".to_string(),
892            default_branch: "main".to_string(),
893        };
894
895        assert!(plan.is_empty());
896        assert_eq!(plan.count_pushes(), 0);
897        assert_eq!(plan.count_creates(), 0);
898    }
899
900    #[test]
901    fn test_plan_counts() {
902        let bm = make_bookmark("a", false, false);
903        let plan = SubmissionPlan {
904            segments: vec![make_segment("a")],
905            constraints: vec![],
906            execution_steps: vec![
907                ExecutionStep::Push(bm.clone()),
908                ExecutionStep::CreatePr(make_create(&bm, "main")),
909            ],
910            existing_prs: HashMap::new(),
911            remote: "origin".to_string(),
912            default_branch: "main".to_string(),
913        };
914
915        assert!(!plan.is_empty());
916        assert_eq!(plan.count_pushes(), 1);
917        assert_eq!(plan.count_creates(), 1);
918        assert_eq!(plan.count_updates(), 0);
919        assert_eq!(plan.count_publishes(), 0);
920    }
921}