cascade_cli/stack/
manager.rs

1use super::metadata::RepositoryMetadata;
2use super::{CommitMetadata, Stack, StackEntry, StackMetadata, StackStatus};
3use crate::config::get_repo_config_dir;
4use crate::errors::{CascadeError, Result};
5use crate::git::GitRepository;
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use tracing::info;
10use uuid::Uuid;
11
12/// Manages all stack operations and persistence
13pub struct StackManager {
14    /// Git repository interface
15    repo: GitRepository,
16    /// Path to the repository root
17    repo_path: PathBuf,
18    /// Path to cascade config directory
19    config_dir: PathBuf,
20    /// Path to stacks data file
21    stacks_file: PathBuf,
22    /// Path to metadata file
23    metadata_file: PathBuf,
24    /// In-memory stack data
25    stacks: HashMap<Uuid, Stack>,
26    /// Repository metadata
27    metadata: RepositoryMetadata,
28}
29
30impl StackManager {
31    /// Create a new StackManager for the given repository
32    pub fn new(repo_path: &Path) -> Result<Self> {
33        let repo = GitRepository::open(repo_path)?;
34        let config_dir = get_repo_config_dir(repo_path)?;
35        let stacks_file = config_dir.join("stacks.json");
36        let metadata_file = config_dir.join("metadata.json");
37
38        // Determine default base branch
39        let default_base = repo
40            .get_current_branch()
41            .unwrap_or_else(|_| "main".to_string());
42
43        let mut manager = Self {
44            repo,
45            repo_path: repo_path.to_path_buf(),
46            config_dir,
47            stacks_file,
48            metadata_file,
49            stacks: HashMap::new(),
50            metadata: RepositoryMetadata::new(default_base),
51        };
52
53        // Load existing data if available
54        manager.load_from_disk()?;
55
56        Ok(manager)
57    }
58
59    /// Create a new stack
60    pub fn create_stack(
61        &mut self,
62        name: String,
63        base_branch: Option<String>,
64        description: Option<String>,
65    ) -> Result<Uuid> {
66        // Check if stack with this name already exists
67        if self.metadata.find_stack_by_name(&name).is_some() {
68            return Err(CascadeError::config(format!(
69                "Stack '{name}' already exists"
70            )));
71        }
72
73        // Use provided base branch or default
74        let base_branch = base_branch.unwrap_or_else(|| self.metadata.default_base_branch.clone());
75
76        // Verify base branch exists
77        if !self.repo.branch_exists(&base_branch) {
78            return Err(CascadeError::branch(format!(
79                "Base branch '{base_branch}' does not exist"
80            )));
81        }
82
83        // Create the stack
84        let stack = Stack::new(name.clone(), base_branch.clone(), description.clone());
85        let stack_id = stack.id;
86
87        // Create metadata
88        let stack_metadata = StackMetadata::new(stack_id, name, base_branch, description);
89
90        // Store in memory
91        self.stacks.insert(stack_id, stack);
92        self.metadata.add_stack(stack_metadata);
93
94        // Set as active if it's the first stack
95        if self.metadata.stacks.len() == 1 {
96            self.set_active_stack(Some(stack_id))?;
97        } else {
98            // Just save to disk if not setting as active
99            self.save_to_disk()?;
100        }
101
102        Ok(stack_id)
103    }
104
105    /// Get a stack by ID
106    pub fn get_stack(&self, stack_id: &Uuid) -> Option<&Stack> {
107        self.stacks.get(stack_id)
108    }
109
110    /// Get a mutable stack by ID
111    pub fn get_stack_mut(&mut self, stack_id: &Uuid) -> Option<&mut Stack> {
112        self.stacks.get_mut(stack_id)
113    }
114
115    /// Get stack by name
116    pub fn get_stack_by_name(&self, name: &str) -> Option<&Stack> {
117        if let Some(metadata) = self.metadata.find_stack_by_name(name) {
118            self.stacks.get(&metadata.stack_id)
119        } else {
120            None
121        }
122    }
123
124    /// Get the currently active stack
125    pub fn get_active_stack(&self) -> Option<&Stack> {
126        self.metadata
127            .active_stack_id
128            .and_then(|id| self.stacks.get(&id))
129    }
130
131    /// Get the currently active stack mutably
132    pub fn get_active_stack_mut(&mut self) -> Option<&mut Stack> {
133        if let Some(id) = self.metadata.active_stack_id {
134            self.stacks.get_mut(&id)
135        } else {
136            None
137        }
138    }
139
140    /// Set the active stack
141    pub fn set_active_stack(&mut self, stack_id: Option<Uuid>) -> Result<()> {
142        // Verify stack exists if provided
143        if let Some(id) = stack_id {
144            if !self.stacks.contains_key(&id) {
145                return Err(CascadeError::config(format!(
146                    "Stack with ID {id} not found"
147                )));
148            }
149        }
150
151        // Update active flag on stacks
152        for stack in self.stacks.values_mut() {
153            stack.set_active(Some(stack.id) == stack_id);
154        }
155
156        // Track the current branch when activating a stack
157        if let Some(id) = stack_id {
158            let current_branch = self.repo.get_current_branch().ok();
159            if let Some(stack_meta) = self.metadata.get_stack_mut(&id) {
160                stack_meta.set_current_branch(current_branch);
161            }
162        }
163
164        self.metadata.set_active_stack(stack_id);
165        self.save_to_disk()?;
166
167        Ok(())
168    }
169
170    /// Set active stack by name
171    pub fn set_active_stack_by_name(&mut self, name: &str) -> Result<()> {
172        if let Some(metadata) = self.metadata.find_stack_by_name(name) {
173            self.set_active_stack(Some(metadata.stack_id))
174        } else {
175            Err(CascadeError::config(format!("Stack '{name}' not found")))
176        }
177    }
178
179    /// Delete a stack
180    pub fn delete_stack(&mut self, stack_id: &Uuid) -> Result<Stack> {
181        let stack = self
182            .stacks
183            .remove(stack_id)
184            .ok_or_else(|| CascadeError::config(format!("Stack with ID {stack_id} not found")))?;
185
186        // Remove metadata
187        self.metadata.remove_stack(stack_id);
188
189        // Remove all associated commit metadata
190        let stack_commits: Vec<String> = self
191            .metadata
192            .commits
193            .values()
194            .filter(|commit| &commit.stack_id == stack_id)
195            .map(|commit| commit.hash.clone())
196            .collect();
197
198        for commit_hash in stack_commits {
199            self.metadata.remove_commit(&commit_hash);
200        }
201
202        // If this was the active stack, find a new one
203        if self.metadata.active_stack_id == Some(*stack_id) {
204            let new_active = self.metadata.stacks.keys().next().copied();
205            self.set_active_stack(new_active)?;
206        }
207
208        self.save_to_disk()?;
209
210        Ok(stack)
211    }
212
213    /// Push a commit to a stack
214    pub fn push_to_stack(
215        &mut self,
216        branch: String,
217        commit_hash: String,
218        message: String,
219        source_branch: String,
220    ) -> Result<Uuid> {
221        let stack_id = self
222            .metadata
223            .active_stack_id
224            .ok_or_else(|| CascadeError::config("No active stack"))?;
225
226        let stack = self
227            .stacks
228            .get_mut(&stack_id)
229            .ok_or_else(|| CascadeError::config("Active stack not found"))?;
230
231        // Verify the commit exists
232        if !self.repo.commit_exists(&commit_hash)? {
233            return Err(CascadeError::branch(format!(
234                "Commit {commit_hash} does not exist"
235            )));
236        }
237
238        // Check for duplicate commit messages within the same stack
239        if let Some(duplicate_entry) = stack.entries.iter().find(|entry| entry.message == message) {
240            return Err(CascadeError::validation(format!(
241                "Duplicate commit message in stack: \"{message}\"\n\n\
242                 This message already exists in entry {} (commit: {})\n\n\
243                 💡 Consider using a more specific message:\n\
244                    • Add context: \"{message} - add validation\"\n\
245                    • Be more specific: \"Fix user authentication timeout bug\"\n\
246                    • Or amend the previous commit: git commit --amend",
247                duplicate_entry.id,
248                &duplicate_entry.commit_hash[..8]
249            )));
250        }
251
252        // Add to stack
253        let entry_id = stack.push_entry(branch.clone(), commit_hash.clone(), message.clone());
254
255        // Create commit metadata
256        let commit_metadata = CommitMetadata::new(
257            commit_hash.clone(),
258            message,
259            entry_id,
260            stack_id,
261            branch.clone(),
262            source_branch,
263        );
264
265        // Update repository metadata
266        self.metadata.add_commit(commit_metadata);
267        if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
268            stack_meta.add_branch(branch);
269            stack_meta.add_commit(commit_hash);
270        }
271
272        self.save_to_disk()?;
273
274        Ok(entry_id)
275    }
276
277    /// Pop the top commit from the active stack
278    pub fn pop_from_stack(&mut self) -> Result<StackEntry> {
279        let stack_id = self
280            .metadata
281            .active_stack_id
282            .ok_or_else(|| CascadeError::config("No active stack"))?;
283
284        let stack = self
285            .stacks
286            .get_mut(&stack_id)
287            .ok_or_else(|| CascadeError::config("Active stack not found"))?;
288
289        let entry = stack
290            .pop_entry()
291            .ok_or_else(|| CascadeError::config("Stack is empty"))?;
292
293        // Remove commit metadata
294        self.metadata.remove_commit(&entry.commit_hash);
295
296        // Update stack metadata
297        if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
298            stack_meta.remove_commit(&entry.commit_hash);
299            // Note: We don't remove the branch as there might be other commits on it
300        }
301
302        self.save_to_disk()?;
303
304        Ok(entry)
305    }
306
307    /// Submit a stack entry for review (mark as submitted)
308    pub fn submit_entry(
309        &mut self,
310        stack_id: &Uuid,
311        entry_id: &Uuid,
312        pull_request_id: String,
313    ) -> Result<()> {
314        let stack = self
315            .stacks
316            .get_mut(stack_id)
317            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
318
319        let entry_commit_hash = {
320            let entry = stack
321                .get_entry(entry_id)
322                .ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found")))?;
323            entry.commit_hash.clone()
324        };
325
326        // Update stack entry
327        if !stack.mark_entry_submitted(entry_id, pull_request_id.clone()) {
328            return Err(CascadeError::config(format!(
329                "Failed to mark entry {entry_id} as submitted"
330            )));
331        }
332
333        // Update commit metadata
334        if let Some(commit_meta) = self.metadata.commits.get_mut(&entry_commit_hash) {
335            commit_meta.mark_submitted(pull_request_id);
336        }
337
338        // Update stack metadata statistics
339        if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
340            let submitted_count = stack.entries.iter().filter(|e| e.is_submitted).count();
341            stack_meta.update_stats(
342                stack.entries.len(),
343                submitted_count,
344                stack_meta.merged_commits,
345            );
346        }
347
348        self.save_to_disk()?;
349
350        Ok(())
351    }
352
353    /// Get all stacks
354    pub fn get_all_stacks(&self) -> Vec<&Stack> {
355        self.stacks.values().collect()
356    }
357
358    /// Get stack metadata
359    pub fn get_stack_metadata(&self, stack_id: &Uuid) -> Option<&StackMetadata> {
360        self.metadata.get_stack(stack_id)
361    }
362
363    /// Get repository metadata
364    pub fn get_repository_metadata(&self) -> &RepositoryMetadata {
365        &self.metadata
366    }
367
368    /// Get the Git repository
369    pub fn git_repo(&self) -> &GitRepository {
370        &self.repo
371    }
372
373    /// Get the repository path
374    pub fn repo_path(&self) -> &Path {
375        &self.repo_path
376    }
377
378    // Edit mode management methods
379
380    /// Check if currently in edit mode
381    pub fn is_in_edit_mode(&self) -> bool {
382        self.metadata
383            .edit_mode
384            .as_ref()
385            .map(|edit_state| edit_state.is_active)
386            .unwrap_or(false)
387    }
388
389    /// Get current edit mode information
390    pub fn get_edit_mode_info(&self) -> Option<&super::metadata::EditModeState> {
391        self.metadata.edit_mode.as_ref()
392    }
393
394    /// Enter edit mode for a specific stack entry
395    pub fn enter_edit_mode(&mut self, stack_id: Uuid, entry_id: Uuid) -> Result<()> {
396        // Get the commit hash first to avoid borrow checker issues
397        let commit_hash = {
398            let stack = self
399                .get_stack(&stack_id)
400                .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
401
402            let entry = stack.get_entry(&entry_id).ok_or_else(|| {
403                CascadeError::config(format!("Entry {entry_id} not found in stack"))
404            })?;
405
406            entry.commit_hash.clone()
407        };
408
409        // If already in edit mode, exit the current one first
410        if self.is_in_edit_mode() {
411            self.exit_edit_mode()?;
412        }
413
414        // Create new edit mode state
415        let edit_state = super::metadata::EditModeState::new(stack_id, entry_id, commit_hash);
416
417        self.metadata.edit_mode = Some(edit_state);
418        self.save_to_disk()?;
419
420        info!(
421            "Entered edit mode for entry {} in stack {}",
422            entry_id, stack_id
423        );
424        Ok(())
425    }
426
427    /// Exit edit mode
428    pub fn exit_edit_mode(&mut self) -> Result<()> {
429        if !self.is_in_edit_mode() {
430            return Err(CascadeError::config("Not currently in edit mode"));
431        }
432
433        // Clear edit mode state
434        self.metadata.edit_mode = None;
435        self.save_to_disk()?;
436
437        info!("Exited edit mode");
438        Ok(())
439    }
440
441    /// Sync stack with Git repository state
442    pub fn sync_stack(&mut self, stack_id: &Uuid) -> Result<()> {
443        let stack = self
444            .stacks
445            .get_mut(stack_id)
446            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
447
448        // Check if all commits still exist
449        let mut missing_commits = Vec::new();
450        for entry in &stack.entries {
451            if !self.repo.commit_exists(&entry.commit_hash)? {
452                missing_commits.push(entry.commit_hash.clone());
453            }
454        }
455
456        if !missing_commits.is_empty() {
457            stack.update_status(StackStatus::OutOfSync);
458            return Err(CascadeError::branch(format!(
459                "Stack {} has missing commits: {}",
460                stack.name,
461                missing_commits.join(", ")
462            )));
463        }
464
465        // Check if base branch exists and has new commits
466        if !self.repo.branch_exists(&stack.base_branch) {
467            return Err(CascadeError::branch(format!(
468                "Base branch '{}' does not exist. Create it or switch to a different base.",
469                stack.base_branch
470            )));
471        }
472
473        let _base_hash = self.repo.get_branch_head(&stack.base_branch)?;
474
475        // Check if any stack entries are missing their commits
476        let mut corrupted_entry = None;
477        for entry in &stack.entries {
478            if !self.repo.commit_exists(&entry.commit_hash)? {
479                corrupted_entry = Some((entry.commit_hash.clone(), entry.branch.clone()));
480                break;
481            }
482        }
483
484        if let Some((commit_hash, branch)) = corrupted_entry {
485            stack.update_status(StackStatus::Corrupted);
486            return Err(CascadeError::branch(format!(
487                "Commit {commit_hash} from stack entry '{branch}' no longer exists"
488            )));
489        }
490
491        // Compare base branch with the earliest commit in the stack
492        let needs_sync = if let Some(first_entry) = stack.entries.first() {
493            // Get commits between base and first entry
494            match self
495                .repo
496                .get_commits_between(&stack.base_branch, &first_entry.commit_hash)
497            {
498                Ok(commits) => !commits.is_empty(), // If there are commits, we need to sync
499                Err(_) => true,                     // If we can't compare, assume we need to sync
500            }
501        } else {
502            false // Empty stack is always clean
503        };
504
505        // Update stack status based on sync needs
506        if needs_sync {
507            stack.update_status(StackStatus::NeedsSync);
508            info!(
509                "Stack '{}' needs sync - new commits on base branch",
510                stack.name
511            );
512        } else {
513            stack.update_status(StackStatus::Clean);
514            info!("Stack '{}' is clean", stack.name);
515        }
516
517        // Update metadata
518        if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
519            stack_meta.set_up_to_date(true);
520        }
521
522        self.save_to_disk()?;
523
524        Ok(())
525    }
526
527    /// List all stacks with their status
528    pub fn list_stacks(&self) -> Vec<(Uuid, &str, &StackStatus, usize, Option<&str>)> {
529        self.stacks
530            .values()
531            .map(|stack| {
532                (
533                    stack.id,
534                    stack.name.as_str(),
535                    &stack.status,
536                    stack.entries.len(),
537                    if stack.is_active {
538                        Some("active")
539                    } else {
540                        None
541                    },
542                )
543            })
544            .collect()
545    }
546
547    /// Get all stacks as Stack objects for TUI
548    pub fn get_all_stacks_objects(&self) -> Result<Vec<Stack>> {
549        let mut stacks: Vec<Stack> = self.stacks.values().cloned().collect();
550        stacks.sort_by(|a, b| a.name.cmp(&b.name));
551        Ok(stacks)
552    }
553
554    /// Validate all stacks
555    pub fn validate_all(&self) -> Result<()> {
556        for stack in self.stacks.values() {
557            stack.validate().map_err(|e| {
558                CascadeError::config(format!("Stack '{}' validation failed: {}", stack.name, e))
559            })?;
560        }
561        Ok(())
562    }
563
564    /// Save all data to disk
565    fn save_to_disk(&self) -> Result<()> {
566        // Ensure config directory exists
567        if !self.config_dir.exists() {
568            fs::create_dir_all(&self.config_dir).map_err(|e| {
569                CascadeError::config(format!("Failed to create config directory: {e}"))
570            })?;
571        }
572
573        // Save stacks atomically
574        crate::utils::atomic_file::write_json(&self.stacks_file, &self.stacks)?;
575
576        // Save metadata atomically
577        crate::utils::atomic_file::write_json(&self.metadata_file, &self.metadata)?;
578
579        Ok(())
580    }
581
582    /// Load data from disk
583    fn load_from_disk(&mut self) -> Result<()> {
584        // Load stacks if file exists
585        if self.stacks_file.exists() {
586            let stacks_content = fs::read_to_string(&self.stacks_file)
587                .map_err(|e| CascadeError::config(format!("Failed to read stacks file: {e}")))?;
588
589            self.stacks = serde_json::from_str(&stacks_content)
590                .map_err(|e| CascadeError::config(format!("Failed to parse stacks file: {e}")))?;
591        }
592
593        // Load metadata if file exists
594        if self.metadata_file.exists() {
595            let metadata_content = fs::read_to_string(&self.metadata_file)
596                .map_err(|e| CascadeError::config(format!("Failed to read metadata file: {e}")))?;
597
598            self.metadata = serde_json::from_str(&metadata_content)
599                .map_err(|e| CascadeError::config(format!("Failed to parse metadata file: {e}")))?;
600        }
601
602        Ok(())
603    }
604
605    /// Check if the user has changed branches since the stack was activated
606    /// Returns true if branch change detected and user wants to proceed
607    pub fn check_for_branch_change(&mut self) -> Result<bool> {
608        // Extract stack information first to avoid borrow conflicts
609        let (stack_id, stack_name, stored_branch) = {
610            if let Some(active_stack) = self.get_active_stack() {
611                let stack_id = active_stack.id;
612                let stack_name = active_stack.name.clone();
613                let stored_branch = if let Some(stack_meta) = self.metadata.get_stack(&stack_id) {
614                    stack_meta.current_branch.clone()
615                } else {
616                    None
617                };
618                (Some(stack_id), stack_name, stored_branch)
619            } else {
620                (None, String::new(), None)
621            }
622        };
623
624        // If no active stack, nothing to check
625        let Some(stack_id) = stack_id else {
626            return Ok(true);
627        };
628
629        let current_branch = self.repo.get_current_branch().ok();
630
631        // Check if branch has changed
632        if stored_branch.as_ref() != current_branch.as_ref() {
633            println!("⚠️  Branch change detected!");
634            println!(
635                "   Stack '{}' was active on: {}",
636                stack_name,
637                stored_branch.as_deref().unwrap_or("unknown")
638            );
639            println!(
640                "   Current branch: {}",
641                current_branch.as_deref().unwrap_or("unknown")
642            );
643            println!();
644            println!("What would you like to do?");
645            println!("   1. Keep stack '{stack_name}' active (continue with stack workflow)");
646            println!("   2. Deactivate stack (use normal Git workflow)");
647            println!("   3. Switch to a different stack");
648            println!("   4. Cancel and stay on current workflow");
649            print!("   Choice (1-4): ");
650
651            use std::io::{self, Write};
652            io::stdout()
653                .flush()
654                .map_err(|e| CascadeError::config(format!("Failed to write to stdout: {e}")))?;
655
656            let mut input = String::new();
657            io::stdin()
658                .read_line(&mut input)
659                .map_err(|e| CascadeError::config(format!("Failed to read user input: {e}")))?;
660
661            match input.trim() {
662                "1" => {
663                    // Update the tracked branch and continue
664                    if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
665                        stack_meta.set_current_branch(current_branch);
666                    }
667                    self.save_to_disk()?;
668                    println!("✅ Continuing with stack '{stack_name}' on current branch");
669                    return Ok(true);
670                }
671                "2" => {
672                    // Deactivate the stack
673                    self.set_active_stack(None)?;
674                    println!("✅ Deactivated stack '{stack_name}' - using normal Git workflow");
675                    return Ok(false);
676                }
677                "3" => {
678                    // Show available stacks
679                    let stacks = self.get_all_stacks();
680                    if stacks.len() <= 1 {
681                        println!("⚠️  No other stacks available. Deactivating current stack.");
682                        self.set_active_stack(None)?;
683                        return Ok(false);
684                    }
685
686                    println!("\nAvailable stacks:");
687                    for (i, stack) in stacks.iter().enumerate() {
688                        if stack.id != stack_id {
689                            println!("   {}. {}", i + 1, stack.name);
690                        }
691                    }
692                    print!("   Enter stack name: ");
693                    io::stdout().flush().map_err(|e| {
694                        CascadeError::config(format!("Failed to write to stdout: {e}"))
695                    })?;
696
697                    let mut stack_name_input = String::new();
698                    io::stdin().read_line(&mut stack_name_input).map_err(|e| {
699                        CascadeError::config(format!("Failed to read user input: {e}"))
700                    })?;
701                    let stack_name_input = stack_name_input.trim();
702
703                    if let Err(e) = self.set_active_stack_by_name(stack_name_input) {
704                        println!("⚠️  {e}");
705                        println!("   Deactivating stack instead.");
706                        self.set_active_stack(None)?;
707                        return Ok(false);
708                    } else {
709                        println!("✅ Switched to stack '{stack_name_input}'");
710                        return Ok(true);
711                    }
712                }
713                _ => {
714                    println!("Cancelled - no changes made");
715                    return Ok(false);
716                }
717            }
718        }
719
720        // No branch change detected
721        Ok(true)
722    }
723}
724
725#[cfg(test)]
726mod tests {
727    use super::*;
728    use std::process::Command;
729    use tempfile::TempDir;
730
731    fn create_test_repo() -> (TempDir, PathBuf) {
732        let temp_dir = TempDir::new().unwrap();
733        let repo_path = temp_dir.path().to_path_buf();
734
735        // Initialize git repository
736        Command::new("git")
737            .args(["init"])
738            .current_dir(&repo_path)
739            .output()
740            .unwrap();
741
742        // Configure git
743        Command::new("git")
744            .args(["config", "user.name", "Test User"])
745            .current_dir(&repo_path)
746            .output()
747            .unwrap();
748
749        Command::new("git")
750            .args(["config", "user.email", "test@example.com"])
751            .current_dir(&repo_path)
752            .output()
753            .unwrap();
754
755        // Create an initial commit
756        std::fs::write(repo_path.join("README.md"), "# Test Repo").unwrap();
757        Command::new("git")
758            .args(["add", "."])
759            .current_dir(&repo_path)
760            .output()
761            .unwrap();
762
763        Command::new("git")
764            .args(["commit", "-m", "Initial commit"])
765            .current_dir(&repo_path)
766            .output()
767            .unwrap();
768
769        // Initialize cascade
770        crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
771            .unwrap();
772
773        (temp_dir, repo_path)
774    }
775
776    #[test]
777    fn test_create_stack_manager() {
778        let (_temp_dir, repo_path) = create_test_repo();
779        let manager = StackManager::new(&repo_path).unwrap();
780
781        assert_eq!(manager.stacks.len(), 0);
782        assert!(manager.get_active_stack().is_none());
783    }
784
785    #[test]
786    fn test_create_and_manage_stack() {
787        let (_temp_dir, repo_path) = create_test_repo();
788        let mut manager = StackManager::new(&repo_path).unwrap();
789
790        // Create a stack using the default branch
791        let stack_id = manager
792            .create_stack(
793                "test-stack".to_string(),
794                None, // Use default branch
795                Some("Test stack description".to_string()),
796            )
797            .unwrap();
798
799        // Verify stack was created
800        assert_eq!(manager.stacks.len(), 1);
801        let stack = manager.get_stack(&stack_id).unwrap();
802        assert_eq!(stack.name, "test-stack");
803        // Should use the default branch (which gets set from the Git repo)
804        assert!(!stack.base_branch.is_empty());
805        assert!(stack.is_active);
806
807        // Verify it's the active stack
808        let active = manager.get_active_stack().unwrap();
809        assert_eq!(active.id, stack_id);
810
811        // Test get by name
812        let found = manager.get_stack_by_name("test-stack").unwrap();
813        assert_eq!(found.id, stack_id);
814    }
815
816    #[test]
817    fn test_stack_persistence() {
818        let (_temp_dir, repo_path) = create_test_repo();
819
820        let stack_id = {
821            let mut manager = StackManager::new(&repo_path).unwrap();
822            manager
823                .create_stack("persistent-stack".to_string(), None, None)
824                .unwrap()
825        };
826
827        // Create new manager and verify data was loaded
828        let manager = StackManager::new(&repo_path).unwrap();
829        assert_eq!(manager.stacks.len(), 1);
830        let stack = manager.get_stack(&stack_id).unwrap();
831        assert_eq!(stack.name, "persistent-stack");
832    }
833
834    #[test]
835    fn test_multiple_stacks() {
836        let (_temp_dir, repo_path) = create_test_repo();
837        let mut manager = StackManager::new(&repo_path).unwrap();
838
839        let stack1_id = manager
840            .create_stack("stack-1".to_string(), None, None)
841            .unwrap();
842        let stack2_id = manager
843            .create_stack("stack-2".to_string(), None, None)
844            .unwrap();
845
846        assert_eq!(manager.stacks.len(), 2);
847
848        // First stack should still be active
849        assert!(manager.get_stack(&stack1_id).unwrap().is_active);
850        assert!(!manager.get_stack(&stack2_id).unwrap().is_active);
851
852        // Change active stack
853        manager.set_active_stack(Some(stack2_id)).unwrap();
854        assert!(!manager.get_stack(&stack1_id).unwrap().is_active);
855        assert!(manager.get_stack(&stack2_id).unwrap().is_active);
856    }
857
858    #[test]
859    fn test_delete_stack() {
860        let (_temp_dir, repo_path) = create_test_repo();
861        let mut manager = StackManager::new(&repo_path).unwrap();
862
863        let stack_id = manager
864            .create_stack("to-delete".to_string(), None, None)
865            .unwrap();
866        assert_eq!(manager.stacks.len(), 1);
867
868        let deleted = manager.delete_stack(&stack_id).unwrap();
869        assert_eq!(deleted.name, "to-delete");
870        assert_eq!(manager.stacks.len(), 0);
871        assert!(manager.get_active_stack().is_none());
872    }
873
874    #[test]
875    fn test_validation() {
876        let (_temp_dir, repo_path) = create_test_repo();
877        let mut manager = StackManager::new(&repo_path).unwrap();
878
879        manager
880            .create_stack("valid-stack".to_string(), None, None)
881            .unwrap();
882
883        // Should pass validation
884        assert!(manager.validate_all().is_ok());
885    }
886
887    #[test]
888    fn test_duplicate_commit_message_detection() {
889        let (_temp_dir, repo_path) = create_test_repo();
890        let mut manager = StackManager::new(&repo_path).unwrap();
891
892        // Create a stack
893        manager
894            .create_stack("test-stack".to_string(), None, None)
895            .unwrap();
896
897        // Create first commit
898        std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
899        Command::new("git")
900            .args(["add", "file1.txt"])
901            .current_dir(&repo_path)
902            .output()
903            .unwrap();
904
905        Command::new("git")
906            .args(["commit", "-m", "Add authentication feature"])
907            .current_dir(&repo_path)
908            .output()
909            .unwrap();
910
911        let commit1_hash = Command::new("git")
912            .args(["rev-parse", "HEAD"])
913            .current_dir(&repo_path)
914            .output()
915            .unwrap();
916        let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
917            .trim()
918            .to_string();
919
920        // Push first commit to stack - should succeed
921        let entry1_id = manager
922            .push_to_stack(
923                "feature/auth".to_string(),
924                commit1_hash,
925                "Add authentication feature".to_string(),
926                "main".to_string(),
927            )
928            .unwrap();
929
930        // Verify first entry was added
931        assert!(manager
932            .get_active_stack()
933            .unwrap()
934            .get_entry(&entry1_id)
935            .is_some());
936
937        // Create second commit
938        std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
939        Command::new("git")
940            .args(["add", "file2.txt"])
941            .current_dir(&repo_path)
942            .output()
943            .unwrap();
944
945        Command::new("git")
946            .args(["commit", "-m", "Different commit message"])
947            .current_dir(&repo_path)
948            .output()
949            .unwrap();
950
951        let commit2_hash = Command::new("git")
952            .args(["rev-parse", "HEAD"])
953            .current_dir(&repo_path)
954            .output()
955            .unwrap();
956        let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
957            .trim()
958            .to_string();
959
960        // Try to push second commit with the SAME message - should fail
961        let result = manager.push_to_stack(
962            "feature/auth2".to_string(),
963            commit2_hash.clone(),
964            "Add authentication feature".to_string(), // Same message as first commit
965            "main".to_string(),
966        );
967
968        // Should fail with validation error
969        assert!(result.is_err());
970        let error = result.unwrap_err();
971        assert!(matches!(error, CascadeError::Validation(_)));
972
973        // Error message should contain helpful information
974        let error_msg = error.to_string();
975        assert!(error_msg.contains("Duplicate commit message"));
976        assert!(error_msg.contains("Add authentication feature"));
977        assert!(error_msg.contains("💡 Consider using a more specific message"));
978
979        // Push with different message - should succeed
980        let entry2_id = manager
981            .push_to_stack(
982                "feature/auth2".to_string(),
983                commit2_hash,
984                "Add authentication validation".to_string(), // Different message
985                "main".to_string(),
986            )
987            .unwrap();
988
989        // Verify both entries exist
990        let stack = manager.get_active_stack().unwrap();
991        assert_eq!(stack.entries.len(), 2);
992        assert!(stack.get_entry(&entry1_id).is_some());
993        assert!(stack.get_entry(&entry2_id).is_some());
994    }
995
996    #[test]
997    fn test_duplicate_message_with_different_case() {
998        let (_temp_dir, repo_path) = create_test_repo();
999        let mut manager = StackManager::new(&repo_path).unwrap();
1000
1001        manager
1002            .create_stack("test-stack".to_string(), None, None)
1003            .unwrap();
1004
1005        // Create and push first commit
1006        std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1007        Command::new("git")
1008            .args(["add", "file1.txt"])
1009            .current_dir(&repo_path)
1010            .output()
1011            .unwrap();
1012
1013        Command::new("git")
1014            .args(["commit", "-m", "fix bug"])
1015            .current_dir(&repo_path)
1016            .output()
1017            .unwrap();
1018
1019        let commit1_hash = Command::new("git")
1020            .args(["rev-parse", "HEAD"])
1021            .current_dir(&repo_path)
1022            .output()
1023            .unwrap();
1024        let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1025            .trim()
1026            .to_string();
1027
1028        manager
1029            .push_to_stack(
1030                "feature/fix1".to_string(),
1031                commit1_hash,
1032                "fix bug".to_string(),
1033                "main".to_string(),
1034            )
1035            .unwrap();
1036
1037        // Create second commit
1038        std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1039        Command::new("git")
1040            .args(["add", "file2.txt"])
1041            .current_dir(&repo_path)
1042            .output()
1043            .unwrap();
1044
1045        Command::new("git")
1046            .args(["commit", "-m", "Fix Bug"])
1047            .current_dir(&repo_path)
1048            .output()
1049            .unwrap();
1050
1051        let commit2_hash = Command::new("git")
1052            .args(["rev-parse", "HEAD"])
1053            .current_dir(&repo_path)
1054            .output()
1055            .unwrap();
1056        let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1057            .trim()
1058            .to_string();
1059
1060        // Different case should be allowed (case-sensitive comparison)
1061        let result = manager.push_to_stack(
1062            "feature/fix2".to_string(),
1063            commit2_hash,
1064            "Fix Bug".to_string(), // Different case
1065            "main".to_string(),
1066        );
1067
1068        // Should succeed because it's case-sensitive
1069        assert!(result.is_ok());
1070    }
1071
1072    #[test]
1073    fn test_duplicate_message_across_different_stacks() {
1074        let (_temp_dir, repo_path) = create_test_repo();
1075        let mut manager = StackManager::new(&repo_path).unwrap();
1076
1077        // Create first stack and push commit
1078        let stack1_id = manager
1079            .create_stack("stack1".to_string(), None, None)
1080            .unwrap();
1081
1082        std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1083        Command::new("git")
1084            .args(["add", "file1.txt"])
1085            .current_dir(&repo_path)
1086            .output()
1087            .unwrap();
1088
1089        Command::new("git")
1090            .args(["commit", "-m", "shared message"])
1091            .current_dir(&repo_path)
1092            .output()
1093            .unwrap();
1094
1095        let commit1_hash = Command::new("git")
1096            .args(["rev-parse", "HEAD"])
1097            .current_dir(&repo_path)
1098            .output()
1099            .unwrap();
1100        let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1101            .trim()
1102            .to_string();
1103
1104        manager
1105            .push_to_stack(
1106                "feature/shared1".to_string(),
1107                commit1_hash,
1108                "shared message".to_string(),
1109                "main".to_string(),
1110            )
1111            .unwrap();
1112
1113        // Create second stack
1114        let stack2_id = manager
1115            .create_stack("stack2".to_string(), None, None)
1116            .unwrap();
1117
1118        // Set second stack as active
1119        manager.set_active_stack(Some(stack2_id)).unwrap();
1120
1121        // Create commit for second stack
1122        std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1123        Command::new("git")
1124            .args(["add", "file2.txt"])
1125            .current_dir(&repo_path)
1126            .output()
1127            .unwrap();
1128
1129        Command::new("git")
1130            .args(["commit", "-m", "shared message"])
1131            .current_dir(&repo_path)
1132            .output()
1133            .unwrap();
1134
1135        let commit2_hash = Command::new("git")
1136            .args(["rev-parse", "HEAD"])
1137            .current_dir(&repo_path)
1138            .output()
1139            .unwrap();
1140        let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1141            .trim()
1142            .to_string();
1143
1144        // Same message in different stack should be allowed
1145        let result = manager.push_to_stack(
1146            "feature/shared2".to_string(),
1147            commit2_hash,
1148            "shared message".to_string(), // Same message but different stack
1149            "main".to_string(),
1150        );
1151
1152        // Should succeed because it's a different stack
1153        assert!(result.is_ok());
1154
1155        // Verify both stacks have entries with the same message
1156        let stack1 = manager.get_stack(&stack1_id).unwrap();
1157        let stack2 = manager.get_stack(&stack2_id).unwrap();
1158
1159        assert_eq!(stack1.entries.len(), 1);
1160        assert_eq!(stack2.entries.len(), 1);
1161        assert_eq!(stack1.entries[0].message, "shared message");
1162        assert_eq!(stack2.entries[0].message, "shared message");
1163    }
1164
1165    #[test]
1166    fn test_duplicate_after_pop() {
1167        let (_temp_dir, repo_path) = create_test_repo();
1168        let mut manager = StackManager::new(&repo_path).unwrap();
1169
1170        manager
1171            .create_stack("test-stack".to_string(), None, None)
1172            .unwrap();
1173
1174        // Create and push first commit
1175        std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1176        Command::new("git")
1177            .args(["add", "file1.txt"])
1178            .current_dir(&repo_path)
1179            .output()
1180            .unwrap();
1181
1182        Command::new("git")
1183            .args(["commit", "-m", "temporary message"])
1184            .current_dir(&repo_path)
1185            .output()
1186            .unwrap();
1187
1188        let commit1_hash = Command::new("git")
1189            .args(["rev-parse", "HEAD"])
1190            .current_dir(&repo_path)
1191            .output()
1192            .unwrap();
1193        let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1194            .trim()
1195            .to_string();
1196
1197        manager
1198            .push_to_stack(
1199                "feature/temp".to_string(),
1200                commit1_hash,
1201                "temporary message".to_string(),
1202                "main".to_string(),
1203            )
1204            .unwrap();
1205
1206        // Pop the entry
1207        let popped = manager.pop_from_stack().unwrap();
1208        assert_eq!(popped.message, "temporary message");
1209
1210        // Create new commit
1211        std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1212        Command::new("git")
1213            .args(["add", "file2.txt"])
1214            .current_dir(&repo_path)
1215            .output()
1216            .unwrap();
1217
1218        Command::new("git")
1219            .args(["commit", "-m", "temporary message"])
1220            .current_dir(&repo_path)
1221            .output()
1222            .unwrap();
1223
1224        let commit2_hash = Command::new("git")
1225            .args(["rev-parse", "HEAD"])
1226            .current_dir(&repo_path)
1227            .output()
1228            .unwrap();
1229        let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1230            .trim()
1231            .to_string();
1232
1233        // Should be able to push same message again after popping
1234        let result = manager.push_to_stack(
1235            "feature/temp2".to_string(),
1236            commit2_hash,
1237            "temporary message".to_string(),
1238            "main".to_string(),
1239        );
1240
1241        assert!(result.is_ok());
1242    }
1243}