Skip to main content

cascade_cli/stack/
stack.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use uuid::Uuid;
5
6/// Represents a single entry in a stack
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct StackEntry {
9    /// Unique identifier for this entry
10    pub id: Uuid,
11    /// Branch name for this entry
12    pub branch: String,
13    /// Commit hash
14    pub commit_hash: String,
15    /// Commit message
16    pub message: String,
17    /// Parent entry ID (None for base)
18    pub parent_id: Option<Uuid>,
19    /// Child entry IDs
20    pub children: Vec<Uuid>,
21    /// When this entry was created
22    pub created_at: DateTime<Utc>,
23    /// When this entry was last updated
24    pub updated_at: DateTime<Utc>,
25    /// Whether this entry has been submitted for review
26    pub is_submitted: bool,
27    /// Pull request ID if submitted
28    pub pull_request_id: Option<String>,
29    /// Whether this entry is synced with remote
30    pub is_synced: bool,
31    /// Whether this entry's PR has been merged
32    #[serde(default)]
33    pub is_merged: bool,
34}
35
36/// Represents the status of a stack
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38pub enum StackStatus {
39    /// Stack is clean and ready
40    Clean,
41    /// Stack has uncommitted changes
42    Dirty,
43    /// Stack needs to be synced with remote
44    OutOfSync,
45    /// Stack has conflicts that need resolution
46    Conflicted,
47    /// Stack is being rebased
48    Rebasing,
49    /// Stack needs sync due to new commits on base branch
50    NeedsSync,
51    /// Stack has corrupted or missing commits
52    Corrupted,
53}
54
55/// Represents a complete stack of commits
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct Stack {
58    /// Unique identifier for this stack
59    pub id: Uuid,
60    /// Human-readable name for the stack
61    pub name: String,
62    /// Description of what this stack implements
63    pub description: Option<String>,
64    /// Base branch this stack is built on
65    pub base_branch: String,
66    /// Working branch where commits are made (e.g., feature-1)
67    pub working_branch: Option<String>,
68    /// All entries in this stack (ordered)
69    pub entries: Vec<StackEntry>,
70    /// Map of entry ID to entry for quick lookup
71    pub entry_map: HashMap<Uuid, StackEntry>,
72    /// Current status of the stack
73    pub status: StackStatus,
74    /// When this stack was created
75    pub created_at: DateTime<Utc>,
76    /// When this stack was last updated
77    pub updated_at: DateTime<Utc>,
78    /// Whether this stack is active (current working stack)
79    pub is_active: bool,
80}
81
82impl Stack {
83    /// Create a new empty stack
84    pub fn new(name: String, base_branch: String, description: Option<String>) -> Self {
85        let now = Utc::now();
86        Self {
87            id: Uuid::new_v4(),
88            name,
89            description,
90            base_branch,
91            working_branch: None,
92            entries: Vec::new(),
93            entry_map: HashMap::new(),
94            status: StackStatus::Clean,
95            created_at: now,
96            updated_at: now,
97            is_active: false,
98        }
99    }
100
101    /// Add a new entry to the top of the stack
102    pub fn push_entry(&mut self, branch: String, commit_hash: String, message: String) -> Uuid {
103        let now = Utc::now();
104        let entry_id = Uuid::new_v4();
105
106        // Find the current top entry to set as parent
107        let parent_id = self.entries.last().map(|entry| entry.id);
108
109        let entry = StackEntry {
110            id: entry_id,
111            branch,
112            commit_hash,
113            message,
114            parent_id,
115            children: Vec::new(),
116            created_at: now,
117            updated_at: now,
118            is_submitted: false,
119            pull_request_id: None,
120            is_synced: false,
121            is_merged: false,
122        };
123
124        // Update parent's children if exists
125        if let Some(parent_id) = parent_id {
126            if let Some(parent) = self.entry_map.get_mut(&parent_id) {
127                parent.children.push(entry_id);
128            }
129        }
130
131        // Add to collections
132        self.entries.push(entry.clone());
133        self.entry_map.insert(entry_id, entry);
134        self.updated_at = now;
135
136        entry_id
137    }
138
139    /// Remove the top entry from the stack
140    pub fn pop_entry(&mut self) -> Option<StackEntry> {
141        if let Some(entry) = self.entries.pop() {
142            let entry_id = entry.id;
143            self.entry_map.remove(&entry_id);
144
145            // Update parent's children if exists
146            if let Some(parent_id) = entry.parent_id {
147                if let Some(parent) = self.entry_map.get_mut(&parent_id) {
148                    parent.children.retain(|&id| id != entry_id);
149                }
150            }
151
152            self.updated_at = Utc::now();
153            Some(entry)
154        } else {
155            None
156        }
157    }
158
159    /// Remove an entry by 0-based index, reparenting children to the removed entry's parent
160    pub fn remove_entry_at(&mut self, index: usize) -> Option<StackEntry> {
161        if index >= self.entries.len() {
162            return None;
163        }
164
165        let entry = self.entries.remove(index);
166        let entry_id = entry.id;
167        self.entry_map.remove(&entry_id);
168
169        // Reparent children to the removed entry's parent
170        for &child_id in &entry.children {
171            if let Some(child) = self.entry_map.get_mut(&child_id) {
172                child.parent_id = entry.parent_id;
173            }
174        }
175
176        // Update parent: remove this entry from children, adopt grandchildren
177        if let Some(parent_id) = entry.parent_id {
178            if let Some(parent) = self.entry_map.get_mut(&parent_id) {
179                parent.children.retain(|&id| id != entry_id);
180                for &child_id in &entry.children {
181                    if !parent.children.contains(&child_id) {
182                        parent.children.push(child_id);
183                    }
184                }
185            }
186        }
187
188        self.sync_entries_from_map();
189        self.updated_at = Utc::now();
190        Some(entry)
191    }
192
193    /// Get an entry by ID
194    pub fn get_entry(&self, id: &Uuid) -> Option<&StackEntry> {
195        self.entry_map.get(id)
196    }
197
198    /// Get a mutable entry by ID
199    pub fn get_entry_mut(&mut self, id: &Uuid) -> Option<&mut StackEntry> {
200        self.entry_map.get_mut(id)
201    }
202
203    /// Update an entry's commit hash in both entries Vec and entry_map
204    /// This ensures the two data structures stay in sync
205    pub fn update_entry_commit_hash(
206        &mut self,
207        entry_id: &Uuid,
208        new_commit_hash: String,
209    ) -> Result<(), String> {
210        // Update in entries Vec
211        let updated_in_vec = self
212            .entries
213            .iter_mut()
214            .find(|e| e.id == *entry_id)
215            .map(|entry| {
216                entry.commit_hash = new_commit_hash.clone();
217            })
218            .is_some();
219
220        // Update in entry_map
221        let updated_in_map = self
222            .entry_map
223            .get_mut(entry_id)
224            .map(|entry| {
225                entry.commit_hash = new_commit_hash;
226            })
227            .is_some();
228
229        if updated_in_vec && updated_in_map {
230            Ok(())
231        } else {
232            Err(format!("Entry {} not found", entry_id))
233        }
234    }
235
236    /// Get the base (first) entry of the stack
237    pub fn get_base_entry(&self) -> Option<&StackEntry> {
238        self.entries.first()
239    }
240
241    /// Get the top (last) entry of the stack
242    pub fn get_top_entry(&self) -> Option<&StackEntry> {
243        self.entries.last()
244    }
245
246    /// Get all entries that are children of the given entry
247    pub fn get_children(&self, entry_id: &Uuid) -> Vec<&StackEntry> {
248        if let Some(entry) = self.get_entry(entry_id) {
249            entry
250                .children
251                .iter()
252                .filter_map(|id| self.get_entry(id))
253                .collect()
254        } else {
255            Vec::new()
256        }
257    }
258
259    /// Get the parent of the given entry
260    pub fn get_parent(&self, entry_id: &Uuid) -> Option<&StackEntry> {
261        if let Some(entry) = self.get_entry(entry_id) {
262            entry
263                .parent_id
264                .and_then(|parent_id| self.get_entry(&parent_id))
265        } else {
266            None
267        }
268    }
269
270    /// Check if the stack is empty
271    pub fn is_empty(&self) -> bool {
272        self.entries.is_empty()
273    }
274
275    /// Get the number of entries in the stack
276    pub fn len(&self) -> usize {
277        self.entries.len()
278    }
279
280    /// Mark an entry as submitted with a pull request ID
281    pub fn mark_entry_submitted(&mut self, entry_id: &Uuid, pull_request_id: String) -> bool {
282        if let Some(entry) = self.get_entry_mut(entry_id) {
283            entry.is_submitted = true;
284            entry.pull_request_id = Some(pull_request_id);
285            entry.updated_at = Utc::now();
286            entry.is_merged = false;
287            self.updated_at = Utc::now();
288
289            // Synchronize the entries vector with the updated entry_map
290            self.sync_entries_from_map();
291            true
292        } else {
293            false
294        }
295    }
296
297    /// Synchronize the entries vector with the entry_map (entry_map is source of truth)
298    fn sync_entries_from_map(&mut self) {
299        for entry in &mut self.entries {
300            if let Some(updated_entry) = self.entry_map.get(&entry.id) {
301                *entry = updated_entry.clone();
302            }
303        }
304    }
305
306    /// Force synchronization of entries from entry_map (public method for fixing corrupted data)
307    pub fn repair_data_consistency(&mut self) {
308        self.sync_entries_from_map();
309    }
310
311    /// Mark an entry as synced
312    pub fn mark_entry_synced(&mut self, entry_id: &Uuid) -> bool {
313        if let Some(entry) = self.get_entry_mut(entry_id) {
314            entry.is_synced = true;
315            entry.updated_at = Utc::now();
316            self.updated_at = Utc::now();
317
318            // Synchronize the entries vector with the updated entry_map
319            self.sync_entries_from_map();
320            true
321        } else {
322            false
323        }
324    }
325
326    /// Mark an entry as merged (or unmerged)
327    pub fn mark_entry_merged(&mut self, entry_id: &Uuid, merged: bool) -> bool {
328        if let Some(entry) = self.get_entry_mut(entry_id) {
329            entry.is_merged = merged;
330            entry.updated_at = Utc::now();
331            self.updated_at = Utc::now();
332            self.sync_entries_from_map();
333            true
334        } else {
335            false
336        }
337    }
338
339    /// Update stack status
340    pub fn update_status(&mut self, status: StackStatus) {
341        self.status = status;
342        self.updated_at = Utc::now();
343    }
344
345    /// Set this stack as active
346    pub fn set_active(&mut self, active: bool) {
347        self.is_active = active;
348        self.updated_at = Utc::now();
349    }
350
351    /// Get all branch names in this stack
352    pub fn get_branch_names(&self) -> Vec<String> {
353        self.entries
354            .iter()
355            .map(|entry| entry.branch.clone())
356            .collect()
357    }
358
359    /// Validate the stack structure and Git state integrity
360    pub fn validate(&self) -> Result<String, String> {
361        // Validate basic structure
362        if self.entries.is_empty() {
363            return Ok("Empty stack is valid".to_string());
364        }
365
366        // Check parent-child relationships
367        for (i, entry) in self.entries.iter().enumerate() {
368            if i == 0 {
369                // First entry should have no parent
370                if entry.parent_id.is_some() {
371                    return Err(format!(
372                        "First entry {} should not have a parent",
373                        entry.short_hash()
374                    ));
375                }
376            } else {
377                // Other entries should have the previous entry as parent
378                let expected_parent = &self.entries[i - 1];
379                if entry.parent_id != Some(expected_parent.id) {
380                    return Err(format!(
381                        "Entry {} has incorrect parent relationship",
382                        entry.short_hash()
383                    ));
384                }
385            }
386
387            // Check if parent exists in map
388            if let Some(parent_id) = entry.parent_id {
389                if !self.entry_map.contains_key(&parent_id) {
390                    return Err(format!(
391                        "Entry {} references non-existent parent {}",
392                        entry.short_hash(),
393                        parent_id
394                    ));
395                }
396            }
397        }
398
399        // Check that all entries are in the map
400        for entry in &self.entries {
401            if !self.entry_map.contains_key(&entry.id) {
402                return Err(format!(
403                    "Entry {} is not in the entry map",
404                    entry.short_hash()
405                ));
406            }
407        }
408
409        // Check for duplicate IDs
410        let mut seen_ids = std::collections::HashSet::new();
411        for entry in &self.entries {
412            if !seen_ids.insert(entry.id) {
413                return Err(format!("Duplicate entry ID: {}", entry.id));
414            }
415        }
416
417        // Check for duplicate branch names
418        let mut seen_branches = std::collections::HashSet::new();
419        for entry in &self.entries {
420            if !seen_branches.insert(&entry.branch) {
421                return Err(format!("Duplicate branch name: {}", entry.branch));
422            }
423        }
424
425        Ok("Stack validation passed".to_string())
426    }
427
428    /// Validate Git state integrity (requires Git repository access)
429    /// This checks that branch HEADs match the expected commit hashes
430    pub fn validate_git_integrity(
431        &self,
432        git_repo: &crate::git::GitRepository,
433    ) -> Result<String, String> {
434        use tracing::warn;
435
436        let mut issues = Vec::new();
437        let mut warnings = Vec::new();
438
439        for entry in &self.entries {
440            // Check if branch exists
441            if !git_repo.branch_exists(&entry.branch) {
442                issues.push(format!(
443                    "Branch '{}' for entry {} does not exist",
444                    entry.branch,
445                    entry.short_hash()
446                ));
447                continue;
448            }
449
450            // Check if branch HEAD matches stored commit hash
451            match git_repo.get_branch_head(&entry.branch) {
452                Ok(branch_head) => {
453                    if branch_head != entry.commit_hash {
454                        issues.push(format!(
455                            "Branch '{}' has diverged from stack metadata\n   \
456                             Expected commit: {} (from stack entry)\n   \
457                             Actual commit:   {} (current branch HEAD)\n   \
458                             This commonly happens after 'ca entry amend' without --restack\n   \
459                             Run 'ca validate' and choose 'Incorporate' to update metadata",
460                            entry.branch,
461                            &entry.commit_hash[..8],
462                            &branch_head[..8]
463                        ));
464                    }
465                }
466                Err(e) => {
467                    warnings.push(format!(
468                        "Could not check branch '{}' HEAD: {}",
469                        entry.branch, e
470                    ));
471                }
472            }
473
474            // Check if commit still exists
475            match git_repo.commit_exists(&entry.commit_hash) {
476                Ok(exists) => {
477                    if !exists {
478                        issues.push(format!(
479                            "Commit {} for entry {} no longer exists",
480                            entry.short_hash(),
481                            entry.id
482                        ));
483                    }
484                }
485                Err(e) => {
486                    warnings.push(format!(
487                        "Could not verify commit {} existence: {}",
488                        entry.short_hash(),
489                        e
490                    ));
491                }
492            }
493        }
494
495        // Log warnings
496        for warning in &warnings {
497            warn!("{}", warning);
498        }
499
500        if !issues.is_empty() {
501            Err(format!(
502                "Git integrity validation failed:\n{}{}",
503                issues.join("\n"),
504                if !warnings.is_empty() {
505                    format!("\n\nWarnings:\n{}", warnings.join("\n"))
506                } else {
507                    String::new()
508                }
509            ))
510        } else if !warnings.is_empty() {
511            Ok(format!(
512                "Git integrity validation passed with warnings:\n{}",
513                warnings.join("\n")
514            ))
515        } else {
516            Ok("Git integrity validation passed".to_string())
517        }
518    }
519}
520
521impl StackEntry {
522    /// Check if this entry can be safely modified
523    pub fn can_modify(&self) -> bool {
524        !self.is_submitted && !self.is_synced && !self.is_merged
525    }
526
527    /// Get a short version of the commit hash
528    pub fn short_hash(&self) -> String {
529        if self.commit_hash.len() >= 8 {
530            self.commit_hash[..8].to_string()
531        } else {
532            self.commit_hash.clone()
533        }
534    }
535
536    /// Get a short version of the commit message
537    pub fn short_message(&self, max_len: usize) -> String {
538        let trimmed = self.message.trim();
539        if trimmed.len() > max_len {
540            format!("{}...", &trimmed[..max_len])
541        } else {
542            trimmed.to_string()
543        }
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550
551    #[test]
552    fn test_create_empty_stack() {
553        let stack = Stack::new(
554            "test-stack".to_string(),
555            "main".to_string(),
556            Some("Test stack description".to_string()),
557        );
558
559        assert_eq!(stack.name, "test-stack");
560        assert_eq!(stack.base_branch, "main");
561        assert_eq!(
562            stack.description,
563            Some("Test stack description".to_string())
564        );
565        assert!(stack.is_empty());
566        assert_eq!(stack.len(), 0);
567        assert_eq!(stack.status, StackStatus::Clean);
568        assert!(!stack.is_active);
569    }
570
571    #[test]
572    fn test_push_pop_entries() {
573        let mut stack = Stack::new("test".to_string(), "main".to_string(), None);
574
575        // Push first entry
576        let entry1_id = stack.push_entry(
577            "feature-1".to_string(),
578            "abc123".to_string(),
579            "Add feature 1".to_string(),
580        );
581
582        assert_eq!(stack.len(), 1);
583        assert!(!stack.is_empty());
584
585        let entry1 = stack.get_entry(&entry1_id).unwrap();
586        assert_eq!(entry1.branch, "feature-1");
587        assert_eq!(entry1.commit_hash, "abc123");
588        assert_eq!(entry1.message, "Add feature 1");
589        assert_eq!(entry1.parent_id, None);
590        assert!(entry1.children.is_empty());
591
592        // Push second entry
593        let entry2_id = stack.push_entry(
594            "feature-2".to_string(),
595            "def456".to_string(),
596            "Add feature 2".to_string(),
597        );
598
599        assert_eq!(stack.len(), 2);
600
601        let entry2 = stack.get_entry(&entry2_id).unwrap();
602        assert_eq!(entry2.parent_id, Some(entry1_id));
603
604        // Check parent-child relationship
605        let updated_entry1 = stack.get_entry(&entry1_id).unwrap();
606        assert_eq!(updated_entry1.children, vec![entry2_id]);
607
608        // Pop entry
609        let popped = stack.pop_entry().unwrap();
610        assert_eq!(popped.id, entry2_id);
611        assert_eq!(stack.len(), 1);
612
613        // Check parent's children were updated
614        let updated_entry1 = stack.get_entry(&entry1_id).unwrap();
615        assert!(updated_entry1.children.is_empty());
616    }
617
618    #[test]
619    fn test_stack_navigation() {
620        let mut stack = Stack::new("test".to_string(), "main".to_string(), None);
621
622        let entry1_id = stack.push_entry(
623            "branch1".to_string(),
624            "hash1".to_string(),
625            "msg1".to_string(),
626        );
627        let entry2_id = stack.push_entry(
628            "branch2".to_string(),
629            "hash2".to_string(),
630            "msg2".to_string(),
631        );
632        let entry3_id = stack.push_entry(
633            "branch3".to_string(),
634            "hash3".to_string(),
635            "msg3".to_string(),
636        );
637
638        // Test base and top
639        assert_eq!(stack.get_base_entry().unwrap().id, entry1_id);
640        assert_eq!(stack.get_top_entry().unwrap().id, entry3_id);
641
642        // Test parent/child relationships
643        assert_eq!(stack.get_parent(&entry2_id).unwrap().id, entry1_id);
644        assert_eq!(stack.get_parent(&entry3_id).unwrap().id, entry2_id);
645        assert!(stack.get_parent(&entry1_id).is_none());
646
647        let children_of_1 = stack.get_children(&entry1_id);
648        assert_eq!(children_of_1.len(), 1);
649        assert_eq!(children_of_1[0].id, entry2_id);
650    }
651
652    #[test]
653    fn test_stack_validation() {
654        let mut stack = Stack::new("test".to_string(), "main".to_string(), None);
655
656        // Empty stack should be valid
657        assert!(stack.validate().is_ok());
658
659        // Add some entries
660        stack.push_entry(
661            "branch1".to_string(),
662            "hash1".to_string(),
663            "msg1".to_string(),
664        );
665        stack.push_entry(
666            "branch2".to_string(),
667            "hash2".to_string(),
668            "msg2".to_string(),
669        );
670
671        // Valid stack should pass validation
672        let result = stack.validate();
673        assert!(result.is_ok());
674        assert!(result.unwrap().contains("validation passed"));
675    }
676
677    #[test]
678    fn test_mark_entry_submitted() {
679        let mut stack = Stack::new("test".to_string(), "main".to_string(), None);
680        let entry_id = stack.push_entry(
681            "branch1".to_string(),
682            "hash1".to_string(),
683            "msg1".to_string(),
684        );
685
686        assert!(!stack.get_entry(&entry_id).unwrap().is_submitted);
687        assert!(stack
688            .get_entry(&entry_id)
689            .unwrap()
690            .pull_request_id
691            .is_none());
692
693        assert!(stack.mark_entry_submitted(&entry_id, "PR-123".to_string()));
694
695        let entry = stack.get_entry(&entry_id).unwrap();
696        assert!(entry.is_submitted);
697        assert_eq!(entry.pull_request_id, Some("PR-123".to_string()));
698    }
699
700    #[test]
701    fn test_mark_entry_merged() {
702        let mut stack = Stack::new("test".to_string(), "main".to_string(), None);
703        let entry_id = stack.push_entry(
704            "branch1".to_string(),
705            "hash1".to_string(),
706            "msg1".to_string(),
707        );
708
709        assert!(!stack.get_entry(&entry_id).unwrap().is_merged);
710        assert!(stack.mark_entry_merged(&entry_id, true));
711        let merged_entry = stack.get_entry(&entry_id).unwrap();
712        assert!(merged_entry.is_merged);
713        assert!(!merged_entry.can_modify());
714
715        assert!(stack.mark_entry_merged(&entry_id, false));
716        assert!(!stack.get_entry(&entry_id).unwrap().is_merged);
717    }
718
719    #[test]
720    fn test_branch_names() {
721        let mut stack = Stack::new("test".to_string(), "main".to_string(), None);
722
723        assert!(stack.get_branch_names().is_empty());
724
725        stack.push_entry(
726            "feature-1".to_string(),
727            "hash1".to_string(),
728            "msg1".to_string(),
729        );
730        stack.push_entry(
731            "feature-2".to_string(),
732            "hash2".to_string(),
733            "msg2".to_string(),
734        );
735
736        let branches = stack.get_branch_names();
737        assert_eq!(branches, vec!["feature-1", "feature-2"]);
738    }
739}