1use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::error::Result;
10use crate::output::{BLUE, BOLD, CYAN, GRAY, GREEN, RED, RESET, YELLOW};
11use crate::prompt;
12use crate::state::{MachineState, RunStatus, StateManager};
13use crate::worktree;
14
15use super::ensure_project_dir;
16
17#[derive(Debug, Default)]
19pub struct CleanupSummary {
20 pub sessions_removed: usize,
22 pub worktrees_removed: usize,
24 pub bytes_freed: u64,
26 pub sessions_skipped: Vec<SkippedSession>,
28 pub errors: Vec<String>,
30}
31
32#[derive(Debug)]
34pub struct SkippedSession {
35 pub session_id: String,
36 pub reason: String,
37}
38
39#[derive(Debug, Default)]
41pub struct CleanOptions {
42 pub worktrees: bool,
44 pub all: bool,
46 pub session: Option<String>,
48 pub orphaned: bool,
50 pub force: bool,
52 pub project: Option<String>,
54}
55
56impl CleanupSummary {
57 pub fn print(&self) {
59 println!();
60
61 if self.sessions_removed == 0 && self.worktrees_removed == 0 {
62 println!("{GRAY}No sessions or worktrees were removed.{RESET}");
63 } else {
64 let freed_str = format_bytes(self.bytes_freed);
65 println!(
66 "{GREEN}Removed {} session{}, {} worktree{}, freed {}{RESET}",
67 self.sessions_removed,
68 if self.sessions_removed == 1 { "" } else { "s" },
69 self.worktrees_removed,
70 if self.worktrees_removed == 1 { "" } else { "s" },
71 freed_str
72 );
73 }
74
75 if !self.sessions_skipped.is_empty() {
76 println!();
77 println!(
78 "{YELLOW}Skipped {} session{}:{RESET}",
79 self.sessions_skipped.len(),
80 if self.sessions_skipped.len() == 1 {
81 ""
82 } else {
83 "s"
84 }
85 );
86 for skipped in &self.sessions_skipped {
87 println!(
88 " {GRAY}-{RESET} {}: {}",
89 skipped.session_id, skipped.reason
90 );
91 }
92 }
93
94 if !self.errors.is_empty() {
95 println!();
96 println!("{RED}Errors during cleanup:{RESET}");
97 for error in &self.errors {
98 println!(" {RED}-{RESET} {}", error);
99 }
100 }
101 }
102}
103
104fn format_bytes(bytes: u64) -> String {
106 const KB: u64 = 1024;
107 const MB: u64 = 1024 * KB;
108 const GB: u64 = 1024 * MB;
109
110 if bytes >= GB {
111 format!("{:.1} GB", bytes as f64 / GB as f64)
112 } else if bytes >= MB {
113 format!("{:.1} MB", bytes as f64 / MB as f64)
114 } else if bytes >= KB {
115 format!("{:.1} KB", bytes as f64 / KB as f64)
116 } else {
117 format!("{} B", bytes)
118 }
119}
120
121fn dir_size(path: &Path) -> u64 {
123 if !path.exists() {
124 return 0;
125 }
126
127 let mut size = 0;
128 if let Ok(entries) = fs::read_dir(path) {
129 for entry in entries.flatten() {
130 let path = entry.path();
131 if path.is_dir() {
132 size += dir_size(&path);
133 } else if let Ok(metadata) = entry.metadata() {
134 size += metadata.len();
135 }
136 }
137 }
138 size
139}
140
141pub fn worktree_has_uncommitted_changes(worktree_path: &Path) -> bool {
146 if !worktree_path.exists() {
147 return false;
148 }
149
150 let output = std::process::Command::new("git")
152 .args([
153 "-C",
154 &worktree_path.to_string_lossy(),
155 "status",
156 "--porcelain",
157 ])
158 .output();
159
160 match output {
161 Ok(output) => {
162 !output.stdout.is_empty()
164 }
165 Err(_) => {
166 false
168 }
169 }
170}
171
172fn get_state_manager(options: &CleanOptions) -> Result<StateManager> {
177 if let Some(project_name) = &options.project {
178 StateManager::for_project(project_name)
179 } else {
180 StateManager::new()
181 }
182}
183
184pub fn clean_command(options: CleanOptions) -> Result<()> {
188 if options.project.is_none() {
190 ensure_project_dir()?;
191 }
192
193 if let Some(session_id) = &options.session {
195 clean_specific_session(session_id, &options)
197 } else if options.orphaned {
198 clean_orphaned_sessions(&options)
200 } else if options.all {
201 clean_all_sessions(&options)
203 } else {
204 clean_completed_sessions(&options)
206 }
207}
208
209fn clean_specific_session(session_id: &str, options: &CleanOptions) -> Result<()> {
211 let state_manager = get_state_manager(options)?;
212 let sessions = state_manager.list_sessions()?;
213
214 let session = sessions.iter().find(|s| s.session_id == session_id);
216
217 match session {
218 Some(metadata) => {
219 println!();
220 println!(
221 "Session {CYAN}{}{RESET} on branch {BLUE}{}{RESET}",
222 metadata.session_id, metadata.branch_name
223 );
224 println!(" Path: {}", metadata.worktree_path.display());
225
226 let mut summary = CleanupSummary::default();
227
228 let current_dir = std::env::current_dir().ok();
230 let is_current = current_dir
231 .as_ref()
232 .map(|cwd| cwd == &metadata.worktree_path)
233 .unwrap_or(false);
234
235 if is_current && !options.force {
236 summary.sessions_skipped.push(SkippedSession {
237 session_id: session_id.to_string(),
238 reason: "Cannot remove current session (use --force to override)".to_string(),
239 });
240 summary.print();
241 return Ok(());
242 }
243
244 if options.worktrees
246 && metadata.worktree_path.exists()
247 && worktree_has_uncommitted_changes(&metadata.worktree_path)
248 && !options.force
249 {
250 summary.sessions_skipped.push(SkippedSession {
251 session_id: session_id.to_string(),
252 reason: "Worktree has uncommitted changes (use --force to override)"
253 .to_string(),
254 });
255 summary.print();
256 return Ok(());
257 }
258
259 let prompt_msg = if options.worktrees && metadata.worktree_path.exists() {
261 format!("Remove session '{}' and its worktree?", metadata.session_id)
262 } else {
263 format!("Remove session '{}'?", metadata.session_id)
264 };
265
266 if !prompt::confirm(&prompt_msg, false) {
267 println!("{GRAY}Cancelled.{RESET}");
268 return Ok(());
269 }
270
271 if let Some(session_sm) = state_manager.get_session(session_id) {
273 if let Ok(Some(state)) = session_sm.load_current() {
274 if let Ok(archive_path) = session_sm.archive(&state) {
275 println!("{GRAY}Archived to: {}{RESET}", archive_path.display());
276 }
277 }
278 }
279
280 if options.worktrees && metadata.worktree_path.exists() {
282 summary.bytes_freed += dir_size(&metadata.worktree_path);
283 if let Err(e) = remove_worktree_safely(&metadata.worktree_path, options.force) {
284 summary.errors.push(format!(
285 "Failed to remove worktree {}: {}",
286 metadata.worktree_path.display(),
287 e
288 ));
289 } else {
290 summary.worktrees_removed += 1;
291 }
292 }
293
294 if let Some(session_sm) = state_manager.get_session(session_id) {
296 summary.bytes_freed += get_session_size(&session_sm);
297 session_sm.clear_current()?;
298 summary.sessions_removed += 1;
299 }
300
301 summary.print();
302 Ok(())
303 }
304 None => {
305 println!("{RED}Session '{}' not found.{RESET}", session_id);
306 println!();
307 println!("Use {CYAN}autom8 status --all{RESET} to list available sessions.");
308 Ok(())
309 }
310 }
311}
312
313fn clean_orphaned_sessions(options: &CleanOptions) -> Result<()> {
315 let state_manager = get_state_manager(options)?;
316 let sessions = state_manager.list_sessions()?;
317
318 let orphaned: Vec<_> = sessions
320 .iter()
321 .filter(|s| !s.worktree_path.exists())
322 .collect();
323
324 if orphaned.is_empty() {
325 println!("{GRAY}No orphaned sessions found.{RESET}");
326 return Ok(());
327 }
328
329 println!();
330 println!("{BOLD}Orphaned sessions (worktree deleted):{RESET}");
331 for session in &orphaned {
332 println!(
333 " {GRAY}●{RESET} {} - {} (path: {})",
334 session.session_id,
335 session.branch_name,
336 session.worktree_path.display()
337 );
338 }
339 println!();
340
341 let prompt_msg = format!(
342 "Remove {} orphaned session{}?",
343 orphaned.len(),
344 if orphaned.len() == 1 { "" } else { "s" }
345 );
346
347 if !prompt::confirm(&prompt_msg, false) {
348 println!("{GRAY}Cancelled.{RESET}");
349 return Ok(());
350 }
351
352 let mut summary = CleanupSummary::default();
353
354 for session in orphaned {
355 if let Some(session_sm) = state_manager.get_session(&session.session_id) {
357 if let Ok(Some(state)) = session_sm.load_current() {
358 let _ = session_sm.archive(&state);
359 }
360
361 summary.bytes_freed += get_session_size(&session_sm);
362 if let Err(e) = session_sm.clear_current() {
363 summary.errors.push(format!(
364 "Failed to remove session {}: {}",
365 session.session_id, e
366 ));
367 } else {
368 summary.sessions_removed += 1;
369 }
370 }
371 }
372
373 summary.print();
374 Ok(())
375}
376
377fn clean_all_sessions(options: &CleanOptions) -> Result<()> {
379 let state_manager = get_state_manager(options)?;
380 let sessions = state_manager.list_sessions()?;
381
382 if sessions.is_empty() {
383 println!("{GRAY}No sessions found.{RESET}");
384 return Ok(());
385 }
386
387 let current_dir = std::env::current_dir().ok();
388
389 println!();
390 println!("{BOLD}All sessions:{RESET}");
391 for session in &sessions {
392 let is_current = current_dir
393 .as_ref()
394 .map(|cwd| cwd == &session.worktree_path)
395 .unwrap_or(false);
396
397 let is_orphaned = !session.worktree_path.exists();
398 let has_uncommitted =
399 !is_orphaned && worktree_has_uncommitted_changes(&session.worktree_path);
400
401 let status_markers = format!(
402 "{}{}{}",
403 if is_current { " (current)" } else { "" },
404 if is_orphaned { " [orphaned]" } else { "" },
405 if has_uncommitted {
406 " [uncommitted changes]"
407 } else {
408 ""
409 }
410 );
411
412 let indicator = if is_orphaned {
413 format!("{GRAY}✗{RESET}")
414 } else if session.is_running {
415 format!("{YELLOW}●{RESET}")
416 } else {
417 format!("{GRAY}○{RESET}")
418 };
419
420 println!(
421 " {} {} - {}{GRAY}{}{RESET}",
422 indicator, session.session_id, session.branch_name, status_markers
423 );
424 }
425 println!();
426
427 let sessions_with_uncommitted: Vec<_> = sessions
429 .iter()
430 .filter(|s| s.worktree_path.exists() && worktree_has_uncommitted_changes(&s.worktree_path))
431 .collect();
432
433 if !sessions_with_uncommitted.is_empty() && options.worktrees && !options.force {
434 println!(
435 "{YELLOW}Warning: {} session{} {} uncommitted changes.{RESET}",
436 sessions_with_uncommitted.len(),
437 if sessions_with_uncommitted.len() == 1 {
438 ""
439 } else {
440 "s"
441 },
442 if sessions_with_uncommitted.len() == 1 {
443 "has"
444 } else {
445 "have"
446 }
447 );
448 println!("{YELLOW}These will be skipped unless you use --force.{RESET}");
449 println!();
450 }
451
452 let prompt_msg = if options.worktrees {
453 format!(
454 "{RED}Remove ALL {} sessions AND their worktrees? This cannot be undone.{RESET}",
455 sessions.len()
456 )
457 } else {
458 format!(
459 "Remove ALL {} session state files? (worktrees will remain)",
460 sessions.len()
461 )
462 };
463
464 if !prompt::confirm(&prompt_msg, false) {
465 println!("{GRAY}Cancelled.{RESET}");
466 return Ok(());
467 }
468
469 let mut summary = CleanupSummary::default();
470
471 for session in &sessions {
472 let is_current = current_dir
473 .as_ref()
474 .map(|cwd| cwd == &session.worktree_path)
475 .unwrap_or(false);
476
477 if is_current && !options.force {
479 summary.sessions_skipped.push(SkippedSession {
480 session_id: session.session_id.clone(),
481 reason: "Current session".to_string(),
482 });
483 continue;
484 }
485
486 if options.worktrees
488 && session.worktree_path.exists()
489 && worktree_has_uncommitted_changes(&session.worktree_path)
490 && !options.force
491 {
492 summary.sessions_skipped.push(SkippedSession {
493 session_id: session.session_id.clone(),
494 reason: "Uncommitted changes".to_string(),
495 });
496 continue;
497 }
498
499 if let Some(session_sm) = state_manager.get_session(&session.session_id) {
501 if let Ok(Some(state)) = session_sm.load_current() {
502 let _ = session_sm.archive(&state);
503 }
504
505 if options.worktrees && session.worktree_path.exists() {
507 summary.bytes_freed += dir_size(&session.worktree_path);
508 if let Err(e) = remove_worktree_safely(&session.worktree_path, options.force) {
509 summary.errors.push(format!(
510 "Failed to remove worktree {}: {}",
511 session.worktree_path.display(),
512 e
513 ));
514 } else {
515 summary.worktrees_removed += 1;
516 }
517 }
518
519 summary.bytes_freed += get_session_size(&session_sm);
521 if let Err(e) = session_sm.clear_current() {
522 summary.errors.push(format!(
523 "Failed to remove session {}: {}",
524 session.session_id, e
525 ));
526 } else {
527 summary.sessions_removed += 1;
528 }
529 }
530 }
531
532 summary.print();
533 Ok(())
534}
535
536fn clean_completed_sessions(options: &CleanOptions) -> Result<()> {
538 let state_manager = get_state_manager(options)?;
539 let sessions = state_manager.list_sessions()?;
540
541 let cleanable: Vec<_> = sessions
543 .iter()
544 .filter(|s| {
545 if let Some(session_sm) = state_manager.get_session(&s.session_id) {
547 if let Ok(Some(state)) = session_sm.load_current() {
548 matches!(
549 state.machine_state,
550 MachineState::Completed | MachineState::Failed
551 ) || matches!(
552 state.status,
553 RunStatus::Completed | RunStatus::Failed | RunStatus::Interrupted
554 )
555 } else {
556 true
558 }
559 } else {
560 false
561 }
562 })
563 .collect();
564
565 let orphaned: Vec<_> = sessions
567 .iter()
568 .filter(|s| !s.worktree_path.exists())
569 .collect();
570
571 let mut to_clean: Vec<_> = cleanable;
573 for orphan in orphaned {
574 if !to_clean.iter().any(|s| s.session_id == orphan.session_id) {
575 to_clean.push(orphan);
576 }
577 }
578
579 if to_clean.is_empty() {
580 println!("{GRAY}No completed, failed, or orphaned sessions to clean.{RESET}");
581 return Ok(());
582 }
583
584 let current_dir = std::env::current_dir().ok();
585
586 println!();
587 println!("{BOLD}Sessions to clean:{RESET}");
588 for session in &to_clean {
589 let is_current = current_dir
590 .as_ref()
591 .map(|cwd| cwd == &session.worktree_path)
592 .unwrap_or(false);
593
594 let is_orphaned = !session.worktree_path.exists();
595
596 let status = if let Some(session_sm) = state_manager.get_session(&session.session_id) {
598 if let Ok(Some(state)) = session_sm.load_current() {
599 match state.machine_state {
600 MachineState::Completed => format!("{GREEN}completed{RESET}"),
601 MachineState::Failed => format!("{RED}failed{RESET}"),
602 _ => format!("{GRAY}idle{RESET}"),
603 }
604 } else {
605 format!("{GRAY}no state{RESET}")
606 }
607 } else {
608 format!("{GRAY}unknown{RESET}")
609 };
610
611 let markers = format!(
612 "{}{}",
613 if is_current { " (current)" } else { "" },
614 if is_orphaned { " [orphaned]" } else { "" }
615 );
616
617 println!(
618 " {GRAY}○{RESET} {} - {} [{}]{GRAY}{}{RESET}",
619 session.session_id, session.branch_name, status, markers
620 );
621 }
622 println!();
623
624 let prompt_msg = format!(
625 "Remove {} session{}{}?",
626 to_clean.len(),
627 if to_clean.len() == 1 { "" } else { "s" },
628 if options.worktrees {
629 " and associated worktrees"
630 } else {
631 ""
632 }
633 );
634
635 if !prompt::confirm(&prompt_msg, false) {
636 println!("{GRAY}Cancelled.{RESET}");
637 return Ok(());
638 }
639
640 let mut summary = CleanupSummary::default();
641
642 for session in to_clean {
643 let is_current = current_dir
644 .as_ref()
645 .map(|cwd| cwd == &session.worktree_path)
646 .unwrap_or(false);
647
648 if is_current && !options.force {
650 summary.sessions_skipped.push(SkippedSession {
651 session_id: session.session_id.clone(),
652 reason: "Current session".to_string(),
653 });
654 continue;
655 }
656
657 if options.worktrees
659 && session.worktree_path.exists()
660 && worktree_has_uncommitted_changes(&session.worktree_path)
661 && !options.force
662 {
663 summary.sessions_skipped.push(SkippedSession {
664 session_id: session.session_id.clone(),
665 reason: "Uncommitted changes".to_string(),
666 });
667 continue;
668 }
669
670 if let Some(session_sm) = state_manager.get_session(&session.session_id) {
672 if let Ok(Some(state)) = session_sm.load_current() {
673 let _ = session_sm.archive(&state);
674 }
675
676 if options.worktrees && session.worktree_path.exists() {
678 summary.bytes_freed += dir_size(&session.worktree_path);
679 if let Err(e) = remove_worktree_safely(&session.worktree_path, options.force) {
680 summary.errors.push(format!(
681 "Failed to remove worktree {}: {}",
682 session.worktree_path.display(),
683 e
684 ));
685 } else {
686 summary.worktrees_removed += 1;
687 }
688 }
689
690 summary.bytes_freed += get_session_size(&session_sm);
692 if let Err(e) = session_sm.clear_current() {
693 summary.errors.push(format!(
694 "Failed to remove session {}: {}",
695 session.session_id, e
696 ));
697 } else {
698 summary.sessions_removed += 1;
699 }
700 }
701 }
702
703 summary.print();
704 Ok(())
705}
706
707#[derive(Debug, Default, Clone)]
713pub struct DirectCleanOptions {
714 pub worktrees: bool,
716 pub force: bool,
718}
719
720pub fn clean_worktrees_direct(
730 project_name: &str,
731 options: DirectCleanOptions,
732) -> Result<CleanupSummary> {
733 let state_manager = StateManager::for_project(project_name)?;
734 let sessions = state_manager.list_sessions()?;
735
736 let to_clean: Vec<_> = sessions
739 .iter()
740 .filter(|s| {
741 if s.session_id == "main" {
743 return false;
744 }
745 true
747 })
748 .collect();
749
750 let mut summary = CleanupSummary::default();
751
752 if to_clean.is_empty() {
753 return Ok(summary);
754 }
755
756 let current_dir = std::env::current_dir().ok();
757
758 for session in to_clean {
759 if session.is_running {
761 summary.sessions_skipped.push(SkippedSession {
762 session_id: session.session_id.clone(),
763 reason: "Active run in progress".to_string(),
764 });
765 continue;
766 }
767
768 let is_current = current_dir
769 .as_ref()
770 .map(|cwd| cwd == &session.worktree_path)
771 .unwrap_or(false);
772
773 if is_current && !options.force {
775 summary.sessions_skipped.push(SkippedSession {
776 session_id: session.session_id.clone(),
777 reason: "Current session".to_string(),
778 });
779 continue;
780 }
781
782 if options.worktrees
784 && session.worktree_path.exists()
785 && worktree_has_uncommitted_changes(&session.worktree_path)
786 && !options.force
787 {
788 summary.sessions_skipped.push(SkippedSession {
789 session_id: session.session_id.clone(),
790 reason: "Uncommitted changes".to_string(),
791 });
792 continue;
793 }
794
795 if let Some(session_sm) = state_manager.get_session(&session.session_id) {
797 if let Ok(Some(state)) = session_sm.load_current() {
798 let _ = session_sm.archive(&state);
799 }
800
801 if options.worktrees && session.worktree_path.exists() {
803 summary.bytes_freed += dir_size(&session.worktree_path);
804 if let Err(e) = remove_worktree_safely(&session.worktree_path, options.force) {
805 summary.errors.push(format!(
806 "Failed to remove worktree {}: {}",
807 session.worktree_path.display(),
808 e
809 ));
810 } else {
811 summary.worktrees_removed += 1;
812 }
813 }
814
815 summary.bytes_freed += get_session_size(&session_sm);
817 if let Err(e) = session_sm.clear_current() {
818 summary.errors.push(format!(
819 "Failed to remove session {}: {}",
820 session.session_id, e
821 ));
822 } else {
823 summary.sessions_removed += 1;
824 }
825 }
826 }
827
828 Ok(summary)
829}
830
831pub fn clean_orphaned_direct(project_name: &str) -> Result<CleanupSummary> {
841 let state_manager = StateManager::for_project(project_name)?;
842 let sessions = state_manager.list_sessions()?;
843
844 let orphaned: Vec<_> = sessions
846 .iter()
847 .filter(|s| !s.worktree_path.exists())
848 .collect();
849
850 let mut summary = CleanupSummary::default();
851
852 if orphaned.is_empty() {
853 return Ok(summary);
854 }
855
856 for session in orphaned {
857 if let Some(session_sm) = state_manager.get_session(&session.session_id) {
859 if let Ok(Some(state)) = session_sm.load_current() {
860 let _ = session_sm.archive(&state);
861 }
862
863 summary.bytes_freed += get_session_size(&session_sm);
864 if let Err(e) = session_sm.clear_current() {
865 summary.errors.push(format!(
866 "Failed to remove session {}: {}",
867 session.session_id, e
868 ));
869 } else {
870 summary.sessions_removed += 1;
871 }
872 }
873 }
874
875 Ok(summary)
876}
877
878pub fn format_bytes_display(bytes: u64) -> String {
880 format_bytes(bytes)
881}
882
883#[derive(Debug, Default)]
889pub struct RemovalSummary {
890 pub worktrees_removed: usize,
892 pub config_deleted: bool,
894 pub bytes_freed: u64,
896 pub worktrees_skipped: Vec<SkippedWorktree>,
898 pub errors: Vec<String>,
900}
901
902#[derive(Debug)]
904pub struct SkippedWorktree {
905 pub path: PathBuf,
906 pub reason: String,
907}
908
909pub fn remove_project_direct(project_name: &str) -> Result<RemovalSummary> {
924 use crate::config::project_config_dir_for;
925
926 let mut summary = RemovalSummary::default();
927
928 let project_dir = project_config_dir_for(project_name)?;
930
931 if !project_dir.exists() {
933 summary.errors.push(format!(
934 "Project '{}' does not exist at {}",
935 project_name,
936 project_dir.display()
937 ));
938 return Ok(summary);
939 }
940
941 if let Ok(state_manager) = StateManager::for_project(project_name) {
944 if let Ok(sessions) = state_manager.list_sessions() {
945 for session in sessions {
946 if session.is_running {
948 summary.worktrees_skipped.push(SkippedWorktree {
949 path: session.worktree_path.clone(),
950 reason: "Active run in progress".to_string(),
951 });
952 continue;
953 }
954
955 if !session.worktree_path.exists() {
957 continue;
958 }
959
960 if session.session_id == "main" {
962 continue;
963 }
964
965 let worktree_size = dir_size(&session.worktree_path);
967
968 match remove_worktree_safely(&session.worktree_path, false) {
970 Ok(()) => {
971 summary.worktrees_removed += 1;
972 summary.bytes_freed += worktree_size;
973 }
974 Err(e) => {
975 summary.errors.push(format!(
976 "Failed to remove worktree {}: {}",
977 session.worktree_path.display(),
978 e
979 ));
980 }
981 }
982 }
983 }
984 }
985
986 let config_size = dir_size(&project_dir);
989
990 match fs::remove_dir_all(&project_dir) {
991 Ok(()) => {
992 summary.config_deleted = true;
993 summary.bytes_freed += config_size;
994 }
995 Err(e) => {
996 summary.errors.push(format!(
997 "Failed to delete config directory {}: {}",
998 project_dir.display(),
999 e
1000 ));
1001 }
1002 }
1003
1004 Ok(summary)
1005}
1006
1007fn remove_worktree_safely(worktree_path: &Path, force: bool) -> Result<()> {
1013 let current_dir = std::env::current_dir().ok();
1015 if current_dir.as_ref() == Some(&worktree_path.to_path_buf()) {
1016 if let Ok(main_repo) = worktree::get_main_repo_root() {
1018 std::env::set_current_dir(&main_repo)?;
1019 }
1020 }
1021
1022 worktree::remove_worktree(worktree_path, force)
1024}
1025
1026fn get_session_size(_session_sm: &StateManager) -> u64 {
1028 0 }
1032
1033#[derive(Debug, Default)]
1039pub struct DataCleanupSummary {
1040 pub specs_removed: usize,
1042 pub runs_removed: usize,
1044 pub bytes_freed: u64,
1046 pub errors: Vec<String>,
1048}
1049
1050pub fn clean_data_direct(project_name: &str) -> Result<DataCleanupSummary> {
1065 let state_manager = StateManager::for_project(project_name)?;
1066 let mut summary = DataCleanupSummary::default();
1067
1068 let spec_dir = state_manager.spec_dir();
1070 let runs_dir = state_manager.runs_dir();
1071
1072 let mut active_spec_paths = std::collections::HashSet::new();
1074 if let Ok(sessions) = state_manager.list_sessions_with_status() {
1075 for status in sessions {
1076 if status.metadata.is_running {
1077 if let Some(session_sm) = state_manager.get_session(&status.metadata.session_id) {
1078 if let Ok(Some(state)) = session_sm.load_current() {
1079 active_spec_paths.insert(state.spec_json_path.clone());
1081 if let Some(md_path) = &state.spec_md_path {
1083 active_spec_paths.insert(md_path.clone());
1084 }
1085 }
1086 }
1087 }
1088 }
1089 }
1090
1091 if spec_dir.exists() {
1093 if let Ok(entries) = fs::read_dir(&spec_dir) {
1094 let mut json_specs: Vec<PathBuf> = Vec::new();
1096 for entry in entries.flatten() {
1097 let path = entry.path();
1098 if path.extension().and_then(|e| e.to_str()) == Some("json") {
1099 json_specs.push(path);
1100 }
1101 }
1102
1103 for json_path in json_specs {
1105 if active_spec_paths.contains(&json_path) {
1107 continue;
1108 }
1109
1110 let md_path = json_path.with_extension("md");
1112
1113 let mut pair_size = 0u64;
1115 if json_path.exists() {
1116 if let Ok(meta) = fs::metadata(&json_path) {
1117 pair_size += meta.len();
1118 }
1119 }
1120 if md_path.exists() {
1121 if let Ok(meta) = fs::metadata(&md_path) {
1122 pair_size += meta.len();
1123 }
1124 }
1125
1126 let mut removed = false;
1128 if json_path.exists() {
1129 if let Err(e) = fs::remove_file(&json_path) {
1130 summary.errors.push(format!(
1131 "Failed to remove {}: {}",
1132 json_path.display(),
1133 e
1134 ));
1135 } else {
1136 removed = true;
1137 }
1138 }
1139 if md_path.exists() {
1140 if let Err(e) = fs::remove_file(&md_path) {
1141 summary.errors.push(format!(
1142 "Failed to remove {}: {}",
1143 md_path.display(),
1144 e
1145 ));
1146 }
1147 }
1148
1149 if removed {
1150 summary.specs_removed += 1;
1151 summary.bytes_freed += pair_size;
1152 }
1153 }
1154 }
1155 }
1156
1157 if runs_dir.exists() {
1159 if let Ok(entries) = fs::read_dir(&runs_dir) {
1160 for entry in entries.flatten() {
1161 let path = entry.path();
1162 if path.is_file() {
1163 let size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
1165
1166 if let Err(e) = fs::remove_file(&path) {
1167 summary
1168 .errors
1169 .push(format!("Failed to remove {}: {}", path.display(), e));
1170 } else {
1171 summary.runs_removed += 1;
1172 summary.bytes_freed += size;
1173 }
1174 }
1175 }
1176 }
1177 }
1178
1179 Ok(summary)
1180}
1181
1182#[cfg(test)]
1187mod tests {
1188 use super::*;
1189 use crate::state::{RunState, SessionMetadata};
1190 use chrono::Utc;
1191 use std::path::PathBuf;
1192 use tempfile::TempDir;
1193
1194 #[test]
1195 fn test_format_bytes() {
1196 assert_eq!(format_bytes(0), "0 B");
1197 assert_eq!(format_bytes(500), "500 B");
1198 assert_eq!(format_bytes(1024), "1.0 KB");
1199 assert_eq!(format_bytes(1536), "1.5 KB");
1200 assert_eq!(format_bytes(1048576), "1.0 MB");
1201 assert_eq!(format_bytes(1572864), "1.5 MB");
1202 assert_eq!(format_bytes(1073741824), "1.0 GB");
1203 }
1204
1205 #[test]
1206 fn test_cleanup_summary_default() {
1207 let summary = CleanupSummary::default();
1208 assert_eq!(summary.sessions_removed, 0);
1209 assert_eq!(summary.worktrees_removed, 0);
1210 assert_eq!(summary.bytes_freed, 0);
1211 assert!(summary.sessions_skipped.is_empty());
1212 assert!(summary.errors.is_empty());
1213 }
1214
1215 #[test]
1216 fn test_clean_options_default() {
1217 let options = CleanOptions::default();
1218 assert!(!options.worktrees);
1219 assert!(!options.all);
1220 assert!(options.session.is_none());
1221 assert!(!options.orphaned);
1222 assert!(!options.force);
1223 }
1224
1225 #[test]
1226 fn test_worktree_has_uncommitted_changes_nonexistent_path() {
1227 let result = worktree_has_uncommitted_changes(Path::new("/nonexistent/path/12345"));
1229 assert!(!result);
1230 }
1231
1232 #[test]
1233 fn test_dir_size_nonexistent() {
1234 let size = dir_size(Path::new("/nonexistent/path/12345"));
1235 assert_eq!(size, 0);
1236 }
1237
1238 #[test]
1239 fn test_dir_size_empty_dir() {
1240 let temp_dir = TempDir::new().unwrap();
1241 let size = dir_size(temp_dir.path());
1242 assert_eq!(size, 0);
1243 }
1244
1245 #[test]
1246 fn test_dir_size_with_files() {
1247 let temp_dir = TempDir::new().unwrap();
1248 let file_path = temp_dir.path().join("test.txt");
1249 fs::write(&file_path, "hello world").unwrap();
1250
1251 let size = dir_size(temp_dir.path());
1252 assert!(size > 0);
1253 assert_eq!(size, 11); }
1255
1256 #[test]
1257 fn test_dir_size_with_nested_dirs() {
1258 let temp_dir = TempDir::new().unwrap();
1259
1260 let subdir = temp_dir.path().join("subdir");
1262 fs::create_dir(&subdir).unwrap();
1263 fs::write(subdir.join("file1.txt"), "hello").unwrap();
1264 fs::write(temp_dir.path().join("file2.txt"), "world").unwrap();
1265
1266 let size = dir_size(temp_dir.path());
1267 assert_eq!(size, 10); }
1269
1270 #[test]
1271 fn test_skipped_session_struct() {
1272 let skipped = SkippedSession {
1273 session_id: "abc123".to_string(),
1274 reason: "test reason".to_string(),
1275 };
1276 assert_eq!(skipped.session_id, "abc123");
1277 assert_eq!(skipped.reason, "test reason");
1278 }
1279
1280 #[test]
1285 fn test_us011_clean_options_worktrees_flag() {
1286 let options = CleanOptions {
1287 worktrees: true,
1288 ..Default::default()
1289 };
1290 assert!(options.worktrees);
1291 }
1292
1293 #[test]
1294 fn test_us011_clean_options_all_flag() {
1295 let options = CleanOptions {
1296 all: true,
1297 ..Default::default()
1298 };
1299 assert!(options.all);
1300 }
1301
1302 #[test]
1303 fn test_us011_clean_options_session_flag() {
1304 let options = CleanOptions {
1305 session: Some("abc123".to_string()),
1306 ..Default::default()
1307 };
1308 assert_eq!(options.session, Some("abc123".to_string()));
1309 }
1310
1311 #[test]
1312 fn test_us011_clean_options_orphaned_flag() {
1313 let options = CleanOptions {
1314 orphaned: true,
1315 ..Default::default()
1316 };
1317 assert!(options.orphaned);
1318 }
1319
1320 #[test]
1321 fn test_us011_clean_options_force_flag() {
1322 let options = CleanOptions {
1323 force: true,
1324 ..Default::default()
1325 };
1326 assert!(options.force);
1327 }
1328
1329 #[test]
1330 fn test_us011_cleanup_summary_with_stats() {
1331 let summary = CleanupSummary {
1332 sessions_removed: 3,
1333 worktrees_removed: 2,
1334 bytes_freed: 1048576, sessions_skipped: vec![SkippedSession {
1336 session_id: "skipped1".to_string(),
1337 reason: "uncommitted changes".to_string(),
1338 }],
1339 errors: vec!["test error".to_string()],
1340 };
1341
1342 assert_eq!(summary.sessions_removed, 3);
1343 assert_eq!(summary.worktrees_removed, 2);
1344 assert_eq!(summary.bytes_freed, 1048576);
1345 assert_eq!(summary.sessions_skipped.len(), 1);
1346 assert_eq!(summary.errors.len(), 1);
1347 }
1348
1349 #[test]
1350 fn test_us011_worktree_uncommitted_check_on_temp_dir() {
1351 let temp_dir = TempDir::new().unwrap();
1353 fs::write(temp_dir.path().join("test.txt"), "content").unwrap();
1354
1355 let result = worktree_has_uncommitted_changes(temp_dir.path());
1357 assert!(!result);
1358 }
1359
1360 #[test]
1361 fn test_us011_archive_before_deletion_pattern() {
1362 let temp_dir = TempDir::new().unwrap();
1364 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
1365
1366 let state = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
1368 sm.save(&state).unwrap();
1369
1370 let archive_path = sm.archive(&state).unwrap();
1372 assert!(archive_path.exists());
1373
1374 sm.clear_current().unwrap();
1376
1377 assert!(sm.load_current().unwrap().is_none());
1379 assert!(archive_path.exists());
1380 }
1381
1382 #[test]
1383 fn test_us011_detect_orphaned_session() {
1384 let metadata = SessionMetadata {
1386 session_id: "orphan123".to_string(),
1387 worktree_path: PathBuf::from("/nonexistent/worktree/path"),
1388 branch_name: "feature/test".to_string(),
1389 created_at: Utc::now(),
1390 last_active_at: Utc::now(),
1391 is_running: false,
1392 spec_json_path: None,
1393 };
1394
1395 assert!(!metadata.worktree_path.exists());
1397 }
1398
1399 #[test]
1400 fn test_us011_completed_session_is_cleanable() {
1401 let state = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
1403 assert!(matches!(state.machine_state, MachineState::Initializing));
1406 }
1407
1408 #[test]
1409 fn test_us011_failed_session_is_cleanable() {
1410 let mut state = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
1411 state.transition_to(MachineState::Failed);
1412
1413 assert!(matches!(state.machine_state, MachineState::Failed));
1414 assert!(matches!(state.status, RunStatus::Failed));
1415 }
1416
1417 #[test]
1422 fn test_us004_direct_clean_options_default() {
1423 let options = DirectCleanOptions::default();
1424 assert!(!options.worktrees);
1425 assert!(!options.force);
1426 }
1427
1428 #[test]
1429 fn test_us004_direct_clean_options_with_worktrees() {
1430 let options = DirectCleanOptions {
1431 worktrees: true,
1432 force: false,
1433 };
1434 assert!(options.worktrees);
1435 assert!(!options.force);
1436 }
1437
1438 #[test]
1439 fn test_us004_direct_clean_options_with_force() {
1440 let options = DirectCleanOptions {
1441 worktrees: false,
1442 force: true,
1443 };
1444 assert!(!options.worktrees);
1445 assert!(options.force);
1446 }
1447
1448 #[test]
1449 fn test_us004_format_bytes_display() {
1450 assert_eq!(format_bytes_display(0), "0 B");
1452 assert_eq!(format_bytes_display(1024), "1.0 KB");
1453 assert_eq!(format_bytes_display(1048576), "1.0 MB");
1454 assert_eq!(format_bytes_display(1073741824), "1.0 GB");
1455 }
1456
1457 #[test]
1458 fn test_us004_clean_worktrees_direct_with_temp_project() {
1459 let temp_dir = TempDir::new().unwrap();
1461 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
1462
1463 let mut state = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
1465 state.transition_to(MachineState::Completed);
1466 sm.save(&state).unwrap();
1467
1468 let options = DirectCleanOptions {
1471 worktrees: true,
1472 force: false,
1473 };
1474 let result = clean_worktrees_direct("nonexistent-project-12345", options);
1477
1478 assert!(result.is_err() || result.unwrap().sessions_removed == 0);
1480 }
1481
1482 #[test]
1483 fn test_us004_clean_orphaned_direct_with_temp_project() {
1484 let result = clean_orphaned_direct("nonexistent-project-12345");
1488
1489 assert!(result.is_err() || result.unwrap().sessions_removed == 0);
1491 }
1492
1493 #[test]
1498 fn test_us004_removal_summary_default() {
1499 let summary = RemovalSummary::default();
1500 assert_eq!(summary.worktrees_removed, 0);
1501 assert!(!summary.config_deleted);
1502 assert_eq!(summary.bytes_freed, 0);
1503 assert!(summary.worktrees_skipped.is_empty());
1504 assert!(summary.errors.is_empty());
1505 }
1506
1507 #[test]
1508 fn test_us004_removal_summary_with_successful_removal() {
1509 let summary = RemovalSummary {
1510 worktrees_removed: 2,
1511 config_deleted: true,
1512 bytes_freed: 1048576, worktrees_skipped: vec![],
1514 errors: vec![],
1515 };
1516
1517 assert_eq!(summary.worktrees_removed, 2);
1518 assert!(summary.config_deleted);
1519 assert_eq!(summary.bytes_freed, 1048576);
1520 assert!(summary.worktrees_skipped.is_empty());
1521 assert!(summary.errors.is_empty());
1522 }
1523
1524 #[test]
1525 fn test_us004_skipped_worktree_struct() {
1526 let skipped = SkippedWorktree {
1527 path: PathBuf::from("/path/to/worktree"),
1528 reason: "Active run in progress".to_string(),
1529 };
1530
1531 assert_eq!(skipped.path, PathBuf::from("/path/to/worktree"));
1532 assert_eq!(skipped.reason, "Active run in progress");
1533 }
1534
1535 #[test]
1536 fn test_us004_removal_summary_with_skipped_worktrees() {
1537 let summary = RemovalSummary {
1539 worktrees_removed: 1,
1540 config_deleted: true,
1541 bytes_freed: 512,
1542 worktrees_skipped: vec![SkippedWorktree {
1543 path: PathBuf::from("/tmp/active-worktree"),
1544 reason: "Active run in progress".to_string(),
1545 }],
1546 errors: vec![],
1547 };
1548
1549 assert_eq!(summary.worktrees_skipped.len(), 1);
1550 assert_eq!(
1551 summary.worktrees_skipped[0].reason,
1552 "Active run in progress"
1553 );
1554 }
1555
1556 #[test]
1557 fn test_us004_removal_summary_with_errors() {
1558 let summary = RemovalSummary {
1560 worktrees_removed: 1,
1561 config_deleted: false, bytes_freed: 1024,
1563 worktrees_skipped: vec![],
1564 errors: vec!["Failed to delete config directory: permission denied".to_string()],
1565 };
1566
1567 assert_eq!(summary.worktrees_removed, 1);
1568 assert!(!summary.config_deleted);
1569 assert_eq!(summary.errors.len(), 1);
1570 assert!(summary.errors[0].contains("permission denied"));
1571 }
1572
1573 #[test]
1574 fn test_us004_removal_summary_partial_cleanup_reports_both() {
1575 let summary = RemovalSummary {
1577 worktrees_removed: 2,
1578 config_deleted: true,
1579 bytes_freed: 2048,
1580 worktrees_skipped: vec![SkippedWorktree {
1581 path: PathBuf::from("/tmp/active"),
1582 reason: "Active run".to_string(),
1583 }],
1584 errors: vec!["Failed to remove one worktree".to_string()],
1585 };
1586
1587 assert_eq!(summary.worktrees_removed, 2);
1589 assert!(summary.config_deleted);
1590
1591 assert_eq!(summary.worktrees_skipped.len(), 1);
1593
1594 assert_eq!(summary.errors.len(), 1);
1596 }
1597
1598 #[test]
1599 fn test_us004_remove_project_direct_nonexistent_project() {
1600 let result = remove_project_direct("nonexistent-project-12345-xyz");
1602
1603 assert!(result.is_ok());
1605 let summary = result.unwrap();
1606 assert!(!summary.config_deleted);
1607 assert_eq!(summary.worktrees_removed, 0);
1608 assert!(!summary.errors.is_empty());
1610 assert!(
1611 summary.errors[0].contains("does not exist"),
1612 "Error should mention project doesn't exist: {}",
1613 summary.errors[0]
1614 );
1615 }
1616
1617 #[test]
1618 fn test_us004_remove_project_returns_summary_type() {
1619 let result = remove_project_direct("any-project-name");
1621
1622 let _summary: RemovalSummary = match result {
1624 Ok(s) => s,
1625 Err(_) => RemovalSummary::default(),
1626 };
1627 }
1628
1629 #[test]
1630 fn test_us004_removal_summary_tracks_worktree_count() {
1631 let summary = RemovalSummary {
1633 worktrees_removed: 5,
1634 config_deleted: true,
1635 bytes_freed: 5000,
1636 worktrees_skipped: vec![],
1637 errors: vec![],
1638 };
1639
1640 assert_eq!(summary.worktrees_removed, 5);
1641 }
1642
1643 #[test]
1644 fn test_us004_removal_summary_tracks_config_deleted() {
1645 let summary = RemovalSummary {
1647 worktrees_removed: 0,
1648 config_deleted: true,
1649 bytes_freed: 100,
1650 worktrees_skipped: vec![],
1651 errors: vec![],
1652 };
1653
1654 assert!(summary.config_deleted);
1655 }
1656
1657 #[test]
1658 fn test_us004_handle_project_with_no_worktrees() {
1659 let summary = RemovalSummary {
1662 worktrees_removed: 0,
1663 config_deleted: true,
1664 bytes_freed: 50,
1665 worktrees_skipped: vec![],
1666 errors: vec![],
1667 };
1668
1669 assert_eq!(summary.worktrees_removed, 0);
1671 assert!(summary.config_deleted);
1672 }
1673
1674 #[test]
1679 fn test_us006_skipped_session_for_active_run() {
1680 let skipped = SkippedSession {
1682 session_id: "abc123".to_string(),
1683 reason: "Active run in progress".to_string(),
1684 };
1685 assert_eq!(skipped.session_id, "abc123");
1686 assert_eq!(skipped.reason, "Active run in progress");
1687 }
1688
1689 #[test]
1690 fn test_us006_cleanup_summary_with_skipped_active_runs() {
1691 let summary = CleanupSummary {
1693 sessions_removed: 2,
1694 worktrees_removed: 2,
1695 bytes_freed: 1024,
1696 sessions_skipped: vec![
1697 SkippedSession {
1698 session_id: "active1".to_string(),
1699 reason: "Active run in progress".to_string(),
1700 },
1701 SkippedSession {
1702 session_id: "active2".to_string(),
1703 reason: "Active run in progress".to_string(),
1704 },
1705 ],
1706 errors: vec![],
1707 };
1708
1709 assert_eq!(summary.sessions_skipped.len(), 2);
1711 assert!(summary.sessions_skipped[0]
1712 .reason
1713 .contains("Active run in progress"));
1714 assert!(summary.sessions_skipped[1]
1715 .reason
1716 .contains("Active run in progress"));
1717 }
1718
1719 #[test]
1720 fn test_us006_direct_clean_options_default() {
1721 let options = DirectCleanOptions::default();
1723 assert!(!options.worktrees);
1724 assert!(!options.force);
1725 }
1726
1727 #[test]
1728 fn test_us006_direct_clean_with_worktrees_flag() {
1729 let options = DirectCleanOptions {
1731 worktrees: true,
1732 force: false,
1733 };
1734 assert!(options.worktrees);
1735 }
1736
1737 #[test]
1738 fn test_us006_cleanup_summary_reports_what_was_removed() {
1739 let summary = CleanupSummary {
1741 sessions_removed: 3,
1742 worktrees_removed: 2,
1743 bytes_freed: 5_000_000, sessions_skipped: vec![SkippedSession {
1745 session_id: "active".to_string(),
1746 reason: "Active run in progress".to_string(),
1747 }],
1748 errors: vec![],
1749 };
1750
1751 assert_eq!(summary.sessions_removed, 3);
1753 assert_eq!(summary.worktrees_removed, 2);
1754 assert!(summary.bytes_freed > 0);
1755 assert_eq!(summary.sessions_skipped.len(), 1);
1756 assert!(summary.errors.is_empty());
1757 }
1758
1759 #[test]
1760 fn test_us006_format_bytes_for_summary() {
1761 assert_eq!(format_bytes_display(0), "0 B");
1763 assert_eq!(format_bytes_display(500), "500 B");
1764 assert_eq!(format_bytes_display(1024), "1.0 KB");
1765 assert_eq!(format_bytes_display(1_048_576), "1.0 MB");
1766 assert_eq!(format_bytes_display(5_242_880), "5.0 MB");
1767 }
1768
1769 #[test]
1774 fn test_us005_data_cleanup_summary_default() {
1775 let summary = DataCleanupSummary::default();
1777 assert_eq!(summary.specs_removed, 0);
1778 assert_eq!(summary.runs_removed, 0);
1779 assert_eq!(summary.bytes_freed, 0);
1780 assert!(summary.errors.is_empty());
1781 }
1782
1783 #[test]
1784 fn test_us005_data_cleanup_summary_with_specs() {
1785 let summary = DataCleanupSummary {
1787 specs_removed: 3,
1788 runs_removed: 0,
1789 bytes_freed: 1500,
1790 errors: vec![],
1791 };
1792 assert_eq!(summary.specs_removed, 3);
1793 assert_eq!(summary.bytes_freed, 1500);
1794 }
1795
1796 #[test]
1797 fn test_us005_data_cleanup_summary_with_runs() {
1798 let summary = DataCleanupSummary {
1800 specs_removed: 0,
1801 runs_removed: 5,
1802 bytes_freed: 5000,
1803 errors: vec![],
1804 };
1805 assert_eq!(summary.runs_removed, 5);
1806 assert_eq!(summary.bytes_freed, 5000);
1807 }
1808
1809 #[test]
1810 fn test_us005_data_cleanup_summary_with_both() {
1811 let summary = DataCleanupSummary {
1813 specs_removed: 2,
1814 runs_removed: 4,
1815 bytes_freed: 6000,
1816 errors: vec![],
1817 };
1818 assert_eq!(summary.specs_removed, 2);
1819 assert_eq!(summary.runs_removed, 4);
1820 assert_eq!(summary.bytes_freed, 6000);
1821 }
1822
1823 #[test]
1824 fn test_us005_data_cleanup_summary_with_errors() {
1825 let summary = DataCleanupSummary {
1827 specs_removed: 1,
1828 runs_removed: 2,
1829 bytes_freed: 3000,
1830 errors: vec![
1831 "Failed to remove spec1.json: permission denied".to_string(),
1832 "Failed to remove run1.json: file busy".to_string(),
1833 ],
1834 };
1835 assert_eq!(summary.specs_removed, 1);
1836 assert_eq!(summary.runs_removed, 2);
1837 assert_eq!(summary.errors.len(), 2);
1838 assert!(summary.errors[0].contains("permission denied"));
1839 assert!(summary.errors[1].contains("file busy"));
1840 }
1841
1842 #[test]
1843 fn test_us005_data_cleanup_partial_success() {
1844 let summary = DataCleanupSummary {
1846 specs_removed: 3, runs_removed: 8, bytes_freed: 11000,
1849 errors: vec![
1850 "Failed to remove spec-active1.json".to_string(),
1851 "Failed to remove spec-active2.json".to_string(),
1852 "Failed to remove run-archived1.json".to_string(),
1853 "Failed to remove run-archived2.json".to_string(),
1854 ],
1855 };
1856
1857 assert_eq!(summary.specs_removed, 3);
1859 assert_eq!(summary.runs_removed, 8);
1860 assert_eq!(summary.errors.len(), 4);
1861 }
1862
1863 #[test]
1864 fn test_us005_clean_data_direct_nonexistent_project() {
1865 let result = clean_data_direct("nonexistent-project-us005-test");
1867
1868 assert!(result.is_err() || result.as_ref().unwrap().specs_removed == 0);
1870 }
1871
1872 #[test]
1873 fn test_us005_clean_data_with_temp_dir() {
1874 let temp_dir = TempDir::new().unwrap();
1876 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
1877
1878 let spec_dir = sm.spec_dir();
1880 let runs_dir = sm.runs_dir();
1881 fs::create_dir_all(&spec_dir).unwrap();
1882 fs::create_dir_all(&runs_dir).unwrap();
1883
1884 fs::write(spec_dir.join("spec-feature1.json"), "{}").unwrap();
1886 fs::write(spec_dir.join("spec-feature1.md"), "# Feature 1").unwrap();
1887 fs::write(spec_dir.join("spec-feature2.json"), "{}").unwrap();
1888
1889 fs::write(runs_dir.join("run-2024-01-01.json"), "{}").unwrap();
1891 fs::write(runs_dir.join("run-2024-01-02.json"), "{}").unwrap();
1892
1893 assert!(spec_dir.join("spec-feature1.json").exists());
1895 assert!(spec_dir.join("spec-feature1.md").exists());
1896 assert!(spec_dir.join("spec-feature2.json").exists());
1897 assert!(runs_dir.join("run-2024-01-01.json").exists());
1898 assert!(runs_dir.join("run-2024-01-02.json").exists());
1899 }
1900
1901 #[test]
1902 fn test_us005_spec_pairs_deleted_together() {
1903 let temp_dir = TempDir::new().unwrap();
1905
1906 let spec_dir = temp_dir.path().join("spec");
1908 fs::create_dir_all(&spec_dir).unwrap();
1909
1910 let json_path = spec_dir.join("spec-test.json");
1912 let md_path = spec_dir.join("spec-test.md");
1913 fs::write(&json_path, r#"{"name": "test"}"#).unwrap();
1914 fs::write(&md_path, "# Test Spec\nDescription").unwrap();
1915
1916 assert!(json_path.exists());
1918 assert!(md_path.exists());
1919
1920 let json_deleted = fs::remove_file(&json_path).is_ok();
1923 let md_deleted = fs::remove_file(&md_path).is_ok();
1924
1925 assert!(json_deleted);
1927 assert!(md_deleted);
1928 assert!(!json_path.exists());
1929 assert!(!md_path.exists());
1930 }
1931
1932 #[test]
1933 fn test_us005_orphaned_md_still_deleted() {
1934 let temp_dir = TempDir::new().unwrap();
1940
1941 let spec_dir = temp_dir.path().join("spec");
1943 fs::create_dir_all(&spec_dir).unwrap();
1944
1945 let orphan_md = spec_dir.join("orphan.md");
1947 fs::write(&orphan_md, "# Orphaned markdown").unwrap();
1948
1949 assert!(orphan_md.exists());
1950
1951 }
1954
1955 #[test]
1956 fn test_us005_errors_collected_for_all_failures() {
1957 let summary = DataCleanupSummary {
1959 specs_removed: 2,
1960 runs_removed: 3,
1961 bytes_freed: 5000,
1962 errors: vec![
1963 "Failed to remove spec-locked.json: file is locked".to_string(),
1964 "Failed to remove spec-locked.md: file is locked".to_string(),
1965 "Failed to remove run-locked.json: permission denied".to_string(),
1966 ],
1967 };
1968
1969 assert_eq!(summary.errors.len(), 3);
1971
1972 assert!(summary.specs_removed > 0);
1974 assert!(summary.runs_removed > 0);
1975 }
1976
1977 #[test]
1978 fn test_us005_bytes_freed_calculated_correctly() {
1979 let temp_dir = TempDir::new().unwrap();
1981
1982 let file1 = temp_dir.path().join("file1.txt");
1984 let file2 = temp_dir.path().join("file2.txt");
1985 fs::write(&file1, "hello").unwrap(); fs::write(&file2, "world!").unwrap(); let size1 = fs::metadata(&file1).unwrap().len();
1989 let size2 = fs::metadata(&file2).unwrap().len();
1990
1991 let total_freed = size1 + size2;
1993 assert_eq!(total_freed, 11); fs::remove_file(file1).unwrap();
1997 fs::remove_file(file2).unwrap();
1998 }
1999
2000 #[test]
2005 fn test_us007_active_session_specs_not_counted_as_cleanable() {
2006 let temp_dir = TempDir::new().unwrap();
2011 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
2012
2013 let spec_dir = sm.spec_dir();
2015 fs::create_dir_all(&spec_dir).unwrap();
2016
2017 let active_spec = spec_dir.join("spec-active-feature.json");
2019 let cleanable_spec = spec_dir.join("spec-cleanable-feature.json");
2020 fs::write(&active_spec, r#"{"name": "active"}"#).unwrap();
2021 fs::write(&cleanable_spec, r#"{"name": "cleanable"}"#).unwrap();
2022
2023 assert!(active_spec.exists());
2025 assert!(cleanable_spec.exists());
2026
2027 let mut active_spec_paths = std::collections::HashSet::new();
2029 active_spec_paths.insert(active_spec.clone());
2030
2031 assert!(
2033 active_spec_paths.contains(&active_spec),
2034 "Active spec should be in the exclusion set"
2035 );
2036 assert!(
2037 !active_spec_paths.contains(&cleanable_spec),
2038 "Cleanable spec should NOT be in the exclusion set"
2039 );
2040
2041 let json_files: Vec<PathBuf> = fs::read_dir(&spec_dir)
2043 .unwrap()
2044 .filter_map(|e| e.ok())
2045 .map(|e| e.path())
2046 .filter(|p| p.extension().map(|e| e == "json").unwrap_or(false))
2047 .filter(|p| !active_spec_paths.contains(p))
2048 .collect();
2049
2050 assert_eq!(json_files.len(), 1, "Only 1 spec should be cleanable");
2051 assert_eq!(
2052 json_files[0], cleanable_spec,
2053 "The cleanable spec should be the non-active one"
2054 );
2055 }
2056
2057 #[test]
2058 fn test_us007_active_session_md_path_also_excluded() {
2059 let temp_dir = TempDir::new().unwrap();
2064 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
2065
2066 let spec_dir = sm.spec_dir();
2067 fs::create_dir_all(&spec_dir).unwrap();
2068
2069 let json_path = spec_dir.join("spec-feature.json");
2071 let md_path = spec_dir.join("spec-feature.md");
2072 fs::write(&json_path, "{}").unwrap();
2073 fs::write(&md_path, "# Feature").unwrap();
2074
2075 let mut active_spec_paths = std::collections::HashSet::new();
2077 active_spec_paths.insert(json_path.clone());
2078 active_spec_paths.insert(md_path.clone());
2079
2080 assert!(active_spec_paths.contains(&json_path));
2082 assert!(active_spec_paths.contains(&md_path));
2083
2084 let cleanable_json_files: Vec<PathBuf> = fs::read_dir(&spec_dir)
2086 .unwrap()
2087 .filter_map(|e| e.ok())
2088 .map(|e| e.path())
2089 .filter(|p| p.extension().map(|e| e == "json").unwrap_or(false))
2090 .filter(|p| !active_spec_paths.contains(p))
2091 .collect();
2092
2093 assert_eq!(
2094 cleanable_json_files.len(),
2095 0,
2096 "No specs should be cleanable when active"
2097 );
2098 }
2099
2100 #[test]
2101 fn test_us007_runs_are_always_cleanable() {
2102 let temp_dir = TempDir::new().unwrap();
2107 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
2108
2109 let runs_dir = sm.runs_dir();
2110 fs::create_dir_all(&runs_dir).unwrap();
2111
2112 fs::write(runs_dir.join("run-2024-01-01.json"), "{}").unwrap();
2114 fs::write(runs_dir.join("run-2024-01-02.json"), "{}").unwrap();
2115 fs::write(runs_dir.join("run-2024-01-03.json"), "{}").unwrap();
2116
2117 let cleanable_runs = fs::read_dir(&runs_dir)
2119 .map(|entries| entries.filter_map(|e| e.ok()).count())
2120 .unwrap_or(0);
2121
2122 assert_eq!(cleanable_runs, 3, "All 3 runs should be cleanable");
2123 }
2124
2125 #[test]
2126 fn test_us007_spec_pairs_counted_as_one() {
2127 let temp_dir = TempDir::new().unwrap();
2131 let spec_dir = temp_dir.path();
2132
2133 fs::write(spec_dir.join("spec-feature1.json"), "{}").unwrap();
2135 fs::write(spec_dir.join("spec-feature1.md"), "# Feature 1").unwrap();
2136 fs::write(spec_dir.join("spec-feature2.json"), "{}").unwrap();
2137 fs::write(spec_dir.join("spec-feature2.md"), "# Feature 2").unwrap();
2138
2139 fs::write(spec_dir.join("spec-feature3.json"), "{}").unwrap();
2141
2142 let spec_count = fs::read_dir(spec_dir)
2144 .unwrap()
2145 .filter_map(|e| e.ok())
2146 .filter(|e| {
2147 e.path()
2148 .extension()
2149 .map(|ext| ext == "json")
2150 .unwrap_or(false)
2151 })
2152 .count();
2153
2154 assert_eq!(spec_count, 3, "Should count 3 specs (pairs counted as 1)");
2156 }
2157
2158 #[test]
2159 fn test_us007_orphaned_md_files_not_deleted() {
2160 let temp_dir = TempDir::new().unwrap();
2165 let spec_dir = temp_dir.path().join("spec");
2166 fs::create_dir_all(&spec_dir).unwrap();
2167
2168 let orphan_md = spec_dir.join("orphan-notes.md");
2170 fs::write(&orphan_md, "# Some notes").unwrap();
2171
2172 let spec_json = spec_dir.join("spec-feature.json");
2174 let spec_md = spec_dir.join("spec-feature.md");
2175 fs::write(&spec_json, "{}").unwrap();
2176 fs::write(&spec_md, "# Feature").unwrap();
2177
2178 let json_specs: Vec<PathBuf> = fs::read_dir(&spec_dir)
2181 .unwrap()
2182 .filter_map(|e| e.ok())
2183 .map(|e| e.path())
2184 .filter(|p| p.extension().map(|e| e == "json").unwrap_or(false))
2185 .collect();
2186
2187 assert_eq!(json_specs.len(), 1);
2189 assert_eq!(json_specs[0], spec_json);
2190
2191 assert!(
2194 !json_specs.contains(&orphan_md),
2195 "Orphan .md should not be in the cleanup list"
2196 );
2197 }
2198
2199 #[test]
2200 fn test_us007_orphaned_md_not_counted_as_spec() {
2201 let temp_dir = TempDir::new().unwrap();
2206 let spec_dir = temp_dir.path();
2207
2208 fs::write(spec_dir.join("spec-feature.json"), "{}").unwrap();
2210 fs::write(spec_dir.join("spec-feature.md"), "# Feature").unwrap();
2211 fs::write(spec_dir.join("orphan1.md"), "# Orphan 1").unwrap();
2212 fs::write(spec_dir.join("orphan2.md"), "# Orphan 2").unwrap();
2213
2214 let total_files = fs::read_dir(spec_dir)
2216 .unwrap()
2217 .filter_map(|e| e.ok())
2218 .count();
2219
2220 let spec_count = fs::read_dir(spec_dir)
2222 .unwrap()
2223 .filter_map(|e| e.ok())
2224 .filter(|e| {
2225 e.path()
2226 .extension()
2227 .map(|ext| ext == "json")
2228 .unwrap_or(false)
2229 })
2230 .count();
2231
2232 assert_eq!(total_files, 4, "Total files should be 4");
2233 assert_eq!(
2234 spec_count, 1,
2235 "Spec count should be 1 (orphaned .md not counted)"
2236 );
2237 }
2238
2239 #[test]
2240 fn test_us007_data_cleanup_summary_combined() {
2241 let summary = DataCleanupSummary {
2243 specs_removed: 3,
2244 runs_removed: 5,
2245 bytes_freed: 8000,
2246 errors: vec![],
2247 };
2248
2249 assert_eq!(summary.specs_removed, 3);
2250 assert_eq!(summary.runs_removed, 5);
2251 assert_eq!(summary.bytes_freed, 8000);
2252
2253 let total_items = summary.specs_removed + summary.runs_removed;
2255 assert_eq!(total_items, 8, "Total items cleaned should be 8");
2256 }
2257
2258 #[test]
2259 fn test_us007_clean_data_excludes_active_session_spec_integration() {
2260 let temp_dir = TempDir::new().unwrap();
2266 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
2267
2268 let spec_dir = sm.spec_dir();
2270 fs::create_dir_all(&spec_dir).unwrap();
2271
2272 let active_spec_json = spec_dir.join("spec-active.json");
2273 let active_spec_md = spec_dir.join("spec-active.md");
2274 let inactive_spec_json = spec_dir.join("spec-inactive.json");
2275 let inactive_spec_md = spec_dir.join("spec-inactive.md");
2276
2277 fs::write(&active_spec_json, "{}").unwrap();
2278 fs::write(&active_spec_md, "# Active").unwrap();
2279 fs::write(&inactive_spec_json, "{}").unwrap();
2280 fs::write(&inactive_spec_md, "# Inactive").unwrap();
2281
2282 let mut active_spec_paths: std::collections::HashSet<PathBuf> =
2284 std::collections::HashSet::new();
2285 active_spec_paths.insert(active_spec_json.clone());
2286 active_spec_paths.insert(active_spec_md.clone());
2287
2288 let json_specs: Vec<PathBuf> = fs::read_dir(&spec_dir)
2290 .unwrap()
2291 .filter_map(|e| e.ok())
2292 .map(|e| e.path())
2293 .filter(|p| p.extension().map(|e| e == "json").unwrap_or(false))
2294 .collect();
2295
2296 let mut specs_removed = 0;
2297 for json_path in json_specs {
2298 if active_spec_paths.contains(&json_path) {
2300 continue;
2301 }
2302
2303 specs_removed += 1;
2305
2306 assert_eq!(json_path, inactive_spec_json);
2308 }
2309
2310 assert_eq!(specs_removed, 1, "Only 1 spec should be removed");
2311 }
2312
2313 #[test]
2314 fn test_us007_multiple_active_sessions_all_excluded() {
2315 let temp_dir = TempDir::new().unwrap();
2317 let spec_dir = temp_dir.path();
2318
2319 let spec1 = spec_dir.join("spec-session1.json");
2321 let spec2 = spec_dir.join("spec-session2.json");
2322 let spec3 = spec_dir.join("spec-session3.json");
2323 fs::write(&spec1, "{}").unwrap();
2324 fs::write(&spec2, "{}").unwrap();
2325 fs::write(&spec3, "{}").unwrap();
2326
2327 let mut active_spec_paths: std::collections::HashSet<PathBuf> =
2329 std::collections::HashSet::new();
2330 active_spec_paths.insert(spec1.clone());
2331 active_spec_paths.insert(spec2.clone());
2332
2333 let cleanable: Vec<PathBuf> = fs::read_dir(spec_dir)
2335 .unwrap()
2336 .filter_map(|e| e.ok())
2337 .map(|e| e.path())
2338 .filter(|p| p.extension().map(|e| e == "json").unwrap_or(false))
2339 .filter(|p| !active_spec_paths.contains(p))
2340 .collect();
2341
2342 assert_eq!(cleanable.len(), 1);
2343 assert_eq!(cleanable[0], spec3);
2344 }
2345
2346 #[test]
2347 fn test_us007_empty_active_sessions_all_specs_cleanable() {
2348 let temp_dir = TempDir::new().unwrap();
2350 let spec_dir = temp_dir.path();
2351
2352 fs::write(spec_dir.join("spec1.json"), "{}").unwrap();
2354 fs::write(spec_dir.join("spec2.json"), "{}").unwrap();
2355 fs::write(spec_dir.join("spec3.json"), "{}").unwrap();
2356
2357 let active_spec_paths: std::collections::HashSet<PathBuf> =
2359 std::collections::HashSet::new();
2360
2361 let cleanable: Vec<PathBuf> = fs::read_dir(spec_dir)
2362 .unwrap()
2363 .filter_map(|e| e.ok())
2364 .map(|e| e.path())
2365 .filter(|p| p.extension().map(|e| e == "json").unwrap_or(false))
2366 .filter(|p| !active_spec_paths.contains(p))
2367 .collect();
2368
2369 assert_eq!(cleanable.len(), 3, "All 3 specs should be cleanable");
2370 }
2371
2372 #[test]
2373 fn test_us007_existing_tests_still_pass() {
2374 let summary = CleanupSummary::default();
2380 assert_eq!(summary.sessions_removed, 0);
2381 assert!(summary.errors.is_empty());
2382
2383 let data_summary = DataCleanupSummary::default();
2385 assert_eq!(data_summary.specs_removed, 0);
2386 assert_eq!(data_summary.runs_removed, 0);
2387
2388 assert_eq!(format_bytes(1024), "1.0 KB");
2390 assert_eq!(format_bytes(1048576), "1.0 MB");
2391 }
2392}