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 read_file(&self, path: &Path) -> Result<String> {
463 fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", path.display()))
464 }
465
466 pub fn archive_dir(&self) -> PathBuf {
470 self.scud_dir().join("archive")
471 }
472
473 pub fn ensure_archive_dir(&self) -> Result<()> {
475 let dir = self.archive_dir();
476 if !dir.exists() {
477 fs::create_dir_all(&dir).context("Failed to create archive directory")?;
478 }
479 Ok(())
480 }
481
482 pub fn archive_filename(&self, tag: Option<&str>) -> String {
485 let date = Local::now().format("%Y-%m-%d");
486 match tag {
487 Some(t) => format!("{}_{}.scg", date, t),
488 None => format!("{}_all.scg", date),
489 }
490 }
491
492 fn unique_archive_path(&self, base_path: &Path) -> PathBuf {
496 if !base_path.exists() {
497 return base_path.to_path_buf();
498 }
499
500 let stem = base_path
501 .file_stem()
502 .and_then(|s| s.to_str())
503 .unwrap_or("archive");
504 let ext = base_path
505 .extension()
506 .and_then(|s| s.to_str())
507 .unwrap_or("scg");
508 let parent = base_path.parent().unwrap_or(Path::new("."));
509
510 for i in 1..100 {
511 let new_name = format!("{}_{}.{}", stem, i, ext);
512 let new_path = parent.join(&new_name);
513 if !new_path.exists() {
514 return new_path;
515 }
516 }
517
518 let ts = Local::now().format("%H%M%S");
520 parent.join(format!("{}_{}.{}", stem, ts, ext))
521 }
522
523 pub fn archive_phase(&self, tag: &str, phases: &HashMap<String, Phase>) -> Result<PathBuf> {
526 self.ensure_archive_dir()?;
527
528 let phase = phases
529 .get(tag)
530 .ok_or_else(|| anyhow::anyhow!("Tag '{}' not found", tag))?;
531
532 let filename = self.archive_filename(Some(tag));
533 let archive_path = self.archive_dir().join(&filename);
534 let final_path = self.unique_archive_path(&archive_path);
535
536 let content = serialize_scg(phase);
538 fs::write(&final_path, content)
539 .with_context(|| format!("Failed to write archive: {}", final_path.display()))?;
540
541 Ok(final_path)
542 }
543
544 pub fn archive_all(&self, phases: &HashMap<String, Phase>) -> Result<PathBuf> {
547 self.ensure_archive_dir()?;
548
549 let filename = self.archive_filename(None);
550 let archive_path = self.archive_dir().join(&filename);
551 let final_path = self.unique_archive_path(&archive_path);
552
553 let mut sorted_tags: Vec<_> = phases.keys().collect();
555 sorted_tags.sort();
556
557 let mut output = String::new();
558 for (i, tag) in sorted_tags.iter().enumerate() {
559 if i > 0 {
560 output.push_str("\n---\n\n");
561 }
562 let phase = phases.get(*tag).unwrap();
563 output.push_str(&serialize_scg(phase));
564 }
565
566 fs::write(&final_path, output)
567 .with_context(|| format!("Failed to write archive: {}", final_path.display()))?;
568
569 Ok(final_path)
570 }
571
572 pub fn parse_archive_filename(filename: &str) -> (String, Option<String>) {
575 let name = filename.trim_end_matches(".scg");
576
577 let parts: Vec<&str> = name.splitn(2, '_').collect();
580
581 if parts.len() == 2 {
582 let date = parts[0].to_string();
583 let rest = parts[1];
584
585 if let Some(last_underscore) = rest.rfind('_') {
588 let potential_counter = &rest[last_underscore + 1..];
589 if potential_counter.chars().all(|c| c.is_ascii_digit())
590 && !potential_counter.is_empty()
591 {
592 let tag_part = &rest[..last_underscore];
594 let tag = if tag_part == "all" {
595 None
596 } else {
597 Some(tag_part.to_string())
598 };
599 return (date, tag);
600 }
601 }
602
603 let tag = if rest == "all" {
605 None
606 } else {
607 Some(rest.to_string())
608 };
609 (date, tag)
610 } else {
611 (name.to_string(), None)
613 }
614 }
615
616 pub fn list_archives(&self) -> Result<Vec<ArchiveInfo>> {
619 let archive_dir = self.archive_dir();
620 if !archive_dir.exists() {
621 return Ok(Vec::new());
622 }
623
624 let mut archives = Vec::new();
625 for entry in fs::read_dir(&archive_dir)? {
626 let entry = entry?;
627 let path = entry.path();
628
629 if path.extension().map(|e| e == "scg").unwrap_or(false) {
630 let filename = path
631 .file_name()
632 .and_then(|n| n.to_str())
633 .unwrap_or("")
634 .to_string();
635
636 let (date, tag) = Self::parse_archive_filename(&filename);
637
638 let task_count = match self.load_archive(&path) {
640 Ok(phases) => phases.values().map(|p| p.tasks.len()).sum(),
641 Err(_) => 0,
642 };
643
644 archives.push(ArchiveInfo {
645 filename,
646 path,
647 date,
648 tag,
649 task_count,
650 });
651 }
652 }
653
654 archives.sort_by(|a, b| b.date.cmp(&a.date));
656 Ok(archives)
657 }
658
659 pub fn load_archive(&self, path: &Path) -> Result<HashMap<String, Phase>> {
662 let content = fs::read_to_string(path)
663 .with_context(|| format!("Failed to read archive: {}", path.display()))?;
664
665 self.parse_multi_phase_scg(&content)
666 }
667
668 pub fn restore_archive(&self, archive_name: &str, replace: bool) -> Result<Vec<String>> {
677 let archive_dir = self.archive_dir();
678
679 let archive_path = if archive_name.ends_with(".scg") {
681 let path = archive_dir.join(archive_name);
682 if !path.exists() {
683 anyhow::bail!("Archive file not found: {}", archive_name);
684 }
685 path
686 } else {
687 let mut found = None;
689 if archive_dir.exists() {
690 for entry in fs::read_dir(&archive_dir)? {
691 let entry = entry?;
692 let filename = entry.file_name().to_string_lossy().to_string();
693 if filename.contains(archive_name) {
694 found = Some(entry.path());
695 break;
696 }
697 }
698 }
699 found.ok_or_else(|| anyhow::anyhow!("Archive '{}' not found", archive_name))?
700 };
701
702 let archived_phases = self.load_archive(&archive_path)?;
703 let mut current_phases = self.load_tasks().unwrap_or_default();
704 let mut restored_tags = Vec::new();
705
706 for (tag, phase) in archived_phases {
707 if replace || !current_phases.contains_key(&tag) {
708 current_phases.insert(tag.clone(), phase);
709 restored_tags.push(tag);
710 }
711 }
712
713 self.save_tasks(¤t_phases)?;
714 Ok(restored_tags)
715 }
716
717 fn create_agent_instructions(&self) -> Result<()> {
719 let claude_md_path = self.project_root.join("CLAUDE.md");
720
721 let scud_instructions = r#"
722## SCUD Task Management
723
724This project uses SCUD Task Manager for task management.
725
726### Session Workflow
727
7281. **Start of session**: Run `scud warmup` to orient yourself
729 - Shows current working directory and recent git history
730 - Displays active tag, task counts, and any stale locks
731 - Identifies the next available task
732
7332. **Claim a task**: Use `/scud:task-next` or `scud next --claim --name "Claude"`
734 - Always claim before starting work to prevent conflicts
735 - Task context is stored in `.scud/current-task`
736
7373. **Work on the task**: Implement the requirements
738 - Reference task details with `/scud:task-show <id>`
739 - Dependencies are automatically tracked by the DAG
740
7414. **Commit with context**: Use `scud commit -m "message"` or `scud commit -a -m "message"`
742 - Automatically prefixes commits with `[TASK-ID]`
743 - Uses task title as default commit message if none provided
744
7455. **Complete the task**: Mark done with `/scud:task-status <id> done`
746 - The stop hook will prompt for task completion
747
748### Progress Journaling
749
750Keep a brief progress log during complex tasks:
751
752```
753## Progress Log
754
755### Session: 2025-01-15
756- Investigated auth module, found issue in token refresh
757- Updated refresh logic to handle edge case
758- Tests passing, ready for review
759```
760
761This helps maintain continuity across sessions and provides context for future work.
762
763### Key Commands
764
765- `scud warmup` - Session orientation
766- `scud next` - Find next available task
767- `scud show <id>` - View task details
768- `scud set-status <id> <status>` - Update task status
769- `scud commit` - Task-aware git commit
770- `scud stats` - View completion statistics
771"#;
772
773 if claude_md_path.exists() {
774 let content = fs::read_to_string(&claude_md_path)
776 .with_context(|| "Failed to read existing CLAUDE.md")?;
777
778 if !content.contains("## SCUD Task Management") {
779 let mut new_content = content;
780 new_content.push_str(scud_instructions);
781 fs::write(&claude_md_path, new_content)
782 .with_context(|| "Failed to update CLAUDE.md")?;
783 }
784 } else {
785 let content = format!("# Project Instructions\n{}", scud_instructions);
787 fs::write(&claude_md_path, content).with_context(|| "Failed to create CLAUDE.md")?;
788 }
789
790 Ok(())
791 }
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797 use std::collections::HashMap;
798 use tempfile::TempDir;
799
800 fn create_test_storage() -> (Storage, TempDir) {
801 let temp_dir = TempDir::new().unwrap();
802 let storage = Storage::new(Some(temp_dir.path().to_path_buf()));
803 storage.initialize().unwrap();
804 (storage, temp_dir)
805 }
806
807 #[test]
808 fn test_write_with_lock_creates_file() {
809 let (storage, _temp_dir) = create_test_storage();
810 let test_file = storage.scud_dir().join("test.json");
811
812 storage
813 .write_with_lock(&test_file, || Ok(r#"{"test": "data"}"#.to_string()))
814 .unwrap();
815
816 assert!(test_file.exists());
817 let content = fs::read_to_string(&test_file).unwrap();
818 assert_eq!(content, r#"{"test": "data"}"#);
819 }
820
821 #[test]
822 fn test_read_with_lock_reads_existing_file() {
823 let (storage, _temp_dir) = create_test_storage();
824 let test_file = storage.scud_dir().join("test.json");
825
826 fs::write(&test_file, r#"{"test": "data"}"#).unwrap();
828
829 let content = storage.read_with_lock(&test_file).unwrap();
831 assert_eq!(content, r#"{"test": "data"}"#);
832 }
833
834 #[test]
835 fn test_read_with_lock_fails_on_missing_file() {
836 let (storage, _temp_dir) = create_test_storage();
837 let test_file = storage.scud_dir().join("nonexistent.json");
838
839 let result = storage.read_with_lock(&test_file);
840 assert!(result.is_err());
841 assert!(result.unwrap_err().to_string().contains("File not found"));
842 }
843
844 #[test]
845 fn test_save_and_load_tasks_with_locking() {
846 let (storage, _temp_dir) = create_test_storage();
847 let mut tasks = HashMap::new();
848
849 let epic = crate::models::Phase::new("TEST-1".to_string());
850 tasks.insert("TEST-1".to_string(), epic);
851
852 storage.save_tasks(&tasks).unwrap();
854
855 let loaded_tasks = storage.load_tasks().unwrap();
857
858 assert_eq!(tasks.len(), loaded_tasks.len());
859 assert!(loaded_tasks.contains_key("TEST-1"));
860 assert_eq!(loaded_tasks.get("TEST-1").unwrap().name, "TEST-1");
861 }
862
863 #[test]
864 fn test_concurrent_writes_dont_corrupt_data() {
865 use std::sync::Arc;
866 use std::thread;
867
868 let (storage, _temp_dir) = create_test_storage();
869 let storage = Arc::new(storage);
870 let mut handles = vec![];
871
872 for i in 0..10 {
874 let storage_clone = Arc::clone(&storage);
875 let handle = thread::spawn(move || {
876 let mut tasks = HashMap::new();
877 let epic = crate::models::Phase::new(format!("EPIC-{}", i));
878 tasks.insert(format!("EPIC-{}", i), epic);
879
880 for _ in 0..5 {
882 storage_clone.save_tasks(&tasks).unwrap();
883 thread::sleep(Duration::from_millis(1));
884 }
885 });
886 handles.push(handle);
887 }
888
889 for handle in handles {
891 handle.join().unwrap();
892 }
893
894 let tasks = storage.load_tasks().unwrap();
896 assert_eq!(tasks.len(), 1);
898 }
899
900 #[test]
901 fn test_lock_retry_on_contention() {
902 use std::sync::Arc;
903
904 let (storage, _temp_dir) = create_test_storage();
905 let storage = Arc::new(storage);
906 let test_file = storage.scud_dir().join("lock-test.json");
907
908 storage
910 .write_with_lock(&test_file, || Ok(r#"{"initial": "data"}"#.to_string()))
911 .unwrap();
912
913 let file = OpenOptions::new().write(true).open(&test_file).unwrap();
915 file.lock_exclusive().unwrap();
916
917 let storage_clone = Arc::clone(&storage);
919 let test_file_clone = test_file.clone();
920 let handle = thread::spawn(move || {
921 storage_clone.write_with_lock(&test_file_clone, || {
923 Ok(r#"{"updated": "data"}"#.to_string())
924 })
925 });
926
927 thread::sleep(Duration::from_millis(200));
929
930 file.unlock().unwrap();
932 drop(file);
933
934 let result = handle.join().unwrap();
936 assert!(result.is_ok());
937 }
938
939 #[test]
942 fn test_load_tasks_with_malformed_json() {
943 let (storage, _temp_dir) = create_test_storage();
944 let tasks_file = storage.tasks_file();
945
946 fs::write(&tasks_file, r#"{"invalid": json here}"#).unwrap();
948
949 let result = storage.load_tasks();
951 assert!(result.is_err());
952 }
953
954 #[test]
955 fn test_load_tasks_with_empty_file() {
956 let (storage, _temp_dir) = create_test_storage();
957 let tasks_file = storage.tasks_file();
958
959 fs::write(&tasks_file, "").unwrap();
961
962 let result = storage.load_tasks();
964 assert!(result.is_ok());
965 assert!(result.unwrap().is_empty());
966 }
967
968 #[test]
969 fn test_load_tasks_missing_file_creates_default() {
970 let (storage, _temp_dir) = create_test_storage();
971 let tasks = storage.load_tasks().unwrap();
975 assert_eq!(tasks.len(), 0);
976 }
977
978 #[test]
979 fn test_save_tasks_creates_directory_if_missing() {
980 let temp_dir = TempDir::new().unwrap();
981 let storage = Storage::new(Some(temp_dir.path().to_path_buf()));
982 let mut tasks = HashMap::new();
985 let epic = crate::models::Phase::new("TEST-1".to_string());
986 tasks.insert("TEST-1".to_string(), epic);
987
988 let result = storage.save_tasks(&tasks);
990 assert!(result.is_ok());
991
992 assert!(storage.scud_dir().exists());
993 assert!(storage.tasks_file().exists());
994 }
995
996 #[test]
997 fn test_write_with_lock_handles_directory_creation() {
998 let temp_dir = TempDir::new().unwrap();
999 let storage = Storage::new(Some(temp_dir.path().to_path_buf()));
1000
1001 let nested_file = temp_dir
1002 .path()
1003 .join("deeply")
1004 .join("nested")
1005 .join("test.json");
1006
1007 let result = storage.write_with_lock(&nested_file, || Ok("{}".to_string()));
1009 assert!(result.is_ok());
1010 assert!(nested_file.exists());
1011 }
1012
1013 #[test]
1014 fn test_load_tasks_with_invalid_structure() {
1015 let (storage, _temp_dir) = create_test_storage();
1016 let tasks_file = storage.tasks_file();
1017
1018 fs::write(&tasks_file, r#"["not", "an", "object"]"#).unwrap();
1020
1021 let result = storage.load_tasks();
1023 assert!(result.is_err());
1024 }
1025
1026 #[test]
1027 fn test_save_and_load_with_unicode_content() {
1028 let (storage, _temp_dir) = create_test_storage();
1029
1030 let mut tasks = HashMap::new();
1031 let mut epic = crate::models::Phase::new("TEST-UNICODE".to_string());
1032
1033 let task = crate::models::Task::new(
1035 "task-1".to_string(),
1036 "测试 Unicode 🚀".to_string(),
1037 "Descripción en español 日本語".to_string(),
1038 );
1039 epic.add_task(task);
1040
1041 tasks.insert("TEST-UNICODE".to_string(), epic);
1042
1043 storage.save_tasks(&tasks).unwrap();
1045 let loaded_tasks = storage.load_tasks().unwrap();
1046
1047 let loaded_epic = loaded_tasks.get("TEST-UNICODE").unwrap();
1048 let loaded_task = loaded_epic.get_task("task-1").unwrap();
1049 assert_eq!(loaded_task.title, "测试 Unicode 🚀");
1050 assert_eq!(loaded_task.description, "Descripción en español 日本語");
1051 }
1052
1053 #[test]
1054 fn test_save_and_load_with_large_dataset() {
1055 let (storage, _temp_dir) = create_test_storage();
1056
1057 let mut tasks = HashMap::new();
1058
1059 for i in 0..100 {
1061 let mut epic = crate::models::Phase::new(format!("EPIC-{}", i));
1062
1063 for j in 0..50 {
1064 let task = crate::models::Task::new(
1065 format!("task-{}-{}", i, j),
1066 format!("Task {} of Epic {}", j, i),
1067 format!("Description for task {}-{}", i, j),
1068 );
1069 epic.add_task(task);
1070 }
1071
1072 tasks.insert(format!("EPIC-{}", i), epic);
1073 }
1074
1075 storage.save_tasks(&tasks).unwrap();
1077 let loaded_tasks = storage.load_tasks().unwrap();
1078
1079 assert_eq!(loaded_tasks.len(), 100);
1080 for i in 0..100 {
1081 let epic = loaded_tasks.get(&format!("EPIC-{}", i)).unwrap();
1082 assert_eq!(epic.tasks.len(), 50);
1083 }
1084 }
1085
1086 #[test]
1087 fn test_concurrent_read_and_write() {
1088 use std::sync::Arc;
1089 use std::thread;
1090
1091 let (storage, _temp_dir) = create_test_storage();
1092 let storage = Arc::new(storage);
1093
1094 let mut tasks = HashMap::new();
1096 let epic = crate::models::Phase::new("INITIAL".to_string());
1097 tasks.insert("INITIAL".to_string(), epic);
1098 storage.save_tasks(&tasks).unwrap();
1099
1100 let mut handles = vec![];
1101
1102 for _ in 0..5 {
1104 let storage_clone = Arc::clone(&storage);
1105 let handle = thread::spawn(move || {
1106 for _ in 0..10 {
1107 let _ = storage_clone.load_tasks();
1108 thread::sleep(Duration::from_millis(1));
1109 }
1110 });
1111 handles.push(handle);
1112 }
1113
1114 for i in 0..2 {
1116 let storage_clone = Arc::clone(&storage);
1117 let handle = thread::spawn(move || {
1118 for j in 0..5 {
1119 let mut tasks = HashMap::new();
1120 let epic = crate::models::Phase::new(format!("WRITER-{}-{}", i, j));
1121 tasks.insert(format!("WRITER-{}-{}", i, j), epic);
1122 storage_clone.save_tasks(&tasks).unwrap();
1123 thread::sleep(Duration::from_millis(2));
1124 }
1125 });
1126 handles.push(handle);
1127 }
1128
1129 for handle in handles {
1131 handle.join().unwrap();
1132 }
1133
1134 let tasks = storage.load_tasks().unwrap();
1136 assert_eq!(tasks.len(), 1); }
1138
1139 #[test]
1142 fn test_active_epic_cached_on_second_call() {
1143 let (storage, _temp_dir) = create_test_storage();
1144
1145 let mut tasks = HashMap::new();
1147 tasks.insert("TEST-1".to_string(), Phase::new("TEST-1".to_string()));
1148 storage.save_tasks(&tasks).unwrap();
1149 storage.set_active_group("TEST-1").unwrap();
1150
1151 let active1 = storage.get_active_group().unwrap();
1153 assert_eq!(active1, Some("TEST-1".to_string()));
1154
1155 let active_tag_file = storage.active_tag_file();
1157 fs::write(&active_tag_file, "DIFFERENT").unwrap();
1158
1159 let active2 = storage.get_active_group().unwrap();
1161 assert_eq!(active2, Some("TEST-1".to_string())); storage.clear_cache();
1165 let active3 = storage.get_active_group().unwrap();
1166 assert_eq!(active3, Some("DIFFERENT".to_string())); }
1168
1169 #[test]
1170 fn test_cache_invalidated_on_set_active_epic() {
1171 let (storage, _temp_dir) = create_test_storage();
1172
1173 let mut tasks = HashMap::new();
1174 tasks.insert("EPIC-1".to_string(), Phase::new("EPIC-1".to_string()));
1175 tasks.insert("EPIC-2".to_string(), Phase::new("EPIC-2".to_string()));
1176 storage.save_tasks(&tasks).unwrap();
1177
1178 storage.set_active_group("EPIC-1").unwrap();
1179 assert_eq!(
1180 storage.get_active_group().unwrap(),
1181 Some("EPIC-1".to_string())
1182 );
1183
1184 storage.set_active_group("EPIC-2").unwrap();
1186 assert_eq!(
1187 storage.get_active_group().unwrap(),
1188 Some("EPIC-2".to_string())
1189 );
1190 }
1191
1192 #[test]
1193 fn test_cache_with_no_active_epic() {
1194 let (storage, _temp_dir) = create_test_storage();
1195
1196 let active = storage.get_active_group().unwrap();
1198 assert_eq!(active, None);
1199
1200 let active2 = storage.get_active_group().unwrap();
1202 assert_eq!(active2, None);
1203 }
1204
1205 #[test]
1208 fn test_load_single_epic_from_many() {
1209 let (storage, _temp_dir) = create_test_storage();
1210
1211 let mut tasks = HashMap::new();
1213 for i in 0..50 {
1214 tasks.insert(format!("EPIC-{}", i), Phase::new(format!("EPIC-{}", i)));
1215 }
1216 storage.save_tasks(&tasks).unwrap();
1217
1218 let epic = storage.load_group("EPIC-25").unwrap();
1220 assert_eq!(epic.name, "EPIC-25");
1221 }
1222
1223 #[test]
1224 fn test_load_epic_not_found() {
1225 let (storage, _temp_dir) = create_test_storage();
1226
1227 let tasks = HashMap::new();
1228 storage.save_tasks(&tasks).unwrap();
1229
1230 let result = storage.load_group("NONEXISTENT");
1231 assert!(result.is_err());
1232 assert!(result.unwrap_err().to_string().contains("not found"));
1233 }
1234
1235 #[test]
1236 fn test_load_epic_matches_full_load() {
1237 let (storage, _temp_dir) = create_test_storage();
1238
1239 let mut tasks = HashMap::new();
1240 let mut epic = Phase::new("TEST-1".to_string());
1241 epic.add_task(crate::models::Task::new(
1242 "task-1".to_string(),
1243 "Test".to_string(),
1244 "Desc".to_string(),
1245 ));
1246 tasks.insert("TEST-1".to_string(), epic.clone());
1247 storage.save_tasks(&tasks).unwrap();
1248
1249 let epic_lazy = storage.load_group("TEST-1").unwrap();
1251 let tasks_full = storage.load_tasks().unwrap();
1252 let epic_full = tasks_full.get("TEST-1").unwrap();
1253
1254 assert_eq!(epic_lazy.name, epic_full.name);
1256 assert_eq!(epic_lazy.tasks.len(), epic_full.tasks.len());
1257 }
1258
1259 #[test]
1260 fn test_load_active_epic() {
1261 let (storage, _temp_dir) = create_test_storage();
1262
1263 let mut tasks = HashMap::new();
1264 let mut epic = Phase::new("ACTIVE-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("ACTIVE-1".to_string(), epic);
1271 storage.save_tasks(&tasks).unwrap();
1272 storage.set_active_group("ACTIVE-1").unwrap();
1273
1274 let epic = storage.load_active_group().unwrap();
1276 assert_eq!(epic.name, "ACTIVE-1");
1277 assert_eq!(epic.tasks.len(), 1);
1278 }
1279
1280 #[test]
1281 fn test_load_active_epic_when_none_set() {
1282 let (storage, _temp_dir) = create_test_storage();
1283
1284 let result = storage.load_active_group();
1286 assert!(result.is_err());
1287 assert!(result
1288 .unwrap_err()
1289 .to_string()
1290 .contains("No active task group"));
1291 }
1292
1293 #[test]
1294 fn test_update_epic_without_loading_all() {
1295 let (storage, _temp_dir) = create_test_storage();
1296
1297 let mut tasks = HashMap::new();
1298 tasks.insert("EPIC-1".to_string(), Phase::new("EPIC-1".to_string()));
1299 tasks.insert("EPIC-2".to_string(), Phase::new("EPIC-2".to_string()));
1300 storage.save_tasks(&tasks).unwrap();
1301
1302 let mut epic1 = storage.load_group("EPIC-1").unwrap();
1304 epic1.add_task(crate::models::Task::new(
1305 "new-task".to_string(),
1306 "New".to_string(),
1307 "Desc".to_string(),
1308 ));
1309 storage.update_group("EPIC-1", &epic1).unwrap();
1310
1311 let loaded = storage.load_group("EPIC-1").unwrap();
1313 assert_eq!(loaded.tasks.len(), 1);
1314
1315 let epic2 = storage.load_group("EPIC-2").unwrap();
1317 assert_eq!(epic2.tasks.len(), 0);
1318 }
1319
1320 #[test]
1323 fn test_archive_dir() {
1324 let (storage, _temp_dir) = create_test_storage();
1325 let archive_dir = storage.archive_dir();
1326 assert!(archive_dir.ends_with(".scud/archive"));
1327 }
1328
1329 #[test]
1330 fn test_ensure_archive_dir() {
1331 let (storage, _temp_dir) = create_test_storage();
1332
1333 assert!(!storage.archive_dir().exists());
1335
1336 storage.ensure_archive_dir().unwrap();
1338 assert!(storage.archive_dir().exists());
1339
1340 storage.ensure_archive_dir().unwrap();
1342 assert!(storage.archive_dir().exists());
1343 }
1344
1345 #[test]
1346 fn test_archive_filename_with_tag() {
1347 let (storage, _temp_dir) = create_test_storage();
1348 let filename = storage.archive_filename(Some("v1"));
1349
1350 assert!(filename.ends_with("_v1.scg"));
1352 assert!(filename.len() == 17); }
1354
1355 #[test]
1356 fn test_archive_filename_all() {
1357 let (storage, _temp_dir) = create_test_storage();
1358 let filename = storage.archive_filename(None);
1359
1360 assert!(filename.ends_with("_all.scg"));
1362 assert!(filename.len() == 18); }
1364
1365 #[test]
1366 fn test_parse_archive_filename_simple() {
1367 let (date, tag) = Storage::parse_archive_filename("2026-01-13_v1.scg");
1368 assert_eq!(date, "2026-01-13");
1369 assert_eq!(tag, Some("v1".to_string()));
1370 }
1371
1372 #[test]
1373 fn test_parse_archive_filename_all() {
1374 let (date, tag) = Storage::parse_archive_filename("2026-01-13_all.scg");
1375 assert_eq!(date, "2026-01-13");
1376 assert_eq!(tag, None);
1377 }
1378
1379 #[test]
1380 fn test_parse_archive_filename_with_counter() {
1381 let (date, tag) = Storage::parse_archive_filename("2026-01-13_v1_2.scg");
1382 assert_eq!(date, "2026-01-13");
1383 assert_eq!(tag, Some("v1".to_string()));
1384 }
1385
1386 #[test]
1387 fn test_parse_archive_filename_all_with_counter() {
1388 let (date, tag) = Storage::parse_archive_filename("2026-01-13_all_5.scg");
1389 assert_eq!(date, "2026-01-13");
1390 assert_eq!(tag, None);
1391 }
1392
1393 #[test]
1394 fn test_archive_single_phase() {
1395 let (storage, _temp_dir) = create_test_storage();
1396
1397 let mut phases = HashMap::new();
1399 let mut phase = Phase::new("v1".to_string());
1400 phase.add_task(crate::models::Task::new(
1401 "task-1".to_string(),
1402 "Test Task".to_string(),
1403 "Description".to_string(),
1404 ));
1405 phases.insert("v1".to_string(), phase);
1406 storage.save_tasks(&phases).unwrap();
1407
1408 let archive_path = storage.archive_phase("v1", &phases).unwrap();
1410
1411 assert!(archive_path.exists());
1412 assert!(archive_path.to_string_lossy().contains("v1"));
1413 assert!(archive_path.extension().unwrap() == "scg");
1414 }
1415
1416 #[test]
1417 fn test_archive_all_phases() {
1418 let (storage, _temp_dir) = create_test_storage();
1419
1420 let mut phases = HashMap::new();
1421 phases.insert("v1".to_string(), Phase::new("v1".to_string()));
1422 phases.insert("v2".to_string(), Phase::new("v2".to_string()));
1423 storage.save_tasks(&phases).unwrap();
1424
1425 let archive_path = storage.archive_all(&phases).unwrap();
1426
1427 assert!(archive_path.exists());
1428 assert!(archive_path.to_string_lossy().contains("all"));
1429
1430 let loaded = storage.load_archive(&archive_path).unwrap();
1432 assert_eq!(loaded.len(), 2);
1433 assert!(loaded.contains_key("v1"));
1434 assert!(loaded.contains_key("v2"));
1435 }
1436
1437 #[test]
1438 fn test_archive_nonexistent_tag() {
1439 let (storage, _temp_dir) = create_test_storage();
1440
1441 let phases = HashMap::new();
1442 let result = storage.archive_phase("nonexistent", &phases);
1443
1444 assert!(result.is_err());
1445 assert!(result.unwrap_err().to_string().contains("not found"));
1446 }
1447
1448 #[test]
1449 fn test_unique_archive_path_no_collision() {
1450 let (storage, _temp_dir) = create_test_storage();
1451 storage.ensure_archive_dir().unwrap();
1452
1453 let base_path = storage.archive_dir().join("test.scg");
1454 let result = storage.unique_archive_path(&base_path);
1455
1456 assert_eq!(result, base_path);
1457 }
1458
1459 #[test]
1460 fn test_unique_archive_path_with_collision() {
1461 let (storage, _temp_dir) = create_test_storage();
1462 storage.ensure_archive_dir().unwrap();
1463
1464 let base_path = storage.archive_dir().join("test.scg");
1466 fs::write(&base_path, "existing").unwrap();
1467
1468 let result = storage.unique_archive_path(&base_path);
1470 assert!(result.to_string_lossy().contains("test_1.scg"));
1471 }
1472
1473 #[test]
1474 fn test_unique_archive_path_multiple_collisions() {
1475 let (storage, _temp_dir) = create_test_storage();
1476 storage.ensure_archive_dir().unwrap();
1477
1478 let base_path = storage.archive_dir().join("test.scg");
1480 fs::write(&base_path, "existing").unwrap();
1481 fs::write(storage.archive_dir().join("test_1.scg"), "existing").unwrap();
1482 fs::write(storage.archive_dir().join("test_2.scg"), "existing").unwrap();
1483
1484 let result = storage.unique_archive_path(&base_path);
1486 assert!(result.to_string_lossy().contains("test_3.scg"));
1487 }
1488
1489 #[test]
1490 fn test_list_archives_empty() {
1491 let (storage, _temp_dir) = create_test_storage();
1492
1493 let archives = storage.list_archives().unwrap();
1494 assert!(archives.is_empty());
1495 }
1496
1497 #[test]
1498 fn test_list_archives_with_archives() {
1499 let (storage, _temp_dir) = create_test_storage();
1500
1501 let mut phases = HashMap::new();
1503 let mut phase = Phase::new("v1".to_string());
1504 phase.add_task(crate::models::Task::new(
1505 "task-1".to_string(),
1506 "Test".to_string(),
1507 "Desc".to_string(),
1508 ));
1509 phases.insert("v1".to_string(), phase);
1510 storage.save_tasks(&phases).unwrap();
1511
1512 storage.archive_phase("v1", &phases).unwrap();
1513
1514 let archives = storage.list_archives().unwrap();
1515 assert_eq!(archives.len(), 1);
1516 assert_eq!(archives[0].tag, Some("v1".to_string()));
1517 assert_eq!(archives[0].task_count, 1);
1518 }
1519
1520 #[test]
1521 fn test_load_archive() {
1522 let (storage, _temp_dir) = create_test_storage();
1523
1524 let mut phases = HashMap::new();
1525 let mut phase = Phase::new("test-tag".to_string());
1526 phase.add_task(crate::models::Task::new(
1527 "task-1".to_string(),
1528 "Test Title".to_string(),
1529 "Test Description".to_string(),
1530 ));
1531 phases.insert("test-tag".to_string(), phase);
1532 storage.save_tasks(&phases).unwrap();
1533
1534 let archive_path = storage.archive_phase("test-tag", &phases).unwrap();
1535
1536 let loaded = storage.load_archive(&archive_path).unwrap();
1538 assert_eq!(loaded.len(), 1);
1539 let loaded_phase = loaded.get("test-tag").unwrap();
1540 assert_eq!(loaded_phase.tasks.len(), 1);
1541 assert_eq!(loaded_phase.tasks[0].title, "Test Title");
1542 }
1543
1544 #[test]
1545 fn test_restore_archive_empty_tasks() {
1546 let (storage, _temp_dir) = create_test_storage();
1547
1548 let mut phases = HashMap::new();
1550 phases.insert("v1".to_string(), Phase::new("v1".to_string()));
1551 storage.save_tasks(&phases).unwrap();
1552
1553 let archive_path = storage.archive_phase("v1", &phases).unwrap();
1554 let archive_name = archive_path.file_name().unwrap().to_str().unwrap();
1555
1556 storage.save_tasks(&HashMap::new()).unwrap();
1558 let empty_check = storage.load_tasks().unwrap();
1559 assert!(empty_check.is_empty());
1560
1561 let restored = storage.restore_archive(archive_name, false).unwrap();
1563 assert_eq!(restored, vec!["v1".to_string()]);
1564
1565 let current = storage.load_tasks().unwrap();
1567 assert!(current.contains_key("v1"));
1568 }
1569
1570 #[test]
1571 fn test_restore_archive_no_replace() {
1572 let (storage, _temp_dir) = create_test_storage();
1573
1574 let mut phases = HashMap::new();
1576 let mut phase = Phase::new("v1".to_string());
1577 phase.add_task(crate::models::Task::new(
1578 "original".to_string(),
1579 "Original".to_string(),
1580 "Desc".to_string(),
1581 ));
1582 phases.insert("v1".to_string(), phase);
1583 storage.save_tasks(&phases).unwrap();
1584
1585 let archive_path = storage.archive_phase("v1", &phases).unwrap();
1587 let archive_name = archive_path.file_name().unwrap().to_str().unwrap();
1588
1589 let mut current = storage.load_tasks().unwrap();
1591 current
1592 .get_mut("v1")
1593 .unwrap()
1594 .add_task(crate::models::Task::new(
1595 "new".to_string(),
1596 "New".to_string(),
1597 "Desc".to_string(),
1598 ));
1599 storage.save_tasks(¤t).unwrap();
1600
1601 let restored = storage.restore_archive(archive_name, false).unwrap();
1603 assert!(restored.is_empty()); let final_tasks = storage.load_tasks().unwrap();
1607 assert_eq!(final_tasks.get("v1").unwrap().tasks.len(), 2);
1608 }
1609
1610 #[test]
1611 fn test_restore_archive_with_replace() {
1612 let (storage, _temp_dir) = create_test_storage();
1613
1614 let mut phases = HashMap::new();
1616 let mut phase = Phase::new("v1".to_string());
1617 phase.add_task(crate::models::Task::new(
1618 "original".to_string(),
1619 "Original".to_string(),
1620 "Desc".to_string(),
1621 ));
1622 phases.insert("v1".to_string(), phase);
1623 storage.save_tasks(&phases).unwrap();
1624
1625 let archive_path = storage.archive_phase("v1", &phases).unwrap();
1627 let archive_name = archive_path.file_name().unwrap().to_str().unwrap();
1628
1629 let mut current = storage.load_tasks().unwrap();
1631 current
1632 .get_mut("v1")
1633 .unwrap()
1634 .add_task(crate::models::Task::new(
1635 "new".to_string(),
1636 "New".to_string(),
1637 "Desc".to_string(),
1638 ));
1639 storage.save_tasks(¤t).unwrap();
1640
1641 let restored = storage.restore_archive(archive_name, true).unwrap();
1643 assert_eq!(restored, vec!["v1".to_string()]);
1644
1645 let final_tasks = storage.load_tasks().unwrap();
1647 assert_eq!(final_tasks.get("v1").unwrap().tasks.len(), 1);
1648 }
1649
1650 #[test]
1651 fn test_restore_archive_partial_match() {
1652 let (storage, _temp_dir) = create_test_storage();
1653
1654 let mut phases = HashMap::new();
1655 phases.insert("myproject".to_string(), Phase::new("myproject".to_string()));
1656 storage.save_tasks(&phases).unwrap();
1657
1658 storage.archive_phase("myproject", &phases).unwrap();
1659
1660 storage.save_tasks(&HashMap::new()).unwrap();
1662
1663 let restored = storage.restore_archive("myproject", false).unwrap();
1664 assert_eq!(restored, vec!["myproject".to_string()]);
1665 }
1666
1667 #[test]
1668 fn test_restore_archive_not_found() {
1669 let (storage, _temp_dir) = create_test_storage();
1670
1671 let result = storage.restore_archive("nonexistent", false);
1672 assert!(result.is_err());
1673 assert!(result.unwrap_err().to_string().contains("not found"));
1674 }
1675}