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    /// Get an entry by ID
160    pub fn get_entry(&self, id: &Uuid) -> Option<&StackEntry> {
161        self.entry_map.get(id)
162    }
163
164    /// Get a mutable entry by ID
165    pub fn get_entry_mut(&mut self, id: &Uuid) -> Option<&mut StackEntry> {
166        self.entry_map.get_mut(id)
167    }
168
169    /// Update an entry's commit hash in both entries Vec and entry_map
170    /// This ensures the two data structures stay in sync
171    pub fn update_entry_commit_hash(
172        &mut self,
173        entry_id: &Uuid,
174        new_commit_hash: String,
175    ) -> Result<(), String> {
176        // Update in entries Vec
177        let updated_in_vec = self
178            .entries
179            .iter_mut()
180            .find(|e| e.id == *entry_id)
181            .map(|entry| {
182                entry.commit_hash = new_commit_hash.clone();
183            })
184            .is_some();
185
186        // Update in entry_map
187        let updated_in_map = self
188            .entry_map
189            .get_mut(entry_id)
190            .map(|entry| {
191                entry.commit_hash = new_commit_hash;
192            })
193            .is_some();
194
195        if updated_in_vec && updated_in_map {
196            Ok(())
197        } else {
198            Err(format!("Entry {} not found", entry_id))
199        }
200    }
201
202    /// Get the base (first) entry of the stack
203    pub fn get_base_entry(&self) -> Option<&StackEntry> {
204        self.entries.first()
205    }
206
207    /// Get the top (last) entry of the stack
208    pub fn get_top_entry(&self) -> Option<&StackEntry> {
209        self.entries.last()
210    }
211
212    /// Get all entries that are children of the given entry
213    pub fn get_children(&self, entry_id: &Uuid) -> Vec<&StackEntry> {
214        if let Some(entry) = self.get_entry(entry_id) {
215            entry
216                .children
217                .iter()
218                .filter_map(|id| self.get_entry(id))
219                .collect()
220        } else {
221            Vec::new()
222        }
223    }
224
225    /// Get the parent of the given entry
226    pub fn get_parent(&self, entry_id: &Uuid) -> Option<&StackEntry> {
227        if let Some(entry) = self.get_entry(entry_id) {
228            entry
229                .parent_id
230                .and_then(|parent_id| self.get_entry(&parent_id))
231        } else {
232            None
233        }
234    }
235
236    /// Check if the stack is empty
237    pub fn is_empty(&self) -> bool {
238        self.entries.is_empty()
239    }
240
241    /// Get the number of entries in the stack
242    pub fn len(&self) -> usize {
243        self.entries.len()
244    }
245
246    /// Mark an entry as submitted with a pull request ID
247    pub fn mark_entry_submitted(&mut self, entry_id: &Uuid, pull_request_id: String) -> bool {
248        if let Some(entry) = self.get_entry_mut(entry_id) {
249            entry.is_submitted = true;
250            entry.pull_request_id = Some(pull_request_id);
251            entry.updated_at = Utc::now();
252            entry.is_merged = false;
253            self.updated_at = Utc::now();
254
255            // Synchronize the entries vector with the updated entry_map
256            self.sync_entries_from_map();
257            true
258        } else {
259            false
260        }
261    }
262
263    /// Synchronize the entries vector with the entry_map (entry_map is source of truth)
264    fn sync_entries_from_map(&mut self) {
265        for entry in &mut self.entries {
266            if let Some(updated_entry) = self.entry_map.get(&entry.id) {
267                *entry = updated_entry.clone();
268            }
269        }
270    }
271
272    /// Force synchronization of entries from entry_map (public method for fixing corrupted data)
273    pub fn repair_data_consistency(&mut self) {
274        self.sync_entries_from_map();
275    }
276
277    /// Mark an entry as synced
278    pub fn mark_entry_synced(&mut self, entry_id: &Uuid) -> bool {
279        if let Some(entry) = self.get_entry_mut(entry_id) {
280            entry.is_synced = true;
281            entry.updated_at = Utc::now();
282            self.updated_at = Utc::now();
283
284            // Synchronize the entries vector with the updated entry_map
285            self.sync_entries_from_map();
286            true
287        } else {
288            false
289        }
290    }
291
292    /// Mark an entry as merged (or unmerged)
293    pub fn mark_entry_merged(&mut self, entry_id: &Uuid, merged: bool) -> bool {
294        if let Some(entry) = self.get_entry_mut(entry_id) {
295            entry.is_merged = merged;
296            entry.updated_at = Utc::now();
297            self.updated_at = Utc::now();
298            self.sync_entries_from_map();
299            true
300        } else {
301            false
302        }
303    }
304
305    /// Update stack status
306    pub fn update_status(&mut self, status: StackStatus) {
307        self.status = status;
308        self.updated_at = Utc::now();
309    }
310
311    /// Set this stack as active
312    pub fn set_active(&mut self, active: bool) {
313        self.is_active = active;
314        self.updated_at = Utc::now();
315    }
316
317    /// Get all branch names in this stack
318    pub fn get_branch_names(&self) -> Vec<String> {
319        self.entries
320            .iter()
321            .map(|entry| entry.branch.clone())
322            .collect()
323    }
324
325    /// Validate the stack structure and Git state integrity
326    pub fn validate(&self) -> Result<String, String> {
327        // Validate basic structure
328        if self.entries.is_empty() {
329            return Ok("Empty stack is valid".to_string());
330        }
331
332        // Check parent-child relationships
333        for (i, entry) in self.entries.iter().enumerate() {
334            if i == 0 {
335                // First entry should have no parent
336                if entry.parent_id.is_some() {
337                    return Err(format!(
338                        "First entry {} should not have a parent",
339                        entry.short_hash()
340                    ));
341                }
342            } else {
343                // Other entries should have the previous entry as parent
344                let expected_parent = &self.entries[i - 1];
345                if entry.parent_id != Some(expected_parent.id) {
346                    return Err(format!(
347                        "Entry {} has incorrect parent relationship",
348                        entry.short_hash()
349                    ));
350                }
351            }
352
353            // Check if parent exists in map
354            if let Some(parent_id) = entry.parent_id {
355                if !self.entry_map.contains_key(&parent_id) {
356                    return Err(format!(
357                        "Entry {} references non-existent parent {}",
358                        entry.short_hash(),
359                        parent_id
360                    ));
361                }
362            }
363        }
364
365        // Check that all entries are in the map
366        for entry in &self.entries {
367            if !self.entry_map.contains_key(&entry.id) {
368                return Err(format!(
369                    "Entry {} is not in the entry map",
370                    entry.short_hash()
371                ));
372            }
373        }
374
375        // Check for duplicate IDs
376        let mut seen_ids = std::collections::HashSet::new();
377        for entry in &self.entries {
378            if !seen_ids.insert(entry.id) {
379                return Err(format!("Duplicate entry ID: {}", entry.id));
380            }
381        }
382
383        // Check for duplicate branch names
384        let mut seen_branches = std::collections::HashSet::new();
385        for entry in &self.entries {
386            if !seen_branches.insert(&entry.branch) {
387                return Err(format!("Duplicate branch name: {}", entry.branch));
388            }
389        }
390
391        Ok("Stack validation passed".to_string())
392    }
393
394    /// Validate Git state integrity (requires Git repository access)
395    /// This checks that branch HEADs match the expected commit hashes
396    pub fn validate_git_integrity(
397        &self,
398        git_repo: &crate::git::GitRepository,
399    ) -> Result<String, String> {
400        use tracing::warn;
401
402        let mut issues = Vec::new();
403        let mut warnings = Vec::new();
404
405        for entry in &self.entries {
406            // Check if branch exists
407            if !git_repo.branch_exists(&entry.branch) {
408                issues.push(format!(
409                    "Branch '{}' for entry {} does not exist",
410                    entry.branch,
411                    entry.short_hash()
412                ));
413                continue;
414            }
415
416            // Check if branch HEAD matches stored commit hash
417            match git_repo.get_branch_head(&entry.branch) {
418                Ok(branch_head) => {
419                    if branch_head != entry.commit_hash {
420                        issues.push(format!(
421                            "Branch '{}' has diverged from stack metadata\n   \
422                             Expected commit: {} (from stack entry)\n   \
423                             Actual commit:   {} (current branch HEAD)\n   \
424                             The branch may have been modified outside of cascade",
425                            entry.branch,
426                            &entry.commit_hash[..8],
427                            &branch_head[..8]
428                        ));
429                    }
430                }
431                Err(e) => {
432                    warnings.push(format!(
433                        "Could not check branch '{}' HEAD: {}",
434                        entry.branch, e
435                    ));
436                }
437            }
438
439            // Check if commit still exists
440            match git_repo.commit_exists(&entry.commit_hash) {
441                Ok(exists) => {
442                    if !exists {
443                        issues.push(format!(
444                            "Commit {} for entry {} no longer exists",
445                            entry.short_hash(),
446                            entry.id
447                        ));
448                    }
449                }
450                Err(e) => {
451                    warnings.push(format!(
452                        "Could not verify commit {} existence: {}",
453                        entry.short_hash(),
454                        e
455                    ));
456                }
457            }
458        }
459
460        // Log warnings
461        for warning in &warnings {
462            warn!("{}", warning);
463        }
464
465        if !issues.is_empty() {
466            Err(format!(
467                "Git integrity validation failed:\n{}{}",
468                issues.join("\n"),
469                if !warnings.is_empty() {
470                    format!("\n\nWarnings:\n{}", warnings.join("\n"))
471                } else {
472                    String::new()
473                }
474            ))
475        } else if !warnings.is_empty() {
476            Ok(format!(
477                "Git integrity validation passed with warnings:\n{}",
478                warnings.join("\n")
479            ))
480        } else {
481            Ok("Git integrity validation passed".to_string())
482        }
483    }
484}
485
486impl StackEntry {
487    /// Check if this entry can be safely modified
488    pub fn can_modify(&self) -> bool {
489        !self.is_submitted && !self.is_synced && !self.is_merged
490    }
491
492    /// Get a short version of the commit hash
493    pub fn short_hash(&self) -> String {
494        if self.commit_hash.len() >= 8 {
495            self.commit_hash[..8].to_string()
496        } else {
497            self.commit_hash.clone()
498        }
499    }
500
501    /// Get a short version of the commit message
502    pub fn short_message(&self, max_len: usize) -> String {
503        let trimmed = self.message.trim();
504        if trimmed.len() > max_len {
505            format!("{}...", &trimmed[..max_len])
506        } else {
507            trimmed.to_string()
508        }
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    #[test]
517    fn test_create_empty_stack() {
518        let stack = Stack::new(
519            "test-stack".to_string(),
520            "main".to_string(),
521            Some("Test stack description".to_string()),
522        );
523
524        assert_eq!(stack.name, "test-stack");
525        assert_eq!(stack.base_branch, "main");
526        assert_eq!(
527            stack.description,
528            Some("Test stack description".to_string())
529        );
530        assert!(stack.is_empty());
531        assert_eq!(stack.len(), 0);
532        assert_eq!(stack.status, StackStatus::Clean);
533        assert!(!stack.is_active);
534    }
535
536    #[test]
537    fn test_push_pop_entries() {
538        let mut stack = Stack::new("test".to_string(), "main".to_string(), None);
539
540        // Push first entry
541        let entry1_id = stack.push_entry(
542            "feature-1".to_string(),
543            "abc123".to_string(),
544            "Add feature 1".to_string(),
545        );
546
547        assert_eq!(stack.len(), 1);
548        assert!(!stack.is_empty());
549
550        let entry1 = stack.get_entry(&entry1_id).unwrap();
551        assert_eq!(entry1.branch, "feature-1");
552        assert_eq!(entry1.commit_hash, "abc123");
553        assert_eq!(entry1.message, "Add feature 1");
554        assert_eq!(entry1.parent_id, None);
555        assert!(entry1.children.is_empty());
556
557        // Push second entry
558        let entry2_id = stack.push_entry(
559            "feature-2".to_string(),
560            "def456".to_string(),
561            "Add feature 2".to_string(),
562        );
563
564        assert_eq!(stack.len(), 2);
565
566        let entry2 = stack.get_entry(&entry2_id).unwrap();
567        assert_eq!(entry2.parent_id, Some(entry1_id));
568
569        // Check parent-child relationship
570        let updated_entry1 = stack.get_entry(&entry1_id).unwrap();
571        assert_eq!(updated_entry1.children, vec![entry2_id]);
572
573        // Pop entry
574        let popped = stack.pop_entry().unwrap();
575        assert_eq!(popped.id, entry2_id);
576        assert_eq!(stack.len(), 1);
577
578        // Check parent's children were updated
579        let updated_entry1 = stack.get_entry(&entry1_id).unwrap();
580        assert!(updated_entry1.children.is_empty());
581    }
582
583    #[test]
584    fn test_stack_navigation() {
585        let mut stack = Stack::new("test".to_string(), "main".to_string(), None);
586
587        let entry1_id = stack.push_entry(
588            "branch1".to_string(),
589            "hash1".to_string(),
590            "msg1".to_string(),
591        );
592        let entry2_id = stack.push_entry(
593            "branch2".to_string(),
594            "hash2".to_string(),
595            "msg2".to_string(),
596        );
597        let entry3_id = stack.push_entry(
598            "branch3".to_string(),
599            "hash3".to_string(),
600            "msg3".to_string(),
601        );
602
603        // Test base and top
604        assert_eq!(stack.get_base_entry().unwrap().id, entry1_id);
605        assert_eq!(stack.get_top_entry().unwrap().id, entry3_id);
606
607        // Test parent/child relationships
608        assert_eq!(stack.get_parent(&entry2_id).unwrap().id, entry1_id);
609        assert_eq!(stack.get_parent(&entry3_id).unwrap().id, entry2_id);
610        assert!(stack.get_parent(&entry1_id).is_none());
611
612        let children_of_1 = stack.get_children(&entry1_id);
613        assert_eq!(children_of_1.len(), 1);
614        assert_eq!(children_of_1[0].id, entry2_id);
615    }
616
617    #[test]
618    fn test_stack_validation() {
619        let mut stack = Stack::new("test".to_string(), "main".to_string(), None);
620
621        // Empty stack should be valid
622        assert!(stack.validate().is_ok());
623
624        // Add some entries
625        stack.push_entry(
626            "branch1".to_string(),
627            "hash1".to_string(),
628            "msg1".to_string(),
629        );
630        stack.push_entry(
631            "branch2".to_string(),
632            "hash2".to_string(),
633            "msg2".to_string(),
634        );
635
636        // Valid stack should pass validation
637        let result = stack.validate();
638        assert!(result.is_ok());
639        assert!(result.unwrap().contains("validation passed"));
640    }
641
642    #[test]
643    fn test_mark_entry_submitted() {
644        let mut stack = Stack::new("test".to_string(), "main".to_string(), None);
645        let entry_id = stack.push_entry(
646            "branch1".to_string(),
647            "hash1".to_string(),
648            "msg1".to_string(),
649        );
650
651        assert!(!stack.get_entry(&entry_id).unwrap().is_submitted);
652        assert!(stack
653            .get_entry(&entry_id)
654            .unwrap()
655            .pull_request_id
656            .is_none());
657
658        assert!(stack.mark_entry_submitted(&entry_id, "PR-123".to_string()));
659
660        let entry = stack.get_entry(&entry_id).unwrap();
661        assert!(entry.is_submitted);
662        assert_eq!(entry.pull_request_id, Some("PR-123".to_string()));
663    }
664
665    #[test]
666    fn test_mark_entry_merged() {
667        let mut stack = Stack::new("test".to_string(), "main".to_string(), None);
668        let entry_id = stack.push_entry(
669            "branch1".to_string(),
670            "hash1".to_string(),
671            "msg1".to_string(),
672        );
673
674        assert!(!stack.get_entry(&entry_id).unwrap().is_merged);
675        assert!(stack.mark_entry_merged(&entry_id, true));
676        let merged_entry = stack.get_entry(&entry_id).unwrap();
677        assert!(merged_entry.is_merged);
678        assert!(!merged_entry.can_modify());
679
680        assert!(stack.mark_entry_merged(&entry_id, false));
681        assert!(!stack.get_entry(&entry_id).unwrap().is_merged);
682    }
683
684    #[test]
685    fn test_branch_names() {
686        let mut stack = Stack::new("test".to_string(), "main".to_string(), None);
687
688        assert!(stack.get_branch_names().is_empty());
689
690        stack.push_entry(
691            "feature-1".to_string(),
692            "hash1".to_string(),
693            "msg1".to_string(),
694        );
695        stack.push_entry(
696            "feature-2".to_string(),
697            "hash2".to_string(),
698            "msg2".to_string(),
699        );
700
701        let branches = stack.get_branch_names();
702        assert_eq!(branches, vec!["feature-1", "feature-2"]);
703    }
704}