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