scud/storage/
mod.rs

1use anyhow::{Context, Result};
2use fs2::FileExt;
3use std::collections::HashMap;
4use std::fs::{self, File, OpenOptions};
5use std::path::{Path, PathBuf};
6use std::sync::RwLock;
7use std::thread;
8use std::time::Duration;
9
10use crate::config::Config;
11use crate::formats::{parse_scg, serialize_scg};
12use crate::models::Phase;
13
14pub struct Storage {
15    project_root: PathBuf,
16    /// Cache for active group to avoid repeated workflow state loads
17    /// Option<Option<String>> represents: None = not cached, Some(None) = no active group, Some(Some(tag)) = cached tag
18    /// Uses RwLock for thread safety (useful for tests and potential daemon mode)
19    active_group_cache: RwLock<Option<Option<String>>>,
20}
21
22impl Storage {
23    pub fn new(project_root: Option<PathBuf>) -> Self {
24        let root = project_root.unwrap_or_else(|| std::env::current_dir().unwrap());
25        Storage {
26            project_root: root,
27            active_group_cache: RwLock::new(None),
28        }
29    }
30
31    /// Get the project root directory
32    pub fn project_root(&self) -> &Path {
33        &self.project_root
34    }
35
36    /// Acquire an exclusive file lock with retry logic
37    fn acquire_lock_with_retry(&self, file: &File, max_retries: u32) -> Result<()> {
38        let mut retries = 0;
39        let mut delay_ms = 10;
40
41        loop {
42            match file.try_lock_exclusive() {
43                Ok(_) => return Ok(()),
44                Err(_) if retries < max_retries => {
45                    retries += 1;
46                    thread::sleep(Duration::from_millis(delay_ms));
47                    delay_ms = (delay_ms * 2).min(1000); // Exponential backoff, max 1s
48                }
49                Err(e) => {
50                    anyhow::bail!(
51                        "Failed to acquire file lock after {} retries: {}",
52                        max_retries,
53                        e
54                    )
55                }
56            }
57        }
58    }
59
60    /// Perform a locked write operation on a file
61    fn write_with_lock<F>(&self, path: &Path, writer: F) -> Result<()>
62    where
63        F: FnOnce() -> Result<String>,
64    {
65        use std::io::Write;
66
67        let dir = path.parent().unwrap();
68        if !dir.exists() {
69            fs::create_dir_all(dir)?;
70        }
71
72        // Open file for writing
73        let mut file = OpenOptions::new()
74            .write(true)
75            .create(true)
76            .truncate(true)
77            .open(path)
78            .with_context(|| format!("Failed to open file for writing: {}", path.display()))?;
79
80        // Acquire lock with retry
81        self.acquire_lock_with_retry(&file, 10)?;
82
83        // Generate content and write through the locked handle
84        let content = writer()?;
85        file.write_all(content.as_bytes())
86            .with_context(|| format!("Failed to write to {}", path.display()))?;
87        file.flush()
88            .with_context(|| format!("Failed to flush {}", path.display()))?;
89
90        // Lock is automatically released when file is dropped
91        Ok(())
92    }
93
94    /// Perform a locked read operation on a file
95    fn read_with_lock(&self, path: &Path) -> Result<String> {
96        use std::io::Read;
97
98        if !path.exists() {
99            anyhow::bail!("File not found: {}", path.display());
100        }
101
102        // Open file for reading
103        let mut file = OpenOptions::new()
104            .read(true)
105            .open(path)
106            .with_context(|| format!("Failed to open file for reading: {}", path.display()))?;
107
108        // Acquire shared lock (allows multiple readers)
109        file.lock_shared()
110            .with_context(|| format!("Failed to acquire read lock on {}", path.display()))?;
111
112        // Read content through the locked handle
113        let mut content = String::new();
114        file.read_to_string(&mut content)
115            .with_context(|| format!("Failed to read from {}", path.display()))?;
116
117        // Lock is automatically released when file is dropped
118        Ok(content)
119    }
120
121    pub fn scud_dir(&self) -> PathBuf {
122        self.project_root.join(".scud")
123    }
124
125    pub fn tasks_file(&self) -> PathBuf {
126        self.scud_dir().join("tasks").join("tasks.scg")
127    }
128
129    fn active_tag_file(&self) -> PathBuf {
130        self.scud_dir().join("active-tag")
131    }
132
133    pub fn config_file(&self) -> PathBuf {
134        self.scud_dir().join("config.toml")
135    }
136
137    pub fn docs_dir(&self) -> PathBuf {
138        self.scud_dir().join("docs")
139    }
140
141    pub fn guidance_dir(&self) -> PathBuf {
142        self.scud_dir().join("guidance")
143    }
144
145    /// Load all .md files from .scud/guidance/ folder
146    /// Returns concatenated content with file headers, or empty string if no files
147    pub fn load_guidance(&self) -> Result<String> {
148        let guidance_dir = self.guidance_dir();
149
150        if !guidance_dir.exists() {
151            return Ok(String::new());
152        }
153
154        let mut guidance_content = String::new();
155        let mut entries: Vec<_> = fs::read_dir(&guidance_dir)?
156            .filter_map(|e| e.ok())
157            .filter(|e| {
158                e.path()
159                    .extension()
160                    .map(|ext| ext == "md")
161                    .unwrap_or(false)
162            })
163            .collect();
164
165        // Sort by filename for consistent ordering
166        entries.sort_by_key(|e| e.path());
167
168        for entry in entries {
169            let path = entry.path();
170            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("unknown");
171
172            match fs::read_to_string(&path) {
173                Ok(content) => {
174                    if !guidance_content.is_empty() {
175                        guidance_content.push_str("\n\n");
176                    }
177                    guidance_content.push_str(&format!("### {}\n\n{}", filename, content));
178                }
179                Err(e) => {
180                    eprintln!(
181                        "Warning: Failed to read guidance file {}: {}",
182                        path.display(),
183                        e
184                    );
185                }
186            }
187        }
188
189        Ok(guidance_content)
190    }
191
192    pub fn is_initialized(&self) -> bool {
193        self.scud_dir().exists() && self.tasks_file().exists()
194    }
195
196    pub fn initialize(&self) -> Result<()> {
197        let config = Config::default();
198        self.initialize_with_config(&config)
199    }
200
201    pub fn initialize_with_config(&self, config: &Config) -> Result<()> {
202        // Create .scud directory structure
203        let scud_dir = self.scud_dir();
204        fs::create_dir_all(scud_dir.join("tasks"))
205            .context("Failed to create .scud/tasks directory")?;
206
207        // Initialize config.toml
208        let config_file = self.config_file();
209        if !config_file.exists() {
210            config.save(&config_file)?;
211        }
212
213        // Initialize tasks.scg with empty content
214        let tasks_file = self.tasks_file();
215        if !tasks_file.exists() {
216            let empty_tasks: HashMap<String, Phase> = HashMap::new();
217            self.save_tasks(&empty_tasks)?;
218        }
219
220        // Create docs directories
221        let docs = self.docs_dir();
222        fs::create_dir_all(docs.join("prd"))?;
223        fs::create_dir_all(docs.join("phases"))?;
224        fs::create_dir_all(docs.join("architecture"))?;
225        fs::create_dir_all(docs.join("retrospectives"))?;
226
227        // Create guidance directory for project-specific AI context
228        fs::create_dir_all(self.guidance_dir())?;
229
230        // Create CLAUDE.md with agent instructions
231        self.create_agent_instructions()?;
232
233        Ok(())
234    }
235
236    pub fn load_config(&self) -> Result<Config> {
237        let config_file = self.config_file();
238        if !config_file.exists() {
239            return Ok(Config::default());
240        }
241        Config::load(&config_file)
242    }
243
244    pub fn load_tasks(&self) -> Result<HashMap<String, Phase>> {
245        let path = self.tasks_file();
246        if !path.exists() {
247            anyhow::bail!("Tasks file not found: {}\nRun: scud init", path.display());
248        }
249
250        let content = self.read_with_lock(&path)?;
251        self.parse_multi_phase_scg(&content)
252    }
253
254    /// Parse multi-phase SCG format (multiple phases separated by ---)
255    fn parse_multi_phase_scg(&self, content: &str) -> Result<HashMap<String, Phase>> {
256        let mut phases = HashMap::new();
257
258        // Empty file returns empty map
259        if content.trim().is_empty() {
260            return Ok(phases);
261        }
262
263        // Split by phase separator (---)
264        let sections: Vec<&str> = content.split("\n---\n").collect();
265
266        for section in sections {
267            let section = section.trim();
268            if section.is_empty() {
269                continue;
270            }
271
272            // Parse the phase section
273            let phase = parse_scg(section).with_context(|| "Failed to parse SCG section")?;
274
275            phases.insert(phase.name.clone(), phase);
276        }
277
278        Ok(phases)
279    }
280
281    pub fn save_tasks(&self, tasks: &HashMap<String, Phase>) -> Result<()> {
282        let path = self.tasks_file();
283        self.write_with_lock(&path, || {
284            // Sort phases by tag for consistent output
285            let mut sorted_tags: Vec<_> = tasks.keys().collect();
286            sorted_tags.sort();
287
288            let mut output = String::new();
289            for (i, tag) in sorted_tags.iter().enumerate() {
290                if i > 0 {
291                    output.push_str("\n---\n\n");
292                }
293                let phase = tasks.get(*tag).unwrap();
294                output.push_str(&serialize_scg(phase));
295            }
296
297            Ok(output)
298        })
299    }
300
301    pub fn get_active_group(&self) -> Result<Option<String>> {
302        // Check cache first (read lock)
303        {
304            let cache = self.active_group_cache.read().unwrap();
305            if let Some(cached) = cache.as_ref() {
306                return Ok(cached.clone());
307            }
308        }
309
310        // Load from active-tag file
311        let active_tag_path = self.active_tag_file();
312        let active = if active_tag_path.exists() {
313            let content = fs::read_to_string(&active_tag_path)
314                .with_context(|| format!("Failed to read {}", active_tag_path.display()))?;
315            let tag = content.trim();
316            if tag.is_empty() {
317                None
318            } else {
319                Some(tag.to_string())
320            }
321        } else {
322            None
323        };
324
325        // Store in cache
326        *self.active_group_cache.write().unwrap() = Some(active.clone());
327
328        Ok(active)
329    }
330
331    pub fn set_active_group(&self, group_tag: &str) -> Result<()> {
332        let tasks = self.load_tasks()?;
333        if !tasks.contains_key(group_tag) {
334            anyhow::bail!("Task group '{}' not found", group_tag);
335        }
336
337        // Write to active-tag file
338        let active_tag_path = self.active_tag_file();
339        fs::write(&active_tag_path, group_tag)
340            .with_context(|| format!("Failed to write {}", active_tag_path.display()))?;
341
342        // Update cache
343        *self.active_group_cache.write().unwrap() = Some(Some(group_tag.to_string()));
344
345        Ok(())
346    }
347
348    /// Clear the active group cache
349    /// Useful when workflow state is modified externally or for testing
350    pub fn clear_cache(&self) {
351        *self.active_group_cache.write().unwrap() = None;
352    }
353
354    /// Clear the active group setting (remove the active-tag file)
355    pub fn clear_active_group(&self) -> Result<()> {
356        let active_tag_path = self.active_tag_file();
357        if active_tag_path.exists() {
358            fs::remove_file(&active_tag_path)
359                .with_context(|| format!("Failed to remove {}", active_tag_path.display()))?;
360        }
361        *self.active_group_cache.write().unwrap() = Some(None);
362        Ok(())
363    }
364
365    /// Load a single task group by tag
366    /// Parses the SCG file and extracts the requested group
367    pub fn load_group(&self, group_tag: &str) -> Result<Phase> {
368        let path = self.tasks_file();
369        let content = self.read_with_lock(&path)?;
370
371        let groups = self.parse_multi_phase_scg(&content)?;
372
373        groups
374            .get(group_tag)
375            .cloned()
376            .ok_or_else(|| anyhow::anyhow!("Task group '{}' not found", group_tag))
377    }
378
379    /// Load the active task group directly (optimized)
380    /// Combines get_active_group() and load_group() in one call
381    pub fn load_active_group(&self) -> Result<Phase> {
382        let active_tag = self
383            .get_active_group()?
384            .ok_or_else(|| anyhow::anyhow!("No active task group. Run: scud use-tag <tag>"))?;
385
386        self.load_group(&active_tag)
387    }
388
389    /// Update a single task group atomically
390    /// Holds exclusive lock across read-modify-write cycle to prevent races
391    pub fn update_group(&self, group_tag: &str, group: &Phase) -> Result<()> {
392        use std::io::{Read, Seek, SeekFrom, Write};
393
394        let path = self.tasks_file();
395
396        let dir = path.parent().unwrap();
397        if !dir.exists() {
398            fs::create_dir_all(dir)?;
399        }
400
401        // Open file for read+write with exclusive lock held throughout
402        // Note: truncate(false) is explicit - we read first, then truncate manually after
403        let mut file = OpenOptions::new()
404            .read(true)
405            .write(true)
406            .create(true)
407            .truncate(false)
408            .open(&path)
409            .with_context(|| format!("Failed to open file: {}", path.display()))?;
410
411        // Acquire exclusive lock with retry (held for entire operation)
412        self.acquire_lock_with_retry(&file, 10)?;
413
414        // Read current content while holding lock
415        let mut content = String::new();
416        file.read_to_string(&mut content)
417            .with_context(|| format!("Failed to read from {}", path.display()))?;
418
419        // Parse, modify, and serialize
420        let mut groups = self.parse_multi_phase_scg(&content)?;
421        groups.insert(group_tag.to_string(), group.clone());
422
423        let mut sorted_tags: Vec<_> = groups.keys().collect();
424        sorted_tags.sort();
425
426        let mut output = String::new();
427        for (i, tag) in sorted_tags.iter().enumerate() {
428            if i > 0 {
429                output.push_str("\n---\n\n");
430            }
431            let grp = groups.get(*tag).unwrap();
432            output.push_str(&serialize_scg(grp));
433        }
434
435        // Truncate and write back while still holding lock
436        file.seek(SeekFrom::Start(0))
437            .with_context(|| "Failed to seek to beginning of file")?;
438        file.set_len(0).with_context(|| "Failed to truncate file")?;
439        file.write_all(output.as_bytes())
440            .with_context(|| format!("Failed to write to {}", path.display()))?;
441        file.flush()
442            .with_context(|| format!("Failed to flush {}", path.display()))?;
443
444        // Lock released when file is dropped
445        Ok(())
446    }
447
448    pub fn read_file(&self, path: &Path) -> Result<String> {
449        fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", path.display()))
450    }
451
452    /// Create or update CLAUDE.md with SCUD agent instructions
453    fn create_agent_instructions(&self) -> Result<()> {
454        let claude_md_path = self.project_root.join("CLAUDE.md");
455
456        let scud_instructions = r#"
457## SCUD Task Management
458
459This project uses SCUD Task Manager for task management.
460
461### Session Workflow
462
4631. **Start of session**: Run `scud warmup` to orient yourself
464   - Shows current working directory and recent git history
465   - Displays active tag, task counts, and any stale locks
466   - Identifies the next available task
467
4682. **Claim a task**: Use `/scud:task-next` or `scud next --claim --name "Claude"`
469   - Always claim before starting work to prevent conflicts
470   - Task context is stored in `.scud/current-task`
471
4723. **Work on the task**: Implement the requirements
473   - Reference task details with `/scud:task-show <id>`
474   - Dependencies are automatically tracked by the DAG
475
4764. **Commit with context**: Use `scud commit -m "message"` or `scud commit -a -m "message"`
477   - Automatically prefixes commits with `[TASK-ID]`
478   - Uses task title as default commit message if none provided
479
4805. **Complete the task**: Mark done with `/scud:task-status <id> done`
481   - The stop hook will prompt for task completion
482
483### Progress Journaling
484
485Keep a brief progress log during complex tasks:
486
487```
488## Progress Log
489
490### Session: 2025-01-15
491- Investigated auth module, found issue in token refresh
492- Updated refresh logic to handle edge case
493- Tests passing, ready for review
494```
495
496This helps maintain continuity across sessions and provides context for future work.
497
498### Key Commands
499
500- `scud warmup` - Session orientation
501- `scud next` - Find next available task
502- `scud show <id>` - View task details
503- `scud set-status <id> <status>` - Update task status
504- `scud commit` - Task-aware git commit
505- `scud stats` - View completion statistics
506"#;
507
508        if claude_md_path.exists() {
509            // Append to existing CLAUDE.md if SCUD section doesn't exist
510            let content = fs::read_to_string(&claude_md_path)
511                .with_context(|| "Failed to read existing CLAUDE.md")?;
512
513            if !content.contains("## SCUD Task Management") {
514                let mut new_content = content;
515                new_content.push_str(scud_instructions);
516                fs::write(&claude_md_path, new_content)
517                    .with_context(|| "Failed to update CLAUDE.md")?;
518            }
519        } else {
520            // Create new CLAUDE.md
521            let content = format!("# Project Instructions\n{}", scud_instructions);
522            fs::write(&claude_md_path, content).with_context(|| "Failed to create CLAUDE.md")?;
523        }
524
525        Ok(())
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use std::collections::HashMap;
533    use tempfile::TempDir;
534
535    fn create_test_storage() -> (Storage, TempDir) {
536        let temp_dir = TempDir::new().unwrap();
537        let storage = Storage::new(Some(temp_dir.path().to_path_buf()));
538        storage.initialize().unwrap();
539        (storage, temp_dir)
540    }
541
542    #[test]
543    fn test_write_with_lock_creates_file() {
544        let (storage, _temp_dir) = create_test_storage();
545        let test_file = storage.scud_dir().join("test.json");
546
547        storage
548            .write_with_lock(&test_file, || Ok(r#"{"test": "data"}"#.to_string()))
549            .unwrap();
550
551        assert!(test_file.exists());
552        let content = fs::read_to_string(&test_file).unwrap();
553        assert_eq!(content, r#"{"test": "data"}"#);
554    }
555
556    #[test]
557    fn test_read_with_lock_reads_existing_file() {
558        let (storage, _temp_dir) = create_test_storage();
559        let test_file = storage.scud_dir().join("test.json");
560
561        // Create a file
562        fs::write(&test_file, r#"{"test": "data"}"#).unwrap();
563
564        // Read with lock
565        let content = storage.read_with_lock(&test_file).unwrap();
566        assert_eq!(content, r#"{"test": "data"}"#);
567    }
568
569    #[test]
570    fn test_read_with_lock_fails_on_missing_file() {
571        let (storage, _temp_dir) = create_test_storage();
572        let test_file = storage.scud_dir().join("nonexistent.json");
573
574        let result = storage.read_with_lock(&test_file);
575        assert!(result.is_err());
576        assert!(result.unwrap_err().to_string().contains("File not found"));
577    }
578
579    #[test]
580    fn test_save_and_load_tasks_with_locking() {
581        let (storage, _temp_dir) = create_test_storage();
582        let mut tasks = HashMap::new();
583
584        let epic = crate::models::Phase::new("TEST-1".to_string());
585        tasks.insert("TEST-1".to_string(), epic);
586
587        // Save tasks
588        storage.save_tasks(&tasks).unwrap();
589
590        // Load tasks
591        let loaded_tasks = storage.load_tasks().unwrap();
592
593        assert_eq!(tasks.len(), loaded_tasks.len());
594        assert!(loaded_tasks.contains_key("TEST-1"));
595        assert_eq!(loaded_tasks.get("TEST-1").unwrap().name, "TEST-1");
596    }
597
598    #[test]
599    fn test_concurrent_writes_dont_corrupt_data() {
600        use std::sync::Arc;
601        use std::thread;
602
603        let (storage, _temp_dir) = create_test_storage();
604        let storage = Arc::new(storage);
605        let mut handles = vec![];
606
607        // Spawn 10 threads that each write tasks
608        for i in 0..10 {
609            let storage_clone = Arc::clone(&storage);
610            let handle = thread::spawn(move || {
611                let mut tasks = HashMap::new();
612                let epic = crate::models::Phase::new(format!("EPIC-{}", i));
613                tasks.insert(format!("EPIC-{}", i), epic);
614
615                // Each thread writes multiple times
616                for _ in 0..5 {
617                    storage_clone.save_tasks(&tasks).unwrap();
618                    thread::sleep(Duration::from_millis(1));
619                }
620            });
621            handles.push(handle);
622        }
623
624        // Wait for all threads to complete
625        for handle in handles {
626            handle.join().unwrap();
627        }
628
629        // Verify that the file is still valid JSON
630        let tasks = storage.load_tasks().unwrap();
631        // Should have the last written data (from one of the threads)
632        assert_eq!(tasks.len(), 1);
633    }
634
635    #[test]
636    fn test_lock_retry_on_contention() {
637        use std::sync::Arc;
638
639        let (storage, _temp_dir) = create_test_storage();
640        let storage = Arc::new(storage);
641        let test_file = storage.scud_dir().join("lock-test.json");
642
643        // Create file
644        storage
645            .write_with_lock(&test_file, || Ok(r#"{"initial": "data"}"#.to_string()))
646            .unwrap();
647
648        // Open and lock the file
649        let file = OpenOptions::new().write(true).open(&test_file).unwrap();
650        file.lock_exclusive().unwrap();
651
652        // Try to acquire lock with retry in another thread
653        let storage_clone = Arc::clone(&storage);
654        let test_file_clone = test_file.clone();
655        let handle = thread::spawn(move || {
656            // This should retry and succeed after lock release
657            storage_clone.write_with_lock(&test_file_clone, || {
658                Ok(r#"{"updated": "data"}"#.to_string())
659            })
660        });
661
662        // Keep lock for a bit
663        thread::sleep(Duration::from_millis(200));
664
665        // Release lock
666        file.unlock().unwrap();
667        drop(file);
668
669        // The write should have succeeded after retrying
670        let result = handle.join().unwrap();
671        assert!(result.is_ok());
672    }
673
674    // ==================== Error Handling Tests ====================
675
676    #[test]
677    fn test_load_tasks_with_malformed_json() {
678        let (storage, _temp_dir) = create_test_storage();
679        let tasks_file = storage.tasks_file();
680
681        // Write malformed JSON
682        fs::write(&tasks_file, r#"{"invalid": json here}"#).unwrap();
683
684        // Should return error
685        let result = storage.load_tasks();
686        assert!(result.is_err());
687    }
688
689    #[test]
690    fn test_load_tasks_with_empty_file() {
691        let (storage, _temp_dir) = create_test_storage();
692        let tasks_file = storage.tasks_file();
693
694        // Write empty file
695        fs::write(&tasks_file, "").unwrap();
696
697        // Empty SCG file is valid and returns empty HashMap
698        let result = storage.load_tasks();
699        assert!(result.is_ok());
700        assert!(result.unwrap().is_empty());
701    }
702
703    #[test]
704    fn test_load_tasks_missing_file_creates_default() {
705        let (storage, _temp_dir) = create_test_storage();
706        // Don't create tasks file
707
708        // Should return empty HashMap (default)
709        let tasks = storage.load_tasks().unwrap();
710        assert_eq!(tasks.len(), 0);
711    }
712
713    #[test]
714    fn test_save_tasks_creates_directory_if_missing() {
715        let temp_dir = TempDir::new().unwrap();
716        let storage = Storage::new(Some(temp_dir.path().to_path_buf()));
717        // Don't call initialize()
718
719        let mut tasks = HashMap::new();
720        let epic = crate::models::Phase::new("TEST-1".to_string());
721        tasks.insert("TEST-1".to_string(), epic);
722
723        // Should create directory and file
724        let result = storage.save_tasks(&tasks);
725        assert!(result.is_ok());
726
727        assert!(storage.scud_dir().exists());
728        assert!(storage.tasks_file().exists());
729    }
730
731    #[test]
732    fn test_write_with_lock_handles_directory_creation() {
733        let temp_dir = TempDir::new().unwrap();
734        let storage = Storage::new(Some(temp_dir.path().to_path_buf()));
735
736        let nested_file = temp_dir
737            .path()
738            .join("deeply")
739            .join("nested")
740            .join("test.json");
741
742        // Should create all parent directories
743        let result = storage.write_with_lock(&nested_file, || Ok("{}".to_string()));
744        assert!(result.is_ok());
745        assert!(nested_file.exists());
746    }
747
748    #[test]
749    fn test_load_tasks_with_invalid_structure() {
750        let (storage, _temp_dir) = create_test_storage();
751        let tasks_file = storage.tasks_file();
752
753        // Write valid JSON but invalid structure (array instead of object)
754        fs::write(&tasks_file, r#"["not", "an", "object"]"#).unwrap();
755
756        // Should return error
757        let result = storage.load_tasks();
758        assert!(result.is_err());
759    }
760
761    #[test]
762    fn test_save_and_load_with_unicode_content() {
763        let (storage, _temp_dir) = create_test_storage();
764
765        let mut tasks = HashMap::new();
766        let mut epic = crate::models::Phase::new("TEST-UNICODE".to_string());
767
768        // Add task with unicode content
769        let task = crate::models::Task::new(
770            "task-1".to_string(),
771            "测试 Unicode 🚀".to_string(),
772            "Descripción en español 日本語".to_string(),
773        );
774        epic.add_task(task);
775
776        tasks.insert("TEST-UNICODE".to_string(), epic);
777
778        // Save and load
779        storage.save_tasks(&tasks).unwrap();
780        let loaded_tasks = storage.load_tasks().unwrap();
781
782        let loaded_epic = loaded_tasks.get("TEST-UNICODE").unwrap();
783        let loaded_task = loaded_epic.get_task("task-1").unwrap();
784        assert_eq!(loaded_task.title, "测试 Unicode 🚀");
785        assert_eq!(loaded_task.description, "Descripción en español 日本語");
786    }
787
788    #[test]
789    fn test_save_and_load_with_large_dataset() {
790        let (storage, _temp_dir) = create_test_storage();
791
792        let mut tasks = HashMap::new();
793
794        // Create 100 epics with 50 tasks each
795        for i in 0..100 {
796            let mut epic = crate::models::Phase::new(format!("EPIC-{}", i));
797
798            for j in 0..50 {
799                let task = crate::models::Task::new(
800                    format!("task-{}-{}", i, j),
801                    format!("Task {} of Epic {}", j, i),
802                    format!("Description for task {}-{}", i, j),
803                );
804                epic.add_task(task);
805            }
806
807            tasks.insert(format!("EPIC-{}", i), epic);
808        }
809
810        // Save and load
811        storage.save_tasks(&tasks).unwrap();
812        let loaded_tasks = storage.load_tasks().unwrap();
813
814        assert_eq!(loaded_tasks.len(), 100);
815        for i in 0..100 {
816            let epic = loaded_tasks.get(&format!("EPIC-{}", i)).unwrap();
817            assert_eq!(epic.tasks.len(), 50);
818        }
819    }
820
821    #[test]
822    fn test_concurrent_read_and_write() {
823        use std::sync::Arc;
824        use std::thread;
825
826        let (storage, _temp_dir) = create_test_storage();
827        let storage = Arc::new(storage);
828
829        // Initialize with some data
830        let mut tasks = HashMap::new();
831        let epic = crate::models::Phase::new("INITIAL".to_string());
832        tasks.insert("INITIAL".to_string(), epic);
833        storage.save_tasks(&tasks).unwrap();
834
835        let mut handles = vec![];
836
837        // Spawn 5 readers
838        for _ in 0..5 {
839            let storage_clone = Arc::clone(&storage);
840            let handle = thread::spawn(move || {
841                for _ in 0..10 {
842                    let _ = storage_clone.load_tasks();
843                    thread::sleep(Duration::from_millis(1));
844                }
845            });
846            handles.push(handle);
847        }
848
849        // Spawn 2 writers
850        for i in 0..2 {
851            let storage_clone = Arc::clone(&storage);
852            let handle = thread::spawn(move || {
853                for j in 0..5 {
854                    let mut tasks = HashMap::new();
855                    let epic = crate::models::Phase::new(format!("WRITER-{}-{}", i, j));
856                    tasks.insert(format!("WRITER-{}-{}", i, j), epic);
857                    storage_clone.save_tasks(&tasks).unwrap();
858                    thread::sleep(Duration::from_millis(2));
859                }
860            });
861            handles.push(handle);
862        }
863
864        // Wait for all threads
865        for handle in handles {
866            handle.join().unwrap();
867        }
868
869        // File should still be valid
870        let tasks = storage.load_tasks().unwrap();
871        assert_eq!(tasks.len(), 1); // Last write wins
872    }
873
874    // ==================== Active Epic Cache Tests ====================
875
876    #[test]
877    fn test_active_epic_cached_on_second_call() {
878        let (storage, _temp_dir) = create_test_storage();
879
880        // Set active epic
881        let mut tasks = HashMap::new();
882        tasks.insert("TEST-1".to_string(), Phase::new("TEST-1".to_string()));
883        storage.save_tasks(&tasks).unwrap();
884        storage.set_active_group("TEST-1").unwrap();
885
886        // First call - loads from file
887        let active1 = storage.get_active_group().unwrap();
888        assert_eq!(active1, Some("TEST-1".to_string()));
889
890        // Modify file directly (bypass storage methods)
891        let active_tag_file = storage.active_tag_file();
892        fs::write(&active_tag_file, "DIFFERENT").unwrap();
893
894        // Second call - should return cached value (not file value)
895        let active2 = storage.get_active_group().unwrap();
896        assert_eq!(active2, Some("TEST-1".to_string())); // Still cached
897
898        // After cache clear - should reload from file
899        storage.clear_cache();
900        let active3 = storage.get_active_group().unwrap();
901        assert_eq!(active3, Some("DIFFERENT".to_string())); // From file
902    }
903
904    #[test]
905    fn test_cache_invalidated_on_set_active_epic() {
906        let (storage, _temp_dir) = create_test_storage();
907
908        let mut tasks = HashMap::new();
909        tasks.insert("EPIC-1".to_string(), Phase::new("EPIC-1".to_string()));
910        tasks.insert("EPIC-2".to_string(), Phase::new("EPIC-2".to_string()));
911        storage.save_tasks(&tasks).unwrap();
912
913        storage.set_active_group("EPIC-1").unwrap();
914        assert_eq!(
915            storage.get_active_group().unwrap(),
916            Some("EPIC-1".to_string())
917        );
918
919        // Change active epic - should update cache
920        storage.set_active_group("EPIC-2").unwrap();
921        assert_eq!(
922            storage.get_active_group().unwrap(),
923            Some("EPIC-2".to_string())
924        );
925    }
926
927    #[test]
928    fn test_cache_with_no_active_epic() {
929        let (storage, _temp_dir) = create_test_storage();
930
931        // Load when no active epic is set
932        let active = storage.get_active_group().unwrap();
933        assert_eq!(active, None);
934
935        // Should cache the None value
936        let active2 = storage.get_active_group().unwrap();
937        assert_eq!(active2, None);
938    }
939
940    // ==================== Lazy Epic Loading Tests ====================
941
942    #[test]
943    fn test_load_single_epic_from_many() {
944        let (storage, _temp_dir) = create_test_storage();
945
946        // Create 50 epics
947        let mut tasks = HashMap::new();
948        for i in 0..50 {
949            tasks.insert(format!("EPIC-{}", i), Phase::new(format!("EPIC-{}", i)));
950        }
951        storage.save_tasks(&tasks).unwrap();
952
953        // Load single epic - should only deserialize that one
954        let epic = storage.load_group("EPIC-25").unwrap();
955        assert_eq!(epic.name, "EPIC-25");
956    }
957
958    #[test]
959    fn test_load_epic_not_found() {
960        let (storage, _temp_dir) = create_test_storage();
961
962        let tasks = HashMap::new();
963        storage.save_tasks(&tasks).unwrap();
964
965        let result = storage.load_group("NONEXISTENT");
966        assert!(result.is_err());
967        assert!(result.unwrap_err().to_string().contains("not found"));
968    }
969
970    #[test]
971    fn test_load_epic_matches_full_load() {
972        let (storage, _temp_dir) = create_test_storage();
973
974        let mut tasks = HashMap::new();
975        let mut epic = Phase::new("TEST-1".to_string());
976        epic.add_task(crate::models::Task::new(
977            "task-1".to_string(),
978            "Test".to_string(),
979            "Desc".to_string(),
980        ));
981        tasks.insert("TEST-1".to_string(), epic.clone());
982        storage.save_tasks(&tasks).unwrap();
983
984        // Load via both methods
985        let epic_lazy = storage.load_group("TEST-1").unwrap();
986        let tasks_full = storage.load_tasks().unwrap();
987        let epic_full = tasks_full.get("TEST-1").unwrap();
988
989        // Should be identical
990        assert_eq!(epic_lazy.name, epic_full.name);
991        assert_eq!(epic_lazy.tasks.len(), epic_full.tasks.len());
992    }
993
994    #[test]
995    fn test_load_active_epic() {
996        let (storage, _temp_dir) = create_test_storage();
997
998        let mut tasks = HashMap::new();
999        let mut epic = Phase::new("ACTIVE-1".to_string());
1000        epic.add_task(crate::models::Task::new(
1001            "task-1".to_string(),
1002            "Test".to_string(),
1003            "Desc".to_string(),
1004        ));
1005        tasks.insert("ACTIVE-1".to_string(), epic);
1006        storage.save_tasks(&tasks).unwrap();
1007        storage.set_active_group("ACTIVE-1").unwrap();
1008
1009        // Load active epic directly
1010        let epic = storage.load_active_group().unwrap();
1011        assert_eq!(epic.name, "ACTIVE-1");
1012        assert_eq!(epic.tasks.len(), 1);
1013    }
1014
1015    #[test]
1016    fn test_load_active_epic_when_none_set() {
1017        let (storage, _temp_dir) = create_test_storage();
1018
1019        // Should error when no active epic
1020        let result = storage.load_active_group();
1021        assert!(result.is_err());
1022        assert!(result
1023            .unwrap_err()
1024            .to_string()
1025            .contains("No active task group"));
1026    }
1027
1028    #[test]
1029    fn test_update_epic_without_loading_all() {
1030        let (storage, _temp_dir) = create_test_storage();
1031
1032        let mut tasks = HashMap::new();
1033        tasks.insert("EPIC-1".to_string(), Phase::new("EPIC-1".to_string()));
1034        tasks.insert("EPIC-2".to_string(), Phase::new("EPIC-2".to_string()));
1035        storage.save_tasks(&tasks).unwrap();
1036
1037        // Update only EPIC-1
1038        let mut epic1 = storage.load_group("EPIC-1").unwrap();
1039        epic1.add_task(crate::models::Task::new(
1040            "new-task".to_string(),
1041            "New".to_string(),
1042            "Desc".to_string(),
1043        ));
1044        storage.update_group("EPIC-1", &epic1).unwrap();
1045
1046        // Verify update
1047        let loaded = storage.load_group("EPIC-1").unwrap();
1048        assert_eq!(loaded.tasks.len(), 1);
1049
1050        // Verify EPIC-2 unchanged
1051        let epic2 = storage.load_group("EPIC-2").unwrap();
1052        assert_eq!(epic2.tasks.len(), 0);
1053    }
1054}