Skip to main content

scud_task_core/
storage.rs

1//! File-based task storage with locking.
2//!
3//! Manages reading and writing of task data to the filesystem with:
4//! - File locking for safe concurrent access
5//! - Caching of active phase selection
6//! - Atomic read-modify-write operations
7
8use anyhow::{Context, Result};
9use fs2::FileExt;
10use std::collections::HashMap;
11use std::fs::{self, File, OpenOptions};
12use std::path::{Path, PathBuf};
13use std::sync::RwLock;
14use std::thread;
15use std::time::Duration;
16
17use crate::formats::{parse_scg, serialize_scg};
18use crate::models::{Phase, TaskStatus};
19
20/// File-based storage for SCUD tasks
21pub struct Storage {
22    project_root: PathBuf,
23    /// Cache for active group to avoid repeated file reads
24    /// Option<Option<String>> represents: None = not cached, Some(None) = no active group, Some(Some(tag)) = cached tag
25    /// Uses RwLock for thread safety
26    active_group_cache: RwLock<Option<Option<String>>>,
27}
28
29impl Storage {
30    pub fn new(project_root: Option<PathBuf>) -> Self {
31        let root = project_root.unwrap_or_else(|| std::env::current_dir().unwrap());
32        Storage {
33            project_root: root,
34            active_group_cache: RwLock::new(None),
35        }
36    }
37
38    /// Get the project root directory
39    pub fn project_root(&self) -> &Path {
40        &self.project_root
41    }
42
43    /// Acquire an exclusive file lock with retry logic
44    fn acquire_lock_with_retry(&self, file: &File, max_retries: u32) -> Result<()> {
45        let mut retries = 0;
46        let mut delay_ms = 10;
47
48        loop {
49            match file.try_lock_exclusive() {
50                Ok(_) => return Ok(()),
51                Err(_) if retries < max_retries => {
52                    retries += 1;
53                    thread::sleep(Duration::from_millis(delay_ms));
54                    delay_ms = (delay_ms * 2).min(1000); // Exponential backoff, max 1s
55                }
56                Err(e) => {
57                    anyhow::bail!(
58                        "Failed to acquire file lock after {} retries: {}",
59                        max_retries,
60                        e
61                    )
62                }
63            }
64        }
65    }
66
67    /// Perform a locked write operation on a file
68    fn write_with_lock<F>(&self, path: &Path, writer: F) -> Result<()>
69    where
70        F: FnOnce() -> Result<String>,
71    {
72        use std::io::Write;
73
74        let dir = path.parent().unwrap();
75        if !dir.exists() {
76            fs::create_dir_all(dir)?;
77        }
78
79        // Open file for writing
80        let mut file = OpenOptions::new()
81            .write(true)
82            .create(true)
83            .truncate(true)
84            .open(path)
85            .with_context(|| format!("Failed to open file for writing: {}", path.display()))?;
86
87        // Acquire lock with retry
88        self.acquire_lock_with_retry(&file, 10)?;
89
90        // Generate content and write through the locked handle
91        let content = writer()?;
92        file.write_all(content.as_bytes())
93            .with_context(|| format!("Failed to write to {}", path.display()))?;
94        file.flush()
95            .with_context(|| format!("Failed to flush {}", path.display()))?;
96
97        // Lock is automatically released when file is dropped
98        Ok(())
99    }
100
101    /// Perform a locked read operation on a file
102    fn read_with_lock(&self, path: &Path) -> Result<String> {
103        use std::io::Read;
104
105        if !path.exists() {
106            anyhow::bail!("File not found: {}", path.display());
107        }
108
109        // Open file for reading
110        let mut file = OpenOptions::new()
111            .read(true)
112            .open(path)
113            .with_context(|| format!("Failed to open file for reading: {}", path.display()))?;
114
115        // Acquire shared lock (allows multiple readers)
116        file.lock_shared()
117            .with_context(|| format!("Failed to acquire read lock on {}", path.display()))?;
118
119        // Read content through the locked handle
120        let mut content = String::new();
121        file.read_to_string(&mut content)
122            .with_context(|| format!("Failed to read from {}", path.display()))?;
123
124        // Lock is automatically released when file is dropped
125        Ok(content)
126    }
127
128    pub fn scud_dir(&self) -> PathBuf {
129        self.project_root.join(".scud")
130    }
131
132    /// Get the path to the tasks file
133    pub fn tasks_file(&self) -> PathBuf {
134        self.scud_dir().join("tasks").join("tasks.scg")
135    }
136
137    /// Get the path to the active tag file
138    fn active_tag_file(&self) -> PathBuf {
139        self.scud_dir().join("active-tag")
140    }
141
142    pub fn is_initialized(&self) -> bool {
143        self.scud_dir().exists() && self.tasks_file().exists()
144    }
145
146    /// Initialize storage directories (minimal version without config)
147    pub fn initialize_dirs(&self) -> Result<()> {
148        let scud_dir = self.scud_dir();
149        fs::create_dir_all(scud_dir.join("tasks"))
150            .context("Failed to create .scud/tasks directory")?;
151        Ok(())
152    }
153
154    pub fn load_tasks(&self) -> Result<HashMap<String, Phase>> {
155        let path = self.tasks_file();
156        if !path.exists() {
157            anyhow::bail!("Tasks file not found: {}\nRun: scud init", path.display());
158        }
159
160        let content = self.read_with_lock(&path)?;
161        self.parse_multi_phase_scg(&content)
162    }
163
164    /// Parse multi-phase SCG format (multiple phases separated by ---)
165    fn parse_multi_phase_scg(&self, content: &str) -> Result<HashMap<String, Phase>> {
166        let mut phases = HashMap::new();
167
168        // Empty file returns empty map
169        if content.trim().is_empty() {
170            return Ok(phases);
171        }
172
173        // Split by phase separator (---)
174        let sections: Vec<&str> = content.split("\n---\n").collect();
175
176        for section in sections {
177            let section = section.trim();
178            if section.is_empty() {
179                continue;
180            }
181
182            // Parse the phase section
183            let phase = parse_scg(section).with_context(|| "Failed to parse SCG section")?;
184
185            phases.insert(phase.name.clone(), phase);
186        }
187
188        Ok(phases)
189    }
190
191    pub fn save_tasks(&self, tasks: &HashMap<String, Phase>) -> Result<()> {
192        let path = self.tasks_file();
193        self.write_with_lock(&path, || {
194            // Sort phases by tag for consistent output
195            let mut sorted_tags: Vec<_> = tasks.keys().collect();
196            sorted_tags.sort();
197
198            let mut output = String::new();
199            for (i, tag) in sorted_tags.iter().enumerate() {
200                if i > 0 {
201                    output.push_str("\n---\n\n");
202                }
203                let phase = tasks.get(*tag).unwrap();
204                output.push_str(&serialize_scg(phase));
205            }
206
207            Ok(output)
208        })
209    }
210
211    pub fn get_active_group(&self) -> Result<Option<String>> {
212        // Check cache first (read lock)
213        {
214            let cache = self.active_group_cache.read().unwrap();
215            if let Some(cached) = cache.as_ref() {
216                return Ok(cached.clone());
217            }
218        }
219
220        // Load from active-tag file
221        let active_tag_path = self.active_tag_file();
222        let active = if active_tag_path.exists() {
223            let content = fs::read_to_string(&active_tag_path)
224                .with_context(|| format!("Failed to read {}", active_tag_path.display()))?;
225            let tag = content.trim();
226            if tag.is_empty() {
227                None
228            } else {
229                Some(tag.to_string())
230            }
231        } else {
232            None
233        };
234
235        // Store in cache
236        *self.active_group_cache.write().unwrap() = Some(active.clone());
237
238        Ok(active)
239    }
240
241    pub fn set_active_group(&self, group_tag: &str) -> Result<()> {
242        let tasks = self.load_tasks()?;
243        if !tasks.contains_key(group_tag) {
244            anyhow::bail!("Task group '{}' not found", group_tag);
245        }
246
247        // Write to active-tag file
248        let active_tag_path = self.active_tag_file();
249        fs::write(&active_tag_path, group_tag)
250            .with_context(|| format!("Failed to write {}", active_tag_path.display()))?;
251
252        // Update cache
253        *self.active_group_cache.write().unwrap() = Some(Some(group_tag.to_string()));
254
255        Ok(())
256    }
257
258    /// Clear the active group cache
259    /// Useful when active-tag file is modified externally or for testing
260    pub fn clear_cache(&self) {
261        *self.active_group_cache.write().unwrap() = None;
262    }
263
264    /// Clear the active group setting (remove the active-tag file)
265    pub fn clear_active_group(&self) -> Result<()> {
266        let active_tag_path = self.active_tag_file();
267        if active_tag_path.exists() {
268            fs::remove_file(&active_tag_path)
269                .with_context(|| format!("Failed to remove {}", active_tag_path.display()))?;
270        }
271        *self.active_group_cache.write().unwrap() = Some(None);
272        Ok(())
273    }
274
275    /// Load a single task group by tag
276    /// Parses the SCG file and extracts the requested group
277    pub fn load_group(&self, group_tag: &str) -> Result<Phase> {
278        let path = self.tasks_file();
279        let content = self.read_with_lock(&path)?;
280
281        let groups = self.parse_multi_phase_scg(&content)?;
282
283        groups
284            .get(group_tag)
285            .cloned()
286            .ok_or_else(|| anyhow::anyhow!("Task group '{}' not found", group_tag))
287    }
288
289    /// Load the active task group directly (optimized)
290    /// Combines get_active_group() and load_group() in one call
291    pub fn load_active_group(&self) -> Result<Phase> {
292        let active_tag = self
293            .get_active_group()?
294            .ok_or_else(|| anyhow::anyhow!("No active task group. Run: scud use-tag <tag>"))?;
295
296        self.load_group(&active_tag)
297    }
298
299    /// Update a single task group atomically
300    /// Holds exclusive lock across read-modify-write cycle to prevent races
301    pub fn update_group(&self, group_tag: &str, group: &Phase) -> Result<()> {
302        use std::io::{Read, Seek, SeekFrom, Write};
303
304        let path = self.tasks_file();
305
306        let dir = path.parent().unwrap();
307        if !dir.exists() {
308            fs::create_dir_all(dir)?;
309        }
310
311        // Open file for read+write with exclusive lock held throughout
312        let mut file = OpenOptions::new()
313            .read(true)
314            .write(true)
315            .create(true)
316            .truncate(false)
317            .open(&path)
318            .with_context(|| format!("Failed to open file: {}", path.display()))?;
319
320        // Acquire exclusive lock with retry (held for entire operation)
321        self.acquire_lock_with_retry(&file, 10)?;
322
323        // Read current content while holding lock
324        let mut content = String::new();
325        file.read_to_string(&mut content)
326            .with_context(|| format!("Failed to read from {}", path.display()))?;
327
328        // Parse, modify, and serialize
329        let mut groups = self.parse_multi_phase_scg(&content)?;
330        groups.insert(group_tag.to_string(), group.clone());
331
332        let mut sorted_tags: Vec<_> = groups.keys().collect();
333        sorted_tags.sort();
334
335        let mut output = String::new();
336        for (i, tag) in sorted_tags.iter().enumerate() {
337            if i > 0 {
338                output.push_str("\n---\n\n");
339            }
340            let grp = groups.get(*tag).unwrap();
341            output.push_str(&serialize_scg(grp));
342        }
343
344        // Truncate and write back while still holding lock
345        file.seek(SeekFrom::Start(0))
346            .with_context(|| "Failed to seek to beginning of file")?;
347        file.set_len(0).with_context(|| "Failed to truncate file")?;
348        file.write_all(output.as_bytes())
349            .with_context(|| format!("Failed to write to {}", path.display()))?;
350        file.flush()
351            .with_context(|| format!("Failed to flush {}", path.display()))?;
352
353        // Lock released when file is dropped
354        Ok(())
355    }
356
357    /// Update a single task's status within a group
358    /// Convenience method that loads, modifies, and saves the group atomically
359    pub fn update_task_status(
360        &self,
361        group_tag: &str,
362        task_id: &str,
363        status: TaskStatus,
364    ) -> Result<()> {
365        let mut group = self.load_group(group_tag)?;
366
367        let task = group
368            .tasks
369            .iter_mut()
370            .find(|t| t.id == task_id)
371            .ok_or_else(|| {
372                anyhow::anyhow!("Task '{}' not found in group '{}'", task_id, group_tag)
373            })?;
374
375        task.status = status;
376        self.update_group(group_tag, &group)
377    }
378
379    pub fn read_file(&self, path: &Path) -> Result<String> {
380        fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", path.display()))
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use tempfile::TempDir;
388
389    fn create_test_storage() -> (Storage, TempDir) {
390        let temp_dir = TempDir::new().unwrap();
391        let storage = Storage::new(Some(temp_dir.path().to_path_buf()));
392        storage.initialize_dirs().unwrap();
393
394        // Create empty tasks file
395        let tasks_file = storage.tasks_file();
396        fs::write(&tasks_file, "").unwrap();
397
398        (storage, temp_dir)
399    }
400
401    #[test]
402    fn test_save_and_load_tasks() {
403        let (storage, _temp_dir) = create_test_storage();
404        let mut tasks = HashMap::new();
405
406        let phase = Phase::new("TEST-1".to_string());
407        tasks.insert("TEST-1".to_string(), phase);
408
409        storage.save_tasks(&tasks).unwrap();
410        let loaded_tasks = storage.load_tasks().unwrap();
411
412        assert_eq!(tasks.len(), loaded_tasks.len());
413        assert!(loaded_tasks.contains_key("TEST-1"));
414        assert_eq!(loaded_tasks.get("TEST-1").unwrap().name, "TEST-1");
415    }
416
417    #[test]
418    fn test_load_single_group() {
419        let (storage, _temp_dir) = create_test_storage();
420
421        let mut tasks = HashMap::new();
422        tasks.insert("PHASE-A".to_string(), Phase::new("PHASE-A".to_string()));
423        tasks.insert("PHASE-B".to_string(), Phase::new("PHASE-B".to_string()));
424        storage.save_tasks(&tasks).unwrap();
425
426        let phase = storage.load_group("PHASE-A").unwrap();
427        assert_eq!(phase.name, "PHASE-A");
428    }
429
430    #[test]
431    fn test_load_group_not_found() {
432        let (storage, _temp_dir) = create_test_storage();
433
434        let tasks = HashMap::new();
435        storage.save_tasks(&tasks).unwrap();
436
437        let result = storage.load_group("NONEXISTENT");
438        assert!(result.is_err());
439        assert!(result.unwrap_err().to_string().contains("not found"));
440    }
441
442    #[test]
443    fn test_active_group_caching() {
444        let (storage, _temp_dir) = create_test_storage();
445
446        let mut tasks = HashMap::new();
447        tasks.insert("TEST-1".to_string(), Phase::new("TEST-1".to_string()));
448        storage.save_tasks(&tasks).unwrap();
449        storage.set_active_group("TEST-1").unwrap();
450
451        // First call
452        let active1 = storage.get_active_group().unwrap();
453        assert_eq!(active1, Some("TEST-1".to_string()));
454
455        // Modify file directly
456        let active_tag_path = storage.active_tag_file();
457        fs::write(&active_tag_path, "DIFFERENT").unwrap();
458
459        // Second call should return cached value
460        let active2 = storage.get_active_group().unwrap();
461        assert_eq!(active2, Some("TEST-1".to_string()));
462
463        // After cache clear should reload
464        storage.clear_cache();
465        let active3 = storage.get_active_group().unwrap();
466        assert_eq!(active3, Some("DIFFERENT".to_string()));
467    }
468
469    #[test]
470    fn test_update_group() {
471        let (storage, _temp_dir) = create_test_storage();
472
473        let mut tasks = HashMap::new();
474        tasks.insert("PHASE-1".to_string(), Phase::new("PHASE-1".to_string()));
475        storage.save_tasks(&tasks).unwrap();
476
477        // Update
478        let mut phase = storage.load_group("PHASE-1").unwrap();
479        phase.add_task(crate::models::Task::new(
480            "task-1".to_string(),
481            "Test".to_string(),
482            "Desc".to_string(),
483        ));
484        storage.update_group("PHASE-1", &phase).unwrap();
485
486        // Verify
487        let loaded = storage.load_group("PHASE-1").unwrap();
488        assert_eq!(loaded.tasks.len(), 1);
489    }
490}