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
11fn archive_dir(storage: &Storage) -> PathBuf {
13 storage.scud_dir().join("archive")
14}
15
16fn 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 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 archives.sort_by(|a, b| b.1.cmp(&a.1)); Ok(archives)
47}
48
49fn 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 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 let content = serialize_scg(phase);
66 fs::write(&archive_file, content).context("Failed to write archive file")?;
67
68 Ok(archive_filename)
69}
70
71fn restore_phase(storage: &Storage, archive_name: &str, force: bool) -> Result<String> {
81 let archive_path = archive_dir(storage);
82
83 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 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 let mut all_tasks = storage.load_tasks()?;
101 if all_tasks.contains_key(&tag_name) {
102 if force {
103 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 all_tasks.insert(tag_name.clone(), phase);
119 storage.save_tasks(&all_tasks)?;
120
121 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 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 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 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 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 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 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 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 let mut archived_files = vec![];
278 let mut cleaned_tags = vec![];
279
280 for tag_name in &tags_to_clean {
281 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 all_tasks.remove(tag_name);
294 cleaned_tags.push(tag_name.clone());
295
296 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 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 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 let path = storage.archive_phase("v1", &phases).unwrap();
362
363 assert!(path.exists());
365 assert!(path.to_string_lossy().contains("v1"));
366 assert!(path.extension().unwrap() == "scg");
367
368 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 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 let path = storage.archive_all(&phases).unwrap();
400
401 assert!(path.exists());
403 assert!(path.to_string_lossy().contains("all"));
404
405 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 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 storage.archive_phase("v1", &phases).unwrap();
429
430 let archives = storage.list_archives().unwrap();
432
433 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 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 let archive_path = storage.archive_phase("v1", &phases).unwrap();
457 let archive_name = archive_path.file_name().unwrap().to_str().unwrap();
458
459 storage.save_tasks(&HashMap::new()).unwrap();
461 let empty_check = storage.load_tasks().unwrap();
462 assert!(empty_check.is_empty());
463
464 let restored = storage.restore_archive(archive_name, false).unwrap();
466 assert_eq!(restored, vec!["v1".to_string()]);
467
468 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 #[test]
477 fn test_archive_duplicate_filename_uses_counter() {
478 let (_temp, storage) = setup_test_storage();
479
480 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 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 assert!(path1.exists());
493 assert!(path2.exists());
494 assert!(path3.exists());
495 assert_ne!(path1, path2);
496 assert_ne!(path2, path3);
497
498 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 let phases = HashMap::new();
511
512 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 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 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 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 let archive_filename = archive_phase(&storage, "v1").unwrap();
558
559 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 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 let archive_filename = archive_phase(&storage, "v1").unwrap();
584
585 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(¤t).unwrap();
593
594 let check = storage.load_tasks().unwrap();
596 assert_eq!(check.get("v1").unwrap().tasks.len(), 2);
597
598 let result = restore_phase(&storage, &archive_filename, true);
600 assert!(result.is_ok());
601 assert_eq!(result.unwrap(), "v1");
602
603 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 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 let archive_filename = archive_phase(&storage, "v1").unwrap();
629
630 storage.save_tasks(&HashMap::new()).unwrap();
632
633 let result = restore_phase(&storage, &archive_filename, false);
635 assert!(result.is_ok());
636 assert_eq!(result.unwrap(), "v1");
637
638 let current = storage.load_tasks().unwrap();
640 assert!(current.contains_key("v1"));
641 assert_eq!(current.get("v1").unwrap().tasks.len(), 1);
642 }
643}