1use anyhow::{Result, bail};
16use schemars::JsonSchema;
17use serde::de::{self, Deserializer};
18use serde::{Deserialize, Serialize};
19use serde_json::json;
20use std::collections::HashMap;
21use std::str::FromStr;
22
23use super::RunnerCliOptionsPatch;
24use super::{Model, ModelEffort, PhaseOverrides, ReasoningEffort, Runner};
25
26#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
29#[serde(deny_unknown_fields)]
30pub struct Task {
31 pub id: String,
32
33 #[serde(default)]
34 pub status: TaskStatus,
35
36 pub title: String,
37
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub description: Option<String>,
41
42 #[serde(default)]
43 pub priority: TaskPriority,
44
45 #[serde(default)]
46 pub tags: Vec<String>,
47
48 #[serde(default)]
49 pub scope: Vec<String>,
50
51 #[serde(default)]
52 pub evidence: Vec<String>,
53
54 #[serde(default)]
55 pub plan: Vec<String>,
56
57 #[serde(default)]
58 pub notes: Vec<String>,
59
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub request: Option<String>,
63
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub agent: Option<TaskAgent>,
67
68 #[schemars(required)]
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub created_at: Option<String>,
72 #[schemars(required)]
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub updated_at: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub completed_at: Option<String>,
77
78 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub started_at: Option<String>,
85
86 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub estimated_minutes: Option<u32>,
90
91 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub actual_minutes: Option<u32>,
95
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub scheduled_start: Option<String>,
99
100 #[serde(default)]
102 pub depends_on: Vec<String>,
103
104 #[serde(default)]
107 pub blocks: Vec<String>,
108
109 #[serde(default)]
112 pub relates_to: Vec<String>,
113
114 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub duplicates: Option<String>,
118
119 #[serde(default, deserialize_with = "deserialize_custom_fields")]
122 #[schemars(schema_with = "custom_fields_schema")]
123 pub custom_fields: HashMap<String, String>,
124
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub parent_id: Option<String>,
128}
129
130#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default, JsonSchema)]
131#[serde(rename_all = "snake_case")]
132pub enum TaskStatus {
133 Draft,
134 #[default]
135 Todo,
136 Doing,
137 Done,
138 Rejected,
139}
140
141#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default, JsonSchema)]
142#[serde(rename_all = "snake_case")]
143pub enum TaskPriority {
144 Critical,
145 High,
146 #[default]
147 Medium,
148 Low,
149}
150
151impl PartialOrd for TaskPriority {
153 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
154 Some(self.cmp(other))
155 }
156}
157
158impl Ord for TaskPriority {
161 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
162 self.weight().cmp(&other.weight())
164 }
165}
166
167impl TaskPriority {
168 pub fn as_str(self) -> &'static str {
169 match self {
170 TaskPriority::Critical => "critical",
171 TaskPriority::High => "high",
172 TaskPriority::Medium => "medium",
173 TaskPriority::Low => "low",
174 }
175 }
176
177 pub fn weight(self) -> u8 {
178 match self {
179 TaskPriority::Critical => 3,
180 TaskPriority::High => 2,
181 TaskPriority::Medium => 1,
182 TaskPriority::Low => 0,
183 }
184 }
185
186 pub fn cycle(self) -> Self {
188 match self {
189 TaskPriority::Low => TaskPriority::Medium,
190 TaskPriority::Medium => TaskPriority::High,
191 TaskPriority::High => TaskPriority::Critical,
192 TaskPriority::Critical => TaskPriority::Low,
193 }
194 }
195}
196
197impl std::fmt::Display for TaskPriority {
198 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199 f.write_str(self.as_str())
200 }
201}
202
203impl FromStr for TaskPriority {
204 type Err = anyhow::Error;
205
206 fn from_str(value: &str) -> Result<Self> {
207 let token = value.trim();
208
209 if token.eq_ignore_ascii_case("critical") {
210 return Ok(TaskPriority::Critical);
211 }
212 if token.eq_ignore_ascii_case("high") {
213 return Ok(TaskPriority::High);
214 }
215 if token.eq_ignore_ascii_case("medium") {
216 return Ok(TaskPriority::Medium);
217 }
218 if token.eq_ignore_ascii_case("low") {
219 return Ok(TaskPriority::Low);
220 }
221
222 bail!(
223 "Invalid priority: '{}'. Expected one of: critical, high, medium, low.",
224 token
225 )
226 }
227}
228
229impl TaskStatus {
230 pub fn as_str(self) -> &'static str {
231 match self {
232 TaskStatus::Draft => "draft",
233 TaskStatus::Todo => "todo",
234 TaskStatus::Doing => "doing",
235 TaskStatus::Done => "done",
236 TaskStatus::Rejected => "rejected",
237 }
238 }
239}
240
241impl std::fmt::Display for TaskStatus {
242 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243 f.write_str(self.as_str())
244 }
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
248#[serde(deny_unknown_fields)]
249pub struct TaskAgent {
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub runner: Option<Runner>,
252
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub model: Option<Model>,
255
256 #[serde(default, skip_serializing_if = "model_effort_is_default")]
258 #[schemars(schema_with = "model_effort_schema")]
259 pub model_effort: ModelEffort,
260
261 #[schemars(range(min = 1, max = 3))]
263 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub phases: Option<u8>,
265
266 #[schemars(range(min = 1))]
268 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub iterations: Option<u8>,
270
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub followup_reasoning_effort: Option<ReasoningEffort>,
274
275 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub runner_cli: Option<RunnerCliOptionsPatch>,
281
282 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub phase_overrides: Option<PhaseOverrides>,
285}
286
287fn model_effort_is_default(value: &ModelEffort) -> bool {
288 matches!(value, ModelEffort::Default)
289}
290
291fn model_effort_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
292 let mut schema = <ModelEffort as JsonSchema>::json_schema(generator);
293 schema
294 .ensure_object()
295 .insert("default".to_string(), json!("default"));
296 schema
297}
298
299fn deserialize_custom_fields<'de, D>(deserializer: D) -> Result<HashMap<String, String>, D::Error>
302where
303 D: Deserializer<'de>,
304{
305 let value: serde_json::Value = serde_json::Value::deserialize(deserializer)?;
306 let raw = match value {
307 serde_json::Value::Object(map) => map,
308 serde_json::Value::Null => {
309 return Err(de::Error::custom(
310 "custom_fields must be an object (map); null is not allowed",
311 ));
312 }
313 other => {
314 return Err(de::Error::custom(format!(
315 "custom_fields must be an object (map); got {}",
316 other
317 )));
318 }
319 };
320
321 raw.into_iter()
322 .map(|(k, v)| {
323 let s = match v {
324 serde_json::Value::String(s) => s,
325 serde_json::Value::Number(n) => n.to_string(),
326 serde_json::Value::Bool(b) => b.to_string(),
327 serde_json::Value::Null => {
328 return Err(de::Error::custom(format!(
329 "custom_fields['{}'] must be a string/number/boolean (null is not allowed)",
330 k
331 )));
332 }
333 serde_json::Value::Array(_) => {
334 return Err(de::Error::custom(format!(
335 "custom_fields['{}'] must be a scalar (string/number/boolean); arrays are not allowed",
336 k
337 )));
338 }
339 serde_json::Value::Object(_) => {
340 return Err(de::Error::custom(format!(
341 "custom_fields['{}'] must be a scalar (string/number/boolean); objects are not allowed",
342 k
343 )));
344 }
345 };
346 Ok((k, s))
347 })
348 .collect()
349}
350
351fn custom_fields_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
353 schemars::json_schema!({
354 "type": "object",
355 "description": "Custom user-defined fields. Values may be written as string/number/boolean; Ralph coerces them to strings when loading the queue.",
356 "additionalProperties": {
357 "anyOf": [
358 {"type": "string"},
359 {"type": "number"},
360 {"type": "boolean"}
361 ]
362 }
363 })
364}
365
366#[cfg(test)]
367mod tests {
368 use super::{Task, TaskPriority};
369 use crate::contracts::{Model, PhaseOverrideConfig, PhaseOverrides, ReasoningEffort, Runner};
370 use std::collections::HashMap;
371
372 #[test]
373 fn task_priority_cycle_wraps_through_all_values() {
374 assert_eq!(TaskPriority::Low.cycle(), TaskPriority::Medium);
375 assert_eq!(TaskPriority::Medium.cycle(), TaskPriority::High);
376 assert_eq!(TaskPriority::High.cycle(), TaskPriority::Critical);
377 assert_eq!(TaskPriority::Critical.cycle(), TaskPriority::Low);
378 }
379
380 #[test]
381 fn task_priority_from_str_is_case_insensitive_and_trims() {
382 assert_eq!("HIGH".parse::<TaskPriority>().unwrap(), TaskPriority::High);
383 assert_eq!(
384 "Medium".parse::<TaskPriority>().unwrap(),
385 TaskPriority::Medium
386 );
387 assert_eq!(" low ".parse::<TaskPriority>().unwrap(), TaskPriority::Low);
388 assert_eq!(
389 "CRITICAL".parse::<TaskPriority>().unwrap(),
390 TaskPriority::Critical
391 );
392 }
393
394 #[test]
395 fn task_priority_from_str_invalid_has_canonical_error_message() {
396 let err = "nope".parse::<TaskPriority>().unwrap_err();
397 assert_eq!(
398 err.to_string(),
399 "Invalid priority: 'nope'. Expected one of: critical, high, medium, low."
400 );
401 }
402
403 #[test]
404 fn task_priority_from_str_empty_string_errors() {
405 let err = "".parse::<TaskPriority>().unwrap_err();
406 assert_eq!(
407 err.to_string(),
408 "Invalid priority: ''. Expected one of: critical, high, medium, low."
409 );
410 }
411
412 #[test]
413 fn task_custom_fields_deserialize_coerces_scalars_to_strings() {
414 let raw = r#"{
415 "id": "RQ-0001",
416 "title": "t",
417 "custom_fields": {
418 "guide_line_count": 1411,
419 "enabled": true,
420 "owner": "ralph"
421 }
422 }"#;
423
424 let task: Task = serde_json::from_str(raw).expect("deserialize");
425 assert_eq!(
426 task.custom_fields
427 .get("guide_line_count")
428 .map(String::as_str),
429 Some("1411")
430 );
431 assert_eq!(
432 task.custom_fields.get("enabled").map(String::as_str),
433 Some("true")
434 );
435 assert_eq!(
436 task.custom_fields.get("owner").map(String::as_str),
437 Some("ralph")
438 );
439 }
440
441 #[test]
442 fn task_custom_fields_deserialize_rejects_null() {
443 let raw = r#"{"id":"RQ-0001","title":"t","custom_fields":{"x":null}}"#;
444 let err = serde_json::from_str::<Task>(raw).unwrap_err();
445 let err_msg = err.to_string().to_lowercase();
446 assert!(
447 err_msg.contains("custom_fields"),
448 "error should mention custom_fields: {}",
449 err_msg
450 );
451 assert!(
452 err_msg.contains("null"),
453 "error should mention null: {}",
454 err_msg
455 );
456 }
457
458 #[test]
459 fn task_custom_fields_deserialize_rejects_custom_fields_null() {
460 let raw = r#"{"id":"RQ-0001","title":"t","custom_fields":null}"#;
461 let err = serde_json::from_str::<Task>(raw).unwrap_err();
462 let err_msg = err.to_string().to_lowercase();
463 assert!(
464 err_msg.contains("custom_fields"),
465 "error should mention custom_fields: {}",
466 err_msg
467 );
468 assert!(
469 err_msg.contains("null"),
470 "error should mention null: {}",
471 err_msg
472 );
473 }
474
475 #[test]
476 fn task_custom_fields_deserialize_rejects_custom_fields_non_object() {
477 let raw = r#"{"id":"RQ-0001","title":"t","custom_fields":123}"#;
478 let err = serde_json::from_str::<Task>(raw).unwrap_err();
479 let err_msg = err.to_string().to_lowercase();
480 assert!(
481 err_msg.contains("custom_fields"),
482 "error should mention custom_fields: {}",
483 err_msg
484 );
485 assert!(
486 err_msg.contains("object") || err_msg.contains("map"),
487 "error should mention object/map: {}",
488 err_msg
489 );
490 }
491
492 #[test]
493 fn task_custom_fields_deserialize_rejects_object_and_array_values() {
494 let raw_obj = r#"{"id":"RQ-0001","title":"t","custom_fields":{"x":{"a":1}}}"#;
495 let raw_arr = r#"{"id":"RQ-0001","title":"t","custom_fields":{"x":[1,2]}}"#;
496
497 let err_obj = serde_json::from_str::<Task>(raw_obj).unwrap_err();
498 let err_arr = serde_json::from_str::<Task>(raw_arr).unwrap_err();
499
500 let err_obj_msg = err_obj.to_string().to_lowercase();
501 let err_arr_msg = err_arr.to_string().to_lowercase();
502
503 assert!(
504 err_obj_msg.contains("custom_fields"),
505 "object error should mention custom_fields: {}",
506 err_obj_msg
507 );
508 assert!(
509 err_arr_msg.contains("custom_fields"),
510 "array error should mention custom_fields: {}",
511 err_arr_msg
512 );
513 }
514
515 #[test]
516 fn task_custom_fields_serializes_as_strings() {
517 let mut custom_fields = HashMap::new();
518 custom_fields.insert("count".to_string(), "42".to_string());
519 custom_fields.insert("enabled".to_string(), "true".to_string());
520
521 let task = Task {
522 id: "RQ-0001".to_string(),
523 title: "Test".to_string(),
524 custom_fields,
525 ..Default::default()
526 };
527
528 let json = serde_json::to_string(&task).expect("serialize");
529 assert!(json.contains("\"count\":\"42\""));
530 assert!(json.contains("\"enabled\":\"true\""));
531 }
532
533 #[test]
534 fn task_agent_deserializes_phases_and_phase_overrides() {
535 let raw = r#"{
536 "id":"RQ-0001",
537 "title":"Task with agent overrides",
538 "agent":{
539 "runner":"codex",
540 "model":"gpt-5.3-codex",
541 "model_effort":"high",
542 "phases":2,
543 "iterations":1,
544 "phase_overrides":{
545 "phase1":{"runner":"codex","model":"gpt-5.3-codex","reasoning_effort":"high"},
546 "phase2":{"runner":"kimi","model":"kimi-code/kimi-for-coding"}
547 }
548 }
549 }"#;
550
551 let task: Task = serde_json::from_str(raw).expect("deserialize");
552 let agent = task.agent.expect("agent should be set");
553 assert_eq!(agent.runner, Some(Runner::Codex));
554 assert_eq!(agent.model, Some(Model::Gpt53Codex));
555 assert_eq!(agent.phases, Some(2));
556 assert_eq!(agent.iterations, Some(1));
557
558 let phase_overrides = agent
559 .phase_overrides
560 .expect("phase overrides should be set");
561 let phase1 = phase_overrides.phase1.expect("phase1 should be set");
562 assert_eq!(phase1.runner, Some(Runner::Codex));
563 assert_eq!(phase1.reasoning_effort, Some(ReasoningEffort::High));
564 let phase2 = phase_overrides.phase2.expect("phase2 should be set");
565 assert_eq!(phase2.runner, Some(Runner::Kimi));
566 }
567
568 #[test]
569 fn task_agent_omits_default_phase_and_effort_fields_when_serializing() {
570 let task = Task {
571 id: "RQ-0001".to_string(),
572 title: "Serialize defaults".to_string(),
573 agent: Some(crate::contracts::TaskAgent {
574 runner: Some(Runner::Codex),
575 model: Some(Model::Gpt53Codex),
576 model_effort: crate::contracts::ModelEffort::Default,
577 phases: None,
578 iterations: None,
579 followup_reasoning_effort: None,
580 runner_cli: None,
581 phase_overrides: Some(PhaseOverrides {
582 phase1: Some(PhaseOverrideConfig {
583 runner: Some(Runner::Codex),
584 model: Some(Model::Gpt53Codex),
585 reasoning_effort: Some(ReasoningEffort::Medium),
586 }),
587 ..Default::default()
588 }),
589 }),
590 ..Default::default()
591 };
592
593 let value = serde_json::to_value(task).expect("serialize");
594 let agent = value
595 .get("agent")
596 .and_then(|v| v.as_object())
597 .expect("agent object should exist");
598 assert!(!agent.contains_key("model_effort"));
599 assert!(!agent.contains_key("phases"));
600 assert!(agent.contains_key("phase_overrides"));
601 }
602}