1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7pub enum TaskState {
8 Todo,
10 Done,
12}
13
14impl Default for TaskState {
15 fn default() -> Self {
16 TaskState::Todo
17 }
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
22pub struct Task {
23 pub id: String,
25 pub title: String,
27 #[serde(default)]
29 pub depends: Vec<String>,
30 #[serde(default)]
32 pub state: TaskState,
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub description: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub deliverable: Option<DeliverableSpec>,
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 pub done_when: Vec<String>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
46#[serde(untagged)]
47pub enum DeliverableSpec {
48 Single(String),
50 Multiple(Vec<String>),
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
56pub struct Epic {
57 pub id: String,
59 pub title: String,
61 #[serde(default)]
63 pub tasks: Vec<Task>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
68pub struct Backlog {
69 pub project: String,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub rust_version: Option<String>,
74 #[serde(default, skip_serializing_if = "Vec::is_empty")]
76 pub success_criteria: Vec<String>,
77 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
79 pub environment: HashMap<String, serde_json::Value>,
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 pub epics: Vec<Epic>,
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 pub tasks: Vec<Task>,
86}
87
88impl Backlog {
89 pub fn validate(&self) -> Result<(), String> {
93 let task_ids = self.all_task_ids();
94
95 for task in self.all_tasks() {
96 for dep_id in &task.depends {
97 if !task_ids.contains(dep_id) {
98 return Err(format!("Task {} depends on non-existent task {}", task.id, dep_id));
99 }
100 }
101 }
102
103 if let Err(cycle) = self.check_cycles() {
104 return Err(format!("Dependency cycle detected: {}", cycle));
105 }
106
107 Ok(())
108 }
109
110 fn all_tasks(&self) -> Vec<&Task> {
112 let mut all_tasks = Vec::new();
113
114 for task in &self.tasks {
115 all_tasks.push(task);
116 }
117
118 for epic in &self.epics {
119 for task in &epic.tasks {
120 all_tasks.push(task);
121 }
122 }
123
124 all_tasks
125 }
126
127 fn all_task_ids(&self) -> Vec<String> {
129 self.all_tasks().iter().map(|t| t.id.clone()).collect()
130 }
131
132 fn check_cycles(&self) -> Result<(), String> {
136 let all_tasks = self.all_tasks();
137 let task_map: HashMap<String, &Task> = all_tasks.into_iter()
138 .map(|t| (t.id.clone(), t))
139 .collect();
140
141 for task in task_map.values() {
142 let mut visited = HashMap::new();
143 let mut path = Vec::new();
144
145 if self.has_cycle(task, &task_map, &mut visited, &mut path) {
146 return Err(path.join(" -> "));
147 }
148 }
149
150 Ok(())
151 }
152
153 fn has_cycle(
157 &self,
158 task: &Task,
159 task_map: &HashMap<String, &Task>,
160 visited: &mut HashMap<String, bool>,
161 path: &mut Vec<String>,
162 ) -> bool {
163 let task_id = &task.id;
164
165 if let Some(in_path) = visited.get(task_id) {
166 if *in_path {
167 path.push(task_id.clone());
168 return true;
169 }
170 return false;
171 }
172
173 visited.insert(task_id.clone(), true);
174 path.push(task_id.clone());
175
176 for dep_id in &task.depends {
177 if let Some(dep_task) = task_map.get(dep_id) {
178 if self.has_cycle(dep_task, task_map, visited, path) {
179 return true;
180 }
181 }
182 }
183
184 visited.insert(task_id.clone(), false);
185 path.pop();
186
187 false
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use serde_yaml;
195
196 #[test]
198 fn roundtrip() {
199 let yaml = r#"
200 project: test-project
201 rust_version: "1.77"
202 tasks:
203 - id: T-1
204 title: "Test task"
205 depends: []
206 deliverable: "src/main.rs"
207 done_when:
208 - "cargo test passes"
209 "#;
210
211 let backlog: Backlog = serde_yaml::from_str(yaml).unwrap();
212 let serialized = serde_yaml::to_string(&backlog).unwrap();
213 let deserialized: Backlog = serde_yaml::from_str(&serialized).unwrap();
214
215 assert_eq!(backlog.project, deserialized.project);
216 assert_eq!(backlog.tasks[0].id, deserialized.tasks[0].id);
217 }
218
219 #[test]
221 fn state_roundtrip() {
222 let yaml = r#"
223 project: test-project
224 tasks:
225 - id: T-1
226 title: "Test task"
227 state: Done
228 depends: []
229 - id: T-2
230 title: "Another task"
231 state: Todo
232 depends: ["T-1"]
233 "#;
234
235 let backlog: Backlog = serde_yaml::from_str(yaml).unwrap();
236
237 match backlog.tasks[0].state {
238 TaskState::Done => {},
239 _ => panic!("Expected task T-1 to be Done"),
240 }
241
242 match backlog.tasks[1].state {
243 TaskState::Todo => {},
244 _ => panic!("Expected task T-2 to be Todo"),
245 }
246
247 let serialized = serde_yaml::to_string(&backlog).unwrap();
248 let deserialized: Backlog = serde_yaml::from_str(&serialized).unwrap();
249
250 match deserialized.tasks[0].state {
251 TaskState::Done => {},
252 _ => panic!("Expected task T-1 to be Done after roundtrip"),
253 }
254
255 match deserialized.tasks[1].state {
256 TaskState::Todo => {},
257 _ => panic!("Expected task T-2 to be Todo after roundtrip"),
258 }
259 }
260}