scud/commands/
clean.rs

1use anyhow::{Context, Result};
2use chrono::Local;
3use colored::Colorize;
4use dialoguer::Confirm;
5use std::fs;
6use std::path::PathBuf;
7
8use crate::formats::{parse_scg, serialize_scg};
9use crate::storage::Storage;
10
11/// Archive directory path within .scud
12fn archive_dir(storage: &Storage) -> PathBuf {
13    storage.scud_dir().join("archive")
14}
15
16/// List all archived phases
17fn list_archives(storage: &Storage) -> Result<Vec<(String, String)>> {
18    let archive_path = archive_dir(storage);
19    if !archive_path.exists() {
20        return Ok(vec![]);
21    }
22
23    let mut archives = vec![];
24    for entry in fs::read_dir(&archive_path)? {
25        let entry = entry?;
26        let path = entry.path();
27        if path.extension().map(|e| e == "scg").unwrap_or(false) {
28            let filename = path
29                .file_stem()
30                .and_then(|s| s.to_str())
31                .unwrap_or("unknown")
32                .to_string();
33            // Extract tag name from filename (format: tag_YYYYMMDD_HHMMSS)
34            let parts: Vec<&str> = filename.rsplitn(3, '_').collect();
35            let tag_name = if parts.len() >= 3 {
36                parts[2].to_string()
37            } else {
38                filename.clone()
39            };
40            archives.push((tag_name, filename));
41        }
42    }
43
44    // Sort by filename (which includes timestamp)
45    archives.sort_by(|a, b| b.1.cmp(&a.1)); // Most recent first
46    Ok(archives)
47}
48
49/// Archive a phase to .scud/archive/
50fn archive_phase(storage: &Storage, tag: &str) -> Result<String> {
51    let all_tasks = storage.load_tasks()?;
52    let phase = all_tasks
53        .get(tag)
54        .ok_or_else(|| anyhow::anyhow!("Tag '{}' not found", tag))?;
55
56    let archive_path = archive_dir(storage);
57    fs::create_dir_all(&archive_path).context("Failed to create archive directory")?;
58
59    // Generate archive filename with timestamp
60    let timestamp = Local::now().format("%Y%m%d_%H%M%S");
61    let archive_filename = format!("{}_{}.scg", tag, timestamp);
62    let archive_file = archive_path.join(&archive_filename);
63
64    // Serialize and write the phase
65    let content = serialize_scg(phase);
66    fs::write(&archive_file, content).context("Failed to write archive file")?;
67
68    Ok(archive_filename)
69}
70
71/// Restore a phase from archive
72///
73/// # Arguments
74/// * `storage` - Storage instance
75/// * `archive_name` - Name of the archive file (with or without .scg extension)
76/// * `force` - If true, replace existing tags; if false, error on conflict
77///
78/// # Returns
79/// The tag name that was restored
80fn restore_phase(storage: &Storage, archive_name: &str, force: bool) -> Result<String> {
81    let archive_path = archive_dir(storage);
82
83    // Find the archive file
84    let archive_file = if archive_name.ends_with(".scg") {
85        archive_path.join(archive_name)
86    } else {
87        archive_path.join(format!("{}.scg", archive_name))
88    };
89
90    if !archive_file.exists() {
91        anyhow::bail!("Archive '{}' not found", archive_name);
92    }
93
94    // Read and parse the archived phase
95    let content = fs::read_to_string(&archive_file).context("Failed to read archive file")?;
96    let phase = parse_scg(&content).context("Failed to parse archive file")?;
97    let tag_name = phase.name.clone();
98
99    // Load current tasks and check for conflicts
100    let mut all_tasks = storage.load_tasks()?;
101    if all_tasks.contains_key(&tag_name) {
102        if force {
103            // With --force, replace the existing tag
104            println!(
105                "{} Replacing existing tag '{}'",
106                "⚠".yellow(),
107                tag_name.cyan()
108            );
109        } else {
110            anyhow::bail!(
111                "Tag '{}' already exists. Use --force to replace it.",
112                tag_name
113            );
114        }
115    }
116
117    // Add the restored phase (replaces if exists)
118    all_tasks.insert(tag_name.clone(), phase);
119    storage.save_tasks(&all_tasks)?;
120
121    // Remove the archive file after successful restore
122    fs::remove_file(&archive_file).context("Failed to remove archive file after restore")?;
123
124    Ok(tag_name)
125}
126
127pub fn run(
128    project_root: Option<PathBuf>,
129    force: bool,
130    tag: Option<&str>,
131    keep: &[String],
132    delete: bool,
133    list: bool,
134    restore: Option<&str>,
135) -> Result<()> {
136    let storage = Storage::new(project_root);
137
138    if !storage.is_initialized() {
139        anyhow::bail!("SCUD not initialized. Run: scud init");
140    }
141
142    // Handle --list flag
143    if list {
144        let archives = list_archives(&storage)?;
145        if archives.is_empty() {
146            println!("{}", "No archived phases found.".yellow());
147        } else {
148            println!("{}", "Archived phases:".cyan().bold());
149            println!();
150            for (tag_name, filename) in archives {
151                println!(
152                    "  {} {}",
153                    tag_name.green(),
154                    format!("({})", filename).dimmed()
155                );
156            }
157            println!();
158            println!(
159                "{}",
160                "Use 'scud clean --restore <filename>' to restore".dimmed()
161            );
162        }
163        return Ok(());
164    }
165
166    // Handle --restore flag
167    if let Some(archive_name) = restore {
168        let restored_tag = restore_phase(&storage, archive_name, force)?;
169        println!();
170        println!(
171            "{} Restored tag '{}' from archive",
172            "✓".green(),
173            restored_tag.cyan()
174        );
175        println!();
176        return Ok(());
177    }
178
179    let mut all_tasks = storage.load_tasks()?;
180
181    if all_tasks.is_empty() {
182        println!("{}", "No tasks to clean.".yellow());
183        return Ok(());
184    }
185
186    // Determine which tags to clean
187    let tags_to_clean: Vec<String> = if let Some(tag_name) = tag {
188        if !all_tasks.contains_key(tag_name) {
189            anyhow::bail!("Tag '{}' not found", tag_name);
190        }
191        if keep.contains(&tag_name.to_string()) {
192            anyhow::bail!("Cannot clean tag '{}' - it's in the keep list", tag_name);
193        }
194        vec![tag_name.to_string()]
195    } else {
196        // Clean all tags except those in --keep
197        all_tasks
198            .keys()
199            .filter(|t| !keep.contains(t))
200            .cloned()
201            .collect()
202    };
203
204    if tags_to_clean.is_empty() {
205        println!("{}", "No tags to clean (all kept).".yellow());
206        return Ok(());
207    }
208
209    // Calculate totals for confirmation message
210    let total_tasks: usize = tags_to_clean
211        .iter()
212        .filter_map(|t| all_tasks.get(t))
213        .map(|p| p.tasks.len())
214        .sum();
215
216    // Build confirmation message based on action type
217    let action_word = if delete { "Delete" } else { "Archive" };
218    let (confirm_msg, warning_msg) = if tags_to_clean.len() == 1 {
219        let tag_name = &tags_to_clean[0];
220        (
221            format!(
222                "{} {} tasks from tag '{}'?",
223                action_word,
224                total_tasks.to_string().cyan(),
225                tag_name.cyan()
226            ),
227            if delete {
228                "This action cannot be undone!".to_string()
229            } else {
230                "Tasks will be archived to .scud/archive/".to_string()
231            },
232        )
233    } else {
234        let kept_msg = if !keep.is_empty() {
235            format!(" (keeping: {})", keep.join(", ").green())
236        } else {
237            String::new()
238        };
239        (
240            format!(
241                "{} {} tasks across {} tags{}?",
242                action_word,
243                total_tasks.to_string().cyan(),
244                tags_to_clean.len().to_string().cyan(),
245                kept_msg
246            ),
247            if delete {
248                "This action cannot be undone!".to_string()
249            } else {
250                "Tasks will be archived to .scud/archive/".to_string()
251            },
252        )
253    };
254
255    // Confirm unless --force
256    if !force {
257        println!();
258        if delete {
259            println!("{}", format!("⚠ WARNING: {}", warning_msg).red().bold());
260        } else {
261            println!("{}", format!("ℹ {}", warning_msg).blue());
262        }
263        println!();
264
265        let confirmed = Confirm::new()
266            .with_prompt(confirm_msg)
267            .default(false)
268            .interact()?;
269
270        if !confirmed {
271            println!("{}", "Cancelled.".yellow());
272            return Ok(());
273        }
274    }
275
276    // Perform the clean
277    let mut archived_files = vec![];
278    let mut cleaned_tags = vec![];
279
280    for tag_name in &tags_to_clean {
281        // Archive first (unless --delete)
282        if !delete {
283            match archive_phase(&storage, tag_name) {
284                Ok(filename) => archived_files.push((tag_name.clone(), filename)),
285                Err(e) => {
286                    eprintln!("{} Failed to archive '{}': {}", "✗".red(), tag_name, e);
287                    continue;
288                }
289            }
290        }
291
292        // Remove the tag from active tasks
293        all_tasks.remove(tag_name);
294        cleaned_tags.push(tag_name.clone());
295
296        // Clear active tag if it was the one we cleaned
297        if let Ok(Some(active)) = storage.get_active_group() {
298            if &active == tag_name {
299                let _ = storage.clear_active_group();
300            }
301        }
302    }
303
304    storage.save_tasks(&all_tasks)?;
305
306    // Print results
307    println!();
308    if delete {
309        println!(
310            "{} Deleted {} tag(s): {}",
311            "✓".green(),
312            cleaned_tags.len(),
313            cleaned_tags.join(", ").cyan()
314        );
315    } else {
316        println!("{} Archived {} tag(s):", "✓".green(), archived_files.len());
317        for (tag_name, filename) in &archived_files {
318            println!("  {} → {}", tag_name.cyan(), filename.dimmed());
319        }
320        println!();
321        println!(
322            "{}",
323            "Use 'scud clean --list' to see archives, '--restore <name>' to restore".dimmed()
324        );
325    }
326    println!();
327
328    Ok(())
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use crate::models::{Phase, Task};
335    use std::collections::HashMap;
336    use tempfile::TempDir;
337
338    fn setup_test_storage() -> (TempDir, Storage) {
339        let temp_dir = TempDir::new().unwrap();
340        let storage = Storage::new(Some(temp_dir.path().to_path_buf()));
341        storage.initialize().unwrap();
342        (temp_dir, storage)
343    }
344
345    #[test]
346    fn test_archive_single_tag() {
347        let (_temp, storage) = setup_test_storage();
348
349        // Create test tasks with one tag
350        let mut phases = HashMap::new();
351        let mut phase = Phase::new("v1".to_string());
352        phase.add_task(Task::new(
353            "task-1".to_string(),
354            "Test Task".to_string(),
355            "Test description".to_string(),
356        ));
357        phases.insert("v1".to_string(), phase);
358        storage.save_tasks(&phases).unwrap();
359
360        // Archive single tag
361        let path = storage.archive_phase("v1", &phases).unwrap();
362
363        // Verify archive was created
364        assert!(path.exists());
365        assert!(path.to_string_lossy().contains("v1"));
366        assert!(path.extension().unwrap() == "scg");
367
368        // Verify archive contains the correct data
369        let loaded = storage.load_archive(&path).unwrap();
370        assert_eq!(loaded.len(), 1);
371        assert!(loaded.contains_key("v1"));
372        assert_eq!(loaded.get("v1").unwrap().tasks.len(), 1);
373    }
374
375    #[test]
376    fn test_archive_all() {
377        let (_temp, storage) = setup_test_storage();
378
379        // Create multiple tags
380        let mut phases = HashMap::new();
381        let mut phase1 = Phase::new("v1".to_string());
382        phase1.add_task(Task::new(
383            "task-1".to_string(),
384            "Task 1".to_string(),
385            "Desc 1".to_string(),
386        ));
387        phases.insert("v1".to_string(), phase1);
388
389        let mut phase2 = Phase::new("v2".to_string());
390        phase2.add_task(Task::new(
391            "task-2".to_string(),
392            "Task 2".to_string(),
393            "Desc 2".to_string(),
394        ));
395        phases.insert("v2".to_string(), phase2);
396        storage.save_tasks(&phases).unwrap();
397
398        // Archive all
399        let path = storage.archive_all(&phases).unwrap();
400
401        // Verify archive was created with "all" in filename
402        assert!(path.exists());
403        assert!(path.to_string_lossy().contains("all"));
404
405        // Verify archive contains all phases
406        let loaded = storage.load_archive(&path).unwrap();
407        assert_eq!(loaded.len(), 2);
408        assert!(loaded.contains_key("v1"));
409        assert!(loaded.contains_key("v2"));
410    }
411
412    #[test]
413    fn test_list_archives() {
414        let (_temp, storage) = setup_test_storage();
415
416        // Create and archive a phase
417        let mut phases = HashMap::new();
418        let mut phase = Phase::new("v1".to_string());
419        phase.add_task(Task::new(
420            "task-1".to_string(),
421            "Test Task".to_string(),
422            "Test description".to_string(),
423        ));
424        phases.insert("v1".to_string(), phase);
425        storage.save_tasks(&phases).unwrap();
426
427        // Archive it
428        storage.archive_phase("v1", &phases).unwrap();
429
430        // List archives
431        let archives = storage.list_archives().unwrap();
432
433        // Verify archive appears in list
434        assert_eq!(archives.len(), 1);
435        assert_eq!(archives[0].tag, Some("v1".to_string()));
436        assert_eq!(archives[0].task_count, 1);
437        assert!(archives[0].filename.contains("v1"));
438    }
439
440    #[test]
441    fn test_restore_archive() {
442        let (_temp, storage) = setup_test_storage();
443
444        // Create and archive a phase
445        let mut phases = HashMap::new();
446        let mut phase = Phase::new("v1".to_string());
447        phase.add_task(Task::new(
448            "task-1".to_string(),
449            "Test Task".to_string(),
450            "Test description".to_string(),
451        ));
452        phases.insert("v1".to_string(), phase);
453        storage.save_tasks(&phases).unwrap();
454
455        // Archive
456        let archive_path = storage.archive_phase("v1", &phases).unwrap();
457        let archive_name = archive_path.file_name().unwrap().to_str().unwrap();
458
459        // Clear current tasks
460        storage.save_tasks(&HashMap::new()).unwrap();
461        let empty_check = storage.load_tasks().unwrap();
462        assert!(empty_check.is_empty());
463
464        // Restore
465        let restored = storage.restore_archive(archive_name, false).unwrap();
466        assert_eq!(restored, vec!["v1".to_string()]);
467
468        // Verify restored data
469        let current = storage.load_tasks().unwrap();
470        assert!(current.contains_key("v1"));
471        assert_eq!(current.get("v1").unwrap().tasks.len(), 1);
472    }
473
474    // Edge case tests
475
476    #[test]
477    fn test_archive_duplicate_filename_uses_counter() {
478        let (_temp, storage) = setup_test_storage();
479
480        // Create test phase
481        let mut phases = HashMap::new();
482        let phase = Phase::new("v1".to_string());
483        phases.insert("v1".to_string(), phase);
484        storage.save_tasks(&phases).unwrap();
485
486        // Archive the same tag multiple times on the same day
487        let path1 = storage.archive_phase("v1", &phases).unwrap();
488        let path2 = storage.archive_phase("v1", &phases).unwrap();
489        let path3 = storage.archive_phase("v1", &phases).unwrap();
490
491        // All paths should be unique
492        assert!(path1.exists());
493        assert!(path2.exists());
494        assert!(path3.exists());
495        assert_ne!(path1, path2);
496        assert_ne!(path2, path3);
497
498        // Second and third should have counter suffix
499        let filename2 = path2.file_name().unwrap().to_string_lossy();
500        let filename3 = path3.file_name().unwrap().to_string_lossy();
501        assert!(filename2.contains("_1.scg") || filename2.contains("_v1_1.scg"));
502        assert!(filename3.contains("_2.scg") || filename3.contains("_v1_2.scg"));
503    }
504
505    #[test]
506    fn test_archive_nonexistent_tag_fails() {
507        let (_temp, storage) = setup_test_storage();
508
509        // Empty phases
510        let phases = HashMap::new();
511
512        // Try to archive a non-existent tag
513        let result = storage.archive_phase("nonexistent", &phases);
514
515        assert!(result.is_err());
516        let err = result.unwrap_err().to_string();
517        assert!(err.contains("not found"));
518    }
519
520    #[test]
521    fn test_restore_nonexistent_archive_fails() {
522        let (_temp, storage) = setup_test_storage();
523
524        // Try to restore an archive that doesn't exist
525        let result = storage.restore_archive("nonexistent", false);
526
527        assert!(result.is_err());
528        let err = result.unwrap_err().to_string();
529        assert!(err.contains("not found"));
530    }
531
532    #[test]
533    fn test_list_archives_empty() {
534        let (_temp, storage) = setup_test_storage();
535
536        // No archives created yet
537        let archives = storage.list_archives().unwrap();
538        assert!(archives.is_empty());
539    }
540
541    #[test]
542    fn test_restore_phase_without_force_errors_on_conflict() {
543        let (_temp, storage) = setup_test_storage();
544
545        // Create and archive a phase
546        let mut phases = HashMap::new();
547        let mut phase = Phase::new("v1".to_string());
548        phase.add_task(Task::new(
549            "task-1".to_string(),
550            "Original Task".to_string(),
551            "Original description".to_string(),
552        ));
553        phases.insert("v1".to_string(), phase);
554        storage.save_tasks(&phases).unwrap();
555
556        // Archive it using the local archive_phase function
557        let archive_filename = archive_phase(&storage, "v1").unwrap();
558
559        // Try to restore without force - should error because v1 still exists
560        let result = restore_phase(&storage, &archive_filename, false);
561        assert!(result.is_err());
562        let err = result.unwrap_err().to_string();
563        assert!(err.contains("already exists"));
564        assert!(err.contains("--force"));
565    }
566
567    #[test]
568    fn test_restore_phase_with_force_replaces_existing() {
569        let (_temp, storage) = setup_test_storage();
570
571        // Create and archive a phase with 1 task
572        let mut phases = HashMap::new();
573        let mut phase = Phase::new("v1".to_string());
574        phase.add_task(Task::new(
575            "task-1".to_string(),
576            "Original Task".to_string(),
577            "Original description".to_string(),
578        ));
579        phases.insert("v1".to_string(), phase);
580        storage.save_tasks(&phases).unwrap();
581
582        // Archive it
583        let archive_filename = archive_phase(&storage, "v1").unwrap();
584
585        // Modify current tasks - add a second task
586        let mut current = storage.load_tasks().unwrap();
587        current.get_mut("v1").unwrap().add_task(Task::new(
588            "task-2".to_string(),
589            "New Task".to_string(),
590            "New description".to_string(),
591        ));
592        storage.save_tasks(&current).unwrap();
593
594        // Verify we have 2 tasks now
595        let check = storage.load_tasks().unwrap();
596        assert_eq!(check.get("v1").unwrap().tasks.len(), 2);
597
598        // Restore with force - should replace
599        let result = restore_phase(&storage, &archive_filename, true);
600        assert!(result.is_ok());
601        assert_eq!(result.unwrap(), "v1");
602
603        // Verify we're back to 1 task (the archived version)
604        let final_tasks = storage.load_tasks().unwrap();
605        assert_eq!(final_tasks.get("v1").unwrap().tasks.len(), 1);
606        assert_eq!(
607            final_tasks.get("v1").unwrap().tasks[0].title,
608            "Original Task"
609        );
610    }
611
612    #[test]
613    fn test_restore_phase_to_empty_tasks() {
614        let (_temp, storage) = setup_test_storage();
615
616        // Create and archive a phase
617        let mut phases = HashMap::new();
618        let mut phase = Phase::new("v1".to_string());
619        phase.add_task(Task::new(
620            "task-1".to_string(),
621            "Test Task".to_string(),
622            "Test description".to_string(),
623        ));
624        phases.insert("v1".to_string(), phase);
625        storage.save_tasks(&phases).unwrap();
626
627        // Archive it
628        let archive_filename = archive_phase(&storage, "v1").unwrap();
629
630        // Clear current tasks
631        storage.save_tasks(&HashMap::new()).unwrap();
632
633        // Restore (no force needed since tag doesn't exist)
634        let result = restore_phase(&storage, &archive_filename, false);
635        assert!(result.is_ok());
636        assert_eq!(result.unwrap(), "v1");
637
638        // Verify restored
639        let current = storage.load_tasks().unwrap();
640        assert!(current.contains_key("v1"));
641        assert_eq!(current.get("v1").unwrap().tasks.len(), 1);
642    }
643}