Skip to main content

autom8/commands/
clean.rs

1//! Clean command handler.
2//!
3//! Provides mechanisms to clean up completed sessions and orphaned worktrees.
4//! This command helps users manage disk space and keep their project clean.
5
6use 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/// Summary of cleanup operations performed.
18#[derive(Debug, Default)]
19pub struct CleanupSummary {
20    /// Number of sessions removed
21    pub sessions_removed: usize,
22    /// Number of worktrees removed
23    pub worktrees_removed: usize,
24    /// Total bytes freed (estimated)
25    pub bytes_freed: u64,
26    /// Sessions that were skipped (e.g., uncommitted changes without --force)
27    pub sessions_skipped: Vec<SkippedSession>,
28    /// Errors encountered during cleanup
29    pub errors: Vec<String>,
30}
31
32/// Information about a session that was skipped during cleanup.
33#[derive(Debug)]
34pub struct SkippedSession {
35    pub session_id: String,
36    pub reason: String,
37}
38
39/// Options for the clean command.
40#[derive(Debug, Default)]
41pub struct CleanOptions {
42    /// Also remove associated worktrees
43    pub worktrees: bool,
44    /// Remove all sessions (requires confirmation)
45    pub all: bool,
46    /// Remove a specific session by ID
47    pub session: Option<String>,
48    /// Only remove orphaned sessions (worktree deleted but session state remains)
49    pub orphaned: bool,
50    /// Force removal even if worktrees have uncommitted changes
51    pub force: bool,
52    /// Target project name (if not specified, uses current directory)
53    pub project: Option<String>,
54}
55
56impl CleanupSummary {
57    /// Print the cleanup summary.
58    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
104/// Format bytes into human-readable string.
105fn 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
121/// Calculate the size of a directory recursively.
122fn 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
141/// Check if a worktree has uncommitted changes.
142///
143/// Returns true if there are uncommitted changes (working directory is dirty).
144/// Returns false if the worktree is clean or doesn't exist.
145pub fn worktree_has_uncommitted_changes(worktree_path: &Path) -> bool {
146    if !worktree_path.exists() {
147        return false;
148    }
149
150    // Run git status in the worktree directory
151    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            // If there's any output, there are uncommitted changes
163            !output.stdout.is_empty()
164        }
165        Err(_) => {
166            // If we can't run git, assume it's safe to remove (not a git directory)
167            false
168        }
169    }
170}
171
172/// Get the appropriate StateManager based on options.
173///
174/// If `--project` is specified, creates a StateManager for that project.
175/// Otherwise, creates a StateManager for the current directory.
176fn 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
184/// Clean up sessions based on the provided options.
185///
186/// This is the main entry point for the clean command.
187pub fn clean_command(options: CleanOptions) -> Result<()> {
188    // If --project is specified, use that; otherwise use current directory
189    if options.project.is_none() {
190        ensure_project_dir()?;
191    }
192
193    // Dispatch to the appropriate cleanup function based on options
194    if let Some(session_id) = &options.session {
195        // Clean a specific session
196        clean_specific_session(session_id, &options)
197    } else if options.orphaned {
198        // Clean only orphaned sessions
199        clean_orphaned_sessions(&options)
200    } else if options.all {
201        // Clean all sessions (with confirmation)
202        clean_all_sessions(&options)
203    } else {
204        // Default: clean completed/failed sessions
205        clean_completed_sessions(&options)
206    }
207}
208
209/// Clean a specific session by ID.
210fn 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    // Find the session
215    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            // Check if this is the current session
229            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            // Check for uncommitted changes if worktree exists
245            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            // Confirm deletion
260            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            // Archive before deletion
272            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            // Remove worktree if requested
281            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            // Remove session state
295            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
313/// Clean only orphaned sessions (worktree deleted but session state remains).
314fn clean_orphaned_sessions(options: &CleanOptions) -> Result<()> {
315    let state_manager = get_state_manager(options)?;
316    let sessions = state_manager.list_sessions()?;
317
318    // Find orphaned sessions
319    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        // Archive before deletion
356        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
377/// Clean all sessions (with confirmation).
378fn 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    // Warning about uncommitted changes
428    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        // Skip current session unless --force
478        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        // Check for uncommitted changes
487        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        // Archive before deletion
500        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            // Remove worktree if requested
506            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            // Remove session state
520            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
536/// Clean completed/failed sessions (default behavior).
537fn clean_completed_sessions(options: &CleanOptions) -> Result<()> {
538    let state_manager = get_state_manager(options)?;
539    let sessions = state_manager.list_sessions()?;
540
541    // Find completed or failed sessions
542    let cleanable: Vec<_> = sessions
543        .iter()
544        .filter(|s| {
545            // Load the state to check if completed or failed
546            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                    // No state file - consider it cleanable
557                    true
558                }
559            } else {
560                false
561            }
562        })
563        .collect();
564
565    // Also include orphaned sessions
566    let orphaned: Vec<_> = sessions
567        .iter()
568        .filter(|s| !s.worktree_path.exists())
569        .collect();
570
571    // Combine cleanable and orphaned (dedupe)
572    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        // Get status
597        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        // Skip current session unless --force
649        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        // Check for uncommitted changes
658        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        // Archive before deletion
671        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            // Remove worktree if requested
677            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            // Remove session state
691            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// =============================================================================
708// Direct Clean Functions (for GUI - no prompts, no printing)
709// =============================================================================
710
711/// Options for direct clean operations (no prompts, no output).
712#[derive(Debug, Default, Clone)]
713pub struct DirectCleanOptions {
714    /// Also remove associated worktrees
715    pub worktrees: bool,
716    /// Force removal even if worktrees have uncommitted changes
717    pub force: bool,
718}
719
720/// Clean worktrees directly (no prompts, no output).
721///
722/// US-006: Updated to clean any worktree (not just completed/failed sessions),
723/// while skipping worktrees with active runs.
724///
725/// This function is designed for programmatic use (e.g., GUI) where the caller
726/// handles confirmation and output display.
727///
728/// Returns a `CleanupSummary` with results of the cleanup operation.
729pub 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    // US-006: Clean any worktree-based session (non-main), skipping active runs
737    // This makes the Clean menu more useful by enabling it whenever there are worktrees
738    let to_clean: Vec<_> = sessions
739        .iter()
740        .filter(|s| {
741            // Skip main session - it's not a worktree created by autom8
742            if s.session_id == "main" {
743                return false;
744            }
745            // Include sessions with existing worktrees or orphaned sessions
746            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        // US-006: Skip sessions with active runs (same as Remove Project)
760        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        // Skip current session unless --force
774        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        // Check for uncommitted changes
783        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        // Archive before deletion
796        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            // Remove worktree if requested
802            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            // Remove session state
816            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
831/// Clean orphaned sessions directly (no prompts, no output).
832///
833/// Orphaned sessions are those where the worktree has been deleted but the
834/// session state remains.
835///
836/// This function is designed for programmatic use (e.g., GUI) where the caller
837/// handles confirmation and output display.
838///
839/// Returns a `CleanupSummary` with results of the cleanup operation.
840pub 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    // Find orphaned sessions
845    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        // Archive before deletion
858        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
878/// Format bytes into human-readable string (public version for GUI).
879pub fn format_bytes_display(bytes: u64) -> String {
880    format_bytes(bytes)
881}
882
883// =============================================================================
884// Remove Project Functions (for GUI - no prompts, no printing)
885// =============================================================================
886
887/// Summary of a project removal operation.
888#[derive(Debug, Default)]
889pub struct RemovalSummary {
890    /// Number of worktrees removed
891    pub worktrees_removed: usize,
892    /// Whether the config directory was deleted
893    pub config_deleted: bool,
894    /// Total bytes freed (estimated)
895    pub bytes_freed: u64,
896    /// Worktrees that were skipped (e.g., active runs)
897    pub worktrees_skipped: Vec<SkippedWorktree>,
898    /// Errors encountered during removal
899    pub errors: Vec<String>,
900}
901
902/// Information about a worktree that was skipped during removal.
903#[derive(Debug)]
904pub struct SkippedWorktree {
905    pub path: PathBuf,
906    pub reason: String,
907}
908
909/// Remove a project from autom8 entirely (no prompts, no output).
910///
911/// This function:
912/// 1. Removes all git worktrees associated with the project (skips active runs)
913/// 2. Deletes the `~/.config/autom8/<project>/` directory
914///
915/// Designed for programmatic use (e.g., GUI) where the caller handles confirmation
916/// and output display.
917///
918/// # Arguments
919/// * `project_name` - The name of the project to remove
920///
921/// # Returns
922/// A `RemovalSummary` with details of what was removed and any errors encountered.
923pub 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    // Get the project config directory path
929    let project_dir = project_config_dir_for(project_name)?;
930
931    // Check if project exists
932    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    // Step 1: Remove worktrees associated with the project
942    // We need to get all sessions and remove their worktrees
943    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                // Skip sessions with active runs
947                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                // Skip if worktree doesn't exist (orphaned session)
956                if !session.worktree_path.exists() {
957                    continue;
958                }
959
960                // Skip the main session - it's not a worktree we created
961                if session.session_id == "main" {
962                    continue;
963                }
964
965                // Calculate size before removal
966                let worktree_size = dir_size(&session.worktree_path);
967
968                // Try to remove the worktree
969                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    // Step 2: Delete the config directory
987    // Calculate size before deletion
988    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
1007/// Remove a worktree safely, with optional force flag.
1008///
1009/// This function:
1010/// 1. Changes to the main repo if we're inside the worktree
1011/// 2. Uses git worktree remove (with force if specified)
1012fn remove_worktree_safely(worktree_path: &Path, force: bool) -> Result<()> {
1013    // Check if we're currently inside this worktree
1014    let current_dir = std::env::current_dir().ok();
1015    if current_dir.as_ref() == Some(&worktree_path.to_path_buf()) {
1016        // We need to change to the main repo first
1017        if let Ok(main_repo) = worktree::get_main_repo_root() {
1018            std::env::set_current_dir(&main_repo)?;
1019        }
1020    }
1021
1022    // Try using git worktree remove first
1023    worktree::remove_worktree(worktree_path, force)
1024}
1025
1026/// Get the size of session state files.
1027fn get_session_size(_session_sm: &StateManager) -> u64 {
1028    // Get the base dir and calculate session dir size
1029    // This is a simplified version - we could expose session_dir() if needed
1030    0 // Session state files are typically very small
1031}
1032
1033// =============================================================================
1034// Clean Data Functions (US-003: Clean specs and archived runs)
1035// =============================================================================
1036
1037/// Summary of a data cleanup operation (specs and archived runs).
1038#[derive(Debug, Default)]
1039pub struct DataCleanupSummary {
1040    /// Number of spec files removed (pairs counted as 1)
1041    pub specs_removed: usize,
1042    /// Number of archived runs removed
1043    pub runs_removed: usize,
1044    /// Total bytes freed (estimated)
1045    pub bytes_freed: u64,
1046    /// Errors encountered during cleanup
1047    pub errors: Vec<String>,
1048}
1049
1050/// Clean data (specs and archived runs) directly (no prompts, no output).
1051///
1052/// US-003: This function removes spec files and archived runs from the project
1053/// configuration directory, excluding any specs that are currently in use by
1054/// active sessions.
1055///
1056/// Designed for programmatic use (e.g., GUI) where the caller handles confirmation
1057/// and output display.
1058///
1059/// # Arguments
1060/// * `project_name` - The name of the project to clean data for
1061///
1062/// # Returns
1063/// A `DataCleanupSummary` with details of what was removed and any errors encountered.
1064pub 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    // Get the spec and runs directories
1069    let spec_dir = state_manager.spec_dir();
1070    let runs_dir = state_manager.runs_dir();
1071
1072    // Get active spec paths from running sessions to exclude them
1073    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                        // spec_json_path is always present (PathBuf, not Option)
1080                        active_spec_paths.insert(state.spec_json_path.clone());
1081                        // spec_md_path is optional
1082                        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    // Clean spec files (pairs of .json/.md counted as 1)
1092    if spec_dir.exists() {
1093        if let Ok(entries) = fs::read_dir(&spec_dir) {
1094            // Collect all .json spec files (canonical for counting pairs)
1095            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            // Remove each spec pair
1104            for json_path in json_specs {
1105                // Skip if this spec is in use by an active session
1106                if active_spec_paths.contains(&json_path) {
1107                    continue;
1108                }
1109
1110                // Calculate the .md companion path
1111                let md_path = json_path.with_extension("md");
1112
1113                // Calculate sizes before removal
1114                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                // Remove the files
1127                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    // Clean archived runs
1158    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                    // Calculate size before removal
1164                    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// =============================================================================
1183// Tests
1184// =============================================================================
1185
1186#[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        // Non-existent path should return false (safe to remove)
1228        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); // "hello world" is 11 bytes
1254    }
1255
1256    #[test]
1257    fn test_dir_size_with_nested_dirs() {
1258        let temp_dir = TempDir::new().unwrap();
1259
1260        // Create nested structure
1261        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); // 5 + 5 bytes
1268    }
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    // =========================================================================
1281    // US-011 Specific Tests
1282    // =========================================================================
1283
1284    #[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, // 1 MB
1335            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        // Create a temp directory that's NOT a git repo
1352        let temp_dir = TempDir::new().unwrap();
1353        fs::write(temp_dir.path().join("test.txt"), "content").unwrap();
1354
1355        // Should return false since it's not a git repo
1356        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        // Verify the archive pattern works with StateManager
1363        let temp_dir = TempDir::new().unwrap();
1364        let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
1365
1366        // Create a test state
1367        let state = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
1368        sm.save(&state).unwrap();
1369
1370        // Archive the state
1371        let archive_path = sm.archive(&state).unwrap();
1372        assert!(archive_path.exists());
1373
1374        // Clear the current state
1375        sm.clear_current().unwrap();
1376
1377        // Verify state is cleared but archive remains
1378        assert!(sm.load_current().unwrap().is_none());
1379        assert!(archive_path.exists());
1380    }
1381
1382    #[test]
1383    fn test_us011_detect_orphaned_session() {
1384        // Create a session with a non-existent worktree path
1385        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        // Check if the worktree exists (should return false)
1396        assert!(!metadata.worktree_path.exists());
1397    }
1398
1399    #[test]
1400    fn test_us011_completed_session_is_cleanable() {
1401        // A completed session should be cleanable
1402        let state = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
1403        // Note: We can't easily test the full machine_state check without more setup,
1404        // but we verify the pattern
1405        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    // =========================================================================
1418    // US-004 Tests: Direct Clean Functions (for GUI)
1419    // =========================================================================
1420
1421    #[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        // Test format_bytes_display is accessible
1451        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        // Test that clean_worktrees_direct returns a CleanupSummary
1460        let temp_dir = TempDir::new().unwrap();
1461        let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
1462
1463        // Create a completed state to clean
1464        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        // Call the direct clean function (this won't find the project by name,
1469        // so we test the function signature and basic behavior)
1470        let options = DirectCleanOptions {
1471            worktrees: true,
1472            force: false,
1473        };
1474        // Note: clean_worktrees_direct expects a project name, not a path
1475        // It will fail to find the project but we verify the return type
1476        let result = clean_worktrees_direct("nonexistent-project-12345", options);
1477
1478        // Should return error for non-existent project (StateManager::for_project fails)
1479        assert!(result.is_err() || result.unwrap().sessions_removed == 0);
1480    }
1481
1482    #[test]
1483    fn test_us004_clean_orphaned_direct_with_temp_project() {
1484        // Test that clean_orphaned_direct returns a CleanupSummary
1485        // Note: clean_orphaned_direct expects a project name, not a path
1486        // It will fail to find the project but we verify the return type
1487        let result = clean_orphaned_direct("nonexistent-project-12345");
1488
1489        // Should return error for non-existent project (StateManager::for_project fails)
1490        assert!(result.is_err() || result.unwrap().sessions_removed == 0);
1491    }
1492
1493    // =========================================================================
1494    // US-004 Tests: Remove Project Backend Logic
1495    // =========================================================================
1496
1497    #[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, // 1 MB
1513            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        // Acceptance criteria: Skip active runs
1538        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        // Acceptance criteria: Handle errors gracefully
1559        let summary = RemovalSummary {
1560            worktrees_removed: 1,
1561            config_deleted: false, // Failed to delete config
1562            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        // Acceptance criteria: Partial cleanup should report what succeeded/failed
1576        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        // Can track what succeeded
1588        assert_eq!(summary.worktrees_removed, 2);
1589        assert!(summary.config_deleted);
1590
1591        // Can track what was skipped
1592        assert_eq!(summary.worktrees_skipped.len(), 1);
1593
1594        // Can track errors
1595        assert_eq!(summary.errors.len(), 1);
1596    }
1597
1598    #[test]
1599    fn test_us004_remove_project_direct_nonexistent_project() {
1600        // Test that remove_project_direct handles non-existent project gracefully
1601        let result = remove_project_direct("nonexistent-project-12345-xyz");
1602
1603        // Should return Ok with error in summary (project doesn't exist)
1604        assert!(result.is_ok());
1605        let summary = result.unwrap();
1606        assert!(!summary.config_deleted);
1607        assert_eq!(summary.worktrees_removed, 0);
1608        // Should have an error explaining the project doesn't exist
1609        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        // Verify the function returns the correct type
1620        let result = remove_project_direct("any-project-name");
1621
1622        // Type check: should be Result<RemovalSummary>
1623        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        // Acceptance criteria: Return a summary of what was removed (worktree count)
1632        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        // Acceptance criteria: Return a summary of what was removed (config deleted)
1646        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        // Acceptance criteria: Handle case where project has no worktrees (still delete config)
1660        // A project can have only config and no worktrees
1661        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        // No worktrees removed, but config was deleted
1670        assert_eq!(summary.worktrees_removed, 0);
1671        assert!(summary.config_deleted);
1672    }
1673
1674    // =========================================================================
1675    // US-006 Tests: Clean Worktrees Skips Active Runs
1676    // =========================================================================
1677
1678    #[test]
1679    fn test_us006_skipped_session_for_active_run() {
1680        // US-006: Active runs should be reported as skipped
1681        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        // US-006: Summary should report sessions skipped due to active runs
1692        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        // Verify skipped sessions are tracked
1710        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        // US-006: Verify DirectCleanOptions defaults
1722        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        // US-006: Clean operation should remove worktrees when flag is set
1730        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        // US-006: "After cleaning, show summary of what was removed"
1740        let summary = CleanupSummary {
1741            sessions_removed: 3,
1742            worktrees_removed: 2,
1743            bytes_freed: 5_000_000, // 5 MB
1744            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        // Verify summary contains all relevant information
1752        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        // US-006: Summary should show human-readable disk space freed
1762        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    // =========================================================================
1770    // US-005 Tests: Clean Data Action Implementation
1771    // =========================================================================
1772
1773    #[test]
1774    fn test_us005_data_cleanup_summary_default() {
1775        // US-005: DataCleanupSummary should have sensible defaults
1776        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        // US-005: Track specs removed
1786        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        // US-005: Track archived runs removed
1799        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        // US-005: Track both specs and runs removed
1812        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        // US-005: Continue on errors and report them
1826        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        // US-005: Partial cleanup should track what succeeded and what failed
1845        let summary = DataCleanupSummary {
1846            specs_removed: 3, // 3 of 5 specs removed
1847            runs_removed: 8,  // 8 of 10 runs removed
1848            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        // Verify we can see both successes and failures
1858        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        // US-005: Handle non-existent project gracefully
1866        let result = clean_data_direct("nonexistent-project-us005-test");
1867
1868        // Should return error for non-existent project (StateManager::for_project fails)
1869        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        // US-005: Test actual cleanup with temp directory
1875        let temp_dir = TempDir::new().unwrap();
1876        let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
1877
1878        // Create spec and runs directories
1879        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        // Create some spec files
1885        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        // Create some archived run files
1890        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        // Verify files exist
1894        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        // US-005: Both .json and .md files should be deleted together
1904        let temp_dir = TempDir::new().unwrap();
1905
1906        // Create spec directory
1907        let spec_dir = temp_dir.path().join("spec");
1908        fs::create_dir_all(&spec_dir).unwrap();
1909
1910        // Create a spec pair
1911        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        // Verify both files exist
1917        assert!(json_path.exists());
1918        assert!(md_path.exists());
1919
1920        // Simulate the deletion logic (what clean_data_direct does)
1921        // This tests the logic that when we find a .json, we also delete the .md
1922        let json_deleted = fs::remove_file(&json_path).is_ok();
1923        let md_deleted = fs::remove_file(&md_path).is_ok();
1924
1925        // Both should be deleted
1926        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        // US-005: An .md file without a matching .json should still be considered
1935        // Note: The current implementation only looks for .json files as the canonical
1936        // spec files. Orphaned .md files (without .json) are NOT automatically cleaned
1937        // by the current implementation, which is intentional - we don't want to delete
1938        // random .md files that might not be spec files.
1939        let temp_dir = TempDir::new().unwrap();
1940
1941        // Create spec directory
1942        let spec_dir = temp_dir.path().join("spec");
1943        fs::create_dir_all(&spec_dir).unwrap();
1944
1945        // Create just an .md file (no matching .json)
1946        let orphan_md = spec_dir.join("orphan.md");
1947        fs::write(&orphan_md, "# Orphaned markdown").unwrap();
1948
1949        assert!(orphan_md.exists());
1950
1951        // The cleanup logic only processes .json files, so this .md would remain
1952        // This is the expected behavior - we don't delete random .md files
1953    }
1954
1955    #[test]
1956    fn test_us005_errors_collected_for_all_failures() {
1957        // US-005: If deletion fails, continue with others and report errors
1958        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        // All errors are collected
1970        assert_eq!(summary.errors.len(), 3);
1971
1972        // Operations continue despite errors
1973        assert!(summary.specs_removed > 0);
1974        assert!(summary.runs_removed > 0);
1975    }
1976
1977    #[test]
1978    fn test_us005_bytes_freed_calculated_correctly() {
1979        // US-005: Track total bytes freed from both specs and runs
1980        let temp_dir = TempDir::new().unwrap();
1981
1982        // Create files of known sizes
1983        let file1 = temp_dir.path().join("file1.txt");
1984        let file2 = temp_dir.path().join("file2.txt");
1985        fs::write(&file1, "hello").unwrap(); // 5 bytes
1986        fs::write(&file2, "world!").unwrap(); // 6 bytes
1987
1988        let size1 = fs::metadata(&file1).unwrap().len();
1989        let size2 = fs::metadata(&file2).unwrap().len();
1990
1991        // Simulate tracking freed bytes
1992        let total_freed = size1 + size2;
1993        assert_eq!(total_freed, 11); // 5 + 6 bytes
1994
1995        // Clean up
1996        fs::remove_file(file1).unwrap();
1997        fs::remove_file(file2).unwrap();
1998    }
1999
2000    // =========================================================================
2001    // US-007 Integration Tests: Verify Clean Functionality
2002    // =========================================================================
2003
2004    #[test]
2005    fn test_us007_active_session_specs_not_counted_as_cleanable() {
2006        // US-007: Specs used by active sessions should NOT be counted as cleanable
2007        //
2008        // This test verifies the logic in clean_data_direct that collects
2009        // active_spec_paths from running sessions and excludes them.
2010        let temp_dir = TempDir::new().unwrap();
2011        let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
2012
2013        // Create spec directory
2014        let spec_dir = sm.spec_dir();
2015        fs::create_dir_all(&spec_dir).unwrap();
2016
2017        // Create spec files - one that would be "active" and one that's cleanable
2018        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        // Verify both files exist before testing
2024        assert!(active_spec.exists());
2025        assert!(cleanable_spec.exists());
2026
2027        // Simulate what clean_data_direct does: build active_spec_paths set
2028        let mut active_spec_paths = std::collections::HashSet::new();
2029        active_spec_paths.insert(active_spec.clone());
2030
2031        // The active spec should NOT be in the cleanable list (it's in active_spec_paths)
2032        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        // Verify the filtering logic: only non-active specs are cleanable
2042        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        // US-007: Both .json and .md paths of active sessions should be excluded
2060        //
2061        // The clean_data_direct function collects both spec_json_path and
2062        // spec_md_path from active sessions.
2063        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        // Create a spec pair
2070        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        // Simulate active session with both paths excluded
2076        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        // Both paths should be excluded
2081        assert!(active_spec_paths.contains(&json_path));
2082        assert!(active_spec_paths.contains(&md_path));
2083
2084        // When filtering for cleanable specs, none should remain
2085        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        // US-007: Runs in the runs/ directory are always cleanable
2103        //
2104        // Unlike specs, archived runs are not associated with active sessions
2105        // and are always cleanable.
2106        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        // Create some archived run files
2113        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        // Count cleanable runs - all files in runs/ directory are cleanable
2118        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        // US-007: Spec pairs (.json + .md) should be counted as 1 spec, not 2
2128        //
2129        // This tests the counting logic that uses .json as the canonical file.
2130        let temp_dir = TempDir::new().unwrap();
2131        let spec_dir = temp_dir.path();
2132
2133        // Create 2 spec pairs (4 files total)
2134        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        // Also add a standalone .json (no .md pair)
2140        fs::write(spec_dir.join("spec-feature3.json"), "{}").unwrap();
2141
2142        // Count specs by counting .json files only
2143        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        // Should count 3 specs (not 5 files)
2155        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        // US-007: Orphaned .md files (no matching .json) are NOT deleted
2161        //
2162        // This is intentional behavior to avoid accidentally deleting
2163        // documentation files like README.md that might be in the spec directory.
2164        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        // Create an orphaned .md file (no matching .json)
2169        let orphan_md = spec_dir.join("orphan-notes.md");
2170        fs::write(&orphan_md, "# Some notes").unwrap();
2171
2172        // Create a proper spec pair
2173        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        // Simulate the cleanup logic (from clean_data_direct):
2179        // Only .json files are collected and processed
2180        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        // Only the .json file should be found (not the orphan .md)
2188        assert_eq!(json_specs.len(), 1);
2189        assert_eq!(json_specs[0], spec_json);
2190
2191        // The orphaned .md file would NOT be deleted by the cleanup logic
2192        // This is verified by checking it's not in the json_specs list
2193        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        // US-007: Orphaned .md files are not counted in spec count
2202        //
2203        // The count_cleanable_specs function only counts .json files,
2204        // so orphaned .md files don't inflate the count.
2205        let temp_dir = TempDir::new().unwrap();
2206        let spec_dir = temp_dir.path();
2207
2208        // Create 1 spec pair and 2 orphaned .md files
2209        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        // Count total files
2215        let total_files = fs::read_dir(spec_dir)
2216            .unwrap()
2217            .filter_map(|e| e.ok())
2218            .count();
2219
2220        // Count only .json files (what count_cleanable_specs does)
2221        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        // US-007: DataCleanupSummary tracks both specs and runs
2242        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        // Combined count for display
2254        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        // US-007: Integration test verifying the full flow of excluding active specs
2261        //
2262        // This tests the actual logic flow in clean_data_direct:
2263        // 1. Collect active_spec_paths from running sessions
2264        // 2. Skip any spec in active_spec_paths during cleanup
2265        let temp_dir = TempDir::new().unwrap();
2266        let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
2267
2268        // Create spec directory with files
2269        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        // Simulate active spec paths (what would come from a running session)
2283        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        // Simulate the cleanup logic from clean_data_direct
2289        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            // Skip if active
2299            if active_spec_paths.contains(&json_path) {
2300                continue;
2301            }
2302
2303            // Would be removed
2304            specs_removed += 1;
2305
2306            // Verify it's the inactive spec
2307            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        // US-007: Multiple active sessions should all have their specs excluded
2316        let temp_dir = TempDir::new().unwrap();
2317        let spec_dir = temp_dir.path();
2318
2319        // Create specs
2320        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        // Sessions 1 and 2 are active
2328        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        // Only spec3 should be cleanable
2334        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        // US-007: When no sessions are active, all specs are cleanable
2349        let temp_dir = TempDir::new().unwrap();
2350        let spec_dir = temp_dir.path();
2351
2352        // Create specs
2353        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        // No active sessions
2358        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        // US-007: Verify that existing test patterns are still valid
2375        //
2376        // This meta-test ensures the testing patterns haven't broken.
2377
2378        // CleanupSummary default is valid
2379        let summary = CleanupSummary::default();
2380        assert_eq!(summary.sessions_removed, 0);
2381        assert!(summary.errors.is_empty());
2382
2383        // DataCleanupSummary default is valid
2384        let data_summary = DataCleanupSummary::default();
2385        assert_eq!(data_summary.specs_removed, 0);
2386        assert_eq!(data_summary.runs_removed, 0);
2387
2388        // format_bytes works correctly
2389        assert_eq!(format_bytes(1024), "1.0 KB");
2390        assert_eq!(format_bytes(1048576), "1.0 MB");
2391    }
2392}