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