rung-core 0.8.0

Core library for Rung - stack model, state management, sync engine
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
//! Stack data model representing a chain of dependent branches.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::BranchName;

/// A stack of dependent branches forming a PR chain.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Stack {
    /// Ordered list of branches from base to tip.
    pub branches: Vec<StackBranch>,

    /// Branches that have been merged (for preserving history in PR comments).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub merged: Vec<MergedBranch>,
}

impl Stack {
    /// Create a new empty stack.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            branches: Vec::new(),
            merged: Vec::new(),
        }
    }

    /// Find a branch by name.
    #[must_use]
    pub fn find_branch(&self, name: &str) -> Option<&StackBranch> {
        self.branches.iter().find(|b| b.name == name)
    }

    /// Find a branch by name (mutable).
    pub fn find_branch_mut(&mut self, name: &str) -> Option<&mut StackBranch> {
        self.branches.iter_mut().find(|b| b.name == name)
    }

    /// Add a new branch to the stack.
    pub fn add_branch(&mut self, branch: StackBranch) {
        self.branches.push(branch);
    }

    /// Remove a branch from the stack.
    pub fn remove_branch(&mut self, name: &str) -> Option<StackBranch> {
        if let Some(pos) = self.branches.iter().position(|b| b.name == name) {
            Some(self.branches.remove(pos))
        } else {
            None
        }
    }

    /// Mark a branch as merged, moving it from active to merged list.
    ///
    /// This preserves the branch info for stack comment history,
    /// including the original parent for ancestry chain traversal.
    /// Returns the removed branch if found.
    pub fn mark_merged(&mut self, name: &str) -> Option<StackBranch> {
        let branch = self.remove_branch(name)?;

        if let Some(pr) = branch.pr {
            self.merged.push(MergedBranch {
                name: branch.name.clone(),
                parent: branch.parent.clone(),
                pr,
                merged_at: Utc::now(),
            });
        }

        Some(branch)
    }

    /// Find a merged branch by name.
    #[must_use]
    pub fn find_merged(&self, name: &str) -> Option<&MergedBranch> {
        self.merged.iter().find(|b| b.name == name)
    }

    /// Find a merged branch by PR number.
    #[must_use]
    pub fn find_merged_by_pr(&self, pr: u64) -> Option<&MergedBranch> {
        self.merged.iter().find(|b| b.pr == pr)
    }

    /// Clear merged branches when stack is empty.
    ///
    /// This should be called after merge operations to clean up
    /// when the entire stack has been merged.
    pub fn clear_merged_if_empty(&mut self) {
        if self.branches.is_empty() {
            self.merged.clear();
        }
    }

    /// Get all children of a branch.
    #[must_use]
    pub fn children_of(&self, name: &str) -> Vec<&StackBranch> {
        self.branches
            .iter()
            .filter(|b| b.parent.as_deref() == Some(name))
            .collect()
    }

    /// Get all descendants of a branch in topological order (parents before children).
    ///
    /// This includes children, grandchildren, etc. The branch itself is NOT included.
    #[must_use]
    pub fn descendants(&self, name: &str) -> Vec<&StackBranch> {
        let mut result = Vec::new();
        let mut stack = vec![name];

        while let Some(current_parent) = stack.pop() {
            for branch in &self.branches {
                if branch.parent.as_deref() == Some(current_parent) {
                    result.push(branch);
                    stack.push(&branch.name);
                }
            }
        }
        result
    }

    /// Get the ancestry chain for a branch (from root to the branch).
    #[must_use]
    pub fn ancestry(&self, name: &str) -> Vec<&StackBranch> {
        let mut chain = vec![];
        let mut current = name;

        while let Some(branch) = self.find_branch(current) {
            chain.push(branch);
            match &branch.parent {
                Some(parent) if self.find_branch(parent).is_some() => {
                    current = parent;
                }
                _ => break,
            }
        }

        chain.reverse();
        chain
    }

    /// Check if the stack is empty.
    #[must_use]
    pub const fn is_empty(&self) -> bool {
        self.branches.is_empty()
    }

    /// Get the number of branches in the stack.
    #[must_use]
    pub const fn len(&self) -> usize {
        self.branches.len()
    }

    /// Check if reparenting `branch` to `new_parent` would create a cycle.
    ///
    /// A cycle would occur if the new parent is a descendant of the branch
    /// (e.g., setting A's parent to B when B is already a child of A).
    #[must_use]
    pub fn would_create_cycle(&self, branch: &str, new_parent: &str) -> bool {
        // Can't be your own parent
        if branch == new_parent {
            return true;
        }

        // Check if new_parent is a descendant of branch
        self.descendants(branch)
            .iter()
            .any(|b| b.name == new_parent)
    }

    /// Reparent a branch to a new parent.
    ///
    /// Updates the parent field of the specified branch. Does not perform
    /// any git operations - the caller is responsible for rebasing.
    ///
    /// # Errors
    ///
    /// Returns an error if the branch is not found or if the reparent
    /// would create a cycle.
    pub fn reparent(&mut self, branch: &str, new_parent: Option<&str>) -> crate::Result<()> {
        // Check for cycle (only if new_parent is in the stack)
        if let Some(parent) = new_parent
            && self.would_create_cycle(branch, parent)
        {
            return Err(crate::error::Error::CyclicDependency(format!(
                "reparenting '{branch}' to '{parent}' would create a cycle"
            )));
        }

        // Find and update the branch
        let branch_entry = self
            .find_branch_mut(branch)
            .ok_or_else(|| crate::error::Error::BranchNotFound(branch.to_string()))?;

        // Parent comes from an existing branch name, so it should be valid
        branch_entry.parent = new_parent.map(BranchName::new).transpose().map_err(|_| {
            crate::error::Error::BranchNotFound(new_parent.unwrap_or_default().to_string())
        })?;

        Ok(())
    }
}

impl Default for Stack {
    fn default() -> Self {
        Self::new()
    }
}

/// A branch within a stack.
///
/// Branch names are validated at construction time to prevent:
/// - Path traversal attacks (`../`)
/// - Shell metacharacters (`$`, `;`, `|`, etc.)
/// - Invalid git branch name characters
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StackBranch {
    /// Branch name (validated).
    pub name: BranchName,

    /// Parent branch name (None for root branches based on main/master).
    pub parent: Option<BranchName>,

    /// Associated PR number (if submitted).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pr: Option<u64>,

    /// When this branch was added to the stack.
    pub created: DateTime<Utc>,
}

impl StackBranch {
    /// Create a new stack branch with pre-validated names.
    #[must_use]
    pub fn new(name: BranchName, parent: Option<BranchName>) -> Self {
        Self {
            name,
            parent,
            pr: None,
            created: Utc::now(),
        }
    }

    /// Create a new stack branch, validating the names.
    ///
    /// # Errors
    ///
    /// Returns an error if the branch name or parent name is invalid.
    pub fn try_new(
        name: impl Into<String>,
        parent: Option<impl Into<String>>,
    ) -> crate::Result<Self> {
        let name = BranchName::new(name)?;
        let parent = parent.map(BranchName::new).transpose()?;
        Ok(Self::new(name, parent))
    }
}

/// A branch that has been merged (for preserving history in PR comments).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MergedBranch {
    /// Branch name.
    pub name: BranchName,

    /// Original parent branch name (preserved for ancestry chain).
    #[serde(default)]
    pub parent: Option<BranchName>,

    /// PR number that was merged.
    pub pr: u64,

    /// When this branch was merged.
    pub merged_at: DateTime<Utc>,
}

/// Synchronization state of a branch relative to its parent.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum BranchState {
    /// Branch is up-to-date with its parent.
    Synced,

    /// Parent has moved forward, branch needs rebase.
    Diverged {
        /// Number of commits the parent is ahead.
        commits_behind: usize,
    },

    /// Rebase resulted in conflicts that need resolution.
    Conflict {
        /// Files with conflicts.
        files: Vec<String>,
    },

    /// Parent branch was deleted or renamed.
    Detached,
}

impl BranchState {
    /// Check if the branch needs syncing.
    #[must_use]
    pub const fn needs_sync(&self) -> bool {
        matches!(self, Self::Diverged { .. })
    }

    /// Check if the branch has conflicts.
    #[must_use]
    pub const fn has_conflicts(&self) -> bool {
        matches!(self, Self::Conflict { .. })
    }

    /// Check if the branch is healthy (synced).
    #[must_use]
    pub const fn is_healthy(&self) -> bool {
        matches!(self, Self::Synced)
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn test_stack_operations() {
        let mut stack = Stack::new();
        assert!(stack.is_empty());

        stack.add_branch(StackBranch::try_new("feature/auth", Some("main")).unwrap());
        stack.add_branch(StackBranch::try_new("feature/auth-ui", Some("feature/auth")).unwrap());

        assert_eq!(stack.len(), 2);
        assert!(stack.find_branch("feature/auth").is_some());

        let children = stack.children_of("feature/auth");
        assert_eq!(children.len(), 1);
        assert_eq!(children[0].name, "feature/auth-ui");
    }

    #[test]
    fn test_ancestry() {
        let mut stack = Stack::new();
        stack.add_branch(StackBranch::try_new("a", Some("main")).unwrap());
        stack.add_branch(StackBranch::try_new("b", Some("a")).unwrap());
        stack.add_branch(StackBranch::try_new("c", Some("b")).unwrap());

        let ancestry = stack.ancestry("c");
        assert_eq!(ancestry.len(), 3);
        assert_eq!(ancestry[0].name, "a");
        assert_eq!(ancestry[1].name, "b");
        assert_eq!(ancestry[2].name, "c");
    }

    #[test]
    fn test_descendants() {
        let mut stack = Stack::new();
        // Create tree: main → a → b → c
        //                    ↘ d
        stack.add_branch(StackBranch::try_new("a", Some("main")).unwrap());
        stack.add_branch(StackBranch::try_new("b", Some("a")).unwrap());
        stack.add_branch(StackBranch::try_new("c", Some("b")).unwrap());
        stack.add_branch(StackBranch::try_new("d", Some("a")).unwrap());

        // Descendants of "a" should be b, c, d (in some order based on traversal)
        let descendants = stack.descendants("a");
        assert_eq!(descendants.len(), 3);
        let names: Vec<&str> = descendants.iter().map(|b| b.name.as_str()).collect();
        assert!(names.contains(&"b"));
        assert!(names.contains(&"c"));
        assert!(names.contains(&"d"));

        // Descendants of "b" should only be c
        let descendants = stack.descendants("b");
        assert_eq!(descendants.len(), 1);
        assert_eq!(descendants[0].name, "c");

        // Descendants of "c" (leaf) should be empty
        let descendants = stack.descendants("c");
        assert!(descendants.is_empty());
    }

    #[test]
    fn test_branch_state() {
        assert!(BranchState::Synced.is_healthy());
        assert!(!BranchState::Synced.needs_sync());

        let diverged = BranchState::Diverged { commits_behind: 3 };
        assert!(diverged.needs_sync());
        assert!(!diverged.is_healthy());

        let conflict = BranchState::Conflict {
            files: vec!["src/main.rs".into()],
        };
        assert!(conflict.has_conflicts());
    }

    #[test]
    fn test_would_create_cycle() {
        let mut stack = Stack::new();
        // Create tree: main → a → b → c
        //                    ↘ d
        stack.add_branch(StackBranch::try_new("a", Some("main")).unwrap());
        stack.add_branch(StackBranch::try_new("b", Some("a")).unwrap());
        stack.add_branch(StackBranch::try_new("c", Some("b")).unwrap());
        stack.add_branch(StackBranch::try_new("d", Some("a")).unwrap());

        // Can't be your own parent
        assert!(stack.would_create_cycle("a", "a"));
        assert!(stack.would_create_cycle("b", "b"));

        // Can't set parent to a descendant
        assert!(stack.would_create_cycle("a", "b")); // b is child of a
        assert!(stack.would_create_cycle("a", "c")); // c is grandchild of a
        assert!(stack.would_create_cycle("a", "d")); // d is child of a
        assert!(stack.would_create_cycle("b", "c")); // c is child of b

        // These are valid reparents (no cycle)
        assert!(!stack.would_create_cycle("c", "a")); // c can have a as parent
        assert!(!stack.would_create_cycle("c", "d")); // c can have d as parent (sibling of b)
        assert!(!stack.would_create_cycle("d", "b")); // d can have b as parent
        assert!(!stack.would_create_cycle("b", "d")); // b can have d as parent (they're siblings)
    }

    #[test]
    fn test_reparent() {
        let mut stack = Stack::new();
        // Create tree: main → a → b
        //                    ↘ c
        stack.add_branch(StackBranch::try_new("a", Some("main")).unwrap());
        stack.add_branch(StackBranch::try_new("b", Some("a")).unwrap());
        stack.add_branch(StackBranch::try_new("c", Some("a")).unwrap());

        // b's parent is a
        assert_eq!(
            stack
                .find_branch("b")
                .unwrap()
                .parent
                .as_ref()
                .unwrap()
                .as_str(),
            "a"
        );

        // Reparent b to c
        stack.reparent("b", Some("c")).unwrap();
        assert_eq!(
            stack
                .find_branch("b")
                .unwrap()
                .parent
                .as_ref()
                .unwrap()
                .as_str(),
            "c"
        );

        // Verify tree is now: main → a → c → b
        let ancestry = stack.ancestry("b");
        assert_eq!(ancestry.len(), 3);
        assert_eq!(ancestry[0].name, "a");
        assert_eq!(ancestry[1].name, "c");
        assert_eq!(ancestry[2].name, "b");
    }

    #[test]
    fn test_reparent_to_none() {
        let mut stack = Stack::new();
        stack.add_branch(StackBranch::try_new("a", Some("main")).unwrap());
        stack.add_branch(StackBranch::try_new("b", Some("a")).unwrap());

        // Reparent b to None (make it a root branch)
        stack.reparent("b", None).unwrap();
        assert!(stack.find_branch("b").unwrap().parent.is_none());
    }

    #[test]
    fn test_reparent_cycle_error() {
        let mut stack = Stack::new();
        stack.add_branch(StackBranch::try_new("a", Some("main")).unwrap());
        stack.add_branch(StackBranch::try_new("b", Some("a")).unwrap());

        // Should fail: can't make a's parent be b (its child)
        let result = stack.reparent("a", Some("b"));
        assert!(result.is_err());
    }

    #[test]
    fn test_reparent_not_found() {
        let mut stack = Stack::new();
        stack.add_branch(StackBranch::try_new("a", Some("main")).unwrap());

        // Should fail: branch doesn't exist
        let result = stack.reparent("nonexistent", Some("a"));
        assert!(result.is_err());
    }
}