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 if !config_file.exists() {
224 config.save(&config_file)?;
225 }
226
227 let tasks_file = self.tasks_file();
229 if !tasks_file.exists() {
230 let empty_tasks: HashMap<String, Phase> = HashMap::new();
231 self.save_tasks(&empty_tasks)?;
232 }
233
234 let docs = self.docs_dir();
236 fs::create_dir_all(docs.join("prd"))?;
237 fs::create_dir_all(docs.join("phases"))?;
238 fs::create_dir_all(docs.join("architecture"))?;
239 fs::create_dir_all(docs.join("retrospectives"))?;
240
241 fs::create_dir_all(self.guidance_dir())?;
243
244 self.create_agent_instructions()?;
246
247 Ok(())
248 }
249
250 pub fn load_config(&self) -> Result<Config> {
251 let config_file = self.config_file();
252 if !config_file.exists() {
253 return Ok(Config::default());
254 }
255 Config::load(&config_file)
256 }
257
258 pub fn load_tasks(&self) -> Result<HashMap<String, Phase>> {
259 let path = self.tasks_file();
260 if !path.exists() {
261 anyhow::bail!("Tasks file not found: {}\nRun: scud init", path.display());
262 }
263
264 let content = self.read_with_lock(&path)?;
265 self.parse_multi_phase_scg(&content)
266 }
267
268 fn parse_multi_phase_scg(&self, content: &str) -> Result<HashMap<String, Phase>> {
270 let mut phases = HashMap::new();
271
272 if content.trim().is_empty() {
274 return Ok(phases);
275 }
276
277 let sections: Vec<&str> = content.split("\n---\n").collect();
279
280 for section in sections {
281 let section = section.trim();
282 if section.is_empty() {
283 continue;
284 }
285
286 let phase = parse_scg(section).with_context(|| "Failed to parse SCG section")?;
288
289 phases.insert(phase.name.clone(), phase);
290 }
291
292 Ok(phases)
293 }
294
295 pub fn save_tasks(&self, tasks: &HashMap<String, Phase>) -> Result<()> {
296 let path = self.tasks_file();
297 self.write_with_lock(&path, || {
298 let mut sorted_tags: Vec<_> = tasks.keys().collect();
300 sorted_tags.sort();
301
302 let mut output = String::new();
303 for (i, tag) in sorted_tags.iter().enumerate() {
304 if i > 0 {
305 output.push_str("\n---\n\n");
306 }
307 let phase = tasks.get(*tag).unwrap();
308 output.push_str(&serialize_scg(phase));
309 }
310
311 Ok(output)
312 })
313 }
314
315 pub fn get_active_group(&self) -> Result<Option<String>> {
316 {
318 let cache = self.active_group_cache.read().unwrap();
319 if let Some(cached) = cache.as_ref() {
320 return Ok(cached.clone());
321 }
322 }
323
324 let active_tag_path = self.active_tag_file();
326 let active = if active_tag_path.exists() {
327 let content = fs::read_to_string(&active_tag_path)
328 .with_context(|| format!("Failed to read {}", active_tag_path.display()))?;
329 let tag = content.trim();
330 if tag.is_empty() {
331 None
332 } else {
333 Some(tag.to_string())
334 }
335 } else {
336 None
337 };
338
339 *self.active_group_cache.write().unwrap() = Some(active.clone());
341
342 Ok(active)
343 }
344
345 pub fn set_active_group(&self, group_tag: &str) -> Result<()> {
346 let tasks = self.load_tasks()?;
347 if !tasks.contains_key(group_tag) {
348 anyhow::bail!("Task group '{}' not found", group_tag);
349 }
350
351 let active_tag_path = self.active_tag_file();
353 fs::write(&active_tag_path, group_tag)
354 .with_context(|| format!("Failed to write {}", active_tag_path.display()))?;
355
356 *self.active_group_cache.write().unwrap() = Some(Some(group_tag.to_string()));
358
359 Ok(())
360 }
361
362 pub fn clear_cache(&self) {
365 *self.active_group_cache.write().unwrap() = None;
366 }
367
368 pub fn clear_active_group(&self) -> Result<()> {
370 let active_tag_path = self.active_tag_file();
371 if active_tag_path.exists() {
372 fs::remove_file(&active_tag_path)
373 .with_context(|| format!("Failed to remove {}", active_tag_path.display()))?;
374 }
375 *self.active_group_cache.write().unwrap() = Some(None);
376 Ok(())
377 }
378
379 pub fn load_group(&self, group_tag: &str) -> Result<Phase> {
382 let path = self.tasks_file();
383 let content = self.read_with_lock(&path)?;
384
385 let groups = self.parse_multi_phase_scg(&content)?;
386
387 groups
388 .get(group_tag)
389 .cloned()
390 .ok_or_else(|| anyhow::anyhow!("Task group '{}' not found", group_tag))
391 }
392
393 pub fn load_active_group(&self) -> Result<Phase> {
396 let active_tag = self
397 .get_active_group()?
398 .ok_or_else(|| anyhow::anyhow!("No active task group. Run: scud use-tag <tag>"))?;
399
400 self.load_group(&active_tag)
401 }
402
403 pub fn update_group(&self, group_tag: &str, group: &Phase) -> Result<()> {
406 use std::io::{Read, Seek, SeekFrom, Write};
407
408 let path = self.tasks_file();
409
410 let dir = path.parent().unwrap();
411 if !dir.exists() {
412 fs::create_dir_all(dir)?;
413 }
414
415 let mut file = OpenOptions::new()
418 .read(true)
419 .write(true)
420 .create(true)
421 .truncate(false)
422 .open(&path)
423 .with_context(|| format!("Failed to open file: {}", path.display()))?;
424
425 self.acquire_lock_with_retry(&file, 10)?;
427
428 let mut content = String::new();
430 file.read_to_string(&mut content)
431 .with_context(|| format!("Failed to read from {}", path.display()))?;
432
433 let mut groups = self.parse_multi_phase_scg(&content)?;
435 groups.insert(group_tag.to_string(), group.clone());
436
437 let mut sorted_tags: Vec<_> = groups.keys().collect();
438 sorted_tags.sort();
439
440 let mut output = String::new();
441 for (i, tag) in sorted_tags.iter().enumerate() {
442 if i > 0 {
443 output.push_str("\n---\n\n");
444 }
445 let grp = groups.get(*tag).unwrap();
446 output.push_str(&serialize_scg(grp));
447 }
448
449 file.seek(SeekFrom::Start(0))
451 .with_context(|| "Failed to seek to beginning of file")?;
452 file.set_len(0).with_context(|| "Failed to truncate file")?;
453 file.write_all(output.as_bytes())
454 .with_context(|| format!("Failed to write to {}", path.display()))?;
455 file.flush()
456 .with_context(|| format!("Failed to flush {}", path.display()))?;
457
458 Ok(())
460 }
461
462 pub fn update_task_status(
465 &self,
466 group_tag: &str,
467 task_id: &str,
468 status: crate::models::task::TaskStatus,
469 ) -> Result<()> {
470 let mut group = self.load_group(group_tag)?;
471
472 let task = group
473 .tasks
474 .iter_mut()
475 .find(|t| t.id == task_id)
476 .ok_or_else(|| anyhow::anyhow!("Task '{}' not found in group '{}'", task_id, group_tag))?;
477
478 task.status = status;
479 self.update_group(group_tag, &group)
480 }
481
482 pub fn read_file(&self, path: &Path) -> Result<String> {
483 fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", path.display()))
484 }
485
486 pub fn archive_dir(&self) -> PathBuf {
490 self.scud_dir().join("archive")
491 }
492
493 pub fn ensure_archive_dir(&self) -> Result<()> {
495 let dir = self.archive_dir();
496 if !dir.exists() {
497 fs::create_dir_all(&dir).context("Failed to create archive directory")?;
498 }
499 Ok(())
500 }
501
502 pub fn archive_filename(&self, tag: Option<&str>) -> String {
505 let date = Local::now().format("%Y-%m-%d");
506 match tag {
507 Some(t) => format!("{}_{}.scg", date, t),
508 None => format!("{}_all.scg", date),
509 }
510 }
511
512 fn unique_archive_path(&self, base_path: &Path) -> PathBuf {
516 if !base_path.exists() {
517 return base_path.to_path_buf();
518 }
519
520 let stem = base_path
521 .file_stem()
522 .and_then(|s| s.to_str())
523 .unwrap_or("archive");
524 let ext = base_path
525 .extension()
526 .and_then(|s| s.to_str())
527 .unwrap_or("scg");
528 let parent = base_path.parent().unwrap_or(Path::new("."));
529
530 for i in 1..100 {
531 let new_name = format!("{}_{}.{}", stem, i, ext);
532 let new_path = parent.join(&new_name);
533 if !new_path.exists() {
534 return new_path;
535 }
536 }
537
538 let ts = Local::now().format("%H%M%S");
540 parent.join(format!("{}_{}.{}", stem, ts, ext))
541 }
542
543 pub fn archive_phase(&self, tag: &str, phases: &HashMap<String, Phase>) -> Result<PathBuf> {
546 self.ensure_archive_dir()?;
547
548 let phase = phases
549 .get(tag)
550 .ok_or_else(|| anyhow::anyhow!("Tag '{}' not found", tag))?;
551
552 let filename = self.archive_filename(Some(tag));
553 let archive_path = self.archive_dir().join(&filename);
554 let final_path = self.unique_archive_path(&archive_path);
555
556 let content = serialize_scg(phase);
558 fs::write(&final_path, content)
559 .with_context(|| format!("Failed to write archive: {}", final_path.display()))?;
560
561 Ok(final_path)
562 }
563
564 pub fn archive_all(&self, phases: &HashMap<String, Phase>) -> Result<PathBuf> {
567 self.ensure_archive_dir()?;
568
569 let filename = self.archive_filename(None);
570 let archive_path = self.archive_dir().join(&filename);
571 let final_path = self.unique_archive_path(&archive_path);
572
573 let mut sorted_tags: Vec<_> = phases.keys().collect();
575 sorted_tags.sort();
576
577 let mut output = String::new();
578 for (i, tag) in sorted_tags.iter().enumerate() {
579 if i > 0 {
580 output.push_str("\n---\n\n");
581 }
582 let phase = phases.get(*tag).unwrap();
583 output.push_str(&serialize_scg(phase));
584 }
585
586 fs::write(&final_path, output)
587 .with_context(|| format!("Failed to write archive: {}", final_path.display()))?;
588
589 Ok(final_path)
590 }
591
592 pub fn parse_archive_filename(filename: &str) -> (String, Option<String>) {
595 let name = filename.trim_end_matches(".scg");
596
597 let parts: Vec<&str> = name.splitn(2, '_').collect();
600
601 if parts.len() == 2 {
602 let date = parts[0].to_string();
603 let rest = parts[1];
604
605 if let Some(last_underscore) = rest.rfind('_') {
608 let potential_counter = &rest[last_underscore + 1..];
609 if potential_counter.chars().all(|c| c.is_ascii_digit())
610 && !potential_counter.is_empty()
611 {
612 let tag_part = &rest[..last_underscore];
614 let tag = if tag_part == "all" {
615 None
616 } else {
617 Some(tag_part.to_string())
618 };
619 return (date, tag);
620 }
621 }
622
623 let tag = if rest == "all" {
625 None
626 } else {
627 Some(rest.to_string())
628 };
629 (date, tag)
630 } else {
631 (name.to_string(), None)
633 }
634 }
635
636 pub fn list_archives(&self) -> Result<Vec<ArchiveInfo>> {
639 let archive_dir = self.archive_dir();
640 if !archive_dir.exists() {
641 return Ok(Vec::new());
642 }
643
644 let mut archives = Vec::new();
645 for entry in fs::read_dir(&archive_dir)? {
646 let entry = entry?;
647 let path = entry.path();
648
649 if path.extension().map(|e| e == "scg").unwrap_or(false) {
650 let filename = path
651 .file_name()
652 .and_then(|n| n.to_str())
653 .unwrap_or("")
654 .to_string();
655
656 let (date, tag) = Self::parse_archive_filename(&filename);
657
658 let task_count = match self.load_archive(&path) {
660 Ok(phases) => phases.values().map(|p| p.tasks.len()).sum(),
661 Err(_) => 0,
662 };
663
664 archives.push(ArchiveInfo {
665 filename,
666 path,
667 date,
668 tag,
669 task_count,
670 });
671 }
672 }
673
674 archives.sort_by(|a, b| b.date.cmp(&a.date));
676 Ok(archives)
677 }
678
679 pub fn load_archive(&self, path: &Path) -> Result<HashMap<String, Phase>> {
682 let content = fs::read_to_string(path)
683 .with_context(|| format!("Failed to read archive: {}", path.display()))?;
684
685 self.parse_multi_phase_scg(&content)
686 }
687
688 pub fn restore_archive(&self, archive_name: &str, replace: bool) -> Result<Vec<String>> {
697 let archive_dir = self.archive_dir();
698
699 let archive_path = if archive_name.ends_with(".scg") {
701 let path = archive_dir.join(archive_name);
702 if !path.exists() {
703 anyhow::bail!("Archive file not found: {}", archive_name);
704 }
705 path
706 } else {
707 let mut found = None;
709 if archive_dir.exists() {
710 for entry in fs::read_dir(&archive_dir)? {
711 let entry = entry?;
712 let filename = entry.file_name().to_string_lossy().to_string();
713 if filename.contains(archive_name) {
714 found = Some(entry.path());
715 break;
716 }
717 }
718 }
719 found.ok_or_else(|| anyhow::anyhow!("Archive '{}' not found", archive_name))?
720 };
721
722 let archived_phases = self.load_archive(&archive_path)?;
723 let mut current_phases = self.load_tasks().unwrap_or_default();
724 let mut restored_tags = Vec::new();
725
726 for (tag, phase) in archived_phases {
727 if replace || !current_phases.contains_key(&tag) {
728 current_phases.insert(tag.clone(), phase);
729 restored_tags.push(tag);
730 }
731 }
732
733 self.save_tasks(¤t_phases)?;
734 Ok(restored_tags)
735 }
736
737 fn create_agent_instructions(&self) -> Result<()> {
739 let claude_md_path = self.project_root.join("CLAUDE.md");
740
741 let scud_instructions = r#"
742## SCUD Task Management
743
744This project uses SCUD Task Manager for task management.
745
746### Session Workflow
747
7481. **Start of session**: Run `scud warmup` to orient yourself
749 - Shows current working directory and recent git history
750 - Displays active tag, task counts, and any stale locks
751 - Identifies the next available task
752
7532. **Get a task**: Use `/scud:next` or `scud next`
754 - Shows the next available task based on DAG dependencies
755 - Use `scud set-status <id> in-progress` to mark you're working on it
756
7573. **Work on the task**: Implement the requirements
758 - Reference task details with `/scud:task-show <id>`
759 - Dependencies are automatically tracked by the DAG
760
7614. **Commit with context**: Use `scud commit -m "message"` or `scud commit -a -m "message"`
762 - Automatically prefixes commits with `[TASK-ID]`
763 - Uses task title as default commit message if none provided
764
7655. **Complete the task**: Mark done with `/scud:task-status <id> done`
766 - The stop hook will prompt for task completion
767
768### Progress Journaling
769
770Keep a brief progress log during complex tasks:
771
772```
773## Progress Log
774
775### Session: 2025-01-15
776- Investigated auth module, found issue in token refresh
777- Updated refresh logic to handle edge case
778- Tests passing, ready for review
779```
780
781This helps maintain continuity across sessions and provides context for future work.
782
783### Key Commands
784
785- `scud warmup` - Session orientation
786- `scud next` - Find next available task
787- `scud show <id>` - View task details
788- `scud set-status <id> <status>` - Update task status
789- `scud commit` - Task-aware git commit
790- `scud stats` - View completion statistics
791"#;
792
793 if claude_md_path.exists() {
794 let content = fs::read_to_string(&claude_md_path)
796 .with_context(|| "Failed to read existing CLAUDE.md")?;
797
798 if !content.contains("## SCUD Task Management") {
799 let mut new_content = content;
800 new_content.push_str(scud_instructions);
801 fs::write(&claude_md_path, new_content)
802 .with_context(|| "Failed to update CLAUDE.md")?;
803 }
804 } else {
805 let content = format!("# Project Instructions\n{}", scud_instructions);
807 fs::write(&claude_md_path, content).with_context(|| "Failed to create CLAUDE.md")?;
808 }
809
810 Ok(())
811 }
812}
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817 use std::collections::HashMap;
818 use tempfile::TempDir;
819
820 fn create_test_storage() -> (Storage, TempDir) {
821 let temp_dir = TempDir::new().unwrap();
822 let storage = Storage::new(Some(temp_dir.path().to_path_buf()));
823 storage.initialize().unwrap();
824 (storage, temp_dir)
825 }
826
827 #[test]
828 fn test_write_with_lock_creates_file() {
829 let (storage, _temp_dir) = create_test_storage();
830 let test_file = storage.scud_dir().join("test.json");
831
832 storage
833 .write_with_lock(&test_file, || Ok(r#"{"test": "data"}"#.to_string()))
834 .unwrap();
835
836 assert!(test_file.exists());
837 let content = fs::read_to_string(&test_file).unwrap();
838 assert_eq!(content, r#"{"test": "data"}"#);
839 }
840
841 #[test]
842 fn test_read_with_lock_reads_existing_file() {
843 let (storage, _temp_dir) = create_test_storage();
844 let test_file = storage.scud_dir().join("test.json");
845
846 fs::write(&test_file, r#"{"test": "data"}"#).unwrap();
848
849 let content = storage.read_with_lock(&test_file).unwrap();
851 assert_eq!(content, r#"{"test": "data"}"#);
852 }
853
854 #[test]
855 fn test_read_with_lock_fails_on_missing_file() {
856 let (storage, _temp_dir) = create_test_storage();
857 let test_file = storage.scud_dir().join("nonexistent.json");
858
859 let result = storage.read_with_lock(&test_file);
860 assert!(result.is_err());
861 assert!(result.unwrap_err().to_string().contains("File not found"));
862 }
863
864 #[test]
865 fn test_save_and_load_tasks_with_locking() {
866 let (storage, _temp_dir) = create_test_storage();
867 let mut tasks = HashMap::new();
868
869 let epic = crate::models::Phase::new("TEST-1".to_string());
870 tasks.insert("TEST-1".to_string(), epic);
871
872 storage.save_tasks(&tasks).unwrap();
874
875 let loaded_tasks = storage.load_tasks().unwrap();
877
878 assert_eq!(tasks.len(), loaded_tasks.len());
879 assert!(loaded_tasks.contains_key("TEST-1"));
880 assert_eq!(loaded_tasks.get("TEST-1").unwrap().name, "TEST-1");
881 }
882
883 #[test]
884 fn test_concurrent_writes_dont_corrupt_data() {
885 use std::sync::Arc;
886 use std::thread;
887
888 let (storage, _temp_dir) = create_test_storage();
889 let storage = Arc::new(storage);
890 let mut handles = vec![];
891
892 for i in 0..10 {
894 let storage_clone = Arc::clone(&storage);
895 let handle = thread::spawn(move || {
896 let mut tasks = HashMap::new();
897 let epic = crate::models::Phase::new(format!("EPIC-{}", i));
898 tasks.insert(format!("EPIC-{}", i), epic);
899
900 for _ in 0..5 {
902 storage_clone.save_tasks(&tasks).unwrap();
903 thread::sleep(Duration::from_millis(1));
904 }
905 });
906 handles.push(handle);
907 }
908
909 for handle in handles {
911 handle.join().unwrap();
912 }
913
914 let tasks = storage.load_tasks().unwrap();
916 assert_eq!(tasks.len(), 1);
918 }
919
920 #[test]
921 fn test_lock_retry_on_contention() {
922 use std::sync::Arc;
923
924 let (storage, _temp_dir) = create_test_storage();
925 let storage = Arc::new(storage);
926 let test_file = storage.scud_dir().join("lock-test.json");
927
928 storage
930 .write_with_lock(&test_file, || Ok(r#"{"initial": "data"}"#.to_string()))
931 .unwrap();
932
933 let file = OpenOptions::new().write(true).open(&test_file).unwrap();
935 file.lock_exclusive().unwrap();
936
937 let storage_clone = Arc::clone(&storage);
939 let test_file_clone = test_file.clone();
940 let handle = thread::spawn(move || {
941 storage_clone.write_with_lock(&test_file_clone, || {
943 Ok(r#"{"updated": "data"}"#.to_string())
944 })
945 });
946
947 thread::sleep(Duration::from_millis(200));
949
950 file.unlock().unwrap();
952 drop(file);
953
954 let result = handle.join().unwrap();
956 assert!(result.is_ok());
957 }
958
959 #[test]
962 fn test_load_tasks_with_malformed_json() {
963 let (storage, _temp_dir) = create_test_storage();
964 let tasks_file = storage.tasks_file();
965
966 fs::write(&tasks_file, r#"{"invalid": json here}"#).unwrap();
968
969 let result = storage.load_tasks();
971 assert!(result.is_err());
972 }
973
974 #[test]
975 fn test_load_tasks_with_empty_file() {
976 let (storage, _temp_dir) = create_test_storage();
977 let tasks_file = storage.tasks_file();
978
979 fs::write(&tasks_file, "").unwrap();
981
982 let result = storage.load_tasks();
984 assert!(result.is_ok());
985 assert!(result.unwrap().is_empty());
986 }
987
988 #[test]
989 fn test_load_tasks_missing_file_creates_default() {
990 let (storage, _temp_dir) = create_test_storage();
991 let tasks = storage.load_tasks().unwrap();
995 assert_eq!(tasks.len(), 0);
996 }
997
998 #[test]
999 fn test_save_tasks_creates_directory_if_missing() {
1000 let temp_dir = TempDir::new().unwrap();
1001 let storage = Storage::new(Some(temp_dir.path().to_path_buf()));
1002 let mut tasks = HashMap::new();
1005 let epic = crate::models::Phase::new("TEST-1".to_string());
1006 tasks.insert("TEST-1".to_string(), epic);
1007
1008 let result = storage.save_tasks(&tasks);
1010 assert!(result.is_ok());
1011
1012 assert!(storage.scud_dir().exists());
1013 assert!(storage.tasks_file().exists());
1014 }
1015
1016 #[test]
1017 fn test_write_with_lock_handles_directory_creation() {
1018 let temp_dir = TempDir::new().unwrap();
1019 let storage = Storage::new(Some(temp_dir.path().to_path_buf()));
1020
1021 let nested_file = temp_dir
1022 .path()
1023 .join("deeply")
1024 .join("nested")
1025 .join("test.json");
1026
1027 let result = storage.write_with_lock(&nested_file, || Ok("{}".to_string()));
1029 assert!(result.is_ok());
1030 assert!(nested_file.exists());
1031 }
1032
1033 #[test]
1034 fn test_load_tasks_with_invalid_structure() {
1035 let (storage, _temp_dir) = create_test_storage();
1036 let tasks_file = storage.tasks_file();
1037
1038 fs::write(&tasks_file, r#"["not", "an", "object"]"#).unwrap();
1040
1041 let result = storage.load_tasks();
1043 assert!(result.is_err());
1044 }
1045
1046 #[test]
1047 fn test_save_and_load_with_unicode_content() {
1048 let (storage, _temp_dir) = create_test_storage();
1049
1050 let mut tasks = HashMap::new();
1051 let mut epic = crate::models::Phase::new("TEST-UNICODE".to_string());
1052
1053 let task = crate::models::Task::new(
1055 "task-1".to_string(),
1056 "测试 Unicode 🚀".to_string(),
1057 "Descripción en español 日本語".to_string(),
1058 );
1059 epic.add_task(task);
1060
1061 tasks.insert("TEST-UNICODE".to_string(), epic);
1062
1063 storage.save_tasks(&tasks).unwrap();
1065 let loaded_tasks = storage.load_tasks().unwrap();
1066
1067 let loaded_epic = loaded_tasks.get("TEST-UNICODE").unwrap();
1068 let loaded_task = loaded_epic.get_task("task-1").unwrap();
1069 assert_eq!(loaded_task.title, "测试 Unicode 🚀");
1070 assert_eq!(loaded_task.description, "Descripción en español 日本語");
1071 }
1072
1073 #[test]
1074 fn test_save_and_load_with_large_dataset() {
1075 let (storage, _temp_dir) = create_test_storage();
1076
1077 let mut tasks = HashMap::new();
1078
1079 for i in 0..100 {
1081 let mut epic = crate::models::Phase::new(format!("EPIC-{}", i));
1082
1083 for j in 0..50 {
1084 let task = crate::models::Task::new(
1085 format!("task-{}-{}", i, j),
1086 format!("Task {} of Epic {}", j, i),
1087 format!("Description for task {}-{}", i, j),
1088 );
1089 epic.add_task(task);
1090 }
1091
1092 tasks.insert(format!("EPIC-{}", i), epic);
1093 }
1094
1095 storage.save_tasks(&tasks).unwrap();
1097 let loaded_tasks = storage.load_tasks().unwrap();
1098
1099 assert_eq!(loaded_tasks.len(), 100);
1100 for i in 0..100 {
1101 let epic = loaded_tasks.get(&format!("EPIC-{}", i)).unwrap();
1102 assert_eq!(epic.tasks.len(), 50);
1103 }
1104 }
1105
1106 #[test]
1107 fn test_concurrent_read_and_write() {
1108 use std::sync::Arc;
1109 use std::thread;
1110
1111 let (storage, _temp_dir) = create_test_storage();
1112 let storage = Arc::new(storage);
1113
1114 let mut tasks = HashMap::new();
1116 let epic = crate::models::Phase::new("INITIAL".to_string());
1117 tasks.insert("INITIAL".to_string(), epic);
1118 storage.save_tasks(&tasks).unwrap();
1119
1120 let mut handles = vec![];
1121
1122 for _ in 0..5 {
1124 let storage_clone = Arc::clone(&storage);
1125 let handle = thread::spawn(move || {
1126 for _ in 0..10 {
1127 let _ = storage_clone.load_tasks();
1128 thread::sleep(Duration::from_millis(1));
1129 }
1130 });
1131 handles.push(handle);
1132 }
1133
1134 for i in 0..2 {
1136 let storage_clone = Arc::clone(&storage);
1137 let handle = thread::spawn(move || {
1138 for j in 0..5 {
1139 let mut tasks = HashMap::new();
1140 let epic = crate::models::Phase::new(format!("WRITER-{}-{}", i, j));
1141 tasks.insert(format!("WRITER-{}-{}", i, j), epic);
1142 storage_clone.save_tasks(&tasks).unwrap();
1143 thread::sleep(Duration::from_millis(2));
1144 }
1145 });
1146 handles.push(handle);
1147 }
1148
1149 for handle in handles {
1151 handle.join().unwrap();
1152 }
1153
1154 let tasks = storage.load_tasks().unwrap();
1156 assert_eq!(tasks.len(), 1); }
1158
1159 #[test]
1162 fn test_active_epic_cached_on_second_call() {
1163 let (storage, _temp_dir) = create_test_storage();
1164
1165 let mut tasks = HashMap::new();
1167 tasks.insert("TEST-1".to_string(), Phase::new("TEST-1".to_string()));
1168 storage.save_tasks(&tasks).unwrap();
1169 storage.set_active_group("TEST-1").unwrap();
1170
1171 let active1 = storage.get_active_group().unwrap();
1173 assert_eq!(active1, Some("TEST-1".to_string()));
1174
1175 let active_tag_file = storage.active_tag_file();
1177 fs::write(&active_tag_file, "DIFFERENT").unwrap();
1178
1179 let active2 = storage.get_active_group().unwrap();
1181 assert_eq!(active2, Some("TEST-1".to_string())); storage.clear_cache();
1185 let active3 = storage.get_active_group().unwrap();
1186 assert_eq!(active3, Some("DIFFERENT".to_string())); }
1188
1189 #[test]
1190 fn test_cache_invalidated_on_set_active_epic() {
1191 let (storage, _temp_dir) = create_test_storage();
1192
1193 let mut tasks = HashMap::new();
1194 tasks.insert("EPIC-1".to_string(), Phase::new("EPIC-1".to_string()));
1195 tasks.insert("EPIC-2".to_string(), Phase::new("EPIC-2".to_string()));
1196 storage.save_tasks(&tasks).unwrap();
1197
1198 storage.set_active_group("EPIC-1").unwrap();
1199 assert_eq!(
1200 storage.get_active_group().unwrap(),
1201 Some("EPIC-1".to_string())
1202 );
1203
1204 storage.set_active_group("EPIC-2").unwrap();
1206 assert_eq!(
1207 storage.get_active_group().unwrap(),
1208 Some("EPIC-2".to_string())
1209 );
1210 }
1211
1212 #[test]
1213 fn test_cache_with_no_active_epic() {
1214 let (storage, _temp_dir) = create_test_storage();
1215
1216 let active = storage.get_active_group().unwrap();
1218 assert_eq!(active, None);
1219
1220 let active2 = storage.get_active_group().unwrap();
1222 assert_eq!(active2, None);
1223 }
1224
1225 #[test]
1228 fn test_load_single_epic_from_many() {
1229 let (storage, _temp_dir) = create_test_storage();
1230
1231 let mut tasks = HashMap::new();
1233 for i in 0..50 {
1234 tasks.insert(format!("EPIC-{}", i), Phase::new(format!("EPIC-{}", i)));
1235 }
1236 storage.save_tasks(&tasks).unwrap();
1237
1238 let epic = storage.load_group("EPIC-25").unwrap();
1240 assert_eq!(epic.name, "EPIC-25");
1241 }
1242
1243 #[test]
1244 fn test_load_epic_not_found() {
1245 let (storage, _temp_dir) = create_test_storage();
1246
1247 let tasks = HashMap::new();
1248 storage.save_tasks(&tasks).unwrap();
1249
1250 let result = storage.load_group("NONEXISTENT");
1251 assert!(result.is_err());
1252 assert!(result.unwrap_err().to_string().contains("not found"));
1253 }
1254
1255 #[test]
1256 fn test_load_epic_matches_full_load() {
1257 let (storage, _temp_dir) = create_test_storage();
1258
1259 let mut tasks = HashMap::new();
1260 let mut epic = Phase::new("TEST-1".to_string());
1261 epic.add_task(crate::models::Task::new(
1262 "task-1".to_string(),
1263 "Test".to_string(),
1264 "Desc".to_string(),
1265 ));
1266 tasks.insert("TEST-1".to_string(), epic.clone());
1267 storage.save_tasks(&tasks).unwrap();
1268
1269 let epic_lazy = storage.load_group("TEST-1").unwrap();
1271 let tasks_full = storage.load_tasks().unwrap();
1272 let epic_full = tasks_full.get("TEST-1").unwrap();
1273
1274 assert_eq!(epic_lazy.name, epic_full.name);
1276 assert_eq!(epic_lazy.tasks.len(), epic_full.tasks.len());
1277 }
1278
1279 #[test]
1280 fn test_load_active_epic() {
1281 let (storage, _temp_dir) = create_test_storage();
1282
1283 let mut tasks = HashMap::new();
1284 let mut epic = Phase::new("ACTIVE-1".to_string());
1285 epic.add_task(crate::models::Task::new(
1286 "task-1".to_string(),
1287 "Test".to_string(),
1288 "Desc".to_string(),
1289 ));
1290 tasks.insert("ACTIVE-1".to_string(), epic);
1291 storage.save_tasks(&tasks).unwrap();
1292 storage.set_active_group("ACTIVE-1").unwrap();
1293
1294 let epic = storage.load_active_group().unwrap();
1296 assert_eq!(epic.name, "ACTIVE-1");
1297 assert_eq!(epic.tasks.len(), 1);
1298 }
1299
1300 #[test]
1301 fn test_load_active_epic_when_none_set() {
1302 let (storage, _temp_dir) = create_test_storage();
1303
1304 let result = storage.load_active_group();
1306 assert!(result.is_err());
1307 assert!(result
1308 .unwrap_err()
1309 .to_string()
1310 .contains("No active task group"));
1311 }
1312
1313 #[test]
1314 fn test_update_epic_without_loading_all() {
1315 let (storage, _temp_dir) = create_test_storage();
1316
1317 let mut tasks = HashMap::new();
1318 tasks.insert("EPIC-1".to_string(), Phase::new("EPIC-1".to_string()));
1319 tasks.insert("EPIC-2".to_string(), Phase::new("EPIC-2".to_string()));
1320 storage.save_tasks(&tasks).unwrap();
1321
1322 let mut epic1 = storage.load_group("EPIC-1").unwrap();
1324 epic1.add_task(crate::models::Task::new(
1325 "new-task".to_string(),
1326 "New".to_string(),
1327 "Desc".to_string(),
1328 ));
1329 storage.update_group("EPIC-1", &epic1).unwrap();
1330
1331 let loaded = storage.load_group("EPIC-1").unwrap();
1333 assert_eq!(loaded.tasks.len(), 1);
1334
1335 let epic2 = storage.load_group("EPIC-2").unwrap();
1337 assert_eq!(epic2.tasks.len(), 0);
1338 }
1339
1340 #[test]
1343 fn test_archive_dir() {
1344 let (storage, _temp_dir) = create_test_storage();
1345 let archive_dir = storage.archive_dir();
1346 assert!(archive_dir.ends_with(".scud/archive"));
1347 }
1348
1349 #[test]
1350 fn test_ensure_archive_dir() {
1351 let (storage, _temp_dir) = create_test_storage();
1352
1353 assert!(!storage.archive_dir().exists());
1355
1356 storage.ensure_archive_dir().unwrap();
1358 assert!(storage.archive_dir().exists());
1359
1360 storage.ensure_archive_dir().unwrap();
1362 assert!(storage.archive_dir().exists());
1363 }
1364
1365 #[test]
1366 fn test_archive_filename_with_tag() {
1367 let (storage, _temp_dir) = create_test_storage();
1368 let filename = storage.archive_filename(Some("v1"));
1369
1370 assert!(filename.ends_with("_v1.scg"));
1372 assert!(filename.len() == 17); }
1374
1375 #[test]
1376 fn test_archive_filename_all() {
1377 let (storage, _temp_dir) = create_test_storage();
1378 let filename = storage.archive_filename(None);
1379
1380 assert!(filename.ends_with("_all.scg"));
1382 assert!(filename.len() == 18); }
1384
1385 #[test]
1386 fn test_parse_archive_filename_simple() {
1387 let (date, tag) = Storage::parse_archive_filename("2026-01-13_v1.scg");
1388 assert_eq!(date, "2026-01-13");
1389 assert_eq!(tag, Some("v1".to_string()));
1390 }
1391
1392 #[test]
1393 fn test_parse_archive_filename_all() {
1394 let (date, tag) = Storage::parse_archive_filename("2026-01-13_all.scg");
1395 assert_eq!(date, "2026-01-13");
1396 assert_eq!(tag, None);
1397 }
1398
1399 #[test]
1400 fn test_parse_archive_filename_with_counter() {
1401 let (date, tag) = Storage::parse_archive_filename("2026-01-13_v1_2.scg");
1402 assert_eq!(date, "2026-01-13");
1403 assert_eq!(tag, Some("v1".to_string()));
1404 }
1405
1406 #[test]
1407 fn test_parse_archive_filename_all_with_counter() {
1408 let (date, tag) = Storage::parse_archive_filename("2026-01-13_all_5.scg");
1409 assert_eq!(date, "2026-01-13");
1410 assert_eq!(tag, None);
1411 }
1412
1413 #[test]
1414 fn test_archive_single_phase() {
1415 let (storage, _temp_dir) = create_test_storage();
1416
1417 let mut phases = HashMap::new();
1419 let mut phase = Phase::new("v1".to_string());
1420 phase.add_task(crate::models::Task::new(
1421 "task-1".to_string(),
1422 "Test Task".to_string(),
1423 "Description".to_string(),
1424 ));
1425 phases.insert("v1".to_string(), phase);
1426 storage.save_tasks(&phases).unwrap();
1427
1428 let archive_path = storage.archive_phase("v1", &phases).unwrap();
1430
1431 assert!(archive_path.exists());
1432 assert!(archive_path.to_string_lossy().contains("v1"));
1433 assert!(archive_path.extension().unwrap() == "scg");
1434 }
1435
1436 #[test]
1437 fn test_archive_all_phases() {
1438 let (storage, _temp_dir) = create_test_storage();
1439
1440 let mut phases = HashMap::new();
1441 phases.insert("v1".to_string(), Phase::new("v1".to_string()));
1442 phases.insert("v2".to_string(), Phase::new("v2".to_string()));
1443 storage.save_tasks(&phases).unwrap();
1444
1445 let archive_path = storage.archive_all(&phases).unwrap();
1446
1447 assert!(archive_path.exists());
1448 assert!(archive_path.to_string_lossy().contains("all"));
1449
1450 let loaded = storage.load_archive(&archive_path).unwrap();
1452 assert_eq!(loaded.len(), 2);
1453 assert!(loaded.contains_key("v1"));
1454 assert!(loaded.contains_key("v2"));
1455 }
1456
1457 #[test]
1458 fn test_archive_nonexistent_tag() {
1459 let (storage, _temp_dir) = create_test_storage();
1460
1461 let phases = HashMap::new();
1462 let result = storage.archive_phase("nonexistent", &phases);
1463
1464 assert!(result.is_err());
1465 assert!(result.unwrap_err().to_string().contains("not found"));
1466 }
1467
1468 #[test]
1469 fn test_unique_archive_path_no_collision() {
1470 let (storage, _temp_dir) = create_test_storage();
1471 storage.ensure_archive_dir().unwrap();
1472
1473 let base_path = storage.archive_dir().join("test.scg");
1474 let result = storage.unique_archive_path(&base_path);
1475
1476 assert_eq!(result, base_path);
1477 }
1478
1479 #[test]
1480 fn test_unique_archive_path_with_collision() {
1481 let (storage, _temp_dir) = create_test_storage();
1482 storage.ensure_archive_dir().unwrap();
1483
1484 let base_path = storage.archive_dir().join("test.scg");
1486 fs::write(&base_path, "existing").unwrap();
1487
1488 let result = storage.unique_archive_path(&base_path);
1490 assert!(result.to_string_lossy().contains("test_1.scg"));
1491 }
1492
1493 #[test]
1494 fn test_unique_archive_path_multiple_collisions() {
1495 let (storage, _temp_dir) = create_test_storage();
1496 storage.ensure_archive_dir().unwrap();
1497
1498 let base_path = storage.archive_dir().join("test.scg");
1500 fs::write(&base_path, "existing").unwrap();
1501 fs::write(storage.archive_dir().join("test_1.scg"), "existing").unwrap();
1502 fs::write(storage.archive_dir().join("test_2.scg"), "existing").unwrap();
1503
1504 let result = storage.unique_archive_path(&base_path);
1506 assert!(result.to_string_lossy().contains("test_3.scg"));
1507 }
1508
1509 #[test]
1510 fn test_list_archives_empty() {
1511 let (storage, _temp_dir) = create_test_storage();
1512
1513 let archives = storage.list_archives().unwrap();
1514 assert!(archives.is_empty());
1515 }
1516
1517 #[test]
1518 fn test_list_archives_with_archives() {
1519 let (storage, _temp_dir) = create_test_storage();
1520
1521 let mut phases = HashMap::new();
1523 let mut phase = Phase::new("v1".to_string());
1524 phase.add_task(crate::models::Task::new(
1525 "task-1".to_string(),
1526 "Test".to_string(),
1527 "Desc".to_string(),
1528 ));
1529 phases.insert("v1".to_string(), phase);
1530 storage.save_tasks(&phases).unwrap();
1531
1532 storage.archive_phase("v1", &phases).unwrap();
1533
1534 let archives = storage.list_archives().unwrap();
1535 assert_eq!(archives.len(), 1);
1536 assert_eq!(archives[0].tag, Some("v1".to_string()));
1537 assert_eq!(archives[0].task_count, 1);
1538 }
1539
1540 #[test]
1541 fn test_load_archive() {
1542 let (storage, _temp_dir) = create_test_storage();
1543
1544 let mut phases = HashMap::new();
1545 let mut phase = Phase::new("test-tag".to_string());
1546 phase.add_task(crate::models::Task::new(
1547 "task-1".to_string(),
1548 "Test Title".to_string(),
1549 "Test Description".to_string(),
1550 ));
1551 phases.insert("test-tag".to_string(), phase);
1552 storage.save_tasks(&phases).unwrap();
1553
1554 let archive_path = storage.archive_phase("test-tag", &phases).unwrap();
1555
1556 let loaded = storage.load_archive(&archive_path).unwrap();
1558 assert_eq!(loaded.len(), 1);
1559 let loaded_phase = loaded.get("test-tag").unwrap();
1560 assert_eq!(loaded_phase.tasks.len(), 1);
1561 assert_eq!(loaded_phase.tasks[0].title, "Test Title");
1562 }
1563
1564 #[test]
1565 fn test_restore_archive_empty_tasks() {
1566 let (storage, _temp_dir) = create_test_storage();
1567
1568 let mut phases = HashMap::new();
1570 phases.insert("v1".to_string(), Phase::new("v1".to_string()));
1571 storage.save_tasks(&phases).unwrap();
1572
1573 let archive_path = storage.archive_phase("v1", &phases).unwrap();
1574 let archive_name = archive_path.file_name().unwrap().to_str().unwrap();
1575
1576 storage.save_tasks(&HashMap::new()).unwrap();
1578 let empty_check = storage.load_tasks().unwrap();
1579 assert!(empty_check.is_empty());
1580
1581 let restored = storage.restore_archive(archive_name, false).unwrap();
1583 assert_eq!(restored, vec!["v1".to_string()]);
1584
1585 let current = storage.load_tasks().unwrap();
1587 assert!(current.contains_key("v1"));
1588 }
1589
1590 #[test]
1591 fn test_restore_archive_no_replace() {
1592 let (storage, _temp_dir) = create_test_storage();
1593
1594 let mut phases = HashMap::new();
1596 let mut phase = Phase::new("v1".to_string());
1597 phase.add_task(crate::models::Task::new(
1598 "original".to_string(),
1599 "Original".to_string(),
1600 "Desc".to_string(),
1601 ));
1602 phases.insert("v1".to_string(), phase);
1603 storage.save_tasks(&phases).unwrap();
1604
1605 let archive_path = storage.archive_phase("v1", &phases).unwrap();
1607 let archive_name = archive_path.file_name().unwrap().to_str().unwrap();
1608
1609 let mut current = storage.load_tasks().unwrap();
1611 current
1612 .get_mut("v1")
1613 .unwrap()
1614 .add_task(crate::models::Task::new(
1615 "new".to_string(),
1616 "New".to_string(),
1617 "Desc".to_string(),
1618 ));
1619 storage.save_tasks(¤t).unwrap();
1620
1621 let restored = storage.restore_archive(archive_name, false).unwrap();
1623 assert!(restored.is_empty()); let final_tasks = storage.load_tasks().unwrap();
1627 assert_eq!(final_tasks.get("v1").unwrap().tasks.len(), 2);
1628 }
1629
1630 #[test]
1631 fn test_restore_archive_with_replace() {
1632 let (storage, _temp_dir) = create_test_storage();
1633
1634 let mut phases = HashMap::new();
1636 let mut phase = Phase::new("v1".to_string());
1637 phase.add_task(crate::models::Task::new(
1638 "original".to_string(),
1639 "Original".to_string(),
1640 "Desc".to_string(),
1641 ));
1642 phases.insert("v1".to_string(), phase);
1643 storage.save_tasks(&phases).unwrap();
1644
1645 let archive_path = storage.archive_phase("v1", &phases).unwrap();
1647 let archive_name = archive_path.file_name().unwrap().to_str().unwrap();
1648
1649 let mut current = storage.load_tasks().unwrap();
1651 current
1652 .get_mut("v1")
1653 .unwrap()
1654 .add_task(crate::models::Task::new(
1655 "new".to_string(),
1656 "New".to_string(),
1657 "Desc".to_string(),
1658 ));
1659 storage.save_tasks(¤t).unwrap();
1660
1661 let restored = storage.restore_archive(archive_name, true).unwrap();
1663 assert_eq!(restored, vec!["v1".to_string()]);
1664
1665 let final_tasks = storage.load_tasks().unwrap();
1667 assert_eq!(final_tasks.get("v1").unwrap().tasks.len(), 1);
1668 }
1669
1670 #[test]
1671 fn test_restore_archive_partial_match() {
1672 let (storage, _temp_dir) = create_test_storage();
1673
1674 let mut phases = HashMap::new();
1675 phases.insert("myproject".to_string(), Phase::new("myproject".to_string()));
1676 storage.save_tasks(&phases).unwrap();
1677
1678 storage.archive_phase("myproject", &phases).unwrap();
1679
1680 storage.save_tasks(&HashMap::new()).unwrap();
1682
1683 let restored = storage.restore_archive("myproject", false).unwrap();
1684 assert_eq!(restored, vec!["myproject".to_string()]);
1685 }
1686
1687 #[test]
1688 fn test_restore_archive_not_found() {
1689 let (storage, _temp_dir) = create_test_storage();
1690
1691 let result = storage.restore_archive("nonexistent", false);
1692 assert!(result.is_err());
1693 assert!(result.unwrap_err().to_string().contains("not found"));
1694 }
1695}