use std::fs;
use std::path::{Path, PathBuf};
use crate::error::Result;
use crate::output::{BLUE, BOLD, CYAN, GRAY, GREEN, RED, RESET, YELLOW};
use crate::prompt;
use crate::state::{MachineState, RunStatus, StateManager};
use crate::worktree;
use super::ensure_project_dir;
#[derive(Debug, Default)]
pub struct CleanupSummary {
pub sessions_removed: usize,
pub worktrees_removed: usize,
pub bytes_freed: u64,
pub sessions_skipped: Vec<SkippedSession>,
pub errors: Vec<String>,
}
#[derive(Debug)]
pub struct SkippedSession {
pub session_id: String,
pub reason: String,
}
#[derive(Debug, Default)]
pub struct CleanOptions {
pub worktrees: bool,
pub all: bool,
pub session: Option<String>,
pub orphaned: bool,
pub force: bool,
pub project: Option<String>,
}
impl CleanupSummary {
pub fn print(&self) {
println!();
if self.sessions_removed == 0 && self.worktrees_removed == 0 {
println!("{GRAY}No sessions or worktrees were removed.{RESET}");
} else {
let freed_str = format_bytes(self.bytes_freed);
println!(
"{GREEN}Removed {} session{}, {} worktree{}, freed {}{RESET}",
self.sessions_removed,
if self.sessions_removed == 1 { "" } else { "s" },
self.worktrees_removed,
if self.worktrees_removed == 1 { "" } else { "s" },
freed_str
);
}
if !self.sessions_skipped.is_empty() {
println!();
println!(
"{YELLOW}Skipped {} session{}:{RESET}",
self.sessions_skipped.len(),
if self.sessions_skipped.len() == 1 {
""
} else {
"s"
}
);
for skipped in &self.sessions_skipped {
println!(
" {GRAY}-{RESET} {}: {}",
skipped.session_id, skipped.reason
);
}
}
if !self.errors.is_empty() {
println!();
println!("{RED}Errors during cleanup:{RESET}");
for error in &self.errors {
println!(" {RED}-{RESET} {}", error);
}
}
}
}
fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
const GB: u64 = 1024 * MB;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}
fn dir_size(path: &Path) -> u64 {
if !path.exists() {
return 0;
}
let mut size = 0;
if let Ok(entries) = fs::read_dir(path) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
size += dir_size(&path);
} else if let Ok(metadata) = entry.metadata() {
size += metadata.len();
}
}
}
size
}
pub fn worktree_has_uncommitted_changes(worktree_path: &Path) -> bool {
if !worktree_path.exists() {
return false;
}
let output = std::process::Command::new("git")
.args([
"-C",
&worktree_path.to_string_lossy(),
"status",
"--porcelain",
])
.output();
match output {
Ok(output) => {
!output.stdout.is_empty()
}
Err(_) => {
false
}
}
}
fn get_state_manager(options: &CleanOptions) -> Result<StateManager> {
if let Some(project_name) = &options.project {
StateManager::for_project(project_name)
} else {
StateManager::new()
}
}
pub fn clean_command(options: CleanOptions) -> Result<()> {
if options.project.is_none() {
ensure_project_dir()?;
}
if let Some(session_id) = &options.session {
clean_specific_session(session_id, &options)
} else if options.orphaned {
clean_orphaned_sessions(&options)
} else if options.all {
clean_all_sessions(&options)
} else {
clean_completed_sessions(&options)
}
}
fn clean_specific_session(session_id: &str, options: &CleanOptions) -> Result<()> {
let state_manager = get_state_manager(options)?;
let sessions = state_manager.list_sessions()?;
let session = sessions.iter().find(|s| s.session_id == session_id);
match session {
Some(metadata) => {
println!();
println!(
"Session {CYAN}{}{RESET} on branch {BLUE}{}{RESET}",
metadata.session_id, metadata.branch_name
);
println!(" Path: {}", metadata.worktree_path.display());
let mut summary = CleanupSummary::default();
let current_dir = std::env::current_dir().ok();
let is_current = current_dir
.as_ref()
.map(|cwd| cwd == &metadata.worktree_path)
.unwrap_or(false);
if is_current && !options.force {
summary.sessions_skipped.push(SkippedSession {
session_id: session_id.to_string(),
reason: "Cannot remove current session (use --force to override)".to_string(),
});
summary.print();
return Ok(());
}
if options.worktrees
&& metadata.worktree_path.exists()
&& worktree_has_uncommitted_changes(&metadata.worktree_path)
&& !options.force
{
summary.sessions_skipped.push(SkippedSession {
session_id: session_id.to_string(),
reason: "Worktree has uncommitted changes (use --force to override)"
.to_string(),
});
summary.print();
return Ok(());
}
let prompt_msg = if options.worktrees && metadata.worktree_path.exists() {
format!("Remove session '{}' and its worktree?", metadata.session_id)
} else {
format!("Remove session '{}'?", metadata.session_id)
};
if !prompt::confirm(&prompt_msg, false) {
println!("{GRAY}Cancelled.{RESET}");
return Ok(());
}
if let Some(session_sm) = state_manager.get_session(session_id) {
if let Ok(Some(state)) = session_sm.load_current() {
if let Ok(archive_path) = session_sm.archive(&state) {
println!("{GRAY}Archived to: {}{RESET}", archive_path.display());
}
}
}
if options.worktrees && metadata.worktree_path.exists() {
summary.bytes_freed += dir_size(&metadata.worktree_path);
if let Err(e) = remove_worktree_safely(&metadata.worktree_path, options.force) {
summary.errors.push(format!(
"Failed to remove worktree {}: {}",
metadata.worktree_path.display(),
e
));
} else {
summary.worktrees_removed += 1;
}
}
if let Some(session_sm) = state_manager.get_session(session_id) {
summary.bytes_freed += get_session_size(&session_sm);
session_sm.clear_current()?;
summary.sessions_removed += 1;
}
summary.print();
Ok(())
}
None => {
println!("{RED}Session '{}' not found.{RESET}", session_id);
println!();
println!("Use {CYAN}autom8 status --all{RESET} to list available sessions.");
Ok(())
}
}
}
fn clean_orphaned_sessions(options: &CleanOptions) -> Result<()> {
let state_manager = get_state_manager(options)?;
let sessions = state_manager.list_sessions()?;
let orphaned: Vec<_> = sessions
.iter()
.filter(|s| !s.worktree_path.exists())
.collect();
if orphaned.is_empty() {
println!("{GRAY}No orphaned sessions found.{RESET}");
return Ok(());
}
println!();
println!("{BOLD}Orphaned sessions (worktree deleted):{RESET}");
for session in &orphaned {
println!(
" {GRAY}●{RESET} {} - {} (path: {})",
session.session_id,
session.branch_name,
session.worktree_path.display()
);
}
println!();
let prompt_msg = format!(
"Remove {} orphaned session{}?",
orphaned.len(),
if orphaned.len() == 1 { "" } else { "s" }
);
if !prompt::confirm(&prompt_msg, false) {
println!("{GRAY}Cancelled.{RESET}");
return Ok(());
}
let mut summary = CleanupSummary::default();
for session in orphaned {
if let Some(session_sm) = state_manager.get_session(&session.session_id) {
if let Ok(Some(state)) = session_sm.load_current() {
let _ = session_sm.archive(&state);
}
summary.bytes_freed += get_session_size(&session_sm);
if let Err(e) = session_sm.clear_current() {
summary.errors.push(format!(
"Failed to remove session {}: {}",
session.session_id, e
));
} else {
summary.sessions_removed += 1;
}
}
}
summary.print();
Ok(())
}
fn clean_all_sessions(options: &CleanOptions) -> Result<()> {
let state_manager = get_state_manager(options)?;
let sessions = state_manager.list_sessions()?;
if sessions.is_empty() {
println!("{GRAY}No sessions found.{RESET}");
return Ok(());
}
let current_dir = std::env::current_dir().ok();
println!();
println!("{BOLD}All sessions:{RESET}");
for session in &sessions {
let is_current = current_dir
.as_ref()
.map(|cwd| cwd == &session.worktree_path)
.unwrap_or(false);
let is_orphaned = !session.worktree_path.exists();
let has_uncommitted =
!is_orphaned && worktree_has_uncommitted_changes(&session.worktree_path);
let status_markers = format!(
"{}{}{}",
if is_current { " (current)" } else { "" },
if is_orphaned { " [orphaned]" } else { "" },
if has_uncommitted {
" [uncommitted changes]"
} else {
""
}
);
let indicator = if is_orphaned {
format!("{GRAY}✗{RESET}")
} else if session.is_running {
format!("{YELLOW}●{RESET}")
} else {
format!("{GRAY}○{RESET}")
};
println!(
" {} {} - {}{GRAY}{}{RESET}",
indicator, session.session_id, session.branch_name, status_markers
);
}
println!();
let sessions_with_uncommitted: Vec<_> = sessions
.iter()
.filter(|s| s.worktree_path.exists() && worktree_has_uncommitted_changes(&s.worktree_path))
.collect();
if !sessions_with_uncommitted.is_empty() && options.worktrees && !options.force {
println!(
"{YELLOW}Warning: {} session{} {} uncommitted changes.{RESET}",
sessions_with_uncommitted.len(),
if sessions_with_uncommitted.len() == 1 {
""
} else {
"s"
},
if sessions_with_uncommitted.len() == 1 {
"has"
} else {
"have"
}
);
println!("{YELLOW}These will be skipped unless you use --force.{RESET}");
println!();
}
let prompt_msg = if options.worktrees {
format!(
"{RED}Remove ALL {} sessions AND their worktrees? This cannot be undone.{RESET}",
sessions.len()
)
} else {
format!(
"Remove ALL {} session state files? (worktrees will remain)",
sessions.len()
)
};
if !prompt::confirm(&prompt_msg, false) {
println!("{GRAY}Cancelled.{RESET}");
return Ok(());
}
let mut summary = CleanupSummary::default();
for session in &sessions {
let is_current = current_dir
.as_ref()
.map(|cwd| cwd == &session.worktree_path)
.unwrap_or(false);
if is_current && !options.force {
summary.sessions_skipped.push(SkippedSession {
session_id: session.session_id.clone(),
reason: "Current session".to_string(),
});
continue;
}
if options.worktrees
&& session.worktree_path.exists()
&& worktree_has_uncommitted_changes(&session.worktree_path)
&& !options.force
{
summary.sessions_skipped.push(SkippedSession {
session_id: session.session_id.clone(),
reason: "Uncommitted changes".to_string(),
});
continue;
}
if let Some(session_sm) = state_manager.get_session(&session.session_id) {
if let Ok(Some(state)) = session_sm.load_current() {
let _ = session_sm.archive(&state);
}
if options.worktrees && session.worktree_path.exists() {
summary.bytes_freed += dir_size(&session.worktree_path);
if let Err(e) = remove_worktree_safely(&session.worktree_path, options.force) {
summary.errors.push(format!(
"Failed to remove worktree {}: {}",
session.worktree_path.display(),
e
));
} else {
summary.worktrees_removed += 1;
}
}
summary.bytes_freed += get_session_size(&session_sm);
if let Err(e) = session_sm.clear_current() {
summary.errors.push(format!(
"Failed to remove session {}: {}",
session.session_id, e
));
} else {
summary.sessions_removed += 1;
}
}
}
summary.print();
Ok(())
}
fn clean_completed_sessions(options: &CleanOptions) -> Result<()> {
let state_manager = get_state_manager(options)?;
let sessions = state_manager.list_sessions()?;
let cleanable: Vec<_> = sessions
.iter()
.filter(|s| {
if let Some(session_sm) = state_manager.get_session(&s.session_id) {
if let Ok(Some(state)) = session_sm.load_current() {
matches!(
state.machine_state,
MachineState::Completed | MachineState::Failed
) || matches!(
state.status,
RunStatus::Completed | RunStatus::Failed | RunStatus::Interrupted
)
} else {
true
}
} else {
false
}
})
.collect();
let orphaned: Vec<_> = sessions
.iter()
.filter(|s| !s.worktree_path.exists())
.collect();
let mut to_clean: Vec<_> = cleanable;
for orphan in orphaned {
if !to_clean.iter().any(|s| s.session_id == orphan.session_id) {
to_clean.push(orphan);
}
}
if to_clean.is_empty() {
println!("{GRAY}No completed, failed, or orphaned sessions to clean.{RESET}");
return Ok(());
}
let current_dir = std::env::current_dir().ok();
println!();
println!("{BOLD}Sessions to clean:{RESET}");
for session in &to_clean {
let is_current = current_dir
.as_ref()
.map(|cwd| cwd == &session.worktree_path)
.unwrap_or(false);
let is_orphaned = !session.worktree_path.exists();
let status = if let Some(session_sm) = state_manager.get_session(&session.session_id) {
if let Ok(Some(state)) = session_sm.load_current() {
match state.machine_state {
MachineState::Completed => format!("{GREEN}completed{RESET}"),
MachineState::Failed => format!("{RED}failed{RESET}"),
_ => format!("{GRAY}idle{RESET}"),
}
} else {
format!("{GRAY}no state{RESET}")
}
} else {
format!("{GRAY}unknown{RESET}")
};
let markers = format!(
"{}{}",
if is_current { " (current)" } else { "" },
if is_orphaned { " [orphaned]" } else { "" }
);
println!(
" {GRAY}○{RESET} {} - {} [{}]{GRAY}{}{RESET}",
session.session_id, session.branch_name, status, markers
);
}
println!();
let prompt_msg = format!(
"Remove {} session{}{}?",
to_clean.len(),
if to_clean.len() == 1 { "" } else { "s" },
if options.worktrees {
" and associated worktrees"
} else {
""
}
);
if !prompt::confirm(&prompt_msg, false) {
println!("{GRAY}Cancelled.{RESET}");
return Ok(());
}
let mut summary = CleanupSummary::default();
for session in to_clean {
let is_current = current_dir
.as_ref()
.map(|cwd| cwd == &session.worktree_path)
.unwrap_or(false);
if is_current && !options.force {
summary.sessions_skipped.push(SkippedSession {
session_id: session.session_id.clone(),
reason: "Current session".to_string(),
});
continue;
}
if options.worktrees
&& session.worktree_path.exists()
&& worktree_has_uncommitted_changes(&session.worktree_path)
&& !options.force
{
summary.sessions_skipped.push(SkippedSession {
session_id: session.session_id.clone(),
reason: "Uncommitted changes".to_string(),
});
continue;
}
if let Some(session_sm) = state_manager.get_session(&session.session_id) {
if let Ok(Some(state)) = session_sm.load_current() {
let _ = session_sm.archive(&state);
}
if options.worktrees && session.worktree_path.exists() {
summary.bytes_freed += dir_size(&session.worktree_path);
if let Err(e) = remove_worktree_safely(&session.worktree_path, options.force) {
summary.errors.push(format!(
"Failed to remove worktree {}: {}",
session.worktree_path.display(),
e
));
} else {
summary.worktrees_removed += 1;
}
}
summary.bytes_freed += get_session_size(&session_sm);
if let Err(e) = session_sm.clear_current() {
summary.errors.push(format!(
"Failed to remove session {}: {}",
session.session_id, e
));
} else {
summary.sessions_removed += 1;
}
}
}
summary.print();
Ok(())
}
#[derive(Debug, Default, Clone)]
pub struct DirectCleanOptions {
pub worktrees: bool,
pub force: bool,
}
pub fn clean_worktrees_direct(
project_name: &str,
options: DirectCleanOptions,
) -> Result<CleanupSummary> {
let state_manager = StateManager::for_project(project_name)?;
let sessions = state_manager.list_sessions()?;
let to_clean: Vec<_> = sessions
.iter()
.filter(|s| {
if s.session_id == "main" {
return false;
}
true
})
.collect();
let mut summary = CleanupSummary::default();
if to_clean.is_empty() {
return Ok(summary);
}
let current_dir = std::env::current_dir().ok();
for session in to_clean {
if session.is_running {
summary.sessions_skipped.push(SkippedSession {
session_id: session.session_id.clone(),
reason: "Active run in progress".to_string(),
});
continue;
}
let is_current = current_dir
.as_ref()
.map(|cwd| cwd == &session.worktree_path)
.unwrap_or(false);
if is_current && !options.force {
summary.sessions_skipped.push(SkippedSession {
session_id: session.session_id.clone(),
reason: "Current session".to_string(),
});
continue;
}
if options.worktrees
&& session.worktree_path.exists()
&& worktree_has_uncommitted_changes(&session.worktree_path)
&& !options.force
{
summary.sessions_skipped.push(SkippedSession {
session_id: session.session_id.clone(),
reason: "Uncommitted changes".to_string(),
});
continue;
}
if let Some(session_sm) = state_manager.get_session(&session.session_id) {
if let Ok(Some(state)) = session_sm.load_current() {
let _ = session_sm.archive(&state);
}
if options.worktrees && session.worktree_path.exists() {
summary.bytes_freed += dir_size(&session.worktree_path);
if let Err(e) = remove_worktree_safely(&session.worktree_path, options.force) {
summary.errors.push(format!(
"Failed to remove worktree {}: {}",
session.worktree_path.display(),
e
));
} else {
summary.worktrees_removed += 1;
}
}
summary.bytes_freed += get_session_size(&session_sm);
if let Err(e) = session_sm.clear_current() {
summary.errors.push(format!(
"Failed to remove session {}: {}",
session.session_id, e
));
} else {
summary.sessions_removed += 1;
}
}
}
Ok(summary)
}
pub fn clean_orphaned_direct(project_name: &str) -> Result<CleanupSummary> {
let state_manager = StateManager::for_project(project_name)?;
let sessions = state_manager.list_sessions()?;
let orphaned: Vec<_> = sessions
.iter()
.filter(|s| !s.worktree_path.exists())
.collect();
let mut summary = CleanupSummary::default();
if orphaned.is_empty() {
return Ok(summary);
}
for session in orphaned {
if let Some(session_sm) = state_manager.get_session(&session.session_id) {
if let Ok(Some(state)) = session_sm.load_current() {
let _ = session_sm.archive(&state);
}
summary.bytes_freed += get_session_size(&session_sm);
if let Err(e) = session_sm.clear_current() {
summary.errors.push(format!(
"Failed to remove session {}: {}",
session.session_id, e
));
} else {
summary.sessions_removed += 1;
}
}
}
Ok(summary)
}
pub fn format_bytes_display(bytes: u64) -> String {
format_bytes(bytes)
}
#[derive(Debug, Default)]
pub struct RemovalSummary {
pub worktrees_removed: usize,
pub config_deleted: bool,
pub bytes_freed: u64,
pub worktrees_skipped: Vec<SkippedWorktree>,
pub errors: Vec<String>,
}
#[derive(Debug)]
pub struct SkippedWorktree {
pub path: PathBuf,
pub reason: String,
}
pub fn remove_project_direct(project_name: &str) -> Result<RemovalSummary> {
use crate::config::project_config_dir_for;
let mut summary = RemovalSummary::default();
let project_dir = project_config_dir_for(project_name)?;
if !project_dir.exists() {
summary.errors.push(format!(
"Project '{}' does not exist at {}",
project_name,
project_dir.display()
));
return Ok(summary);
}
if let Ok(state_manager) = StateManager::for_project(project_name) {
if let Ok(sessions) = state_manager.list_sessions() {
for session in sessions {
if session.is_running {
summary.worktrees_skipped.push(SkippedWorktree {
path: session.worktree_path.clone(),
reason: "Active run in progress".to_string(),
});
continue;
}
if !session.worktree_path.exists() {
continue;
}
if session.session_id == "main" {
continue;
}
let worktree_size = dir_size(&session.worktree_path);
match remove_worktree_safely(&session.worktree_path, false) {
Ok(()) => {
summary.worktrees_removed += 1;
summary.bytes_freed += worktree_size;
}
Err(e) => {
summary.errors.push(format!(
"Failed to remove worktree {}: {}",
session.worktree_path.display(),
e
));
}
}
}
}
}
let config_size = dir_size(&project_dir);
match fs::remove_dir_all(&project_dir) {
Ok(()) => {
summary.config_deleted = true;
summary.bytes_freed += config_size;
}
Err(e) => {
summary.errors.push(format!(
"Failed to delete config directory {}: {}",
project_dir.display(),
e
));
}
}
Ok(summary)
}
fn remove_worktree_safely(worktree_path: &Path, force: bool) -> Result<()> {
let current_dir = std::env::current_dir().ok();
if current_dir.as_ref() == Some(&worktree_path.to_path_buf()) {
if let Ok(main_repo) = worktree::get_main_repo_root() {
std::env::set_current_dir(&main_repo)?;
}
}
worktree::remove_worktree(worktree_path, force)
}
fn get_session_size(_session_sm: &StateManager) -> u64 {
0 }
#[derive(Debug, Default)]
pub struct DataCleanupSummary {
pub specs_removed: usize,
pub runs_removed: usize,
pub bytes_freed: u64,
pub errors: Vec<String>,
}
pub fn clean_data_direct(project_name: &str) -> Result<DataCleanupSummary> {
let state_manager = StateManager::for_project(project_name)?;
let mut summary = DataCleanupSummary::default();
let spec_dir = state_manager.spec_dir();
let runs_dir = state_manager.runs_dir();
let mut active_spec_paths = std::collections::HashSet::new();
if let Ok(sessions) = state_manager.list_sessions_with_status() {
for status in sessions {
if status.metadata.is_running {
if let Some(session_sm) = state_manager.get_session(&status.metadata.session_id) {
if let Ok(Some(state)) = session_sm.load_current() {
active_spec_paths.insert(state.spec_json_path.clone());
if let Some(md_path) = &state.spec_md_path {
active_spec_paths.insert(md_path.clone());
}
}
}
}
}
}
if spec_dir.exists() {
if let Ok(entries) = fs::read_dir(&spec_dir) {
let mut json_specs: Vec<PathBuf> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("json") {
json_specs.push(path);
}
}
for json_path in json_specs {
if active_spec_paths.contains(&json_path) {
continue;
}
let md_path = json_path.with_extension("md");
let mut pair_size = 0u64;
if json_path.exists() {
if let Ok(meta) = fs::metadata(&json_path) {
pair_size += meta.len();
}
}
if md_path.exists() {
if let Ok(meta) = fs::metadata(&md_path) {
pair_size += meta.len();
}
}
let mut removed = false;
if json_path.exists() {
if let Err(e) = fs::remove_file(&json_path) {
summary.errors.push(format!(
"Failed to remove {}: {}",
json_path.display(),
e
));
} else {
removed = true;
}
}
if md_path.exists() {
if let Err(e) = fs::remove_file(&md_path) {
summary.errors.push(format!(
"Failed to remove {}: {}",
md_path.display(),
e
));
}
}
if removed {
summary.specs_removed += 1;
summary.bytes_freed += pair_size;
}
}
}
}
if runs_dir.exists() {
if let Ok(entries) = fs::read_dir(&runs_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
let size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
if let Err(e) = fs::remove_file(&path) {
summary
.errors
.push(format!("Failed to remove {}: {}", path.display(), e));
} else {
summary.runs_removed += 1;
summary.bytes_freed += size;
}
}
}
}
}
Ok(summary)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::{RunState, SessionMetadata};
use chrono::Utc;
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
fn test_format_bytes() {
assert_eq!(format_bytes(0), "0 B");
assert_eq!(format_bytes(500), "500 B");
assert_eq!(format_bytes(1024), "1.0 KB");
assert_eq!(format_bytes(1536), "1.5 KB");
assert_eq!(format_bytes(1048576), "1.0 MB");
assert_eq!(format_bytes(1572864), "1.5 MB");
assert_eq!(format_bytes(1073741824), "1.0 GB");
}
#[test]
fn test_cleanup_summary_default() {
let summary = CleanupSummary::default();
assert_eq!(summary.sessions_removed, 0);
assert_eq!(summary.worktrees_removed, 0);
assert_eq!(summary.bytes_freed, 0);
assert!(summary.sessions_skipped.is_empty());
assert!(summary.errors.is_empty());
}
#[test]
fn test_clean_options_default() {
let options = CleanOptions::default();
assert!(!options.worktrees);
assert!(!options.all);
assert!(options.session.is_none());
assert!(!options.orphaned);
assert!(!options.force);
}
#[test]
fn test_worktree_has_uncommitted_changes_nonexistent_path() {
let result = worktree_has_uncommitted_changes(Path::new("/nonexistent/path/12345"));
assert!(!result);
}
#[test]
fn test_dir_size_nonexistent() {
let size = dir_size(Path::new("/nonexistent/path/12345"));
assert_eq!(size, 0);
}
#[test]
fn test_dir_size_empty_dir() {
let temp_dir = TempDir::new().unwrap();
let size = dir_size(temp_dir.path());
assert_eq!(size, 0);
}
#[test]
fn test_dir_size_with_files() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
fs::write(&file_path, "hello world").unwrap();
let size = dir_size(temp_dir.path());
assert!(size > 0);
assert_eq!(size, 11); }
#[test]
fn test_dir_size_with_nested_dirs() {
let temp_dir = TempDir::new().unwrap();
let subdir = temp_dir.path().join("subdir");
fs::create_dir(&subdir).unwrap();
fs::write(subdir.join("file1.txt"), "hello").unwrap();
fs::write(temp_dir.path().join("file2.txt"), "world").unwrap();
let size = dir_size(temp_dir.path());
assert_eq!(size, 10); }
#[test]
fn test_skipped_session_struct() {
let skipped = SkippedSession {
session_id: "abc123".to_string(),
reason: "test reason".to_string(),
};
assert_eq!(skipped.session_id, "abc123");
assert_eq!(skipped.reason, "test reason");
}
#[test]
fn test_us011_clean_options_worktrees_flag() {
let options = CleanOptions {
worktrees: true,
..Default::default()
};
assert!(options.worktrees);
}
#[test]
fn test_us011_clean_options_all_flag() {
let options = CleanOptions {
all: true,
..Default::default()
};
assert!(options.all);
}
#[test]
fn test_us011_clean_options_session_flag() {
let options = CleanOptions {
session: Some("abc123".to_string()),
..Default::default()
};
assert_eq!(options.session, Some("abc123".to_string()));
}
#[test]
fn test_us011_clean_options_orphaned_flag() {
let options = CleanOptions {
orphaned: true,
..Default::default()
};
assert!(options.orphaned);
}
#[test]
fn test_us011_clean_options_force_flag() {
let options = CleanOptions {
force: true,
..Default::default()
};
assert!(options.force);
}
#[test]
fn test_us011_cleanup_summary_with_stats() {
let summary = CleanupSummary {
sessions_removed: 3,
worktrees_removed: 2,
bytes_freed: 1048576, sessions_skipped: vec![SkippedSession {
session_id: "skipped1".to_string(),
reason: "uncommitted changes".to_string(),
}],
errors: vec!["test error".to_string()],
};
assert_eq!(summary.sessions_removed, 3);
assert_eq!(summary.worktrees_removed, 2);
assert_eq!(summary.bytes_freed, 1048576);
assert_eq!(summary.sessions_skipped.len(), 1);
assert_eq!(summary.errors.len(), 1);
}
#[test]
fn test_us011_worktree_uncommitted_check_on_temp_dir() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("test.txt"), "content").unwrap();
let result = worktree_has_uncommitted_changes(temp_dir.path());
assert!(!result);
}
#[test]
fn test_us011_archive_before_deletion_pattern() {
let temp_dir = TempDir::new().unwrap();
let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
let state = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
sm.save(&state).unwrap();
let archive_path = sm.archive(&state).unwrap();
assert!(archive_path.exists());
sm.clear_current().unwrap();
assert!(sm.load_current().unwrap().is_none());
assert!(archive_path.exists());
}
#[test]
fn test_us011_detect_orphaned_session() {
let metadata = SessionMetadata {
session_id: "orphan123".to_string(),
worktree_path: PathBuf::from("/nonexistent/worktree/path"),
branch_name: "feature/test".to_string(),
created_at: Utc::now(),
last_active_at: Utc::now(),
is_running: false,
spec_json_path: None,
};
assert!(!metadata.worktree_path.exists());
}
#[test]
fn test_us011_completed_session_is_cleanable() {
let state = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
assert!(matches!(state.machine_state, MachineState::Initializing));
}
#[test]
fn test_us011_failed_session_is_cleanable() {
let mut state = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
state.transition_to(MachineState::Failed);
assert!(matches!(state.machine_state, MachineState::Failed));
assert!(matches!(state.status, RunStatus::Failed));
}
#[test]
fn test_us004_direct_clean_options_default() {
let options = DirectCleanOptions::default();
assert!(!options.worktrees);
assert!(!options.force);
}
#[test]
fn test_us004_direct_clean_options_with_worktrees() {
let options = DirectCleanOptions {
worktrees: true,
force: false,
};
assert!(options.worktrees);
assert!(!options.force);
}
#[test]
fn test_us004_direct_clean_options_with_force() {
let options = DirectCleanOptions {
worktrees: false,
force: true,
};
assert!(!options.worktrees);
assert!(options.force);
}
#[test]
fn test_us004_format_bytes_display() {
assert_eq!(format_bytes_display(0), "0 B");
assert_eq!(format_bytes_display(1024), "1.0 KB");
assert_eq!(format_bytes_display(1048576), "1.0 MB");
assert_eq!(format_bytes_display(1073741824), "1.0 GB");
}
#[test]
fn test_us004_clean_worktrees_direct_with_temp_project() {
let temp_dir = TempDir::new().unwrap();
let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
let mut state = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
state.transition_to(MachineState::Completed);
sm.save(&state).unwrap();
let options = DirectCleanOptions {
worktrees: true,
force: false,
};
let result = clean_worktrees_direct("nonexistent-project-12345", options);
assert!(result.is_err() || result.unwrap().sessions_removed == 0);
}
#[test]
fn test_us004_clean_orphaned_direct_with_temp_project() {
let result = clean_orphaned_direct("nonexistent-project-12345");
assert!(result.is_err() || result.unwrap().sessions_removed == 0);
}
#[test]
fn test_us004_removal_summary_default() {
let summary = RemovalSummary::default();
assert_eq!(summary.worktrees_removed, 0);
assert!(!summary.config_deleted);
assert_eq!(summary.bytes_freed, 0);
assert!(summary.worktrees_skipped.is_empty());
assert!(summary.errors.is_empty());
}
#[test]
fn test_us004_removal_summary_with_successful_removal() {
let summary = RemovalSummary {
worktrees_removed: 2,
config_deleted: true,
bytes_freed: 1048576, worktrees_skipped: vec![],
errors: vec![],
};
assert_eq!(summary.worktrees_removed, 2);
assert!(summary.config_deleted);
assert_eq!(summary.bytes_freed, 1048576);
assert!(summary.worktrees_skipped.is_empty());
assert!(summary.errors.is_empty());
}
#[test]
fn test_us004_skipped_worktree_struct() {
let skipped = SkippedWorktree {
path: PathBuf::from("/path/to/worktree"),
reason: "Active run in progress".to_string(),
};
assert_eq!(skipped.path, PathBuf::from("/path/to/worktree"));
assert_eq!(skipped.reason, "Active run in progress");
}
#[test]
fn test_us004_removal_summary_with_skipped_worktrees() {
let summary = RemovalSummary {
worktrees_removed: 1,
config_deleted: true,
bytes_freed: 512,
worktrees_skipped: vec![SkippedWorktree {
path: PathBuf::from("/tmp/active-worktree"),
reason: "Active run in progress".to_string(),
}],
errors: vec![],
};
assert_eq!(summary.worktrees_skipped.len(), 1);
assert_eq!(
summary.worktrees_skipped[0].reason,
"Active run in progress"
);
}
#[test]
fn test_us004_removal_summary_with_errors() {
let summary = RemovalSummary {
worktrees_removed: 1,
config_deleted: false, bytes_freed: 1024,
worktrees_skipped: vec![],
errors: vec!["Failed to delete config directory: permission denied".to_string()],
};
assert_eq!(summary.worktrees_removed, 1);
assert!(!summary.config_deleted);
assert_eq!(summary.errors.len(), 1);
assert!(summary.errors[0].contains("permission denied"));
}
#[test]
fn test_us004_removal_summary_partial_cleanup_reports_both() {
let summary = RemovalSummary {
worktrees_removed: 2,
config_deleted: true,
bytes_freed: 2048,
worktrees_skipped: vec![SkippedWorktree {
path: PathBuf::from("/tmp/active"),
reason: "Active run".to_string(),
}],
errors: vec!["Failed to remove one worktree".to_string()],
};
assert_eq!(summary.worktrees_removed, 2);
assert!(summary.config_deleted);
assert_eq!(summary.worktrees_skipped.len(), 1);
assert_eq!(summary.errors.len(), 1);
}
#[test]
fn test_us004_remove_project_direct_nonexistent_project() {
let result = remove_project_direct("nonexistent-project-12345-xyz");
assert!(result.is_ok());
let summary = result.unwrap();
assert!(!summary.config_deleted);
assert_eq!(summary.worktrees_removed, 0);
assert!(!summary.errors.is_empty());
assert!(
summary.errors[0].contains("does not exist"),
"Error should mention project doesn't exist: {}",
summary.errors[0]
);
}
#[test]
fn test_us004_remove_project_returns_summary_type() {
let result = remove_project_direct("any-project-name");
let _summary: RemovalSummary = match result {
Ok(s) => s,
Err(_) => RemovalSummary::default(),
};
}
#[test]
fn test_us004_removal_summary_tracks_worktree_count() {
let summary = RemovalSummary {
worktrees_removed: 5,
config_deleted: true,
bytes_freed: 5000,
worktrees_skipped: vec![],
errors: vec![],
};
assert_eq!(summary.worktrees_removed, 5);
}
#[test]
fn test_us004_removal_summary_tracks_config_deleted() {
let summary = RemovalSummary {
worktrees_removed: 0,
config_deleted: true,
bytes_freed: 100,
worktrees_skipped: vec![],
errors: vec![],
};
assert!(summary.config_deleted);
}
#[test]
fn test_us004_handle_project_with_no_worktrees() {
let summary = RemovalSummary {
worktrees_removed: 0,
config_deleted: true,
bytes_freed: 50,
worktrees_skipped: vec![],
errors: vec![],
};
assert_eq!(summary.worktrees_removed, 0);
assert!(summary.config_deleted);
}
#[test]
fn test_us006_skipped_session_for_active_run() {
let skipped = SkippedSession {
session_id: "abc123".to_string(),
reason: "Active run in progress".to_string(),
};
assert_eq!(skipped.session_id, "abc123");
assert_eq!(skipped.reason, "Active run in progress");
}
#[test]
fn test_us006_cleanup_summary_with_skipped_active_runs() {
let summary = CleanupSummary {
sessions_removed: 2,
worktrees_removed: 2,
bytes_freed: 1024,
sessions_skipped: vec![
SkippedSession {
session_id: "active1".to_string(),
reason: "Active run in progress".to_string(),
},
SkippedSession {
session_id: "active2".to_string(),
reason: "Active run in progress".to_string(),
},
],
errors: vec![],
};
assert_eq!(summary.sessions_skipped.len(), 2);
assert!(summary.sessions_skipped[0]
.reason
.contains("Active run in progress"));
assert!(summary.sessions_skipped[1]
.reason
.contains("Active run in progress"));
}
#[test]
fn test_us006_direct_clean_options_default() {
let options = DirectCleanOptions::default();
assert!(!options.worktrees);
assert!(!options.force);
}
#[test]
fn test_us006_direct_clean_with_worktrees_flag() {
let options = DirectCleanOptions {
worktrees: true,
force: false,
};
assert!(options.worktrees);
}
#[test]
fn test_us006_cleanup_summary_reports_what_was_removed() {
let summary = CleanupSummary {
sessions_removed: 3,
worktrees_removed: 2,
bytes_freed: 5_000_000, sessions_skipped: vec![SkippedSession {
session_id: "active".to_string(),
reason: "Active run in progress".to_string(),
}],
errors: vec![],
};
assert_eq!(summary.sessions_removed, 3);
assert_eq!(summary.worktrees_removed, 2);
assert!(summary.bytes_freed > 0);
assert_eq!(summary.sessions_skipped.len(), 1);
assert!(summary.errors.is_empty());
}
#[test]
fn test_us006_format_bytes_for_summary() {
assert_eq!(format_bytes_display(0), "0 B");
assert_eq!(format_bytes_display(500), "500 B");
assert_eq!(format_bytes_display(1024), "1.0 KB");
assert_eq!(format_bytes_display(1_048_576), "1.0 MB");
assert_eq!(format_bytes_display(5_242_880), "5.0 MB");
}
#[test]
fn test_us005_data_cleanup_summary_default() {
let summary = DataCleanupSummary::default();
assert_eq!(summary.specs_removed, 0);
assert_eq!(summary.runs_removed, 0);
assert_eq!(summary.bytes_freed, 0);
assert!(summary.errors.is_empty());
}
#[test]
fn test_us005_data_cleanup_summary_with_specs() {
let summary = DataCleanupSummary {
specs_removed: 3,
runs_removed: 0,
bytes_freed: 1500,
errors: vec![],
};
assert_eq!(summary.specs_removed, 3);
assert_eq!(summary.bytes_freed, 1500);
}
#[test]
fn test_us005_data_cleanup_summary_with_runs() {
let summary = DataCleanupSummary {
specs_removed: 0,
runs_removed: 5,
bytes_freed: 5000,
errors: vec![],
};
assert_eq!(summary.runs_removed, 5);
assert_eq!(summary.bytes_freed, 5000);
}
#[test]
fn test_us005_data_cleanup_summary_with_both() {
let summary = DataCleanupSummary {
specs_removed: 2,
runs_removed: 4,
bytes_freed: 6000,
errors: vec![],
};
assert_eq!(summary.specs_removed, 2);
assert_eq!(summary.runs_removed, 4);
assert_eq!(summary.bytes_freed, 6000);
}
#[test]
fn test_us005_data_cleanup_summary_with_errors() {
let summary = DataCleanupSummary {
specs_removed: 1,
runs_removed: 2,
bytes_freed: 3000,
errors: vec![
"Failed to remove spec1.json: permission denied".to_string(),
"Failed to remove run1.json: file busy".to_string(),
],
};
assert_eq!(summary.specs_removed, 1);
assert_eq!(summary.runs_removed, 2);
assert_eq!(summary.errors.len(), 2);
assert!(summary.errors[0].contains("permission denied"));
assert!(summary.errors[1].contains("file busy"));
}
#[test]
fn test_us005_data_cleanup_partial_success() {
let summary = DataCleanupSummary {
specs_removed: 3, runs_removed: 8, bytes_freed: 11000,
errors: vec![
"Failed to remove spec-active1.json".to_string(),
"Failed to remove spec-active2.json".to_string(),
"Failed to remove run-archived1.json".to_string(),
"Failed to remove run-archived2.json".to_string(),
],
};
assert_eq!(summary.specs_removed, 3);
assert_eq!(summary.runs_removed, 8);
assert_eq!(summary.errors.len(), 4);
}
#[test]
fn test_us005_clean_data_direct_nonexistent_project() {
let result = clean_data_direct("nonexistent-project-us005-test");
assert!(result.is_err() || result.as_ref().unwrap().specs_removed == 0);
}
#[test]
fn test_us005_clean_data_with_temp_dir() {
let temp_dir = TempDir::new().unwrap();
let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
let spec_dir = sm.spec_dir();
let runs_dir = sm.runs_dir();
fs::create_dir_all(&spec_dir).unwrap();
fs::create_dir_all(&runs_dir).unwrap();
fs::write(spec_dir.join("spec-feature1.json"), "{}").unwrap();
fs::write(spec_dir.join("spec-feature1.md"), "# Feature 1").unwrap();
fs::write(spec_dir.join("spec-feature2.json"), "{}").unwrap();
fs::write(runs_dir.join("run-2024-01-01.json"), "{}").unwrap();
fs::write(runs_dir.join("run-2024-01-02.json"), "{}").unwrap();
assert!(spec_dir.join("spec-feature1.json").exists());
assert!(spec_dir.join("spec-feature1.md").exists());
assert!(spec_dir.join("spec-feature2.json").exists());
assert!(runs_dir.join("run-2024-01-01.json").exists());
assert!(runs_dir.join("run-2024-01-02.json").exists());
}
#[test]
fn test_us005_spec_pairs_deleted_together() {
let temp_dir = TempDir::new().unwrap();
let spec_dir = temp_dir.path().join("spec");
fs::create_dir_all(&spec_dir).unwrap();
let json_path = spec_dir.join("spec-test.json");
let md_path = spec_dir.join("spec-test.md");
fs::write(&json_path, r#"{"name": "test"}"#).unwrap();
fs::write(&md_path, "# Test Spec\nDescription").unwrap();
assert!(json_path.exists());
assert!(md_path.exists());
let json_deleted = fs::remove_file(&json_path).is_ok();
let md_deleted = fs::remove_file(&md_path).is_ok();
assert!(json_deleted);
assert!(md_deleted);
assert!(!json_path.exists());
assert!(!md_path.exists());
}
#[test]
fn test_us005_orphaned_md_still_deleted() {
let temp_dir = TempDir::new().unwrap();
let spec_dir = temp_dir.path().join("spec");
fs::create_dir_all(&spec_dir).unwrap();
let orphan_md = spec_dir.join("orphan.md");
fs::write(&orphan_md, "# Orphaned markdown").unwrap();
assert!(orphan_md.exists());
}
#[test]
fn test_us005_errors_collected_for_all_failures() {
let summary = DataCleanupSummary {
specs_removed: 2,
runs_removed: 3,
bytes_freed: 5000,
errors: vec![
"Failed to remove spec-locked.json: file is locked".to_string(),
"Failed to remove spec-locked.md: file is locked".to_string(),
"Failed to remove run-locked.json: permission denied".to_string(),
],
};
assert_eq!(summary.errors.len(), 3);
assert!(summary.specs_removed > 0);
assert!(summary.runs_removed > 0);
}
#[test]
fn test_us005_bytes_freed_calculated_correctly() {
let temp_dir = TempDir::new().unwrap();
let file1 = temp_dir.path().join("file1.txt");
let file2 = temp_dir.path().join("file2.txt");
fs::write(&file1, "hello").unwrap(); fs::write(&file2, "world!").unwrap();
let size1 = fs::metadata(&file1).unwrap().len();
let size2 = fs::metadata(&file2).unwrap().len();
let total_freed = size1 + size2;
assert_eq!(total_freed, 11);
fs::remove_file(file1).unwrap();
fs::remove_file(file2).unwrap();
}
#[test]
fn test_us007_active_session_specs_not_counted_as_cleanable() {
let temp_dir = TempDir::new().unwrap();
let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
let spec_dir = sm.spec_dir();
fs::create_dir_all(&spec_dir).unwrap();
let active_spec = spec_dir.join("spec-active-feature.json");
let cleanable_spec = spec_dir.join("spec-cleanable-feature.json");
fs::write(&active_spec, r#"{"name": "active"}"#).unwrap();
fs::write(&cleanable_spec, r#"{"name": "cleanable"}"#).unwrap();
assert!(active_spec.exists());
assert!(cleanable_spec.exists());
let mut active_spec_paths = std::collections::HashSet::new();
active_spec_paths.insert(active_spec.clone());
assert!(
active_spec_paths.contains(&active_spec),
"Active spec should be in the exclusion set"
);
assert!(
!active_spec_paths.contains(&cleanable_spec),
"Cleanable spec should NOT be in the exclusion set"
);
let json_files: Vec<PathBuf> = fs::read_dir(&spec_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map(|e| e == "json").unwrap_or(false))
.filter(|p| !active_spec_paths.contains(p))
.collect();
assert_eq!(json_files.len(), 1, "Only 1 spec should be cleanable");
assert_eq!(
json_files[0], cleanable_spec,
"The cleanable spec should be the non-active one"
);
}
#[test]
fn test_us007_active_session_md_path_also_excluded() {
let temp_dir = TempDir::new().unwrap();
let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
let spec_dir = sm.spec_dir();
fs::create_dir_all(&spec_dir).unwrap();
let json_path = spec_dir.join("spec-feature.json");
let md_path = spec_dir.join("spec-feature.md");
fs::write(&json_path, "{}").unwrap();
fs::write(&md_path, "# Feature").unwrap();
let mut active_spec_paths = std::collections::HashSet::new();
active_spec_paths.insert(json_path.clone());
active_spec_paths.insert(md_path.clone());
assert!(active_spec_paths.contains(&json_path));
assert!(active_spec_paths.contains(&md_path));
let cleanable_json_files: Vec<PathBuf> = fs::read_dir(&spec_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map(|e| e == "json").unwrap_or(false))
.filter(|p| !active_spec_paths.contains(p))
.collect();
assert_eq!(
cleanable_json_files.len(),
0,
"No specs should be cleanable when active"
);
}
#[test]
fn test_us007_runs_are_always_cleanable() {
let temp_dir = TempDir::new().unwrap();
let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
let runs_dir = sm.runs_dir();
fs::create_dir_all(&runs_dir).unwrap();
fs::write(runs_dir.join("run-2024-01-01.json"), "{}").unwrap();
fs::write(runs_dir.join("run-2024-01-02.json"), "{}").unwrap();
fs::write(runs_dir.join("run-2024-01-03.json"), "{}").unwrap();
let cleanable_runs = fs::read_dir(&runs_dir)
.map(|entries| entries.filter_map(|e| e.ok()).count())
.unwrap_or(0);
assert_eq!(cleanable_runs, 3, "All 3 runs should be cleanable");
}
#[test]
fn test_us007_spec_pairs_counted_as_one() {
let temp_dir = TempDir::new().unwrap();
let spec_dir = temp_dir.path();
fs::write(spec_dir.join("spec-feature1.json"), "{}").unwrap();
fs::write(spec_dir.join("spec-feature1.md"), "# Feature 1").unwrap();
fs::write(spec_dir.join("spec-feature2.json"), "{}").unwrap();
fs::write(spec_dir.join("spec-feature2.md"), "# Feature 2").unwrap();
fs::write(spec_dir.join("spec-feature3.json"), "{}").unwrap();
let spec_count = fs::read_dir(spec_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "json")
.unwrap_or(false)
})
.count();
assert_eq!(spec_count, 3, "Should count 3 specs (pairs counted as 1)");
}
#[test]
fn test_us007_orphaned_md_files_not_deleted() {
let temp_dir = TempDir::new().unwrap();
let spec_dir = temp_dir.path().join("spec");
fs::create_dir_all(&spec_dir).unwrap();
let orphan_md = spec_dir.join("orphan-notes.md");
fs::write(&orphan_md, "# Some notes").unwrap();
let spec_json = spec_dir.join("spec-feature.json");
let spec_md = spec_dir.join("spec-feature.md");
fs::write(&spec_json, "{}").unwrap();
fs::write(&spec_md, "# Feature").unwrap();
let json_specs: Vec<PathBuf> = fs::read_dir(&spec_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map(|e| e == "json").unwrap_or(false))
.collect();
assert_eq!(json_specs.len(), 1);
assert_eq!(json_specs[0], spec_json);
assert!(
!json_specs.contains(&orphan_md),
"Orphan .md should not be in the cleanup list"
);
}
#[test]
fn test_us007_orphaned_md_not_counted_as_spec() {
let temp_dir = TempDir::new().unwrap();
let spec_dir = temp_dir.path();
fs::write(spec_dir.join("spec-feature.json"), "{}").unwrap();
fs::write(spec_dir.join("spec-feature.md"), "# Feature").unwrap();
fs::write(spec_dir.join("orphan1.md"), "# Orphan 1").unwrap();
fs::write(spec_dir.join("orphan2.md"), "# Orphan 2").unwrap();
let total_files = fs::read_dir(spec_dir)
.unwrap()
.filter_map(|e| e.ok())
.count();
let spec_count = fs::read_dir(spec_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "json")
.unwrap_or(false)
})
.count();
assert_eq!(total_files, 4, "Total files should be 4");
assert_eq!(
spec_count, 1,
"Spec count should be 1 (orphaned .md not counted)"
);
}
#[test]
fn test_us007_data_cleanup_summary_combined() {
let summary = DataCleanupSummary {
specs_removed: 3,
runs_removed: 5,
bytes_freed: 8000,
errors: vec![],
};
assert_eq!(summary.specs_removed, 3);
assert_eq!(summary.runs_removed, 5);
assert_eq!(summary.bytes_freed, 8000);
let total_items = summary.specs_removed + summary.runs_removed;
assert_eq!(total_items, 8, "Total items cleaned should be 8");
}
#[test]
fn test_us007_clean_data_excludes_active_session_spec_integration() {
let temp_dir = TempDir::new().unwrap();
let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
let spec_dir = sm.spec_dir();
fs::create_dir_all(&spec_dir).unwrap();
let active_spec_json = spec_dir.join("spec-active.json");
let active_spec_md = spec_dir.join("spec-active.md");
let inactive_spec_json = spec_dir.join("spec-inactive.json");
let inactive_spec_md = spec_dir.join("spec-inactive.md");
fs::write(&active_spec_json, "{}").unwrap();
fs::write(&active_spec_md, "# Active").unwrap();
fs::write(&inactive_spec_json, "{}").unwrap();
fs::write(&inactive_spec_md, "# Inactive").unwrap();
let mut active_spec_paths: std::collections::HashSet<PathBuf> =
std::collections::HashSet::new();
active_spec_paths.insert(active_spec_json.clone());
active_spec_paths.insert(active_spec_md.clone());
let json_specs: Vec<PathBuf> = fs::read_dir(&spec_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map(|e| e == "json").unwrap_or(false))
.collect();
let mut specs_removed = 0;
for json_path in json_specs {
if active_spec_paths.contains(&json_path) {
continue;
}
specs_removed += 1;
assert_eq!(json_path, inactive_spec_json);
}
assert_eq!(specs_removed, 1, "Only 1 spec should be removed");
}
#[test]
fn test_us007_multiple_active_sessions_all_excluded() {
let temp_dir = TempDir::new().unwrap();
let spec_dir = temp_dir.path();
let spec1 = spec_dir.join("spec-session1.json");
let spec2 = spec_dir.join("spec-session2.json");
let spec3 = spec_dir.join("spec-session3.json");
fs::write(&spec1, "{}").unwrap();
fs::write(&spec2, "{}").unwrap();
fs::write(&spec3, "{}").unwrap();
let mut active_spec_paths: std::collections::HashSet<PathBuf> =
std::collections::HashSet::new();
active_spec_paths.insert(spec1.clone());
active_spec_paths.insert(spec2.clone());
let cleanable: Vec<PathBuf> = fs::read_dir(spec_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map(|e| e == "json").unwrap_or(false))
.filter(|p| !active_spec_paths.contains(p))
.collect();
assert_eq!(cleanable.len(), 1);
assert_eq!(cleanable[0], spec3);
}
#[test]
fn test_us007_empty_active_sessions_all_specs_cleanable() {
let temp_dir = TempDir::new().unwrap();
let spec_dir = temp_dir.path();
fs::write(spec_dir.join("spec1.json"), "{}").unwrap();
fs::write(spec_dir.join("spec2.json"), "{}").unwrap();
fs::write(spec_dir.join("spec3.json"), "{}").unwrap();
let active_spec_paths: std::collections::HashSet<PathBuf> =
std::collections::HashSet::new();
let cleanable: Vec<PathBuf> = fs::read_dir(spec_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map(|e| e == "json").unwrap_or(false))
.filter(|p| !active_spec_paths.contains(p))
.collect();
assert_eq!(cleanable.len(), 3, "All 3 specs should be cleanable");
}
#[test]
fn test_us007_existing_tests_still_pass() {
let summary = CleanupSummary::default();
assert_eq!(summary.sessions_removed, 0);
assert!(summary.errors.is_empty());
let data_summary = DataCleanupSummary::default();
assert_eq!(data_summary.specs_removed, 0);
assert_eq!(data_summary.runs_removed, 0);
assert_eq!(format_bytes(1024), "1.0 KB");
assert_eq!(format_bytes(1048576), "1.0 MB");
}
}