taskai_schema/
lib.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5/// Represents the state of a task.
6#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7pub enum TaskState {
8    /// The task is yet to be completed.
9    Todo,
10    /// The task has been completed.
11    Done,
12}
13
14impl Default for TaskState {
15    fn default() -> Self {
16        TaskState::Todo
17    }
18}
19
20/// Represents a single task in the backlog.
21#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
22pub struct Task {
23    /// Unique identifier for the task.
24    pub id: String,
25    /// Title of the task.
26    pub title: String,
27    /// List of task IDs that this task depends on.
28    #[serde(default)]
29    pub depends: Vec<String>,
30    /// Current state of the task.
31    #[serde(default)]
32    pub state: TaskState,
33    /// Optional description of the task.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub description: Option<String>,
36    /// Optional deliverable specification for the task.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub deliverable: Option<DeliverableSpec>,
39    /// List of criteria that define when the task is considered done.
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub done_when: Vec<String>,
42}
43
44/// Represents the deliverable(s) for a task.
45#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
46#[serde(untagged)]
47pub enum DeliverableSpec {
48    /// A single deliverable as a string.
49    Single(String),
50    /// Multiple deliverables as a list of strings.
51    Multiple(Vec<String>),
52}
53
54/// Represents an epic, which is a collection of related tasks.
55#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
56pub struct Epic {
57    /// Unique identifier for the epic.
58    pub id: String,
59    /// Title of the epic.
60    pub title: String,
61    /// List of tasks associated with the epic.
62    #[serde(default)]
63    pub tasks: Vec<Task>,
64}
65
66/// Represents the entire project backlog, including tasks, epics, and metadata.
67#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
68pub struct Backlog {
69    /// Name of the project.
70    pub project: String,
71    /// Optional Rust version for the project.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub rust_version: Option<String>,
74    /// List of success criteria for the project.
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub success_criteria: Vec<String>,
77    /// Environment variables or settings for the project.
78    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
79    pub environment: HashMap<String, serde_json::Value>,
80    /// List of epics in the backlog.
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub epics: Vec<Epic>,
83    /// List of standalone tasks in the backlog.
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub tasks: Vec<Task>,
86}
87
88impl Backlog {
89    /// Validates the backlog for missing dependencies and cycles.
90    ///
91    /// Returns `Ok(())` if the backlog is valid, or an error message otherwise.
92    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    /// Returns a vector of references to all tasks, including those in epics.
111    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    /// Returns a vector of all task IDs in the backlog.
128    fn all_task_ids(&self) -> Vec<String> {
129        self.all_tasks().iter().map(|t| t.id.clone()).collect()
130    }
131    
132    /// Checks for cycles in the task dependency graph.
133    ///
134    /// Returns `Ok(())` if no cycles are found, or an error message with the cycle path.
135    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    /// Helper function to detect cycles starting from a given task.
154    ///
155    /// Returns `true` if a cycle is found, otherwise `false`.
156    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    /// Tests serialization and deserialization of the Backlog struct.
197    #[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    /// Tests roundtrip serialization and deserialization of task states.
220    #[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}