Skip to main content

ccboard_core/parsers/
task.rs

1//! Parser for Claude Code task list files
2//!
3//! Parses `~/.claude/tasks/<list-id>/<task-id>.json` to extract task metadata.
4
5use anyhow::{Context, Result};
6use serde::Deserialize;
7use std::fs;
8use std::path::Path;
9
10/// Task status from Claude Code task list
11#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum TaskStatus {
14    Pending,
15    InProgress,
16    Completed,
17}
18
19/// Task metadata from task list JSON
20#[derive(Debug, Clone, PartialEq, Deserialize)]
21pub struct Task {
22    pub id: String,
23    pub status: TaskStatus,
24    pub subject: String,
25    pub description: Option<String>,
26    pub blocked_by: Vec<String>,
27}
28
29/// Parser for task JSON files
30pub struct TaskParser;
31
32impl TaskParser {
33    /// Parse a task from JSON string
34    ///
35    /// This is the entry point for TDD - we'll build this incrementally
36    pub fn parse(json: &str) -> Result<Task> {
37        // GREEN: Minimal implementation to pass test_parses_minimal_pending_task
38        let task: Task = serde_json::from_str(json).context("Failed to parse task JSON")?;
39        Ok(task)
40    }
41
42    /// Load a task from file path
43    pub fn load(path: &Path) -> Result<Task> {
44        let content = fs::read_to_string(path)
45            .with_context(|| format!("Failed to read task file: {}", path.display()))?;
46
47        Self::parse(&content)
48            .with_context(|| format!("Failed to parse task from: {}", path.display()))
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    // TDD Cycle 1: Minimal task parsing
57    // RED: This test will fail because parse() is unimplemented
58    #[test]
59    fn test_parses_minimal_pending_task() {
60        let json = r#"{
61            "id": "task-1",
62            "status": "pending",
63            "subject": "Write tests first",
64            "blocked_by": []
65        }"#;
66
67        let task = TaskParser::parse(json).unwrap();
68
69        assert_eq!(task.id, "task-1");
70        assert_eq!(task.status, TaskStatus::Pending);
71        assert_eq!(task.subject, "Write tests first");
72        assert!(task.blocked_by.is_empty());
73        assert!(task.description.is_none());
74    }
75
76    // TDD Cycle 2: Task with description and dependencies
77    #[test]
78    fn test_parses_task_with_description_and_dependencies() {
79        let json = r#"{
80            "id": "task-2",
81            "status": "inprogress",
82            "subject": "Implement feature",
83            "description": "Detailed implementation steps",
84            "blocked_by": ["task-1", "task-3"]
85        }"#;
86
87        let task = TaskParser::parse(json).unwrap();
88
89        assert_eq!(task.id, "task-2");
90        assert_eq!(task.status, TaskStatus::InProgress);
91        assert_eq!(task.subject, "Implement feature");
92        assert_eq!(
93            task.description,
94            Some("Detailed implementation steps".to_string())
95        );
96        assert_eq!(task.blocked_by, vec!["task-1", "task-3"]);
97    }
98
99    // TDD Cycle 3: Completed task
100    #[test]
101    fn test_parses_completed_task() {
102        let json = r#"{
103            "id": "task-3",
104            "status": "completed",
105            "subject": "Done task",
106            "blocked_by": []
107        }"#;
108
109        let task = TaskParser::parse(json).unwrap();
110        assert_eq!(task.status, TaskStatus::Completed);
111    }
112
113    // TDD Cycle 4: Edge case - Invalid JSON returns error with context
114    #[test]
115    fn test_invalid_json_returns_error_with_context() {
116        let invalid_json = "{ invalid json }";
117
118        let result = TaskParser::parse(invalid_json);
119
120        assert!(result.is_err());
121        let err_msg = format!("{:?}", result.unwrap_err());
122        assert!(err_msg.contains("Failed to parse task JSON"));
123    }
124
125    // TDD Cycle 5: Edge case - Missing required field
126    #[test]
127    fn test_missing_required_field_returns_error() {
128        let json = r#"{
129            "id": "task-4",
130            "status": "pending"
131        }"#;
132
133        let result = TaskParser::parse(json);
134
135        // Should fail because 'subject' is required
136        assert!(result.is_err());
137    }
138
139    // TDD Cycle 6: Edge case - Unknown status value
140    #[test]
141    fn test_unknown_status_returns_error() {
142        let json = r#"{
143            "id": "task-5",
144            "status": "invalid_status",
145            "subject": "Test",
146            "blocked_by": []
147        }"#;
148
149        let result = TaskParser::parse(json);
150
151        // Should fail because status is not valid enum variant
152        assert!(result.is_err());
153    }
154
155    // TDD Cycle 7: Load from file
156    #[test]
157    fn test_load_from_file() {
158        use std::io::Write;
159        use tempfile::NamedTempFile;
160
161        let json = r#"{
162            "id": "task-file",
163            "status": "pending",
164            "subject": "Test from file",
165            "blocked_by": []
166        }"#;
167
168        let mut temp_file = NamedTempFile::new().unwrap();
169        temp_file.write_all(json.as_bytes()).unwrap();
170
171        let task = TaskParser::load(temp_file.path()).unwrap();
172
173        assert_eq!(task.id, "task-file");
174        assert_eq!(task.subject, "Test from file");
175    }
176
177    // TDD Cycle 8: Load from non-existent file
178    #[test]
179    fn test_load_from_missing_file_returns_error() {
180        use std::path::PathBuf;
181
182        let path = PathBuf::from("/nonexistent/path/task.json");
183        let result = TaskParser::load(&path);
184
185        assert!(result.is_err());
186        let err_msg = format!("{:?}", result.unwrap_err());
187        assert!(err_msg.contains("Failed to read task file"));
188    }
189
190    // TDD Cycle 9: Real fixture validation
191    #[test]
192    fn test_parse_real_fixture_pending() {
193        let fixture = include_str!("../../tests/fixtures/tasks/task-pending.json");
194        let task = TaskParser::parse(fixture).unwrap();
195
196        assert_eq!(task.id, "task-123");
197        assert_eq!(task.status, TaskStatus::Pending);
198        assert!(task.description.is_some());
199        assert!(task.blocked_by.is_empty());
200    }
201
202    #[test]
203    fn test_parse_real_fixture_with_dependencies() {
204        let fixture = include_str!("../../tests/fixtures/tasks/task-inprogress.json");
205        let task = TaskParser::parse(fixture).unwrap();
206
207        assert_eq!(task.id, "task-456");
208        assert_eq!(task.status, TaskStatus::InProgress);
209        assert_eq!(task.blocked_by, vec!["task-123"]);
210    }
211}