use chrono::{DateTime, Local, Utc};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::path::PathBuf;
fn default_true() -> bool {
true
}
fn deserialize_parent_id<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
let opt: Option<String> = Option::deserialize(deserializer)?;
Ok(opt.into_iter().collect())
}
fn serialize_parent_id<S>(ids: &[String], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match ids.first() {
Some(id) => serializer.serialize_some(id),
None => serializer.serialize_none(),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum TaskStatus {
#[serde(rename = "QUEUED")]
Queued,
#[serde(rename = "RUNNING")]
Running,
#[serde(rename = "FAILED")]
Failed,
#[serde(rename = "COMPLETE")]
Complete,
#[serde(rename = "CANCELLED")]
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub id: String,
pub repo_root: PathBuf,
pub name: String,
pub task_type: String,
pub instructions_file: String,
pub agent: String,
pub status: TaskStatus,
pub created_at: DateTime<Local>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub branch_name: String,
pub error_message: Option<String>,
pub source_commit: String,
#[serde(default)]
pub source_branch: Option<String>,
#[serde(alias = "tech_stack")]
pub stack: String,
pub project: String,
#[serde(default)]
pub copied_repo_path: Option<PathBuf>,
#[serde(default)]
pub is_interactive: bool,
#[serde(
default,
rename = "parent_id",
deserialize_with = "deserialize_parent_id",
serialize_with = "serialize_parent_id"
)]
pub parent_ids: Vec<String>,
#[serde(default = "default_true")]
pub network_isolation: bool,
#[serde(default)]
pub dind: bool,
#[serde(default)]
pub resolved_config: Option<String>,
}
impl Task {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: String,
repo_root: PathBuf,
name: String,
task_type: String,
instructions_file: String,
agent: String,
branch_name: String,
source_commit: String,
source_branch: Option<String>,
stack: String,
project: String,
created_at: DateTime<Local>,
copied_repo_path: Option<PathBuf>,
is_interactive: bool,
parent_ids: Vec<String>,
network_isolation: bool,
dind: bool,
resolved_config: Option<String>,
) -> Self {
Self {
id,
repo_root,
name,
task_type,
instructions_file,
agent,
status: TaskStatus::Queued,
created_at,
started_at: None,
completed_at: None,
branch_name,
error_message: None,
source_commit,
source_branch,
stack,
project,
copied_repo_path,
is_interactive,
parent_ids,
network_isolation,
dind,
resolved_config,
}
}
}
pub use crate::task_builder::TaskBuilder;
#[cfg(test)]
impl Task {
pub fn test_default() -> Self {
Self {
id: "test-id".to_string(),
repo_root: PathBuf::from("/test"),
name: "test-task".to_string(),
task_type: "feat".to_string(),
instructions_file: "instructions.md".to_string(),
agent: "claude".to_string(),
status: TaskStatus::Queued,
created_at: chrono::Local::now(),
started_at: None,
completed_at: None,
branch_name: "tsk/feat/test-task/test-id".to_string(),
error_message: None,
source_commit: "abc123".to_string(),
source_branch: Some("main".to_string()),
stack: "default".to_string(),
project: "default".to_string(),
copied_repo_path: Some(PathBuf::from("/test/copied")),
is_interactive: false,
parent_ids: vec![],
network_isolation: true,
dind: false,
resolved_config: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_task_status_serialization() {
assert_eq!(
serde_json::to_string(&TaskStatus::Queued).unwrap(),
"\"QUEUED\""
);
assert_eq!(
serde_json::to_string(&TaskStatus::Running).unwrap(),
"\"RUNNING\""
);
assert_eq!(
serde_json::to_string(&TaskStatus::Failed).unwrap(),
"\"FAILED\""
);
assert_eq!(
serde_json::to_string(&TaskStatus::Complete).unwrap(),
"\"COMPLETE\""
);
assert_eq!(
serde_json::to_string(&TaskStatus::Cancelled).unwrap(),
"\"CANCELLED\""
);
}
#[test]
fn test_task_creation() {
let task = Task::test_default();
assert_eq!(task.id, "test-id");
assert_eq!(task.name, "test-task");
assert_eq!(task.task_type, "feat");
assert_eq!(task.status, TaskStatus::Queued);
assert!(task.started_at.is_none());
assert!(task.completed_at.is_none());
assert!(task.error_message.is_none());
assert!(!task.is_interactive);
assert_eq!(task.source_branch, Some("main".to_string()));
assert!(task.parent_ids.is_empty());
assert!(task.copied_repo_path.is_some());
}
#[test]
fn test_task_creation_detached_head() {
let task = Task {
source_branch: None,
..Task::test_default()
};
assert!(task.source_branch.is_none());
}
#[test]
fn test_task_creation_with_parent() {
let task = Task {
id: "child-id".to_string(),
name: "child-task".to_string(),
branch_name: "tsk/feat/child-task/child-id".to_string(),
source_branch: None,
copied_repo_path: None,
parent_ids: vec!["parent-id".to_string()],
..Task::test_default()
};
assert_eq!(task.parent_ids, vec!["parent-id"]);
assert!(task.copied_repo_path.is_none());
assert!(task.source_branch.is_none());
}
#[test]
fn test_deserialize_parent_id_present() {
let json = r#"{
"id": "test-id",
"repo_root": "/test",
"name": "test",
"task_type": "feat",
"instructions_file": "instructions.md",
"agent": "claude",
"status": "QUEUED",
"created_at": "2025-01-01T00:00:00+00:00",
"branch_name": "tsk/feat/test/test-id",
"source_commit": "abc123",
"stack": "rust",
"project": "test",
"parent_id": "legacy-parent"
}"#;
let task: Task = serde_json::from_str(json).unwrap();
assert_eq!(task.parent_ids, vec!["legacy-parent"]);
}
#[test]
fn test_deserialize_parent_id_null() {
let json = r#"{
"id": "test-id",
"repo_root": "/test",
"name": "test",
"task_type": "feat",
"instructions_file": "instructions.md",
"agent": "claude",
"status": "QUEUED",
"created_at": "2025-01-01T00:00:00+00:00",
"branch_name": "tsk/feat/test/test-id",
"source_commit": "abc123",
"stack": "rust",
"project": "test",
"parent_id": null
}"#;
let task: Task = serde_json::from_str(json).unwrap();
assert!(task.parent_ids.is_empty());
}
#[test]
fn test_deserialize_parent_id_missing() {
let json = r#"{
"id": "test-id",
"repo_root": "/test",
"name": "test",
"task_type": "feat",
"instructions_file": "instructions.md",
"agent": "claude",
"status": "QUEUED",
"created_at": "2025-01-01T00:00:00+00:00",
"branch_name": "tsk/feat/test/test-id",
"source_commit": "abc123",
"stack": "rust",
"project": "test"
}"#;
let task: Task = serde_json::from_str(json).unwrap();
assert!(task.parent_ids.is_empty());
}
#[test]
fn test_json_round_trip_with_parent() {
let json = r#"{
"id": "test-id",
"repo_root": "/test",
"name": "test",
"task_type": "feat",
"instructions_file": "instructions.md",
"agent": "claude",
"status": "QUEUED",
"created_at": "2025-01-01T00:00:00+00:00",
"branch_name": "tsk/feat/test/test-id",
"source_commit": "abc123",
"stack": "rust",
"project": "test",
"parent_id": "parent-123"
}"#;
let task: Task = serde_json::from_str(json).unwrap();
let serialized = serde_json::to_string(&task).unwrap();
let deserialized: Task = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.parent_ids, vec!["parent-123"]);
}
}