use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub enum TaskState {
Todo,
Done,
}
impl Default for TaskState {
fn default() -> Self {
TaskState::Todo
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Task {
pub id: String,
pub title: String,
#[serde(default)]
pub depends: Vec<String>,
#[serde(default)]
pub state: TaskState,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deliverable: Option<DeliverableSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub done_when: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum DeliverableSpec {
Single(String),
Multiple(Vec<String>),
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Epic {
pub id: String,
pub title: String,
#[serde(default)]
pub tasks: Vec<Task>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Backlog {
pub project: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub rust_version: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub success_criteria: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub environment: HashMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub epics: Vec<Epic>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tasks: Vec<Task>,
}
impl Backlog {
pub fn validate(&self) -> Result<(), String> {
let task_ids = self.all_task_ids();
for task in self.all_tasks() {
for dep_id in &task.depends {
if !task_ids.contains(dep_id) {
return Err(format!("Task {} depends on non-existent task {}", task.id, dep_id));
}
}
}
if let Err(cycle) = self.check_cycles() {
return Err(format!("Dependency cycle detected: {}", cycle));
}
Ok(())
}
fn all_tasks(&self) -> Vec<&Task> {
let mut all_tasks = Vec::new();
for task in &self.tasks {
all_tasks.push(task);
}
for epic in &self.epics {
for task in &epic.tasks {
all_tasks.push(task);
}
}
all_tasks
}
fn all_task_ids(&self) -> Vec<String> {
self.all_tasks().iter().map(|t| t.id.clone()).collect()
}
fn check_cycles(&self) -> Result<(), String> {
let all_tasks = self.all_tasks();
let task_map: HashMap<String, &Task> = all_tasks.into_iter()
.map(|t| (t.id.clone(), t))
.collect();
for task in task_map.values() {
let mut visited = HashMap::new();
let mut path = Vec::new();
if self.has_cycle(task, &task_map, &mut visited, &mut path) {
return Err(path.join(" -> "));
}
}
Ok(())
}
fn has_cycle(
&self,
task: &Task,
task_map: &HashMap<String, &Task>,
visited: &mut HashMap<String, bool>,
path: &mut Vec<String>,
) -> bool {
let task_id = &task.id;
if let Some(in_path) = visited.get(task_id) {
if *in_path {
path.push(task_id.clone());
return true;
}
return false;
}
visited.insert(task_id.clone(), true);
path.push(task_id.clone());
for dep_id in &task.depends {
if let Some(dep_task) = task_map.get(dep_id) {
if self.has_cycle(dep_task, task_map, visited, path) {
return true;
}
}
}
visited.insert(task_id.clone(), false);
path.pop();
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_yaml;
#[test]
fn roundtrip() {
let yaml = r#"
project: test-project
rust_version: "1.77"
tasks:
- id: T-1
title: "Test task"
depends: []
deliverable: "src/main.rs"
done_when:
- "cargo test passes"
"#;
let backlog: Backlog = serde_yaml::from_str(yaml).unwrap();
let serialized = serde_yaml::to_string(&backlog).unwrap();
let deserialized: Backlog = serde_yaml::from_str(&serialized).unwrap();
assert_eq!(backlog.project, deserialized.project);
assert_eq!(backlog.tasks[0].id, deserialized.tasks[0].id);
}
#[test]
fn state_roundtrip() {
let yaml = r#"
project: test-project
tasks:
- id: T-1
title: "Test task"
state: Done
depends: []
- id: T-2
title: "Another task"
state: Todo
depends: ["T-1"]
"#;
let backlog: Backlog = serde_yaml::from_str(yaml).unwrap();
match backlog.tasks[0].state {
TaskState::Done => {},
_ => panic!("Expected task T-1 to be Done"),
}
match backlog.tasks[1].state {
TaskState::Todo => {},
_ => panic!("Expected task T-2 to be Todo"),
}
let serialized = serde_yaml::to_string(&backlog).unwrap();
let deserialized: Backlog = serde_yaml::from_str(&serialized).unwrap();
match deserialized.tasks[0].state {
TaskState::Done => {},
_ => panic!("Expected task T-1 to be Done after roundtrip"),
}
match deserialized.tasks[1].state {
TaskState::Todo => {},
_ => panic!("Expected task T-2 to be Todo after roundtrip"),
}
}
}