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