scud/storage/
mod.rs

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/// Information about an archived phase
16#[derive(Debug, Clone)]
17pub struct ArchiveInfo {
18    /// The filename of the archive (e.g., "2026-01-13_v1.scg")
19    pub filename: String,
20    /// Full path to the archive file
21    pub path: PathBuf,
22    /// The date extracted from the filename (e.g., "2026-01-13")
23    pub date: String,
24    /// The tag name if this is a single-phase archive, None if "all"
25    pub tag: Option<String>,
26    /// Number of tasks in the archive
27    pub task_count: usize,
28}
29
30pub struct Storage {
31    project_root: PathBuf,
32    /// Cache for active group to avoid repeated file reads
33    /// Option<Option<String>> represents: None = not cached, Some(None) = no active group, Some(Some(tag)) = cached tag
34    /// Uses RwLock for thread safety (useful for tests and potential daemon mode)
35    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    /// Get the project root directory
48    pub fn project_root(&self) -> &Path {
49        &self.project_root
50    }
51
52    /// Acquire an exclusive file lock with retry logic
53    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); // Exponential backoff, max 1s
64                }
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    /// Perform a locked write operation on a file
77    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        // Open file for writing
89        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        // Acquire lock with retry
97        self.acquire_lock_with_retry(&file, 10)?;
98
99        // Generate content and write through the locked handle
100        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        // Lock is automatically released when file is dropped
107        Ok(())
108    }
109
110    /// Perform a locked read operation on a file
111    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        // Open file for reading
119        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        // Acquire shared lock (allows multiple readers)
125        file.lock_shared()
126            .with_context(|| format!("Failed to acquire read lock on {}", path.display()))?;
127
128        // Read content through the locked handle
129        let mut content = String::new();
130        file.read_to_string(&mut content)
131            .with_context(|| format!("Failed to read from {}", path.display()))?;
132
133        // Lock is automatically released when file is dropped
134        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    /// Load all .md files from .scud/guidance/ folder
162    /// Returns concatenated content with file headers, or empty string if no files
163    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        // Sort by filename for consistent ordering
177        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        // Create .scud directory structure
217        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        // Initialize config.toml
222        let config_file = self.config_file();
223        if !config_file.exists() {
224            config.save(&config_file)?;
225        }
226
227        // Initialize tasks.scg with empty content
228        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        // Create docs directories
235        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        // Create guidance directory for project-specific AI context
242        fs::create_dir_all(self.guidance_dir())?;
243
244        // Create CLAUDE.md with agent instructions
245        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    /// Parse multi-phase SCG format (multiple phases separated by ---)
269    fn parse_multi_phase_scg(&self, content: &str) -> Result<HashMap<String, Phase>> {
270        let mut phases = HashMap::new();
271
272        // Empty file returns empty map
273        if content.trim().is_empty() {
274            return Ok(phases);
275        }
276
277        // Split by phase separator (---)
278        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            // Parse the phase section
287            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            // Sort phases by tag for consistent output
299            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        // Check cache first (read lock)
317        {
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        // Load from active-tag file
325        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        // Store in cache
340        *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        // Write to active-tag file
352        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        // Update cache
357        *self.active_group_cache.write().unwrap() = Some(Some(group_tag.to_string()));
358
359        Ok(())
360    }
361
362    /// Clear the active group cache
363    /// Useful when active-tag file is modified externally or for testing
364    pub fn clear_cache(&self) {
365        *self.active_group_cache.write().unwrap() = None;
366    }
367
368    /// Clear the active group setting (remove the active-tag file)
369    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    /// Load a single task group by tag
380    /// Parses the SCG file and extracts the requested group
381    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    /// Load the active task group directly (optimized)
394    /// Combines get_active_group() and load_group() in one call
395    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    /// Update a single task group atomically
404    /// Holds exclusive lock across read-modify-write cycle to prevent races
405    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        // Open file for read+write with exclusive lock held throughout
416        // Note: truncate(false) is explicit - we read first, then truncate manually after
417        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        // Acquire exclusive lock with retry (held for entire operation)
426        self.acquire_lock_with_retry(&file, 10)?;
427
428        // Read current content while holding lock
429        let mut content = String::new();
430        file.read_to_string(&mut content)
431            .with_context(|| format!("Failed to read from {}", path.display()))?;
432
433        // Parse, modify, and serialize
434        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        // Truncate and write back while still holding lock
450        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        // Lock released when file is dropped
459        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    // ==================== Archive Methods ====================
467
468    /// Get the archive directory path
469    pub fn archive_dir(&self) -> PathBuf {
470        self.scud_dir().join("archive")
471    }
472
473    /// Ensure archive directory exists
474    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    /// Generate archive filename for a tag or all tasks
483    /// Format: {YYYY-MM-DD}_{tag}.scg or {YYYY-MM-DD}_all.scg
484    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    /// Get unique archive path by appending counter if file exists
493    /// Tries base path, then base_1, base_2, etc. up to 100
494    /// Falls back to timestamp suffix if all counters exhausted
495    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        // Fallback with timestamp
519        let ts = Local::now().format("%H%M%S");
520        parent.join(format!("{}_{}.{}", stem, ts, ext))
521    }
522
523    /// Archive a single phase/tag
524    /// Returns the path to the created archive file
525    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        // Serialize single phase to SCG format
537        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    /// Archive all phases together
545    /// Returns the path to the created archive file
546    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        // Serialize all phases (same format as tasks.scg)
554        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    /// Parse archive filename to extract date and tag
573    /// Returns (date, tag) where tag is None if "all"
574    pub fn parse_archive_filename(filename: &str) -> (String, Option<String>) {
575        let name = filename.trim_end_matches(".scg");
576
577        // Handle filenames with counter suffix: YYYY-MM-DD_tag_N
578        // or just YYYY-MM-DD_tag
579        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            // Check if rest ends with _N (counter suffix)
586            // We need to detect if there's a trailing _NUMBER
587            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                    // Has a counter suffix, extract tag without it
593                    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            // No counter suffix
604            let tag = if rest == "all" {
605                None
606            } else {
607                Some(rest.to_string())
608            };
609            (date, tag)
610        } else {
611            // Fallback for malformed filenames
612            (name.to_string(), None)
613        }
614    }
615
616    /// List all archives in the archive directory
617    /// Returns sorted by date descending (newest first)
618    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                // Get task count by loading the archive
639                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        // Sort by date descending (newest first)
655        archives.sort_by(|a, b| b.date.cmp(&a.date));
656        Ok(archives)
657    }
658
659    /// Load an archive file
660    /// Returns the phases contained in the archive
661    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    /// Restore an archive by merging it into current tasks.
669    ///
670    /// # Arguments
671    /// * `archive_name` - filename or partial match (e.g., "v1", "2026-01-13_v1", "2026-01-13_v1.scg")
672    /// * `replace` - if true, replace existing tags; if false, skip existing
673    ///
674    /// # Returns
675    /// The list of restored tag names
676    pub fn restore_archive(&self, archive_name: &str, replace: bool) -> Result<Vec<String>> {
677        let archive_dir = self.archive_dir();
678
679        // Find matching archive
680        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            // Search for matching archive
688            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(&current_phases)?;
714        Ok(restored_tags)
715    }
716
717    /// Create or update CLAUDE.md with SCUD agent instructions
718    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            // Append to existing CLAUDE.md if SCUD section doesn't exist
775            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            // Create new CLAUDE.md
786            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        // Create a file
827        fs::write(&test_file, r#"{"test": "data"}"#).unwrap();
828
829        // Read with lock
830        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        // Save tasks
853        storage.save_tasks(&tasks).unwrap();
854
855        // Load tasks
856        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        // Spawn 10 threads that each write tasks
873        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                // Each thread writes multiple times
881                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        // Wait for all threads to complete
890        for handle in handles {
891            handle.join().unwrap();
892        }
893
894        // Verify that the file is still valid JSON
895        let tasks = storage.load_tasks().unwrap();
896        // Should have the last written data (from one of the threads)
897        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        // Create file
909        storage
910            .write_with_lock(&test_file, || Ok(r#"{"initial": "data"}"#.to_string()))
911            .unwrap();
912
913        // Open and lock the file
914        let file = OpenOptions::new().write(true).open(&test_file).unwrap();
915        file.lock_exclusive().unwrap();
916
917        // Try to acquire lock with retry in another thread
918        let storage_clone = Arc::clone(&storage);
919        let test_file_clone = test_file.clone();
920        let handle = thread::spawn(move || {
921            // This should retry and succeed after lock release
922            storage_clone.write_with_lock(&test_file_clone, || {
923                Ok(r#"{"updated": "data"}"#.to_string())
924            })
925        });
926
927        // Keep lock for a bit
928        thread::sleep(Duration::from_millis(200));
929
930        // Release lock
931        file.unlock().unwrap();
932        drop(file);
933
934        // The write should have succeeded after retrying
935        let result = handle.join().unwrap();
936        assert!(result.is_ok());
937    }
938
939    // ==================== Error Handling Tests ====================
940
941    #[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        // Write malformed JSON
947        fs::write(&tasks_file, r#"{"invalid": json here}"#).unwrap();
948
949        // Should return error
950        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        // Write empty file
960        fs::write(&tasks_file, "").unwrap();
961
962        // Empty SCG file is valid and returns empty HashMap
963        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        // Don't create tasks file
972
973        // Should return empty HashMap (default)
974        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        // Don't call initialize()
983
984        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        // Should create directory and file
989        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        // Should create all parent directories
1008        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        // Write valid JSON but invalid structure (array instead of object)
1019        fs::write(&tasks_file, r#"["not", "an", "object"]"#).unwrap();
1020
1021        // Should return error
1022        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        // Add task with unicode content
1034        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        // Save and load
1044        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        // Create 100 epics with 50 tasks each
1060        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        // Save and load
1076        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        // Initialize with some data
1095        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        // Spawn 5 readers
1103        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        // Spawn 2 writers
1115        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        // Wait for all threads
1130        for handle in handles {
1131            handle.join().unwrap();
1132        }
1133
1134        // File should still be valid
1135        let tasks = storage.load_tasks().unwrap();
1136        assert_eq!(tasks.len(), 1); // Last write wins
1137    }
1138
1139    // ==================== Active Epic Cache Tests ====================
1140
1141    #[test]
1142    fn test_active_epic_cached_on_second_call() {
1143        let (storage, _temp_dir) = create_test_storage();
1144
1145        // Set active epic
1146        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        // First call - loads from file
1152        let active1 = storage.get_active_group().unwrap();
1153        assert_eq!(active1, Some("TEST-1".to_string()));
1154
1155        // Modify file directly (bypass storage methods)
1156        let active_tag_file = storage.active_tag_file();
1157        fs::write(&active_tag_file, "DIFFERENT").unwrap();
1158
1159        // Second call - should return cached value (not file value)
1160        let active2 = storage.get_active_group().unwrap();
1161        assert_eq!(active2, Some("TEST-1".to_string())); // Still cached
1162
1163        // After cache clear - should reload from file
1164        storage.clear_cache();
1165        let active3 = storage.get_active_group().unwrap();
1166        assert_eq!(active3, Some("DIFFERENT".to_string())); // From file
1167    }
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        // Change active epic - should update cache
1185        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        // Load when no active epic is set
1197        let active = storage.get_active_group().unwrap();
1198        assert_eq!(active, None);
1199
1200        // Should cache the None value
1201        let active2 = storage.get_active_group().unwrap();
1202        assert_eq!(active2, None);
1203    }
1204
1205    // ==================== Lazy Epic Loading Tests ====================
1206
1207    #[test]
1208    fn test_load_single_epic_from_many() {
1209        let (storage, _temp_dir) = create_test_storage();
1210
1211        // Create 50 epics
1212        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        // Load single epic - should only deserialize that one
1219        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        // Load via both methods
1250        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        // Should be identical
1255        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        // Load active epic directly
1275        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        // Should error when no active epic
1285        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        // Update only EPIC-1
1303        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        // Verify update
1312        let loaded = storage.load_group("EPIC-1").unwrap();
1313        assert_eq!(loaded.tasks.len(), 1);
1314
1315        // Verify EPIC-2 unchanged
1316        let epic2 = storage.load_group("EPIC-2").unwrap();
1317        assert_eq!(epic2.tasks.len(), 0);
1318    }
1319
1320    // ==================== Archive Tests ====================
1321
1322    #[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        // Directory shouldn't exist initially
1334        assert!(!storage.archive_dir().exists());
1335
1336        // Create it
1337        storage.ensure_archive_dir().unwrap();
1338        assert!(storage.archive_dir().exists());
1339
1340        // Second call should be idempotent
1341        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        // Should match pattern YYYY-MM-DD_v1.scg
1351        assert!(filename.ends_with("_v1.scg"));
1352        assert!(filename.len() == 17); // YYYY-MM-DD_v1.scg = 17 chars
1353    }
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        // Should match pattern YYYY-MM-DD_all.scg
1361        assert!(filename.ends_with("_all.scg"));
1362        assert!(filename.len() == 18); // YYYY-MM-DD_all.scg = 18 chars
1363    }
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        // Create test phases
1398        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        // Archive
1409        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        // Verify it contains both phases
1431        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        // Create existing file
1465        let base_path = storage.archive_dir().join("test.scg");
1466        fs::write(&base_path, "existing").unwrap();
1467
1468        // Should get test_1.scg
1469        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        // Create existing files
1479        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        // Should get test_3.scg
1485        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        // Create test phases and archive them
1502        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        // Load and verify
1537        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        // Create and archive a phase
1549        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        // Clear current tasks
1557        storage.save_tasks(&HashMap::new()).unwrap();
1558        let empty_check = storage.load_tasks().unwrap();
1559        assert!(empty_check.is_empty());
1560
1561        // Restore
1562        let restored = storage.restore_archive(archive_name, false).unwrap();
1563        assert_eq!(restored, vec!["v1".to_string()]);
1564
1565        // Verify restored
1566        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        // Create initial phase with task
1575        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        // Archive it
1586        let archive_path = storage.archive_phase("v1", &phases).unwrap();
1587        let archive_name = archive_path.file_name().unwrap().to_str().unwrap();
1588
1589        // Modify current tasks
1590        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(&current).unwrap();
1600
1601        // Restore without replace - should not overwrite
1602        let restored = storage.restore_archive(archive_name, false).unwrap();
1603        assert!(restored.is_empty()); // Nothing restored since v1 exists
1604
1605        // Verify current still has both tasks
1606        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        // Create initial phase with task
1615        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        // Archive it
1626        let archive_path = storage.archive_phase("v1", &phases).unwrap();
1627        let archive_name = archive_path.file_name().unwrap().to_str().unwrap();
1628
1629        // Modify current tasks
1630        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(&current).unwrap();
1640
1641        // Restore with replace - should overwrite
1642        let restored = storage.restore_archive(archive_name, true).unwrap();
1643        assert_eq!(restored, vec!["v1".to_string()]);
1644
1645        // Verify archive version restored (only 1 task)
1646        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        // Clear and restore using partial name
1661        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}