1use 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
20pub struct Storage {
22 project_root: PathBuf,
23 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 pub fn project_root(&self) -> &Path {
40 &self.project_root
41 }
42
43 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); }
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 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 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 self.acquire_lock_with_retry(&file, 10)?;
89
90 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 Ok(())
99 }
100
101 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 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 file.lock_shared()
117 .with_context(|| format!("Failed to acquire read lock on {}", path.display()))?;
118
119 let mut content = String::new();
121 file.read_to_string(&mut content)
122 .with_context(|| format!("Failed to read from {}", path.display()))?;
123
124 Ok(content)
126 }
127
128 pub fn scud_dir(&self) -> PathBuf {
129 self.project_root.join(".scud")
130 }
131
132 pub fn tasks_file(&self) -> PathBuf {
134 self.scud_dir().join("tasks").join("tasks.scg")
135 }
136
137 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 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 fn parse_multi_phase_scg(&self, content: &str) -> Result<HashMap<String, Phase>> {
166 let mut phases = HashMap::new();
167
168 if content.trim().is_empty() {
170 return Ok(phases);
171 }
172
173 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 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 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 {
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 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 *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 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 *self.active_group_cache.write().unwrap() = Some(Some(group_tag.to_string()));
254
255 Ok(())
256 }
257
258 pub fn clear_cache(&self) {
261 *self.active_group_cache.write().unwrap() = None;
262 }
263
264 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 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 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 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 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 self.acquire_lock_with_retry(&file, 10)?;
322
323 let mut content = String::new();
325 file.read_to_string(&mut content)
326 .with_context(|| format!("Failed to read from {}", path.display()))?;
327
328 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 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 Ok(())
355 }
356
357 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 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 let active1 = storage.get_active_group().unwrap();
453 assert_eq!(active1, Some("TEST-1".to_string()));
454
455 let active_tag_path = storage.active_tag_file();
457 fs::write(&active_tag_path, "DIFFERENT").unwrap();
458
459 let active2 = storage.get_active_group().unwrap();
461 assert_eq!(active2, Some("TEST-1".to_string()));
462
463 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 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 let loaded = storage.load_group("PHASE-1").unwrap();
488 assert_eq!(loaded.tasks.len(), 1);
489 }
490}