1use anyhow::{Context, Result};
2use chrono::Local;
3use fs2::FileExt;
4use std::collections::HashMap;
5use std::fs::{self, File, OpenOptions};
6use std::path::{Path, PathBuf};
7use std::sync::RwLock;
8use std::thread;
9use std::time::Duration;
10
11use crate::config::Config;
12use crate::formats::{parse_scg, serialize_scg};
13use crate::models::Phase;
14
15#[derive(Debug, Clone)]
17pub struct ArchiveInfo {
18 pub filename: String,
20 pub path: PathBuf,
22 pub date: String,
24 pub tag: Option<String>,
26 pub task_count: usize,
28}
29
30pub struct Storage {
31 project_root: PathBuf,
32 active_group_cache: RwLock<Option<Option<String>>>,
36}
37
38impl Storage {
39 pub fn new(project_root: Option<PathBuf>) -> Self {
40 let root = project_root.unwrap_or_else(|| std::env::current_dir().unwrap());
41 Storage {
42 project_root: root,
43 active_group_cache: RwLock::new(None),
44 }
45 }
46
47 pub fn project_root(&self) -> &Path {
49 &self.project_root
50 }
51
52 fn acquire_lock_with_retry(&self, file: &File, max_retries: u32) -> Result<()> {
54 let mut retries = 0;
55 let mut delay_ms = 10;
56
57 loop {
58 match file.try_lock_exclusive() {
59 Ok(_) => return Ok(()),
60 Err(_) if retries < max_retries => {
61 retries += 1;
62 thread::sleep(Duration::from_millis(delay_ms));
63 delay_ms = (delay_ms * 2).min(1000); }
65 Err(e) => {
66 anyhow::bail!(
67 "Failed to acquire file lock after {} retries: {}",
68 max_retries,
69 e
70 )
71 }
72 }
73 }
74 }
75
76 fn write_with_lock<F>(&self, path: &Path, writer: F) -> Result<()>
78 where
79 F: FnOnce() -> Result<String>,
80 {
81 use std::io::Write;
82
83 let dir = path.parent().unwrap();
84 if !dir.exists() {
85 fs::create_dir_all(dir)?;
86 }
87
88 let mut file = OpenOptions::new()
90 .write(true)
91 .create(true)
92 .truncate(true)
93 .open(path)
94 .with_context(|| format!("Failed to open file for writing: {}", path.display()))?;
95
96 self.acquire_lock_with_retry(&file, 10)?;
98
99 let content = writer()?;
101 file.write_all(content.as_bytes())
102 .with_context(|| format!("Failed to write to {}", path.display()))?;
103 file.flush()
104 .with_context(|| format!("Failed to flush {}", path.display()))?;
105
106 Ok(())
108 }
109
110 fn read_with_lock(&self, path: &Path) -> Result<String> {
112 use std::io::Read;
113
114 if !path.exists() {
115 anyhow::bail!("File not found: {}", path.display());
116 }
117
118 let mut file = OpenOptions::new()
120 .read(true)
121 .open(path)
122 .with_context(|| format!("Failed to open file for reading: {}", path.display()))?;
123
124 file.lock_shared()
126 .with_context(|| format!("Failed to acquire read lock on {}", path.display()))?;
127
128 let mut content = String::new();
130 file.read_to_string(&mut content)
131 .with_context(|| format!("Failed to read from {}", path.display()))?;
132
133 Ok(content)
135 }
136
137 pub fn scud_dir(&self) -> PathBuf {
138 self.project_root.join(".scud")
139 }
140
141 pub fn tasks_file(&self) -> PathBuf {
142 self.scud_dir().join("tasks").join("tasks.scg")
143 }
144
145 fn active_tag_file(&self) -> PathBuf {
146 self.scud_dir().join("active-tag")
147 }
148
149 pub fn config_file(&self) -> PathBuf {
150 self.scud_dir().join("config.toml")
151 }
152
153 pub fn docs_dir(&self) -> PathBuf {
154 self.scud_dir().join("docs")
155 }
156
157 pub fn guidance_dir(&self) -> PathBuf {
158 self.scud_dir().join("guidance")
159 }
160
161 pub fn load_guidance(&self) -> Result<String> {
164 let guidance_dir = self.guidance_dir();
165
166 if !guidance_dir.exists() {
167 return Ok(String::new());
168 }
169
170 let mut guidance_content = String::new();
171 let mut entries: Vec<_> = fs::read_dir(&guidance_dir)?
172 .filter_map(|e| e.ok())
173 .filter(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false))
174 .collect();
175
176 entries.sort_by_key(|e| e.path());
178
179 for entry in entries {
180 let path = entry.path();
181 let filename = path
182 .file_name()
183 .and_then(|n| n.to_str())
184 .unwrap_or("unknown");
185
186 match fs::read_to_string(&path) {
187 Ok(content) => {
188 if !guidance_content.is_empty() {
189 guidance_content.push_str("\n\n");
190 }
191 guidance_content.push_str(&format!("### {}\n\n{}", filename, content));
192 }
193 Err(e) => {
194 eprintln!(
195 "Warning: Failed to read guidance file {}: {}",
196 path.display(),
197 e
198 );
199 }
200 }
201 }
202
203 Ok(guidance_content)
204 }
205
206 pub fn is_initialized(&self) -> bool {
207 self.scud_dir().exists() && self.tasks_file().exists()
208 }
209
210 pub fn initialize(&self) -> Result<()> {
211 let config = Config::default();
212 self.initialize_with_config(&config)
213 }
214
215 pub fn initialize_with_config(&self, config: &Config) -> Result<()> {
216 let scud_dir = self.scud_dir();
218 fs::create_dir_all(scud_dir.join("tasks"))
219 .context("Failed to create .scud/tasks directory")?;
220
221 let config_file = self.config_file();
223 config.save(&config_file)?;
224
225 let tasks_file = self.tasks_file();
227 if !tasks_file.exists() {
228 let empty_tasks: HashMap<String, Phase> = HashMap::new();
229 self.save_tasks(&empty_tasks)?;
230 }
231
232 let docs = self.docs_dir();
234 fs::create_dir_all(docs.join("prd"))?;
235 fs::create_dir_all(docs.join("phases"))?;
236 fs::create_dir_all(docs.join("architecture"))?;
237 fs::create_dir_all(docs.join("retrospectives"))?;
238
239 fs::create_dir_all(self.guidance_dir())?;
241
242 let db = crate::db::Database::new(&self.project_root);
244 db.initialize()?;
245
246 self.create_agent_instructions()?;
248
249 Ok(())
250 }
251
252 pub fn load_config(&self) -> Result<Config> {
253 let config_file = self.config_file();
254 if !config_file.exists() {
255 return Ok(Config::default());
256 }
257 Config::load(&config_file)
258 }
259
260 pub fn load_tasks(&self) -> Result<HashMap<String, Phase>> {
261 let path = self.tasks_file();
262 if !path.exists() {
263 anyhow::bail!("Tasks file not found: {}\nRun: scud init", path.display());
264 }
265
266 let content = self.read_with_lock(&path)?;
267 self.parse_multi_phase_scg(&content)
268 }
269
270 fn parse_multi_phase_scg(&self, content: &str) -> Result<HashMap<String, Phase>> {
272 let mut phases = HashMap::new();
273
274 if content.trim().is_empty() {
276 return Ok(phases);
277 }
278
279 let sections: Vec<&str> = content.split("\n---\n").collect();
281
282 for section in sections {
283 let section = section.trim();
284 if section.is_empty() {
285 continue;
286 }
287
288 let phase = parse_scg(section).with_context(|| "Failed to parse SCG section")?;
290
291 phases.insert(phase.name.clone(), phase);
292 }
293
294 Ok(phases)
295 }
296
297 pub fn save_tasks(&self, tasks: &HashMap<String, Phase>) -> Result<()> {
298 let path = self.tasks_file();
299 self.write_with_lock(&path, || {
300 let mut sorted_tags: Vec<_> = tasks.keys().collect();
302 sorted_tags.sort();
303
304 let mut output = String::new();
305 for (i, tag) in sorted_tags.iter().enumerate() {
306 if i > 0 {
307 output.push_str("\n---\n\n");
308 }
309 let phase = tasks.get(*tag).unwrap();
310 output.push_str(&serialize_scg(phase));
311 }
312
313 Ok(output)
314 })
315 }
316
317 pub fn get_active_group(&self) -> Result<Option<String>> {
318 {
320 let cache = self.active_group_cache.read().unwrap();
321 if let Some(cached) = cache.as_ref() {
322 return Ok(cached.clone());
323 }
324 }
325
326 let active_tag_path = self.active_tag_file();
328 let active = if active_tag_path.exists() {
329 let content = fs::read_to_string(&active_tag_path)
330 .with_context(|| format!("Failed to read {}", active_tag_path.display()))?;
331 let tag = content.trim();
332 if tag.is_empty() {
333 None
334 } else {
335 Some(tag.to_string())
336 }
337 } else {
338 None
339 };
340
341 *self.active_group_cache.write().unwrap() = Some(active.clone());
343
344 Ok(active)
345 }
346
347 pub fn set_active_group(&self, group_tag: &str) -> Result<()> {
348 let tasks = self.load_tasks()?;
349 if !tasks.contains_key(group_tag) {
350 anyhow::bail!("Task group '{}' not found", group_tag);
351 }
352
353 let active_tag_path = self.active_tag_file();
355 fs::write(&active_tag_path, group_tag)
356 .with_context(|| format!("Failed to write {}", active_tag_path.display()))?;
357
358 *self.active_group_cache.write().unwrap() = Some(Some(group_tag.to_string()));
360
361 Ok(())
362 }
363
364 pub fn clear_cache(&self) {
367 *self.active_group_cache.write().unwrap() = None;
368 }
369
370 pub fn clear_active_group(&self) -> Result<()> {
372 let active_tag_path = self.active_tag_file();
373 if active_tag_path.exists() {
374 fs::remove_file(&active_tag_path)
375 .with_context(|| format!("Failed to remove {}", active_tag_path.display()))?;
376 }
377 *self.active_group_cache.write().unwrap() = Some(None);
378 Ok(())
379 }
380
381 pub fn load_group(&self, group_tag: &str) -> Result<Phase> {
384 let path = self.tasks_file();
385 let content = self.read_with_lock(&path)?;
386
387 let groups = self.parse_multi_phase_scg(&content)?;
388
389 groups
390 .get(group_tag)
391 .cloned()
392 .ok_or_else(|| anyhow::anyhow!("Task group '{}' not found", group_tag))
393 }
394
395 pub fn load_active_group(&self) -> Result<Phase> {
398 let active_tag = self
399 .get_active_group()?
400 .ok_or_else(|| anyhow::anyhow!("No active task group. Run: scud use-tag <tag>"))?;
401
402 self.load_group(&active_tag)
403 }
404
405 pub fn update_group(&self, group_tag: &str, group: &Phase) -> Result<()> {
408 use std::io::{Read, Seek, SeekFrom, Write};
409
410 let path = self.tasks_file();
411
412 let dir = path.parent().unwrap();
413 if !dir.exists() {
414 fs::create_dir_all(dir)?;
415 }
416
417 let mut file = OpenOptions::new()
420 .read(true)
421 .write(true)
422 .create(true)
423 .truncate(false)
424 .open(&path)
425 .with_context(|| format!("Failed to open file: {}", path.display()))?;
426
427 self.acquire_lock_with_retry(&file, 10)?;
429
430 let mut content = String::new();
432 file.read_to_string(&mut content)
433 .with_context(|| format!("Failed to read from {}", path.display()))?;
434
435 let mut groups = self.parse_multi_phase_scg(&content)?;
437 groups.insert(group_tag.to_string(), group.clone());
438
439 let mut sorted_tags: Vec<_> = groups.keys().collect();
440 sorted_tags.sort();
441
442 let mut output = String::new();
443 for (i, tag) in sorted_tags.iter().enumerate() {
444 if i > 0 {
445 output.push_str("\n---\n\n");
446 }
447 let grp = groups.get(*tag).unwrap();
448 output.push_str(&serialize_scg(grp));
449 }
450
451 file.seek(SeekFrom::Start(0))
453 .with_context(|| "Failed to seek to beginning of file")?;
454 file.set_len(0).with_context(|| "Failed to truncate file")?;
455 file.write_all(output.as_bytes())
456 .with_context(|| format!("Failed to write to {}", path.display()))?;
457 file.flush()
458 .with_context(|| format!("Failed to flush {}", path.display()))?;
459
460 Ok(())
462 }
463
464 pub fn update_task_status(
467 &self,
468 group_tag: &str,
469 task_id: &str,
470 status: crate::models::task::TaskStatus,
471 ) -> Result<()> {
472 let mut group = self.load_group(group_tag)?;
473
474 let task = group
475 .tasks
476 .iter_mut()
477 .find(|t| t.id == task_id)
478 .ok_or_else(|| anyhow::anyhow!("Task '{}' not found in group '{}'", task_id, group_tag))?;
479
480 task.status = status;
481 self.update_group(group_tag, &group)
482 }
483
484 pub fn read_file(&self, path: &Path) -> Result<String> {
485 fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", path.display()))
486 }
487
488 pub fn archive_dir(&self) -> PathBuf {
492 self.scud_dir().join("archive")
493 }
494
495 pub fn ensure_archive_dir(&self) -> Result<()> {
497 let dir = self.archive_dir();
498 if !dir.exists() {
499 fs::create_dir_all(&dir).context("Failed to create archive directory")?;
500 }
501 Ok(())
502 }
503
504 pub fn archive_filename(&self, tag: Option<&str>) -> String {
507 let date = Local::now().format("%Y-%m-%d");
508 match tag {
509 Some(t) => format!("{}_{}.scg", date, t),
510 None => format!("{}_all.scg", date),
511 }
512 }
513
514 fn unique_archive_path(&self, base_path: &Path) -> PathBuf {
518 if !base_path.exists() {
519 return base_path.to_path_buf();
520 }
521
522 let stem = base_path
523 .file_stem()
524 .and_then(|s| s.to_str())
525 .unwrap_or("archive");
526 let ext = base_path
527 .extension()
528 .and_then(|s| s.to_str())
529 .unwrap_or("scg");
530 let parent = base_path.parent().unwrap_or(Path::new("."));
531
532 for i in 1..100 {
533 let new_name = format!("{}_{}.{}", stem, i, ext);
534 let new_path = parent.join(&new_name);
535 if !new_path.exists() {
536 return new_path;
537 }
538 }
539
540 let ts = Local::now().format("%H%M%S");
542 parent.join(format!("{}_{}.{}", stem, ts, ext))
543 }
544
545 pub fn archive_phase(&self, tag: &str, phases: &HashMap<String, Phase>) -> Result<PathBuf> {
548 self.ensure_archive_dir()?;
549
550 let phase = phases
551 .get(tag)
552 .ok_or_else(|| anyhow::anyhow!("Tag '{}' not found", tag))?;
553
554 let filename = self.archive_filename(Some(tag));
555 let archive_path = self.archive_dir().join(&filename);
556 let final_path = self.unique_archive_path(&archive_path);
557
558 let content = serialize_scg(phase);
560 fs::write(&final_path, content)
561 .with_context(|| format!("Failed to write archive: {}", final_path.display()))?;
562
563 Ok(final_path)
564 }
565
566 pub fn archive_all(&self, phases: &HashMap<String, Phase>) -> Result<PathBuf> {
569 self.ensure_archive_dir()?;
570
571 let filename = self.archive_filename(None);
572 let archive_path = self.archive_dir().join(&filename);
573 let final_path = self.unique_archive_path(&archive_path);
574
575 let mut sorted_tags: Vec<_> = phases.keys().collect();
577 sorted_tags.sort();
578
579 let mut output = String::new();
580 for (i, tag) in sorted_tags.iter().enumerate() {
581 if i > 0 {
582 output.push_str("\n---\n\n");
583 }
584 let phase = phases.get(*tag).unwrap();
585 output.push_str(&serialize_scg(phase));
586 }
587
588 fs::write(&final_path, output)
589 .with_context(|| format!("Failed to write archive: {}", final_path.display()))?;
590
591 Ok(final_path)
592 }
593
594 pub fn parse_archive_filename(filename: &str) -> (String, Option<String>) {
597 let name = filename.trim_end_matches(".scg");
598
599 let parts: Vec<&str> = name.splitn(2, '_').collect();
602
603 if parts.len() == 2 {
604 let date = parts[0].to_string();
605 let rest = parts[1];
606
607 if let Some(last_underscore) = rest.rfind('_') {
610 let potential_counter = &rest[last_underscore + 1..];
611 if potential_counter.chars().all(|c| c.is_ascii_digit())
612 && !potential_counter.is_empty()
613 {
614 let tag_part = &rest[..last_underscore];
616 let tag = if tag_part == "all" {
617 None
618 } else {
619 Some(tag_part.to_string())
620 };
621 return (date, tag);
622 }
623 }
624
625 let tag = if rest == "all" {
627 None
628 } else {
629 Some(rest.to_string())
630 };
631 (date, tag)
632 } else {
633 (name.to_string(), None)
635 }
636 }
637
638 pub fn list_archives(&self) -> Result<Vec<ArchiveInfo>> {
641 let archive_dir = self.archive_dir();
642 if !archive_dir.exists() {
643 return Ok(Vec::new());
644 }
645
646 let mut archives = Vec::new();
647 for entry in fs::read_dir(&archive_dir)? {
648 let entry = entry?;
649 let path = entry.path();
650
651 if path.extension().map(|e| e == "scg").unwrap_or(false) {
652 let filename = path
653 .file_name()
654 .and_then(|n| n.to_str())
655 .unwrap_or("")
656 .to_string();
657
658 let (date, tag) = Self::parse_archive_filename(&filename);
659
660 let task_count = match self.load_archive(&path) {
662 Ok(phases) => phases.values().map(|p| p.tasks.len()).sum(),
663 Err(_) => 0,
664 };
665
666 archives.push(ArchiveInfo {
667 filename,
668 path,
669 date,
670 tag,
671 task_count,
672 });
673 }
674 }
675
676 archives.sort_by(|a, b| b.date.cmp(&a.date));
678 Ok(archives)
679 }
680
681 pub fn load_archive(&self, path: &Path) -> Result<HashMap<String, Phase>> {
684 let content = fs::read_to_string(path)
685 .with_context(|| format!("Failed to read archive: {}", path.display()))?;
686
687 self.parse_multi_phase_scg(&content)
688 }
689
690 pub fn restore_archive(&self, archive_name: &str, replace: bool) -> Result<Vec<String>> {
699 let archive_dir = self.archive_dir();
700
701 let archive_path = if archive_name.ends_with(".scg") {
703 let path = archive_dir.join(archive_name);
704 if !path.exists() {
705 anyhow::bail!("Archive file not found: {}", archive_name);
706 }
707 path
708 } else {
709 let mut found = None;
711 if archive_dir.exists() {
712 for entry in fs::read_dir(&archive_dir)? {
713 let entry = entry?;
714 let filename = entry.file_name().to_string_lossy().to_string();
715 if filename.contains(archive_name) {
716 found = Some(entry.path());
717 break;
718 }
719 }
720 }
721 found.ok_or_else(|| anyhow::anyhow!("Archive '{}' not found", archive_name))?
722 };
723
724 let archived_phases = self.load_archive(&archive_path)?;
725 let mut current_phases = self.load_tasks().unwrap_or_default();
726 let mut restored_tags = Vec::new();
727
728 for (tag, phase) in archived_phases {
729 if replace || !current_phases.contains_key(&tag) {
730 current_phases.insert(tag.clone(), phase);
731 restored_tags.push(tag);
732 }
733 }
734
735 self.save_tasks(¤t_phases)?;
736 Ok(restored_tags)
737 }
738
739 fn create_agent_instructions(&self) -> Result<()> {
741 let claude_md_path = self.project_root.join("CLAUDE.md");
742
743 let scud_instructions = r#"
744## SCUD Task Management
745
746This project uses SCUD Task Manager for task management.
747
748### Session Workflow
749
7501. **Start of session**: Run `scud warmup` to orient yourself
751 - Shows current working directory and recent git history
752 - Displays active tag, task counts, and any stale locks
753 - Identifies the next available task
754
7552. **Get a task**: Use `/scud:next` or `scud next`
756 - Shows the next available task based on DAG dependencies
757 - Use `scud set-status <id> in-progress` to mark you're working on it
758
7593. **Work on the task**: Implement the requirements
760 - Reference task details with `/scud:task-show <id>`
761 - Dependencies are automatically tracked by the DAG
762
7634. **Commit with context**: Use `scud commit -m "message"` or `scud commit -a -m "message"`
764 - Automatically prefixes commits with `[TASK-ID]`
765 - Uses task title as default commit message if none provided
766
7675. **Complete the task**: Mark done with `/scud:task-status <id> done`
768 - The stop hook will prompt for task completion
769
770### Progress Journaling
771
772Keep a brief progress log during complex tasks:
773
774```
775## Progress Log
776
777### Session: 2025-01-15
778- Investigated auth module, found issue in token refresh
779- Updated refresh logic to handle edge case
780- Tests passing, ready for review
781```
782
783This helps maintain continuity across sessions and provides context for future work.
784
785### Key Commands
786
787- `scud warmup` - Session orientation
788- `scud next` - Find next available task
789- `scud show <id>` - View task details
790- `scud set-status <id> <status>` - Update task status
791- `scud commit` - Task-aware git commit
792- `scud stats` - View completion statistics
793"#;
794
795 if claude_md_path.exists() {
796 let content = fs::read_to_string(&claude_md_path)
798 .with_context(|| "Failed to read existing CLAUDE.md")?;
799
800 if !content.contains("## SCUD Task Management") {
801 let mut new_content = content;
802 new_content.push_str(scud_instructions);
803 fs::write(&claude_md_path, new_content)
804 .with_context(|| "Failed to update CLAUDE.md")?;
805 }
806 } else {
807 let content = format!("# Project Instructions\n{}", scud_instructions);
809 fs::write(&claude_md_path, content).with_context(|| "Failed to create CLAUDE.md")?;
810 }
811
812 Ok(())
813 }
814}
815
816#[cfg(test)]
817mod tests {
818 use super::*;
819 use std::collections::HashMap;
820 use tempfile::TempDir;
821
822 fn create_test_storage() -> (Storage, TempDir) {
823 let temp_dir = TempDir::new().unwrap();
824 let storage = Storage::new(Some(temp_dir.path().to_path_buf()));
825 storage.initialize().unwrap();
826 (storage, temp_dir)
827 }
828
829 #[test]
830 fn test_write_with_lock_creates_file() {
831 let (storage, _temp_dir) = create_test_storage();
832 let test_file = storage.scud_dir().join("test.json");
833
834 storage
835 .write_with_lock(&test_file, || Ok(r#"{"test": "data"}"#.to_string()))
836 .unwrap();
837
838 assert!(test_file.exists());
839 let content = fs::read_to_string(&test_file).unwrap();
840 assert_eq!(content, r#"{"test": "data"}"#);
841 }
842
843 #[test]
844 fn test_read_with_lock_reads_existing_file() {
845 let (storage, _temp_dir) = create_test_storage();
846 let test_file = storage.scud_dir().join("test.json");
847
848 fs::write(&test_file, r#"{"test": "data"}"#).unwrap();
850
851 let content = storage.read_with_lock(&test_file).unwrap();
853 assert_eq!(content, r#"{"test": "data"}"#);
854 }
855
856 #[test]
857 fn test_read_with_lock_fails_on_missing_file() {
858 let (storage, _temp_dir) = create_test_storage();
859 let test_file = storage.scud_dir().join("nonexistent.json");
860
861 let result = storage.read_with_lock(&test_file);
862 assert!(result.is_err());
863 assert!(result.unwrap_err().to_string().contains("File not found"));
864 }
865
866 #[test]
867 fn test_save_and_load_tasks_with_locking() {
868 let (storage, _temp_dir) = create_test_storage();
869 let mut tasks = HashMap::new();
870
871 let epic = crate::models::Phase::new("TEST-1".to_string());
872 tasks.insert("TEST-1".to_string(), epic);
873
874 storage.save_tasks(&tasks).unwrap();
876
877 let loaded_tasks = storage.load_tasks().unwrap();
879
880 assert_eq!(tasks.len(), loaded_tasks.len());
881 assert!(loaded_tasks.contains_key("TEST-1"));
882 assert_eq!(loaded_tasks.get("TEST-1").unwrap().name, "TEST-1");
883 }
884
885 #[test]
886 fn test_concurrent_writes_dont_corrupt_data() {
887 use std::sync::Arc;
888 use std::thread;
889
890 let (storage, _temp_dir) = create_test_storage();
891 let storage = Arc::new(storage);
892 let mut handles = vec![];
893
894 for i in 0..10 {
896 let storage_clone = Arc::clone(&storage);
897 let handle = thread::spawn(move || {
898 let mut tasks = HashMap::new();
899 let epic = crate::models::Phase::new(format!("EPIC-{}", i));
900 tasks.insert(format!("EPIC-{}", i), epic);
901
902 for _ in 0..5 {
904 storage_clone.save_tasks(&tasks).unwrap();
905 thread::sleep(Duration::from_millis(1));
906 }
907 });
908 handles.push(handle);
909 }
910
911 for handle in handles {
913 handle.join().unwrap();
914 }
915
916 let tasks = storage.load_tasks().unwrap();
918 assert_eq!(tasks.len(), 1);
920 }
921
922 #[test]
923 fn test_lock_retry_on_contention() {
924 use std::sync::Arc;
925
926 let (storage, _temp_dir) = create_test_storage();
927 let storage = Arc::new(storage);
928 let test_file = storage.scud_dir().join("lock-test.json");
929
930 storage
932 .write_with_lock(&test_file, || Ok(r#"{"initial": "data"}"#.to_string()))
933 .unwrap();
934
935 let file = OpenOptions::new().write(true).open(&test_file).unwrap();
937 file.lock_exclusive().unwrap();
938
939 let storage_clone = Arc::clone(&storage);
941 let test_file_clone = test_file.clone();
942 let handle = thread::spawn(move || {
943 storage_clone.write_with_lock(&test_file_clone, || {
945 Ok(r#"{"updated": "data"}"#.to_string())
946 })
947 });
948
949 thread::sleep(Duration::from_millis(200));
951
952 file.unlock().unwrap();
954 drop(file);
955
956 let result = handle.join().unwrap();
958 assert!(result.is_ok());
959 }
960
961 #[test]
964 fn test_load_tasks_with_malformed_json() {
965 let (storage, _temp_dir) = create_test_storage();
966 let tasks_file = storage.tasks_file();
967
968 fs::write(&tasks_file, r#"{"invalid": json here}"#).unwrap();
970
971 let result = storage.load_tasks();
973 assert!(result.is_err());
974 }
975
976 #[test]
977 fn test_load_tasks_with_empty_file() {
978 let (storage, _temp_dir) = create_test_storage();
979 let tasks_file = storage.tasks_file();
980
981 fs::write(&tasks_file, "").unwrap();
983
984 let result = storage.load_tasks();
986 assert!(result.is_ok());
987 assert!(result.unwrap().is_empty());
988 }
989
990 #[test]
991 fn test_load_tasks_missing_file_creates_default() {
992 let (storage, _temp_dir) = create_test_storage();
993 let tasks = storage.load_tasks().unwrap();
997 assert_eq!(tasks.len(), 0);
998 }
999
1000 #[test]
1001 fn test_save_tasks_creates_directory_if_missing() {
1002 let temp_dir = TempDir::new().unwrap();
1003 let storage = Storage::new(Some(temp_dir.path().to_path_buf()));
1004 let mut tasks = HashMap::new();
1007 let epic = crate::models::Phase::new("TEST-1".to_string());
1008 tasks.insert("TEST-1".to_string(), epic);
1009
1010 let result = storage.save_tasks(&tasks);
1012 assert!(result.is_ok());
1013
1014 assert!(storage.scud_dir().exists());
1015 assert!(storage.tasks_file().exists());
1016 }
1017
1018 #[test]
1019 fn test_write_with_lock_handles_directory_creation() {
1020 let temp_dir = TempDir::new().unwrap();
1021 let storage = Storage::new(Some(temp_dir.path().to_path_buf()));
1022
1023 let nested_file = temp_dir
1024 .path()
1025 .join("deeply")
1026 .join("nested")
1027 .join("test.json");
1028
1029 let result = storage.write_with_lock(&nested_file, || Ok("{}".to_string()));
1031 assert!(result.is_ok());
1032 assert!(nested_file.exists());
1033 }
1034
1035 #[test]
1036 fn test_load_tasks_with_invalid_structure() {
1037 let (storage, _temp_dir) = create_test_storage();
1038 let tasks_file = storage.tasks_file();
1039
1040 fs::write(&tasks_file, r#"["not", "an", "object"]"#).unwrap();
1042
1043 let result = storage.load_tasks();
1045 assert!(result.is_err());
1046 }
1047
1048 #[test]
1049 fn test_save_and_load_with_unicode_content() {
1050 let (storage, _temp_dir) = create_test_storage();
1051
1052 let mut tasks = HashMap::new();
1053 let mut epic = crate::models::Phase::new("TEST-UNICODE".to_string());
1054
1055 let task = crate::models::Task::new(
1057 "task-1".to_string(),
1058 "测试 Unicode 🚀".to_string(),
1059 "Descripción en español 日本語".to_string(),
1060 );
1061 epic.add_task(task);
1062
1063 tasks.insert("TEST-UNICODE".to_string(), epic);
1064
1065 storage.save_tasks(&tasks).unwrap();
1067 let loaded_tasks = storage.load_tasks().unwrap();
1068
1069 let loaded_epic = loaded_tasks.get("TEST-UNICODE").unwrap();
1070 let loaded_task = loaded_epic.get_task("task-1").unwrap();
1071 assert_eq!(loaded_task.title, "测试 Unicode 🚀");
1072 assert_eq!(loaded_task.description, "Descripción en español 日本語");
1073 }
1074
1075 #[test]
1076 fn test_save_and_load_with_large_dataset() {
1077 let (storage, _temp_dir) = create_test_storage();
1078
1079 let mut tasks = HashMap::new();
1080
1081 for i in 0..100 {
1083 let mut epic = crate::models::Phase::new(format!("EPIC-{}", i));
1084
1085 for j in 0..50 {
1086 let task = crate::models::Task::new(
1087 format!("task-{}-{}", i, j),
1088 format!("Task {} of Epic {}", j, i),
1089 format!("Description for task {}-{}", i, j),
1090 );
1091 epic.add_task(task);
1092 }
1093
1094 tasks.insert(format!("EPIC-{}", i), epic);
1095 }
1096
1097 storage.save_tasks(&tasks).unwrap();
1099 let loaded_tasks = storage.load_tasks().unwrap();
1100
1101 assert_eq!(loaded_tasks.len(), 100);
1102 for i in 0..100 {
1103 let epic = loaded_tasks.get(&format!("EPIC-{}", i)).unwrap();
1104 assert_eq!(epic.tasks.len(), 50);
1105 }
1106 }
1107
1108 #[test]
1109 fn test_concurrent_read_and_write() {
1110 use std::sync::Arc;
1111 use std::thread;
1112
1113 let (storage, _temp_dir) = create_test_storage();
1114 let storage = Arc::new(storage);
1115
1116 let mut tasks = HashMap::new();
1118 let epic = crate::models::Phase::new("INITIAL".to_string());
1119 tasks.insert("INITIAL".to_string(), epic);
1120 storage.save_tasks(&tasks).unwrap();
1121
1122 let mut handles = vec![];
1123
1124 for _ in 0..5 {
1126 let storage_clone = Arc::clone(&storage);
1127 let handle = thread::spawn(move || {
1128 for _ in 0..10 {
1129 let _ = storage_clone.load_tasks();
1130 thread::sleep(Duration::from_millis(1));
1131 }
1132 });
1133 handles.push(handle);
1134 }
1135
1136 for i in 0..2 {
1138 let storage_clone = Arc::clone(&storage);
1139 let handle = thread::spawn(move || {
1140 for j in 0..5 {
1141 let mut tasks = HashMap::new();
1142 let epic = crate::models::Phase::new(format!("WRITER-{}-{}", i, j));
1143 tasks.insert(format!("WRITER-{}-{}", i, j), epic);
1144 storage_clone.save_tasks(&tasks).unwrap();
1145 thread::sleep(Duration::from_millis(2));
1146 }
1147 });
1148 handles.push(handle);
1149 }
1150
1151 for handle in handles {
1153 handle.join().unwrap();
1154 }
1155
1156 let tasks = storage.load_tasks().unwrap();
1158 assert_eq!(tasks.len(), 1); }
1160
1161 #[test]
1164 fn test_active_epic_cached_on_second_call() {
1165 let (storage, _temp_dir) = create_test_storage();
1166
1167 let mut tasks = HashMap::new();
1169 tasks.insert("TEST-1".to_string(), Phase::new("TEST-1".to_string()));
1170 storage.save_tasks(&tasks).unwrap();
1171 storage.set_active_group("TEST-1").unwrap();
1172
1173 let active1 = storage.get_active_group().unwrap();
1175 assert_eq!(active1, Some("TEST-1".to_string()));
1176
1177 let active_tag_file = storage.active_tag_file();
1179 fs::write(&active_tag_file, "DIFFERENT").unwrap();
1180
1181 let active2 = storage.get_active_group().unwrap();
1183 assert_eq!(active2, Some("TEST-1".to_string())); storage.clear_cache();
1187 let active3 = storage.get_active_group().unwrap();
1188 assert_eq!(active3, Some("DIFFERENT".to_string())); }
1190
1191 #[test]
1192 fn test_cache_invalidated_on_set_active_epic() {
1193 let (storage, _temp_dir) = create_test_storage();
1194
1195 let mut tasks = HashMap::new();
1196 tasks.insert("EPIC-1".to_string(), Phase::new("EPIC-1".to_string()));
1197 tasks.insert("EPIC-2".to_string(), Phase::new("EPIC-2".to_string()));
1198 storage.save_tasks(&tasks).unwrap();
1199
1200 storage.set_active_group("EPIC-1").unwrap();
1201 assert_eq!(
1202 storage.get_active_group().unwrap(),
1203 Some("EPIC-1".to_string())
1204 );
1205
1206 storage.set_active_group("EPIC-2").unwrap();
1208 assert_eq!(
1209 storage.get_active_group().unwrap(),
1210 Some("EPIC-2".to_string())
1211 );
1212 }
1213
1214 #[test]
1215 fn test_cache_with_no_active_epic() {
1216 let (storage, _temp_dir) = create_test_storage();
1217
1218 let active = storage.get_active_group().unwrap();
1220 assert_eq!(active, None);
1221
1222 let active2 = storage.get_active_group().unwrap();
1224 assert_eq!(active2, None);
1225 }
1226
1227 #[test]
1230 fn test_load_single_epic_from_many() {
1231 let (storage, _temp_dir) = create_test_storage();
1232
1233 let mut tasks = HashMap::new();
1235 for i in 0..50 {
1236 tasks.insert(format!("EPIC-{}", i), Phase::new(format!("EPIC-{}", i)));
1237 }
1238 storage.save_tasks(&tasks).unwrap();
1239
1240 let epic = storage.load_group("EPIC-25").unwrap();
1242 assert_eq!(epic.name, "EPIC-25");
1243 }
1244
1245 #[test]
1246 fn test_load_epic_not_found() {
1247 let (storage, _temp_dir) = create_test_storage();
1248
1249 let tasks = HashMap::new();
1250 storage.save_tasks(&tasks).unwrap();
1251
1252 let result = storage.load_group("NONEXISTENT");
1253 assert!(result.is_err());
1254 assert!(result.unwrap_err().to_string().contains("not found"));
1255 }
1256
1257 #[test]
1258 fn test_load_epic_matches_full_load() {
1259 let (storage, _temp_dir) = create_test_storage();
1260
1261 let mut tasks = HashMap::new();
1262 let mut epic = Phase::new("TEST-1".to_string());
1263 epic.add_task(crate::models::Task::new(
1264 "task-1".to_string(),
1265 "Test".to_string(),
1266 "Desc".to_string(),
1267 ));
1268 tasks.insert("TEST-1".to_string(), epic.clone());
1269 storage.save_tasks(&tasks).unwrap();
1270
1271 let epic_lazy = storage.load_group("TEST-1").unwrap();
1273 let tasks_full = storage.load_tasks().unwrap();
1274 let epic_full = tasks_full.get("TEST-1").unwrap();
1275
1276 assert_eq!(epic_lazy.name, epic_full.name);
1278 assert_eq!(epic_lazy.tasks.len(), epic_full.tasks.len());
1279 }
1280
1281 #[test]
1282 fn test_load_active_epic() {
1283 let (storage, _temp_dir) = create_test_storage();
1284
1285 let mut tasks = HashMap::new();
1286 let mut epic = Phase::new("ACTIVE-1".to_string());
1287 epic.add_task(crate::models::Task::new(
1288 "task-1".to_string(),
1289 "Test".to_string(),
1290 "Desc".to_string(),
1291 ));
1292 tasks.insert("ACTIVE-1".to_string(), epic);
1293 storage.save_tasks(&tasks).unwrap();
1294 storage.set_active_group("ACTIVE-1").unwrap();
1295
1296 let epic = storage.load_active_group().unwrap();
1298 assert_eq!(epic.name, "ACTIVE-1");
1299 assert_eq!(epic.tasks.len(), 1);
1300 }
1301
1302 #[test]
1303 fn test_load_active_epic_when_none_set() {
1304 let (storage, _temp_dir) = create_test_storage();
1305
1306 let result = storage.load_active_group();
1308 assert!(result.is_err());
1309 assert!(result
1310 .unwrap_err()
1311 .to_string()
1312 .contains("No active task group"));
1313 }
1314
1315 #[test]
1316 fn test_update_epic_without_loading_all() {
1317 let (storage, _temp_dir) = create_test_storage();
1318
1319 let mut tasks = HashMap::new();
1320 tasks.insert("EPIC-1".to_string(), Phase::new("EPIC-1".to_string()));
1321 tasks.insert("EPIC-2".to_string(), Phase::new("EPIC-2".to_string()));
1322 storage.save_tasks(&tasks).unwrap();
1323
1324 let mut epic1 = storage.load_group("EPIC-1").unwrap();
1326 epic1.add_task(crate::models::Task::new(
1327 "new-task".to_string(),
1328 "New".to_string(),
1329 "Desc".to_string(),
1330 ));
1331 storage.update_group("EPIC-1", &epic1).unwrap();
1332
1333 let loaded = storage.load_group("EPIC-1").unwrap();
1335 assert_eq!(loaded.tasks.len(), 1);
1336
1337 let epic2 = storage.load_group("EPIC-2").unwrap();
1339 assert_eq!(epic2.tasks.len(), 0);
1340 }
1341
1342 #[test]
1345 fn test_archive_dir() {
1346 let (storage, _temp_dir) = create_test_storage();
1347 let archive_dir = storage.archive_dir();
1348 assert!(archive_dir.ends_with(".scud/archive"));
1349 }
1350
1351 #[test]
1352 fn test_ensure_archive_dir() {
1353 let (storage, _temp_dir) = create_test_storage();
1354
1355 assert!(!storage.archive_dir().exists());
1357
1358 storage.ensure_archive_dir().unwrap();
1360 assert!(storage.archive_dir().exists());
1361
1362 storage.ensure_archive_dir().unwrap();
1364 assert!(storage.archive_dir().exists());
1365 }
1366
1367 #[test]
1368 fn test_archive_filename_with_tag() {
1369 let (storage, _temp_dir) = create_test_storage();
1370 let filename = storage.archive_filename(Some("v1"));
1371
1372 assert!(filename.ends_with("_v1.scg"));
1374 assert!(filename.len() == 17); }
1376
1377 #[test]
1378 fn test_archive_filename_all() {
1379 let (storage, _temp_dir) = create_test_storage();
1380 let filename = storage.archive_filename(None);
1381
1382 assert!(filename.ends_with("_all.scg"));
1384 assert!(filename.len() == 18); }
1386
1387 #[test]
1388 fn test_parse_archive_filename_simple() {
1389 let (date, tag) = Storage::parse_archive_filename("2026-01-13_v1.scg");
1390 assert_eq!(date, "2026-01-13");
1391 assert_eq!(tag, Some("v1".to_string()));
1392 }
1393
1394 #[test]
1395 fn test_parse_archive_filename_all() {
1396 let (date, tag) = Storage::parse_archive_filename("2026-01-13_all.scg");
1397 assert_eq!(date, "2026-01-13");
1398 assert_eq!(tag, None);
1399 }
1400
1401 #[test]
1402 fn test_parse_archive_filename_with_counter() {
1403 let (date, tag) = Storage::parse_archive_filename("2026-01-13_v1_2.scg");
1404 assert_eq!(date, "2026-01-13");
1405 assert_eq!(tag, Some("v1".to_string()));
1406 }
1407
1408 #[test]
1409 fn test_parse_archive_filename_all_with_counter() {
1410 let (date, tag) = Storage::parse_archive_filename("2026-01-13_all_5.scg");
1411 assert_eq!(date, "2026-01-13");
1412 assert_eq!(tag, None);
1413 }
1414
1415 #[test]
1416 fn test_archive_single_phase() {
1417 let (storage, _temp_dir) = create_test_storage();
1418
1419 let mut phases = HashMap::new();
1421 let mut phase = Phase::new("v1".to_string());
1422 phase.add_task(crate::models::Task::new(
1423 "task-1".to_string(),
1424 "Test Task".to_string(),
1425 "Description".to_string(),
1426 ));
1427 phases.insert("v1".to_string(), phase);
1428 storage.save_tasks(&phases).unwrap();
1429
1430 let archive_path = storage.archive_phase("v1", &phases).unwrap();
1432
1433 assert!(archive_path.exists());
1434 assert!(archive_path.to_string_lossy().contains("v1"));
1435 assert!(archive_path.extension().unwrap() == "scg");
1436 }
1437
1438 #[test]
1439 fn test_archive_all_phases() {
1440 let (storage, _temp_dir) = create_test_storage();
1441
1442 let mut phases = HashMap::new();
1443 phases.insert("v1".to_string(), Phase::new("v1".to_string()));
1444 phases.insert("v2".to_string(), Phase::new("v2".to_string()));
1445 storage.save_tasks(&phases).unwrap();
1446
1447 let archive_path = storage.archive_all(&phases).unwrap();
1448
1449 assert!(archive_path.exists());
1450 assert!(archive_path.to_string_lossy().contains("all"));
1451
1452 let loaded = storage.load_archive(&archive_path).unwrap();
1454 assert_eq!(loaded.len(), 2);
1455 assert!(loaded.contains_key("v1"));
1456 assert!(loaded.contains_key("v2"));
1457 }
1458
1459 #[test]
1460 fn test_archive_nonexistent_tag() {
1461 let (storage, _temp_dir) = create_test_storage();
1462
1463 let phases = HashMap::new();
1464 let result = storage.archive_phase("nonexistent", &phases);
1465
1466 assert!(result.is_err());
1467 assert!(result.unwrap_err().to_string().contains("not found"));
1468 }
1469
1470 #[test]
1471 fn test_unique_archive_path_no_collision() {
1472 let (storage, _temp_dir) = create_test_storage();
1473 storage.ensure_archive_dir().unwrap();
1474
1475 let base_path = storage.archive_dir().join("test.scg");
1476 let result = storage.unique_archive_path(&base_path);
1477
1478 assert_eq!(result, base_path);
1479 }
1480
1481 #[test]
1482 fn test_unique_archive_path_with_collision() {
1483 let (storage, _temp_dir) = create_test_storage();
1484 storage.ensure_archive_dir().unwrap();
1485
1486 let base_path = storage.archive_dir().join("test.scg");
1488 fs::write(&base_path, "existing").unwrap();
1489
1490 let result = storage.unique_archive_path(&base_path);
1492 assert!(result.to_string_lossy().contains("test_1.scg"));
1493 }
1494
1495 #[test]
1496 fn test_unique_archive_path_multiple_collisions() {
1497 let (storage, _temp_dir) = create_test_storage();
1498 storage.ensure_archive_dir().unwrap();
1499
1500 let base_path = storage.archive_dir().join("test.scg");
1502 fs::write(&base_path, "existing").unwrap();
1503 fs::write(storage.archive_dir().join("test_1.scg"), "existing").unwrap();
1504 fs::write(storage.archive_dir().join("test_2.scg"), "existing").unwrap();
1505
1506 let result = storage.unique_archive_path(&base_path);
1508 assert!(result.to_string_lossy().contains("test_3.scg"));
1509 }
1510
1511 #[test]
1512 fn test_list_archives_empty() {
1513 let (storage, _temp_dir) = create_test_storage();
1514
1515 let archives = storage.list_archives().unwrap();
1516 assert!(archives.is_empty());
1517 }
1518
1519 #[test]
1520 fn test_list_archives_with_archives() {
1521 let (storage, _temp_dir) = create_test_storage();
1522
1523 let mut phases = HashMap::new();
1525 let mut phase = Phase::new("v1".to_string());
1526 phase.add_task(crate::models::Task::new(
1527 "task-1".to_string(),
1528 "Test".to_string(),
1529 "Desc".to_string(),
1530 ));
1531 phases.insert("v1".to_string(), phase);
1532 storage.save_tasks(&phases).unwrap();
1533
1534 storage.archive_phase("v1", &phases).unwrap();
1535
1536 let archives = storage.list_archives().unwrap();
1537 assert_eq!(archives.len(), 1);
1538 assert_eq!(archives[0].tag, Some("v1".to_string()));
1539 assert_eq!(archives[0].task_count, 1);
1540 }
1541
1542 #[test]
1543 fn test_load_archive() {
1544 let (storage, _temp_dir) = create_test_storage();
1545
1546 let mut phases = HashMap::new();
1547 let mut phase = Phase::new("test-tag".to_string());
1548 phase.add_task(crate::models::Task::new(
1549 "task-1".to_string(),
1550 "Test Title".to_string(),
1551 "Test Description".to_string(),
1552 ));
1553 phases.insert("test-tag".to_string(), phase);
1554 storage.save_tasks(&phases).unwrap();
1555
1556 let archive_path = storage.archive_phase("test-tag", &phases).unwrap();
1557
1558 let loaded = storage.load_archive(&archive_path).unwrap();
1560 assert_eq!(loaded.len(), 1);
1561 let loaded_phase = loaded.get("test-tag").unwrap();
1562 assert_eq!(loaded_phase.tasks.len(), 1);
1563 assert_eq!(loaded_phase.tasks[0].title, "Test Title");
1564 }
1565
1566 #[test]
1567 fn test_restore_archive_empty_tasks() {
1568 let (storage, _temp_dir) = create_test_storage();
1569
1570 let mut phases = HashMap::new();
1572 phases.insert("v1".to_string(), Phase::new("v1".to_string()));
1573 storage.save_tasks(&phases).unwrap();
1574
1575 let archive_path = storage.archive_phase("v1", &phases).unwrap();
1576 let archive_name = archive_path.file_name().unwrap().to_str().unwrap();
1577
1578 storage.save_tasks(&HashMap::new()).unwrap();
1580 let empty_check = storage.load_tasks().unwrap();
1581 assert!(empty_check.is_empty());
1582
1583 let restored = storage.restore_archive(archive_name, false).unwrap();
1585 assert_eq!(restored, vec!["v1".to_string()]);
1586
1587 let current = storage.load_tasks().unwrap();
1589 assert!(current.contains_key("v1"));
1590 }
1591
1592 #[test]
1593 fn test_restore_archive_no_replace() {
1594 let (storage, _temp_dir) = create_test_storage();
1595
1596 let mut phases = HashMap::new();
1598 let mut phase = Phase::new("v1".to_string());
1599 phase.add_task(crate::models::Task::new(
1600 "original".to_string(),
1601 "Original".to_string(),
1602 "Desc".to_string(),
1603 ));
1604 phases.insert("v1".to_string(), phase);
1605 storage.save_tasks(&phases).unwrap();
1606
1607 let archive_path = storage.archive_phase("v1", &phases).unwrap();
1609 let archive_name = archive_path.file_name().unwrap().to_str().unwrap();
1610
1611 let mut current = storage.load_tasks().unwrap();
1613 current
1614 .get_mut("v1")
1615 .unwrap()
1616 .add_task(crate::models::Task::new(
1617 "new".to_string(),
1618 "New".to_string(),
1619 "Desc".to_string(),
1620 ));
1621 storage.save_tasks(¤t).unwrap();
1622
1623 let restored = storage.restore_archive(archive_name, false).unwrap();
1625 assert!(restored.is_empty()); let final_tasks = storage.load_tasks().unwrap();
1629 assert_eq!(final_tasks.get("v1").unwrap().tasks.len(), 2);
1630 }
1631
1632 #[test]
1633 fn test_restore_archive_with_replace() {
1634 let (storage, _temp_dir) = create_test_storage();
1635
1636 let mut phases = HashMap::new();
1638 let mut phase = Phase::new("v1".to_string());
1639 phase.add_task(crate::models::Task::new(
1640 "original".to_string(),
1641 "Original".to_string(),
1642 "Desc".to_string(),
1643 ));
1644 phases.insert("v1".to_string(), phase);
1645 storage.save_tasks(&phases).unwrap();
1646
1647 let archive_path = storage.archive_phase("v1", &phases).unwrap();
1649 let archive_name = archive_path.file_name().unwrap().to_str().unwrap();
1650
1651 let mut current = storage.load_tasks().unwrap();
1653 current
1654 .get_mut("v1")
1655 .unwrap()
1656 .add_task(crate::models::Task::new(
1657 "new".to_string(),
1658 "New".to_string(),
1659 "Desc".to_string(),
1660 ));
1661 storage.save_tasks(¤t).unwrap();
1662
1663 let restored = storage.restore_archive(archive_name, true).unwrap();
1665 assert_eq!(restored, vec!["v1".to_string()]);
1666
1667 let final_tasks = storage.load_tasks().unwrap();
1669 assert_eq!(final_tasks.get("v1").unwrap().tasks.len(), 1);
1670 }
1671
1672 #[test]
1673 fn test_restore_archive_partial_match() {
1674 let (storage, _temp_dir) = create_test_storage();
1675
1676 let mut phases = HashMap::new();
1677 phases.insert("myproject".to_string(), Phase::new("myproject".to_string()));
1678 storage.save_tasks(&phases).unwrap();
1679
1680 storage.archive_phase("myproject", &phases).unwrap();
1681
1682 storage.save_tasks(&HashMap::new()).unwrap();
1684
1685 let restored = storage.restore_archive("myproject", false).unwrap();
1686 assert_eq!(restored, vec!["myproject".to_string()]);
1687 }
1688
1689 #[test]
1690 fn test_restore_archive_not_found() {
1691 let (storage, _temp_dir) = create_test_storage();
1692
1693 let result = storage.restore_archive("nonexistent", false);
1694 assert!(result.is_err());
1695 assert!(result.unwrap_err().to_string().contains("not found"));
1696 }
1697}