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 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 pub fn project_root(&self) -> &Path {
33 &self.project_root
34 }
35
36 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); }
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 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 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 self.acquire_lock_with_retry(&file, 10)?;
82
83 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 Ok(())
92 }
93
94 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 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 file.lock_shared()
110 .with_context(|| format!("Failed to acquire read lock on {}", path.display()))?;
111
112 let mut content = String::new();
114 file.read_to_string(&mut content)
115 .with_context(|| format!("Failed to read from {}", path.display()))?;
116
117 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 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 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 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 let config_file = self.config_file();
207 if !config_file.exists() {
208 config.save(&config_file)?;
209 }
210
211 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 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 fs::create_dir_all(self.guidance_dir())?;
227
228 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 fn parse_multi_phase_scg(&self, content: &str) -> Result<HashMap<String, Phase>> {
254 let mut phases = HashMap::new();
255
256 if content.trim().is_empty() {
258 return Ok(phases);
259 }
260
261 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 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 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 {
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 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 *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 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 *self.active_group_cache.write().unwrap() = Some(Some(group_tag.to_string()));
342
343 Ok(())
344 }
345
346 pub fn clear_cache(&self) {
349 *self.active_group_cache.write().unwrap() = None;
350 }
351
352 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 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 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 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 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 self.acquire_lock_with_retry(&file, 10)?;
411
412 let mut content = String::new();
414 file.read_to_string(&mut content)
415 .with_context(|| format!("Failed to read from {}", path.display()))?;
416
417 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 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 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 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 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 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 fs::write(&test_file, r#"{"test": "data"}"#).unwrap();
561
562 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 storage.save_tasks(&tasks).unwrap();
587
588 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 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 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 for handle in handles {
624 handle.join().unwrap();
625 }
626
627 let tasks = storage.load_tasks().unwrap();
629 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 storage
643 .write_with_lock(&test_file, || Ok(r#"{"initial": "data"}"#.to_string()))
644 .unwrap();
645
646 let file = OpenOptions::new().write(true).open(&test_file).unwrap();
648 file.lock_exclusive().unwrap();
649
650 let storage_clone = Arc::clone(&storage);
652 let test_file_clone = test_file.clone();
653 let handle = thread::spawn(move || {
654 storage_clone.write_with_lock(&test_file_clone, || {
656 Ok(r#"{"updated": "data"}"#.to_string())
657 })
658 });
659
660 thread::sleep(Duration::from_millis(200));
662
663 file.unlock().unwrap();
665 drop(file);
666
667 let result = handle.join().unwrap();
669 assert!(result.is_ok());
670 }
671
672 #[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 fs::write(&tasks_file, r#"{"invalid": json here}"#).unwrap();
681
682 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 fs::write(&tasks_file, "").unwrap();
694
695 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 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 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 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 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 fs::write(&tasks_file, r#"["not", "an", "object"]"#).unwrap();
753
754 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 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 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 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 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 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 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 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 for handle in handles {
864 handle.join().unwrap();
865 }
866
867 let tasks = storage.load_tasks().unwrap();
869 assert_eq!(tasks.len(), 1); }
871
872 #[test]
875 fn test_active_epic_cached_on_second_call() {
876 let (storage, _temp_dir) = create_test_storage();
877
878 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 let active1 = storage.get_active_group().unwrap();
886 assert_eq!(active1, Some("TEST-1".to_string()));
887
888 let active_tag_file = storage.active_tag_file();
890 fs::write(&active_tag_file, "DIFFERENT").unwrap();
891
892 let active2 = storage.get_active_group().unwrap();
894 assert_eq!(active2, Some("TEST-1".to_string())); storage.clear_cache();
898 let active3 = storage.get_active_group().unwrap();
899 assert_eq!(active3, Some("DIFFERENT".to_string())); }
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 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 let active = storage.get_active_group().unwrap();
931 assert_eq!(active, None);
932
933 let active2 = storage.get_active_group().unwrap();
935 assert_eq!(active2, None);
936 }
937
938 #[test]
941 fn test_load_single_epic_from_many() {
942 let (storage, _temp_dir) = create_test_storage();
943
944 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 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 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 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 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 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 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 let loaded = storage.load_group("EPIC-1").unwrap();
1046 assert_eq!(loaded.tasks.len(), 1);
1047
1048 let epic2 = storage.load_group("EPIC-2").unwrap();
1050 assert_eq!(epic2.tasks.len(), 0);
1051 }
1052}