Skip to main content

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