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 = 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 manager.load_from_disk()?;
55
56 Ok(manager)
57 }
58
59 pub fn create_stack(
61 &mut self,
62 name: String,
63 base_branch: Option<String>,
64 description: Option<String>,
65 ) -> Result<Uuid> {
66 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 let base_branch = base_branch.unwrap_or_else(|| self.metadata.default_base_branch.clone());
75
76 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 let stack = Stack::new(name.clone(), base_branch.clone(), description.clone());
85 let stack_id = stack.id;
86
87 let stack_metadata = StackMetadata::new(stack_id, name, base_branch, description);
89
90 self.stacks.insert(stack_id, stack);
92 self.metadata.add_stack(stack_metadata);
93
94 if self.metadata.stacks.len() == 1 {
96 self.set_active_stack(Some(stack_id))?;
97 } else {
98 self.save_to_disk()?;
100 }
101
102 Ok(stack_id)
103 }
104
105 pub fn get_stack(&self, stack_id: &Uuid) -> Option<&Stack> {
107 self.stacks.get(stack_id)
108 }
109
110 pub fn get_stack_mut(&mut self, stack_id: &Uuid) -> Option<&mut Stack> {
112 self.stacks.get_mut(stack_id)
113 }
114
115 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 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 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 pub fn set_active_stack(&mut self, stack_id: Option<Uuid>) -> Result<()> {
142 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 for stack in self.stacks.values_mut() {
153 stack.set_active(Some(stack.id) == stack_id);
154 }
155
156 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 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 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 self.metadata.remove_stack(stack_id);
188
189 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 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 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 if !self.repo.commit_exists(&commit_hash)? {
233 return Err(CascadeError::branch(format!(
234 "Commit {commit_hash} does not exist"
235 )));
236 }
237
238 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 let entry_id = stack.push_entry(branch.clone(), commit_hash.clone(), message.clone());
254
255 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 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 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 self.metadata.remove_commit(&entry.commit_hash);
295
296 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
298 stack_meta.remove_commit(&entry.commit_hash);
299 }
301
302 self.save_to_disk()?;
303
304 Ok(entry)
305 }
306
307 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 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 if let Some(commit_meta) = self.metadata.commits.get_mut(&entry_commit_hash) {
335 commit_meta.mark_submitted(pull_request_id);
336 }
337
338 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 pub fn get_all_stacks(&self) -> Vec<&Stack> {
355 self.stacks.values().collect()
356 }
357
358 pub fn get_stack_metadata(&self, stack_id: &Uuid) -> Option<&StackMetadata> {
360 self.metadata.get_stack(stack_id)
361 }
362
363 pub fn get_repository_metadata(&self) -> &RepositoryMetadata {
365 &self.metadata
366 }
367
368 pub fn git_repo(&self) -> &GitRepository {
370 &self.repo
371 }
372
373 pub fn repo_path(&self) -> &Path {
375 &self.repo_path
376 }
377
378 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 pub fn get_edit_mode_info(&self) -> Option<&super::metadata::EditModeState> {
391 self.metadata.edit_mode.as_ref()
392 }
393
394 pub fn enter_edit_mode(&mut self, stack_id: Uuid, entry_id: Uuid) -> Result<()> {
396 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 self.is_in_edit_mode() {
411 self.exit_edit_mode()?;
412 }
413
414 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 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 self.metadata.edit_mode = None;
435 self.save_to_disk()?;
436
437 info!("Exited edit mode");
438 Ok(())
439 }
440
441 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 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 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 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 let needs_sync = if let Some(first_entry) = stack.entries.first() {
493 match self
495 .repo
496 .get_commits_between(&stack.base_branch, &first_entry.commit_hash)
497 {
498 Ok(commits) => !commits.is_empty(), Err(_) => true, }
501 } else {
502 false };
504
505 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 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 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 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 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 fn save_to_disk(&self) -> Result<()> {
566 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 crate::utils::atomic_file::write_json(&self.stacks_file, &self.stacks)?;
575
576 crate::utils::atomic_file::write_json(&self.metadata_file, &self.metadata)?;
578
579 Ok(())
580 }
581
582 fn load_from_disk(&mut self) -> Result<()> {
584 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 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 pub fn check_for_branch_change(&mut self) -> Result<bool> {
608 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 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 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 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 self.set_active_stack(None)?;
674 println!("✅ Deactivated stack '{stack_name}' - using normal Git workflow");
675 return Ok(false);
676 }
677 "3" => {
678 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 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 Command::new("git")
737 .args(["init"])
738 .current_dir(&repo_path)
739 .output()
740 .unwrap();
741
742 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 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 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 let stack_id = manager
792 .create_stack(
793 "test-stack".to_string(),
794 None, Some("Test stack description".to_string()),
796 )
797 .unwrap();
798
799 assert_eq!(manager.stacks.len(), 1);
801 let stack = manager.get_stack(&stack_id).unwrap();
802 assert_eq!(stack.name, "test-stack");
803 assert!(!stack.base_branch.is_empty());
805 assert!(stack.is_active);
806
807 let active = manager.get_active_stack().unwrap();
809 assert_eq!(active.id, stack_id);
810
811 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 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 assert!(manager.get_stack(&stack1_id).unwrap().is_active);
850 assert!(!manager.get_stack(&stack2_id).unwrap().is_active);
851
852 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 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 manager
894 .create_stack("test-stack".to_string(), None, None)
895 .unwrap();
896
897 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 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 assert!(manager
932 .get_active_stack()
933 .unwrap()
934 .get_entry(&entry1_id)
935 .is_some());
936
937 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 let result = manager.push_to_stack(
962 "feature/auth2".to_string(),
963 commit2_hash.clone(),
964 "Add authentication feature".to_string(), "main".to_string(),
966 );
967
968 assert!(result.is_err());
970 let error = result.unwrap_err();
971 assert!(matches!(error, CascadeError::Validation(_)));
972
973 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 let entry2_id = manager
981 .push_to_stack(
982 "feature/auth2".to_string(),
983 commit2_hash,
984 "Add authentication validation".to_string(), "main".to_string(),
986 )
987 .unwrap();
988
989 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 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 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 let result = manager.push_to_stack(
1062 "feature/fix2".to_string(),
1063 commit2_hash,
1064 "Fix Bug".to_string(), "main".to_string(),
1066 );
1067
1068 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 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 let stack2_id = manager
1115 .create_stack("stack2".to_string(), None, None)
1116 .unwrap();
1117
1118 manager.set_active_stack(Some(stack2_id)).unwrap();
1120
1121 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 let result = manager.push_to_stack(
1146 "feature/shared2".to_string(),
1147 commit2_hash,
1148 "shared message".to_string(), "main".to_string(),
1150 );
1151
1152 assert!(result.is_ok());
1154
1155 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 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 let popped = manager.pop_from_stack().unwrap();
1208 assert_eq!(popped.message, "temporary message");
1209
1210 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 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}