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