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| {
158 e.path()
159 .extension()
160 .map(|ext| ext == "md")
161 .unwrap_or(false)
162 })
163 .collect();
164
165 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 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 let config_file = self.config_file();
209 if !config_file.exists() {
210 config.save(&config_file)?;
211 }
212
213 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 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 fs::create_dir_all(self.guidance_dir())?;
229
230 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 fn parse_multi_phase_scg(&self, content: &str) -> Result<HashMap<String, Phase>> {
256 let mut phases = HashMap::new();
257
258 if content.trim().is_empty() {
260 return Ok(phases);
261 }
262
263 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 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 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 {
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 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 *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 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 *self.active_group_cache.write().unwrap() = Some(Some(group_tag.to_string()));
344
345 Ok(())
346 }
347
348 pub fn clear_cache(&self) {
351 *self.active_group_cache.write().unwrap() = None;
352 }
353
354 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 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 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 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 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 self.acquire_lock_with_retry(&file, 10)?;
413
414 let mut content = String::new();
416 file.read_to_string(&mut content)
417 .with_context(|| format!("Failed to read from {}", path.display()))?;
418
419 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 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 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 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 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 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 fs::write(&test_file, r#"{"test": "data"}"#).unwrap();
563
564 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 storage.save_tasks(&tasks).unwrap();
589
590 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 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 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 for handle in handles {
626 handle.join().unwrap();
627 }
628
629 let tasks = storage.load_tasks().unwrap();
631 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 storage
645 .write_with_lock(&test_file, || Ok(r#"{"initial": "data"}"#.to_string()))
646 .unwrap();
647
648 let file = OpenOptions::new().write(true).open(&test_file).unwrap();
650 file.lock_exclusive().unwrap();
651
652 let storage_clone = Arc::clone(&storage);
654 let test_file_clone = test_file.clone();
655 let handle = thread::spawn(move || {
656 storage_clone.write_with_lock(&test_file_clone, || {
658 Ok(r#"{"updated": "data"}"#.to_string())
659 })
660 });
661
662 thread::sleep(Duration::from_millis(200));
664
665 file.unlock().unwrap();
667 drop(file);
668
669 let result = handle.join().unwrap();
671 assert!(result.is_ok());
672 }
673
674 #[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 fs::write(&tasks_file, r#"{"invalid": json here}"#).unwrap();
683
684 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 fs::write(&tasks_file, "").unwrap();
696
697 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 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 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 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 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 fs::write(&tasks_file, r#"["not", "an", "object"]"#).unwrap();
755
756 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 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 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 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 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 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 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 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 for handle in handles {
866 handle.join().unwrap();
867 }
868
869 let tasks = storage.load_tasks().unwrap();
871 assert_eq!(tasks.len(), 1); }
873
874 #[test]
877 fn test_active_epic_cached_on_second_call() {
878 let (storage, _temp_dir) = create_test_storage();
879
880 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 let active1 = storage.get_active_group().unwrap();
888 assert_eq!(active1, Some("TEST-1".to_string()));
889
890 let active_tag_file = storage.active_tag_file();
892 fs::write(&active_tag_file, "DIFFERENT").unwrap();
893
894 let active2 = storage.get_active_group().unwrap();
896 assert_eq!(active2, Some("TEST-1".to_string())); storage.clear_cache();
900 let active3 = storage.get_active_group().unwrap();
901 assert_eq!(active3, Some("DIFFERENT".to_string())); }
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 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 let active = storage.get_active_group().unwrap();
933 assert_eq!(active, None);
934
935 let active2 = storage.get_active_group().unwrap();
937 assert_eq!(active2, None);
938 }
939
940 #[test]
943 fn test_load_single_epic_from_many() {
944 let (storage, _temp_dir) = create_test_storage();
945
946 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 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 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 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 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 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 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 let loaded = storage.load_group("EPIC-1").unwrap();
1048 assert_eq!(loaded.tasks.len(), 1);
1049
1050 let epic2 = storage.load_group("EPIC-2").unwrap();
1052 assert_eq!(epic2.tasks.len(), 0);
1053 }
1054}