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    /// Update a single task's status within a group
463    /// Convenience method that loads, modifies, and saves the group atomically
464    pub fn update_task_status(
465        &self,
466        group_tag: &str,
467        task_id: &str,
468        status: crate::models::task::TaskStatus,
469    ) -> Result<()> {
470        let mut group = self.load_group(group_tag)?;
471
472        let task = group
473            .tasks
474            .iter_mut()
475            .find(|t| t.id == task_id)
476            .ok_or_else(|| anyhow::anyhow!("Task '{}' not found in group '{}'", task_id, group_tag))?;
477
478        task.status = status;
479        self.update_group(group_tag, &group)
480    }
481
482    pub fn read_file(&self, path: &Path) -> Result<String> {
483        fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", path.display()))
484    }
485
486    // ==================== Archive Methods ====================
487
488    /// Get the archive directory path
489    pub fn archive_dir(&self) -> PathBuf {
490        self.scud_dir().join("archive")
491    }
492
493    /// Ensure archive directory exists
494    pub fn ensure_archive_dir(&self) -> Result<()> {
495        let dir = self.archive_dir();
496        if !dir.exists() {
497            fs::create_dir_all(&dir).context("Failed to create archive directory")?;
498        }
499        Ok(())
500    }
501
502    /// Generate archive filename for a tag or all tasks
503    /// Format: {YYYY-MM-DD}_{tag}.scg or {YYYY-MM-DD}_all.scg
504    pub fn archive_filename(&self, tag: Option<&str>) -> String {
505        let date = Local::now().format("%Y-%m-%d");
506        match tag {
507            Some(t) => format!("{}_{}.scg", date, t),
508            None => format!("{}_all.scg", date),
509        }
510    }
511
512    /// Get unique archive path by appending counter if file exists
513    /// Tries base path, then base_1, base_2, etc. up to 100
514    /// Falls back to timestamp suffix if all counters exhausted
515    fn unique_archive_path(&self, base_path: &Path) -> PathBuf {
516        if !base_path.exists() {
517            return base_path.to_path_buf();
518        }
519
520        let stem = base_path
521            .file_stem()
522            .and_then(|s| s.to_str())
523            .unwrap_or("archive");
524        let ext = base_path
525            .extension()
526            .and_then(|s| s.to_str())
527            .unwrap_or("scg");
528        let parent = base_path.parent().unwrap_or(Path::new("."));
529
530        for i in 1..100 {
531            let new_name = format!("{}_{}.{}", stem, i, ext);
532            let new_path = parent.join(&new_name);
533            if !new_path.exists() {
534                return new_path;
535            }
536        }
537
538        // Fallback with timestamp
539        let ts = Local::now().format("%H%M%S");
540        parent.join(format!("{}_{}.{}", stem, ts, ext))
541    }
542
543    /// Archive a single phase/tag
544    /// Returns the path to the created archive file
545    pub fn archive_phase(&self, tag: &str, phases: &HashMap<String, Phase>) -> Result<PathBuf> {
546        self.ensure_archive_dir()?;
547
548        let phase = phases
549            .get(tag)
550            .ok_or_else(|| anyhow::anyhow!("Tag '{}' not found", tag))?;
551
552        let filename = self.archive_filename(Some(tag));
553        let archive_path = self.archive_dir().join(&filename);
554        let final_path = self.unique_archive_path(&archive_path);
555
556        // Serialize single phase to SCG format
557        let content = serialize_scg(phase);
558        fs::write(&final_path, content)
559            .with_context(|| format!("Failed to write archive: {}", final_path.display()))?;
560
561        Ok(final_path)
562    }
563
564    /// Archive all phases together
565    /// Returns the path to the created archive file
566    pub fn archive_all(&self, phases: &HashMap<String, Phase>) -> Result<PathBuf> {
567        self.ensure_archive_dir()?;
568
569        let filename = self.archive_filename(None);
570        let archive_path = self.archive_dir().join(&filename);
571        let final_path = self.unique_archive_path(&archive_path);
572
573        // Serialize all phases (same format as tasks.scg)
574        let mut sorted_tags: Vec<_> = phases.keys().collect();
575        sorted_tags.sort();
576
577        let mut output = String::new();
578        for (i, tag) in sorted_tags.iter().enumerate() {
579            if i > 0 {
580                output.push_str("\n---\n\n");
581            }
582            let phase = phases.get(*tag).unwrap();
583            output.push_str(&serialize_scg(phase));
584        }
585
586        fs::write(&final_path, output)
587            .with_context(|| format!("Failed to write archive: {}", final_path.display()))?;
588
589        Ok(final_path)
590    }
591
592    /// Parse archive filename to extract date and tag
593    /// Returns (date, tag) where tag is None if "all"
594    pub fn parse_archive_filename(filename: &str) -> (String, Option<String>) {
595        let name = filename.trim_end_matches(".scg");
596
597        // Handle filenames with counter suffix: YYYY-MM-DD_tag_N
598        // or just YYYY-MM-DD_tag
599        let parts: Vec<&str> = name.splitn(2, '_').collect();
600
601        if parts.len() == 2 {
602            let date = parts[0].to_string();
603            let rest = parts[1];
604
605            // Check if rest ends with _N (counter suffix)
606            // We need to detect if there's a trailing _NUMBER
607            if let Some(last_underscore) = rest.rfind('_') {
608                let potential_counter = &rest[last_underscore + 1..];
609                if potential_counter.chars().all(|c| c.is_ascii_digit())
610                    && !potential_counter.is_empty()
611                {
612                    // Has a counter suffix, extract tag without it
613                    let tag_part = &rest[..last_underscore];
614                    let tag = if tag_part == "all" {
615                        None
616                    } else {
617                        Some(tag_part.to_string())
618                    };
619                    return (date, tag);
620                }
621            }
622
623            // No counter suffix
624            let tag = if rest == "all" {
625                None
626            } else {
627                Some(rest.to_string())
628            };
629            (date, tag)
630        } else {
631            // Fallback for malformed filenames
632            (name.to_string(), None)
633        }
634    }
635
636    /// List all archives in the archive directory
637    /// Returns sorted by date descending (newest first)
638    pub fn list_archives(&self) -> Result<Vec<ArchiveInfo>> {
639        let archive_dir = self.archive_dir();
640        if !archive_dir.exists() {
641            return Ok(Vec::new());
642        }
643
644        let mut archives = Vec::new();
645        for entry in fs::read_dir(&archive_dir)? {
646            let entry = entry?;
647            let path = entry.path();
648
649            if path.extension().map(|e| e == "scg").unwrap_or(false) {
650                let filename = path
651                    .file_name()
652                    .and_then(|n| n.to_str())
653                    .unwrap_or("")
654                    .to_string();
655
656                let (date, tag) = Self::parse_archive_filename(&filename);
657
658                // Get task count by loading the archive
659                let task_count = match self.load_archive(&path) {
660                    Ok(phases) => phases.values().map(|p| p.tasks.len()).sum(),
661                    Err(_) => 0,
662                };
663
664                archives.push(ArchiveInfo {
665                    filename,
666                    path,
667                    date,
668                    tag,
669                    task_count,
670                });
671            }
672        }
673
674        // Sort by date descending (newest first)
675        archives.sort_by(|a, b| b.date.cmp(&a.date));
676        Ok(archives)
677    }
678
679    /// Load an archive file
680    /// Returns the phases contained in the archive
681    pub fn load_archive(&self, path: &Path) -> Result<HashMap<String, Phase>> {
682        let content = fs::read_to_string(path)
683            .with_context(|| format!("Failed to read archive: {}", path.display()))?;
684
685        self.parse_multi_phase_scg(&content)
686    }
687
688    /// Restore an archive by merging it into current tasks.
689    ///
690    /// # Arguments
691    /// * `archive_name` - filename or partial match (e.g., "v1", "2026-01-13_v1", "2026-01-13_v1.scg")
692    /// * `replace` - if true, replace existing tags; if false, skip existing
693    ///
694    /// # Returns
695    /// The list of restored tag names
696    pub fn restore_archive(&self, archive_name: &str, replace: bool) -> Result<Vec<String>> {
697        let archive_dir = self.archive_dir();
698
699        // Find matching archive
700        let archive_path = if archive_name.ends_with(".scg") {
701            let path = archive_dir.join(archive_name);
702            if !path.exists() {
703                anyhow::bail!("Archive file not found: {}", archive_name);
704            }
705            path
706        } else {
707            // Search for matching archive
708            let mut found = None;
709            if archive_dir.exists() {
710                for entry in fs::read_dir(&archive_dir)? {
711                    let entry = entry?;
712                    let filename = entry.file_name().to_string_lossy().to_string();
713                    if filename.contains(archive_name) {
714                        found = Some(entry.path());
715                        break;
716                    }
717                }
718            }
719            found.ok_or_else(|| anyhow::anyhow!("Archive '{}' not found", archive_name))?
720        };
721
722        let archived_phases = self.load_archive(&archive_path)?;
723        let mut current_phases = self.load_tasks().unwrap_or_default();
724        let mut restored_tags = Vec::new();
725
726        for (tag, phase) in archived_phases {
727            if replace || !current_phases.contains_key(&tag) {
728                current_phases.insert(tag.clone(), phase);
729                restored_tags.push(tag);
730            }
731        }
732
733        self.save_tasks(&current_phases)?;
734        Ok(restored_tags)
735    }
736
737    /// Create or update CLAUDE.md with SCUD agent instructions
738    fn create_agent_instructions(&self) -> Result<()> {
739        let claude_md_path = self.project_root.join("CLAUDE.md");
740
741        let scud_instructions = r#"
742## SCUD Task Management
743
744This project uses SCUD Task Manager for task management.
745
746### Session Workflow
747
7481. **Start of session**: Run `scud warmup` to orient yourself
749   - Shows current working directory and recent git history
750   - Displays active tag, task counts, and any stale locks
751   - Identifies the next available task
752
7532. **Get a task**: Use `/scud:next` or `scud next`
754   - Shows the next available task based on DAG dependencies
755   - Use `scud set-status <id> in-progress` to mark you're working on it
756
7573. **Work on the task**: Implement the requirements
758   - Reference task details with `/scud:task-show <id>`
759   - Dependencies are automatically tracked by the DAG
760
7614. **Commit with context**: Use `scud commit -m "message"` or `scud commit -a -m "message"`
762   - Automatically prefixes commits with `[TASK-ID]`
763   - Uses task title as default commit message if none provided
764
7655. **Complete the task**: Mark done with `/scud:task-status <id> done`
766   - The stop hook will prompt for task completion
767
768### Progress Journaling
769
770Keep a brief progress log during complex tasks:
771
772```
773## Progress Log
774
775### Session: 2025-01-15
776- Investigated auth module, found issue in token refresh
777- Updated refresh logic to handle edge case
778- Tests passing, ready for review
779```
780
781This helps maintain continuity across sessions and provides context for future work.
782
783### Key Commands
784
785- `scud warmup` - Session orientation
786- `scud next` - Find next available task
787- `scud show <id>` - View task details
788- `scud set-status <id> <status>` - Update task status
789- `scud commit` - Task-aware git commit
790- `scud stats` - View completion statistics
791"#;
792
793        if claude_md_path.exists() {
794            // Append to existing CLAUDE.md if SCUD section doesn't exist
795            let content = fs::read_to_string(&claude_md_path)
796                .with_context(|| "Failed to read existing CLAUDE.md")?;
797
798            if !content.contains("## SCUD Task Management") {
799                let mut new_content = content;
800                new_content.push_str(scud_instructions);
801                fs::write(&claude_md_path, new_content)
802                    .with_context(|| "Failed to update CLAUDE.md")?;
803            }
804        } else {
805            // Create new CLAUDE.md
806            let content = format!("# Project Instructions\n{}", scud_instructions);
807            fs::write(&claude_md_path, content).with_context(|| "Failed to create CLAUDE.md")?;
808        }
809
810        Ok(())
811    }
812}
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817    use std::collections::HashMap;
818    use tempfile::TempDir;
819
820    fn create_test_storage() -> (Storage, TempDir) {
821        let temp_dir = TempDir::new().unwrap();
822        let storage = Storage::new(Some(temp_dir.path().to_path_buf()));
823        storage.initialize().unwrap();
824        (storage, temp_dir)
825    }
826
827    #[test]
828    fn test_write_with_lock_creates_file() {
829        let (storage, _temp_dir) = create_test_storage();
830        let test_file = storage.scud_dir().join("test.json");
831
832        storage
833            .write_with_lock(&test_file, || Ok(r#"{"test": "data"}"#.to_string()))
834            .unwrap();
835
836        assert!(test_file.exists());
837        let content = fs::read_to_string(&test_file).unwrap();
838        assert_eq!(content, r#"{"test": "data"}"#);
839    }
840
841    #[test]
842    fn test_read_with_lock_reads_existing_file() {
843        let (storage, _temp_dir) = create_test_storage();
844        let test_file = storage.scud_dir().join("test.json");
845
846        // Create a file
847        fs::write(&test_file, r#"{"test": "data"}"#).unwrap();
848
849        // Read with lock
850        let content = storage.read_with_lock(&test_file).unwrap();
851        assert_eq!(content, r#"{"test": "data"}"#);
852    }
853
854    #[test]
855    fn test_read_with_lock_fails_on_missing_file() {
856        let (storage, _temp_dir) = create_test_storage();
857        let test_file = storage.scud_dir().join("nonexistent.json");
858
859        let result = storage.read_with_lock(&test_file);
860        assert!(result.is_err());
861        assert!(result.unwrap_err().to_string().contains("File not found"));
862    }
863
864    #[test]
865    fn test_save_and_load_tasks_with_locking() {
866        let (storage, _temp_dir) = create_test_storage();
867        let mut tasks = HashMap::new();
868
869        let epic = crate::models::Phase::new("TEST-1".to_string());
870        tasks.insert("TEST-1".to_string(), epic);
871
872        // Save tasks
873        storage.save_tasks(&tasks).unwrap();
874
875        // Load tasks
876        let loaded_tasks = storage.load_tasks().unwrap();
877
878        assert_eq!(tasks.len(), loaded_tasks.len());
879        assert!(loaded_tasks.contains_key("TEST-1"));
880        assert_eq!(loaded_tasks.get("TEST-1").unwrap().name, "TEST-1");
881    }
882
883    #[test]
884    fn test_concurrent_writes_dont_corrupt_data() {
885        use std::sync::Arc;
886        use std::thread;
887
888        let (storage, _temp_dir) = create_test_storage();
889        let storage = Arc::new(storage);
890        let mut handles = vec![];
891
892        // Spawn 10 threads that each write tasks
893        for i in 0..10 {
894            let storage_clone = Arc::clone(&storage);
895            let handle = thread::spawn(move || {
896                let mut tasks = HashMap::new();
897                let epic = crate::models::Phase::new(format!("EPIC-{}", i));
898                tasks.insert(format!("EPIC-{}", i), epic);
899
900                // Each thread writes multiple times
901                for _ in 0..5 {
902                    storage_clone.save_tasks(&tasks).unwrap();
903                    thread::sleep(Duration::from_millis(1));
904                }
905            });
906            handles.push(handle);
907        }
908
909        // Wait for all threads to complete
910        for handle in handles {
911            handle.join().unwrap();
912        }
913
914        // Verify that the file is still valid JSON
915        let tasks = storage.load_tasks().unwrap();
916        // Should have the last written data (from one of the threads)
917        assert_eq!(tasks.len(), 1);
918    }
919
920    #[test]
921    fn test_lock_retry_on_contention() {
922        use std::sync::Arc;
923
924        let (storage, _temp_dir) = create_test_storage();
925        let storage = Arc::new(storage);
926        let test_file = storage.scud_dir().join("lock-test.json");
927
928        // Create file
929        storage
930            .write_with_lock(&test_file, || Ok(r#"{"initial": "data"}"#.to_string()))
931            .unwrap();
932
933        // Open and lock the file
934        let file = OpenOptions::new().write(true).open(&test_file).unwrap();
935        file.lock_exclusive().unwrap();
936
937        // Try to acquire lock with retry in another thread
938        let storage_clone = Arc::clone(&storage);
939        let test_file_clone = test_file.clone();
940        let handle = thread::spawn(move || {
941            // This should retry and succeed after lock release
942            storage_clone.write_with_lock(&test_file_clone, || {
943                Ok(r#"{"updated": "data"}"#.to_string())
944            })
945        });
946
947        // Keep lock for a bit
948        thread::sleep(Duration::from_millis(200));
949
950        // Release lock
951        file.unlock().unwrap();
952        drop(file);
953
954        // The write should have succeeded after retrying
955        let result = handle.join().unwrap();
956        assert!(result.is_ok());
957    }
958
959    // ==================== Error Handling Tests ====================
960
961    #[test]
962    fn test_load_tasks_with_malformed_json() {
963        let (storage, _temp_dir) = create_test_storage();
964        let tasks_file = storage.tasks_file();
965
966        // Write malformed JSON
967        fs::write(&tasks_file, r#"{"invalid": json here}"#).unwrap();
968
969        // Should return error
970        let result = storage.load_tasks();
971        assert!(result.is_err());
972    }
973
974    #[test]
975    fn test_load_tasks_with_empty_file() {
976        let (storage, _temp_dir) = create_test_storage();
977        let tasks_file = storage.tasks_file();
978
979        // Write empty file
980        fs::write(&tasks_file, "").unwrap();
981
982        // Empty SCG file is valid and returns empty HashMap
983        let result = storage.load_tasks();
984        assert!(result.is_ok());
985        assert!(result.unwrap().is_empty());
986    }
987
988    #[test]
989    fn test_load_tasks_missing_file_creates_default() {
990        let (storage, _temp_dir) = create_test_storage();
991        // Don't create tasks file
992
993        // Should return empty HashMap (default)
994        let tasks = storage.load_tasks().unwrap();
995        assert_eq!(tasks.len(), 0);
996    }
997
998    #[test]
999    fn test_save_tasks_creates_directory_if_missing() {
1000        let temp_dir = TempDir::new().unwrap();
1001        let storage = Storage::new(Some(temp_dir.path().to_path_buf()));
1002        // Don't call initialize()
1003
1004        let mut tasks = HashMap::new();
1005        let epic = crate::models::Phase::new("TEST-1".to_string());
1006        tasks.insert("TEST-1".to_string(), epic);
1007
1008        // Should create directory and file
1009        let result = storage.save_tasks(&tasks);
1010        assert!(result.is_ok());
1011
1012        assert!(storage.scud_dir().exists());
1013        assert!(storage.tasks_file().exists());
1014    }
1015
1016    #[test]
1017    fn test_write_with_lock_handles_directory_creation() {
1018        let temp_dir = TempDir::new().unwrap();
1019        let storage = Storage::new(Some(temp_dir.path().to_path_buf()));
1020
1021        let nested_file = temp_dir
1022            .path()
1023            .join("deeply")
1024            .join("nested")
1025            .join("test.json");
1026
1027        // Should create all parent directories
1028        let result = storage.write_with_lock(&nested_file, || Ok("{}".to_string()));
1029        assert!(result.is_ok());
1030        assert!(nested_file.exists());
1031    }
1032
1033    #[test]
1034    fn test_load_tasks_with_invalid_structure() {
1035        let (storage, _temp_dir) = create_test_storage();
1036        let tasks_file = storage.tasks_file();
1037
1038        // Write valid JSON but invalid structure (array instead of object)
1039        fs::write(&tasks_file, r#"["not", "an", "object"]"#).unwrap();
1040
1041        // Should return error
1042        let result = storage.load_tasks();
1043        assert!(result.is_err());
1044    }
1045
1046    #[test]
1047    fn test_save_and_load_with_unicode_content() {
1048        let (storage, _temp_dir) = create_test_storage();
1049
1050        let mut tasks = HashMap::new();
1051        let mut epic = crate::models::Phase::new("TEST-UNICODE".to_string());
1052
1053        // Add task with unicode content
1054        let task = crate::models::Task::new(
1055            "task-1".to_string(),
1056            "测试 Unicode 🚀".to_string(),
1057            "Descripción en español 日本語".to_string(),
1058        );
1059        epic.add_task(task);
1060
1061        tasks.insert("TEST-UNICODE".to_string(), epic);
1062
1063        // Save and load
1064        storage.save_tasks(&tasks).unwrap();
1065        let loaded_tasks = storage.load_tasks().unwrap();
1066
1067        let loaded_epic = loaded_tasks.get("TEST-UNICODE").unwrap();
1068        let loaded_task = loaded_epic.get_task("task-1").unwrap();
1069        assert_eq!(loaded_task.title, "测试 Unicode 🚀");
1070        assert_eq!(loaded_task.description, "Descripción en español 日本語");
1071    }
1072
1073    #[test]
1074    fn test_save_and_load_with_large_dataset() {
1075        let (storage, _temp_dir) = create_test_storage();
1076
1077        let mut tasks = HashMap::new();
1078
1079        // Create 100 epics with 50 tasks each
1080        for i in 0..100 {
1081            let mut epic = crate::models::Phase::new(format!("EPIC-{}", i));
1082
1083            for j in 0..50 {
1084                let task = crate::models::Task::new(
1085                    format!("task-{}-{}", i, j),
1086                    format!("Task {} of Epic {}", j, i),
1087                    format!("Description for task {}-{}", i, j),
1088                );
1089                epic.add_task(task);
1090            }
1091
1092            tasks.insert(format!("EPIC-{}", i), epic);
1093        }
1094
1095        // Save and load
1096        storage.save_tasks(&tasks).unwrap();
1097        let loaded_tasks = storage.load_tasks().unwrap();
1098
1099        assert_eq!(loaded_tasks.len(), 100);
1100        for i in 0..100 {
1101            let epic = loaded_tasks.get(&format!("EPIC-{}", i)).unwrap();
1102            assert_eq!(epic.tasks.len(), 50);
1103        }
1104    }
1105
1106    #[test]
1107    fn test_concurrent_read_and_write() {
1108        use std::sync::Arc;
1109        use std::thread;
1110
1111        let (storage, _temp_dir) = create_test_storage();
1112        let storage = Arc::new(storage);
1113
1114        // Initialize with some data
1115        let mut tasks = HashMap::new();
1116        let epic = crate::models::Phase::new("INITIAL".to_string());
1117        tasks.insert("INITIAL".to_string(), epic);
1118        storage.save_tasks(&tasks).unwrap();
1119
1120        let mut handles = vec![];
1121
1122        // Spawn 5 readers
1123        for _ in 0..5 {
1124            let storage_clone = Arc::clone(&storage);
1125            let handle = thread::spawn(move || {
1126                for _ in 0..10 {
1127                    let _ = storage_clone.load_tasks();
1128                    thread::sleep(Duration::from_millis(1));
1129                }
1130            });
1131            handles.push(handle);
1132        }
1133
1134        // Spawn 2 writers
1135        for i in 0..2 {
1136            let storage_clone = Arc::clone(&storage);
1137            let handle = thread::spawn(move || {
1138                for j in 0..5 {
1139                    let mut tasks = HashMap::new();
1140                    let epic = crate::models::Phase::new(format!("WRITER-{}-{}", i, j));
1141                    tasks.insert(format!("WRITER-{}-{}", i, j), epic);
1142                    storage_clone.save_tasks(&tasks).unwrap();
1143                    thread::sleep(Duration::from_millis(2));
1144                }
1145            });
1146            handles.push(handle);
1147        }
1148
1149        // Wait for all threads
1150        for handle in handles {
1151            handle.join().unwrap();
1152        }
1153
1154        // File should still be valid
1155        let tasks = storage.load_tasks().unwrap();
1156        assert_eq!(tasks.len(), 1); // Last write wins
1157    }
1158
1159    // ==================== Active Epic Cache Tests ====================
1160
1161    #[test]
1162    fn test_active_epic_cached_on_second_call() {
1163        let (storage, _temp_dir) = create_test_storage();
1164
1165        // Set active epic
1166        let mut tasks = HashMap::new();
1167        tasks.insert("TEST-1".to_string(), Phase::new("TEST-1".to_string()));
1168        storage.save_tasks(&tasks).unwrap();
1169        storage.set_active_group("TEST-1").unwrap();
1170
1171        // First call - loads from file
1172        let active1 = storage.get_active_group().unwrap();
1173        assert_eq!(active1, Some("TEST-1".to_string()));
1174
1175        // Modify file directly (bypass storage methods)
1176        let active_tag_file = storage.active_tag_file();
1177        fs::write(&active_tag_file, "DIFFERENT").unwrap();
1178
1179        // Second call - should return cached value (not file value)
1180        let active2 = storage.get_active_group().unwrap();
1181        assert_eq!(active2, Some("TEST-1".to_string())); // Still cached
1182
1183        // After cache clear - should reload from file
1184        storage.clear_cache();
1185        let active3 = storage.get_active_group().unwrap();
1186        assert_eq!(active3, Some("DIFFERENT".to_string())); // From file
1187    }
1188
1189    #[test]
1190    fn test_cache_invalidated_on_set_active_epic() {
1191        let (storage, _temp_dir) = create_test_storage();
1192
1193        let mut tasks = HashMap::new();
1194        tasks.insert("EPIC-1".to_string(), Phase::new("EPIC-1".to_string()));
1195        tasks.insert("EPIC-2".to_string(), Phase::new("EPIC-2".to_string()));
1196        storage.save_tasks(&tasks).unwrap();
1197
1198        storage.set_active_group("EPIC-1").unwrap();
1199        assert_eq!(
1200            storage.get_active_group().unwrap(),
1201            Some("EPIC-1".to_string())
1202        );
1203
1204        // Change active epic - should update cache
1205        storage.set_active_group("EPIC-2").unwrap();
1206        assert_eq!(
1207            storage.get_active_group().unwrap(),
1208            Some("EPIC-2".to_string())
1209        );
1210    }
1211
1212    #[test]
1213    fn test_cache_with_no_active_epic() {
1214        let (storage, _temp_dir) = create_test_storage();
1215
1216        // Load when no active epic is set
1217        let active = storage.get_active_group().unwrap();
1218        assert_eq!(active, None);
1219
1220        // Should cache the None value
1221        let active2 = storage.get_active_group().unwrap();
1222        assert_eq!(active2, None);
1223    }
1224
1225    // ==================== Lazy Epic Loading Tests ====================
1226
1227    #[test]
1228    fn test_load_single_epic_from_many() {
1229        let (storage, _temp_dir) = create_test_storage();
1230
1231        // Create 50 epics
1232        let mut tasks = HashMap::new();
1233        for i in 0..50 {
1234            tasks.insert(format!("EPIC-{}", i), Phase::new(format!("EPIC-{}", i)));
1235        }
1236        storage.save_tasks(&tasks).unwrap();
1237
1238        // Load single epic - should only deserialize that one
1239        let epic = storage.load_group("EPIC-25").unwrap();
1240        assert_eq!(epic.name, "EPIC-25");
1241    }
1242
1243    #[test]
1244    fn test_load_epic_not_found() {
1245        let (storage, _temp_dir) = create_test_storage();
1246
1247        let tasks = HashMap::new();
1248        storage.save_tasks(&tasks).unwrap();
1249
1250        let result = storage.load_group("NONEXISTENT");
1251        assert!(result.is_err());
1252        assert!(result.unwrap_err().to_string().contains("not found"));
1253    }
1254
1255    #[test]
1256    fn test_load_epic_matches_full_load() {
1257        let (storage, _temp_dir) = create_test_storage();
1258
1259        let mut tasks = HashMap::new();
1260        let mut epic = Phase::new("TEST-1".to_string());
1261        epic.add_task(crate::models::Task::new(
1262            "task-1".to_string(),
1263            "Test".to_string(),
1264            "Desc".to_string(),
1265        ));
1266        tasks.insert("TEST-1".to_string(), epic.clone());
1267        storage.save_tasks(&tasks).unwrap();
1268
1269        // Load via both methods
1270        let epic_lazy = storage.load_group("TEST-1").unwrap();
1271        let tasks_full = storage.load_tasks().unwrap();
1272        let epic_full = tasks_full.get("TEST-1").unwrap();
1273
1274        // Should be identical
1275        assert_eq!(epic_lazy.name, epic_full.name);
1276        assert_eq!(epic_lazy.tasks.len(), epic_full.tasks.len());
1277    }
1278
1279    #[test]
1280    fn test_load_active_epic() {
1281        let (storage, _temp_dir) = create_test_storage();
1282
1283        let mut tasks = HashMap::new();
1284        let mut epic = Phase::new("ACTIVE-1".to_string());
1285        epic.add_task(crate::models::Task::new(
1286            "task-1".to_string(),
1287            "Test".to_string(),
1288            "Desc".to_string(),
1289        ));
1290        tasks.insert("ACTIVE-1".to_string(), epic);
1291        storage.save_tasks(&tasks).unwrap();
1292        storage.set_active_group("ACTIVE-1").unwrap();
1293
1294        // Load active epic directly
1295        let epic = storage.load_active_group().unwrap();
1296        assert_eq!(epic.name, "ACTIVE-1");
1297        assert_eq!(epic.tasks.len(), 1);
1298    }
1299
1300    #[test]
1301    fn test_load_active_epic_when_none_set() {
1302        let (storage, _temp_dir) = create_test_storage();
1303
1304        // Should error when no active epic
1305        let result = storage.load_active_group();
1306        assert!(result.is_err());
1307        assert!(result
1308            .unwrap_err()
1309            .to_string()
1310            .contains("No active task group"));
1311    }
1312
1313    #[test]
1314    fn test_update_epic_without_loading_all() {
1315        let (storage, _temp_dir) = create_test_storage();
1316
1317        let mut tasks = HashMap::new();
1318        tasks.insert("EPIC-1".to_string(), Phase::new("EPIC-1".to_string()));
1319        tasks.insert("EPIC-2".to_string(), Phase::new("EPIC-2".to_string()));
1320        storage.save_tasks(&tasks).unwrap();
1321
1322        // Update only EPIC-1
1323        let mut epic1 = storage.load_group("EPIC-1").unwrap();
1324        epic1.add_task(crate::models::Task::new(
1325            "new-task".to_string(),
1326            "New".to_string(),
1327            "Desc".to_string(),
1328        ));
1329        storage.update_group("EPIC-1", &epic1).unwrap();
1330
1331        // Verify update
1332        let loaded = storage.load_group("EPIC-1").unwrap();
1333        assert_eq!(loaded.tasks.len(), 1);
1334
1335        // Verify EPIC-2 unchanged
1336        let epic2 = storage.load_group("EPIC-2").unwrap();
1337        assert_eq!(epic2.tasks.len(), 0);
1338    }
1339
1340    // ==================== Archive Tests ====================
1341
1342    #[test]
1343    fn test_archive_dir() {
1344        let (storage, _temp_dir) = create_test_storage();
1345        let archive_dir = storage.archive_dir();
1346        assert!(archive_dir.ends_with(".scud/archive"));
1347    }
1348
1349    #[test]
1350    fn test_ensure_archive_dir() {
1351        let (storage, _temp_dir) = create_test_storage();
1352
1353        // Directory shouldn't exist initially
1354        assert!(!storage.archive_dir().exists());
1355
1356        // Create it
1357        storage.ensure_archive_dir().unwrap();
1358        assert!(storage.archive_dir().exists());
1359
1360        // Second call should be idempotent
1361        storage.ensure_archive_dir().unwrap();
1362        assert!(storage.archive_dir().exists());
1363    }
1364
1365    #[test]
1366    fn test_archive_filename_with_tag() {
1367        let (storage, _temp_dir) = create_test_storage();
1368        let filename = storage.archive_filename(Some("v1"));
1369
1370        // Should match pattern YYYY-MM-DD_v1.scg
1371        assert!(filename.ends_with("_v1.scg"));
1372        assert!(filename.len() == 17); // YYYY-MM-DD_v1.scg = 17 chars
1373    }
1374
1375    #[test]
1376    fn test_archive_filename_all() {
1377        let (storage, _temp_dir) = create_test_storage();
1378        let filename = storage.archive_filename(None);
1379
1380        // Should match pattern YYYY-MM-DD_all.scg
1381        assert!(filename.ends_with("_all.scg"));
1382        assert!(filename.len() == 18); // YYYY-MM-DD_all.scg = 18 chars
1383    }
1384
1385    #[test]
1386    fn test_parse_archive_filename_simple() {
1387        let (date, tag) = Storage::parse_archive_filename("2026-01-13_v1.scg");
1388        assert_eq!(date, "2026-01-13");
1389        assert_eq!(tag, Some("v1".to_string()));
1390    }
1391
1392    #[test]
1393    fn test_parse_archive_filename_all() {
1394        let (date, tag) = Storage::parse_archive_filename("2026-01-13_all.scg");
1395        assert_eq!(date, "2026-01-13");
1396        assert_eq!(tag, None);
1397    }
1398
1399    #[test]
1400    fn test_parse_archive_filename_with_counter() {
1401        let (date, tag) = Storage::parse_archive_filename("2026-01-13_v1_2.scg");
1402        assert_eq!(date, "2026-01-13");
1403        assert_eq!(tag, Some("v1".to_string()));
1404    }
1405
1406    #[test]
1407    fn test_parse_archive_filename_all_with_counter() {
1408        let (date, tag) = Storage::parse_archive_filename("2026-01-13_all_5.scg");
1409        assert_eq!(date, "2026-01-13");
1410        assert_eq!(tag, None);
1411    }
1412
1413    #[test]
1414    fn test_archive_single_phase() {
1415        let (storage, _temp_dir) = create_test_storage();
1416
1417        // Create test phases
1418        let mut phases = HashMap::new();
1419        let mut phase = Phase::new("v1".to_string());
1420        phase.add_task(crate::models::Task::new(
1421            "task-1".to_string(),
1422            "Test Task".to_string(),
1423            "Description".to_string(),
1424        ));
1425        phases.insert("v1".to_string(), phase);
1426        storage.save_tasks(&phases).unwrap();
1427
1428        // Archive
1429        let archive_path = storage.archive_phase("v1", &phases).unwrap();
1430
1431        assert!(archive_path.exists());
1432        assert!(archive_path.to_string_lossy().contains("v1"));
1433        assert!(archive_path.extension().unwrap() == "scg");
1434    }
1435
1436    #[test]
1437    fn test_archive_all_phases() {
1438        let (storage, _temp_dir) = create_test_storage();
1439
1440        let mut phases = HashMap::new();
1441        phases.insert("v1".to_string(), Phase::new("v1".to_string()));
1442        phases.insert("v2".to_string(), Phase::new("v2".to_string()));
1443        storage.save_tasks(&phases).unwrap();
1444
1445        let archive_path = storage.archive_all(&phases).unwrap();
1446
1447        assert!(archive_path.exists());
1448        assert!(archive_path.to_string_lossy().contains("all"));
1449
1450        // Verify it contains both phases
1451        let loaded = storage.load_archive(&archive_path).unwrap();
1452        assert_eq!(loaded.len(), 2);
1453        assert!(loaded.contains_key("v1"));
1454        assert!(loaded.contains_key("v2"));
1455    }
1456
1457    #[test]
1458    fn test_archive_nonexistent_tag() {
1459        let (storage, _temp_dir) = create_test_storage();
1460
1461        let phases = HashMap::new();
1462        let result = storage.archive_phase("nonexistent", &phases);
1463
1464        assert!(result.is_err());
1465        assert!(result.unwrap_err().to_string().contains("not found"));
1466    }
1467
1468    #[test]
1469    fn test_unique_archive_path_no_collision() {
1470        let (storage, _temp_dir) = create_test_storage();
1471        storage.ensure_archive_dir().unwrap();
1472
1473        let base_path = storage.archive_dir().join("test.scg");
1474        let result = storage.unique_archive_path(&base_path);
1475
1476        assert_eq!(result, base_path);
1477    }
1478
1479    #[test]
1480    fn test_unique_archive_path_with_collision() {
1481        let (storage, _temp_dir) = create_test_storage();
1482        storage.ensure_archive_dir().unwrap();
1483
1484        // Create existing file
1485        let base_path = storage.archive_dir().join("test.scg");
1486        fs::write(&base_path, "existing").unwrap();
1487
1488        // Should get test_1.scg
1489        let result = storage.unique_archive_path(&base_path);
1490        assert!(result.to_string_lossy().contains("test_1.scg"));
1491    }
1492
1493    #[test]
1494    fn test_unique_archive_path_multiple_collisions() {
1495        let (storage, _temp_dir) = create_test_storage();
1496        storage.ensure_archive_dir().unwrap();
1497
1498        // Create existing files
1499        let base_path = storage.archive_dir().join("test.scg");
1500        fs::write(&base_path, "existing").unwrap();
1501        fs::write(storage.archive_dir().join("test_1.scg"), "existing").unwrap();
1502        fs::write(storage.archive_dir().join("test_2.scg"), "existing").unwrap();
1503
1504        // Should get test_3.scg
1505        let result = storage.unique_archive_path(&base_path);
1506        assert!(result.to_string_lossy().contains("test_3.scg"));
1507    }
1508
1509    #[test]
1510    fn test_list_archives_empty() {
1511        let (storage, _temp_dir) = create_test_storage();
1512
1513        let archives = storage.list_archives().unwrap();
1514        assert!(archives.is_empty());
1515    }
1516
1517    #[test]
1518    fn test_list_archives_with_archives() {
1519        let (storage, _temp_dir) = create_test_storage();
1520
1521        // Create test phases and archive them
1522        let mut phases = HashMap::new();
1523        let mut phase = Phase::new("v1".to_string());
1524        phase.add_task(crate::models::Task::new(
1525            "task-1".to_string(),
1526            "Test".to_string(),
1527            "Desc".to_string(),
1528        ));
1529        phases.insert("v1".to_string(), phase);
1530        storage.save_tasks(&phases).unwrap();
1531
1532        storage.archive_phase("v1", &phases).unwrap();
1533
1534        let archives = storage.list_archives().unwrap();
1535        assert_eq!(archives.len(), 1);
1536        assert_eq!(archives[0].tag, Some("v1".to_string()));
1537        assert_eq!(archives[0].task_count, 1);
1538    }
1539
1540    #[test]
1541    fn test_load_archive() {
1542        let (storage, _temp_dir) = create_test_storage();
1543
1544        let mut phases = HashMap::new();
1545        let mut phase = Phase::new("test-tag".to_string());
1546        phase.add_task(crate::models::Task::new(
1547            "task-1".to_string(),
1548            "Test Title".to_string(),
1549            "Test Description".to_string(),
1550        ));
1551        phases.insert("test-tag".to_string(), phase);
1552        storage.save_tasks(&phases).unwrap();
1553
1554        let archive_path = storage.archive_phase("test-tag", &phases).unwrap();
1555
1556        // Load and verify
1557        let loaded = storage.load_archive(&archive_path).unwrap();
1558        assert_eq!(loaded.len(), 1);
1559        let loaded_phase = loaded.get("test-tag").unwrap();
1560        assert_eq!(loaded_phase.tasks.len(), 1);
1561        assert_eq!(loaded_phase.tasks[0].title, "Test Title");
1562    }
1563
1564    #[test]
1565    fn test_restore_archive_empty_tasks() {
1566        let (storage, _temp_dir) = create_test_storage();
1567
1568        // Create and archive a phase
1569        let mut phases = HashMap::new();
1570        phases.insert("v1".to_string(), Phase::new("v1".to_string()));
1571        storage.save_tasks(&phases).unwrap();
1572
1573        let archive_path = storage.archive_phase("v1", &phases).unwrap();
1574        let archive_name = archive_path.file_name().unwrap().to_str().unwrap();
1575
1576        // Clear current tasks
1577        storage.save_tasks(&HashMap::new()).unwrap();
1578        let empty_check = storage.load_tasks().unwrap();
1579        assert!(empty_check.is_empty());
1580
1581        // Restore
1582        let restored = storage.restore_archive(archive_name, false).unwrap();
1583        assert_eq!(restored, vec!["v1".to_string()]);
1584
1585        // Verify restored
1586        let current = storage.load_tasks().unwrap();
1587        assert!(current.contains_key("v1"));
1588    }
1589
1590    #[test]
1591    fn test_restore_archive_no_replace() {
1592        let (storage, _temp_dir) = create_test_storage();
1593
1594        // Create initial phase with task
1595        let mut phases = HashMap::new();
1596        let mut phase = Phase::new("v1".to_string());
1597        phase.add_task(crate::models::Task::new(
1598            "original".to_string(),
1599            "Original".to_string(),
1600            "Desc".to_string(),
1601        ));
1602        phases.insert("v1".to_string(), phase);
1603        storage.save_tasks(&phases).unwrap();
1604
1605        // Archive it
1606        let archive_path = storage.archive_phase("v1", &phases).unwrap();
1607        let archive_name = archive_path.file_name().unwrap().to_str().unwrap();
1608
1609        // Modify current tasks
1610        let mut current = storage.load_tasks().unwrap();
1611        current
1612            .get_mut("v1")
1613            .unwrap()
1614            .add_task(crate::models::Task::new(
1615                "new".to_string(),
1616                "New".to_string(),
1617                "Desc".to_string(),
1618            ));
1619        storage.save_tasks(&current).unwrap();
1620
1621        // Restore without replace - should not overwrite
1622        let restored = storage.restore_archive(archive_name, false).unwrap();
1623        assert!(restored.is_empty()); // Nothing restored since v1 exists
1624
1625        // Verify current still has both tasks
1626        let final_tasks = storage.load_tasks().unwrap();
1627        assert_eq!(final_tasks.get("v1").unwrap().tasks.len(), 2);
1628    }
1629
1630    #[test]
1631    fn test_restore_archive_with_replace() {
1632        let (storage, _temp_dir) = create_test_storage();
1633
1634        // Create initial phase with task
1635        let mut phases = HashMap::new();
1636        let mut phase = Phase::new("v1".to_string());
1637        phase.add_task(crate::models::Task::new(
1638            "original".to_string(),
1639            "Original".to_string(),
1640            "Desc".to_string(),
1641        ));
1642        phases.insert("v1".to_string(), phase);
1643        storage.save_tasks(&phases).unwrap();
1644
1645        // Archive it
1646        let archive_path = storage.archive_phase("v1", &phases).unwrap();
1647        let archive_name = archive_path.file_name().unwrap().to_str().unwrap();
1648
1649        // Modify current tasks
1650        let mut current = storage.load_tasks().unwrap();
1651        current
1652            .get_mut("v1")
1653            .unwrap()
1654            .add_task(crate::models::Task::new(
1655                "new".to_string(),
1656                "New".to_string(),
1657                "Desc".to_string(),
1658            ));
1659        storage.save_tasks(&current).unwrap();
1660
1661        // Restore with replace - should overwrite
1662        let restored = storage.restore_archive(archive_name, true).unwrap();
1663        assert_eq!(restored, vec!["v1".to_string()]);
1664
1665        // Verify archive version restored (only 1 task)
1666        let final_tasks = storage.load_tasks().unwrap();
1667        assert_eq!(final_tasks.get("v1").unwrap().tasks.len(), 1);
1668    }
1669
1670    #[test]
1671    fn test_restore_archive_partial_match() {
1672        let (storage, _temp_dir) = create_test_storage();
1673
1674        let mut phases = HashMap::new();
1675        phases.insert("myproject".to_string(), Phase::new("myproject".to_string()));
1676        storage.save_tasks(&phases).unwrap();
1677
1678        storage.archive_phase("myproject", &phases).unwrap();
1679
1680        // Clear and restore using partial name
1681        storage.save_tasks(&HashMap::new()).unwrap();
1682
1683        let restored = storage.restore_archive("myproject", false).unwrap();
1684        assert_eq!(restored, vec!["myproject".to_string()]);
1685    }
1686
1687    #[test]
1688    fn test_restore_archive_not_found() {
1689        let (storage, _temp_dir) = create_test_storage();
1690
1691        let result = storage.restore_archive("nonexistent", false);
1692        assert!(result.is_err());
1693        assert!(result.unwrap_err().to_string().contains("not found"));
1694    }
1695}