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