An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.

feat: add spec data structure with JSON persistence

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

+117
+1
src/lib.rs
··· 1 1 pub mod config; 2 2 pub mod llm; 3 3 pub mod tools; 4 + pub mod spec;
+68
src/spec.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + use std::fs; 3 + use std::path::Path; 4 + 5 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 6 + #[serde(rename_all = "snake_case")] 7 + pub enum TaskStatus { 8 + Pending, 9 + InProgress, 10 + Complete, 11 + Blocked, 12 + } 13 + 14 + #[derive(Debug, Clone, Serialize, Deserialize)] 15 + pub struct Task { 16 + pub id: String, 17 + pub title: String, 18 + pub description: String, 19 + pub acceptance_criteria: Vec<String>, 20 + pub status: TaskStatus, 21 + #[serde(skip_serializing_if = "Option::is_none")] 22 + pub blocked_reason: Option<String>, 23 + #[serde(skip_serializing_if = "Option::is_none")] 24 + pub completed_at: Option<String>, 25 + } 26 + 27 + #[derive(Debug, Clone, Serialize, Deserialize)] 28 + pub struct Spec { 29 + pub name: String, 30 + pub description: String, 31 + pub branch_name: String, 32 + pub created_at: String, 33 + pub tasks: Vec<Task>, 34 + pub learnings: Vec<String>, 35 + } 36 + 37 + impl Spec { 38 + /// Load a spec from a JSON file 39 + pub fn load(path: impl AsRef<Path>) -> anyhow::Result<Self> { 40 + let content = fs::read_to_string(path)?; 41 + let spec = serde_json::from_str(&content)?; 42 + Ok(spec) 43 + } 44 + 45 + /// Save the spec to a JSON file 46 + pub fn save(&self, path: impl AsRef<Path>) -> anyhow::Result<()> { 47 + let json = serde_json::to_string_pretty(self)?; 48 + fs::write(path, json)?; 49 + Ok(()) 50 + } 51 + 52 + /// Find the next task that is pending (not in progress, complete, or blocked) 53 + pub fn find_next_task(&self) -> Option<&Task> { 54 + self.tasks 55 + .iter() 56 + .find(|task| task.status == TaskStatus::Pending) 57 + } 58 + 59 + /// Find a task by ID and return a mutable reference 60 + pub fn find_task_mut(&mut self, task_id: &str) -> Option<&mut Task> { 61 + self.tasks.iter_mut().find(|task| task.id == task_id) 62 + } 63 + 64 + /// Add a learning to the spec 65 + pub fn add_learning(&mut self, learning: String) { 66 + self.learnings.push(learning); 67 + } 68 + }
+48
tests/spec_test.rs
··· 1 + use rustagent::spec::{Spec, Task, TaskStatus}; 2 + use tempfile::TempDir; 3 + 4 + #[test] 5 + fn test_spec_serialization() { 6 + let spec = Spec { 7 + name: "test-feature".to_string(), 8 + description: "A test feature".to_string(), 9 + branch_name: "feature/test".to_string(), 10 + created_at: "2026-01-19T12:00:00Z".to_string(), 11 + tasks: vec![ 12 + Task { 13 + id: "task-1".to_string(), 14 + title: "Implement X".to_string(), 15 + description: "Description".to_string(), 16 + acceptance_criteria: vec!["Criterion 1".to_string()], 17 + status: TaskStatus::Pending, 18 + blocked_reason: None, 19 + completed_at: None, 20 + } 21 + ], 22 + learnings: vec![], 23 + }; 24 + 25 + let json = serde_json::to_string_pretty(&spec).unwrap(); 26 + assert!(json.contains("test-feature")); 27 + assert!(json.contains("pending")); 28 + } 29 + 30 + #[test] 31 + fn test_spec_save_and_load() { 32 + let temp = TempDir::new().unwrap(); 33 + let spec_path = temp.path().join("test.json"); 34 + 35 + let spec = Spec { 36 + name: "test".to_string(), 37 + description: "Test".to_string(), 38 + branch_name: "feature/test".to_string(), 39 + created_at: "2026-01-19T12:00:00Z".to_string(), 40 + tasks: vec![], 41 + learnings: vec![], 42 + }; 43 + 44 + spec.save(&spec_path).unwrap(); 45 + let loaded = Spec::load(&spec_path).unwrap(); 46 + 47 + assert_eq!(loaded.name, "test"); 48 + }