1use std::collections::HashSet;
2use std::fmt;
3
4use chrono::{DateTime, Utc};
5use rand::RngExt;
6use serde::{Deserialize, Serialize};
7
8use crate::error::{Error, Result};
9
10macro_rules! impl_str_enum {
14 (
15 $ty:ty,
16 $err_prefix:literal,
17 $( $variant:path => $s:literal ),+ $(,)?
18 ) => {
19 impl fmt::Display for $ty {
20 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21 let s = match self {
22 $( $variant => $s, )+
23 };
24 f.write_str(s)
25 }
26 }
27
28 impl std::str::FromStr for $ty {
29 type Err = String;
30 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
31 match s {
32 $( $s => Ok($variant), )+
33 _ => Err(format!("{}: {s}", $err_prefix)),
34 }
35 }
36 }
37 };
38}
39
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42pub enum Status {
43 #[serde(rename = "proposed")]
45 Proposed,
46 #[serde(rename = "open")]
47 Open,
48 #[serde(rename = "in_progress")]
49 InProgress,
50 #[serde(rename = "review")]
52 Review,
53 #[serde(rename = "done")]
54 Done,
55 #[serde(rename = "blocked")]
56 Blocked,
57 #[serde(rename = "cancelled")]
58 Cancelled,
59}
60
61impl_str_enum!(
62 Status,
63 "invalid status",
64 Status::Proposed => "proposed",
65 Status::Open => "open",
66 Status::InProgress => "in_progress",
67 Status::Review => "review",
68 Status::Done => "done",
69 Status::Blocked => "blocked",
70 Status::Cancelled => "cancelled",
71);
72
73#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
75pub enum TaskType {
76 #[default]
77 #[serde(rename = "task")]
78 Task,
79 #[serde(rename = "epic")]
80 Epic,
81}
82
83impl TaskType {
84 pub fn is_task(self) -> bool {
85 self == TaskType::Task
86 }
87
88 pub fn is_epic(self) -> bool {
89 self == TaskType::Epic
90 }
91}
92
93impl_str_enum!(
94 TaskType,
95 "invalid task type",
96 TaskType::Task => "task",
97 TaskType::Epic => "epic",
98);
99
100#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
101#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
102pub enum Priority {
103 #[serde(rename = "P0")]
104 P0,
105 #[serde(rename = "P1")]
106 P1,
107 #[serde(rename = "P2")]
108 P2,
109 #[serde(rename = "P3")]
110 P3,
111}
112
113impl_str_enum!(
114 Priority,
115 "invalid priority",
116 Priority::P0 => "P0",
117 Priority::P1 => "P1",
118 Priority::P2 => "P2",
119 Priority::P3 => "P3",
120);
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct Task {
124 #[serde(deserialize_with = "lenient_string")]
125 pub id: String,
126 #[serde(deserialize_with = "lenient_string")]
127 pub title: String,
128 #[serde(default, rename = "type", skip_serializing_if = "is_default_task_type")]
129 pub task_type: TaskType,
130 pub status: Status,
131 pub priority: Priority,
132 pub created: DateTime<Utc>,
133 pub updated: DateTime<Utc>,
134 #[serde(default, skip_serializing_if = "Vec::is_empty")]
135 pub tags: Vec<String>,
136 #[serde(default, skip_serializing_if = "Vec::is_empty")]
137 pub depends_on: Vec<String>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub parent: Option<String>,
140 #[serde(default, skip_serializing_if = "String::is_empty")]
141 pub assignee: String,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub attempts: Option<u32>,
146 #[serde(skip)]
147 pub body: String,
148}
149
150fn lenient_string<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<String, D::Error> {
157 struct V;
158 impl serde::de::Visitor<'_> for V {
159 type Value = String;
160 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
161 f.write_str("a string or scalar")
162 }
163 fn visit_str<E: serde::de::Error>(self, v: &str) -> std::result::Result<String, E> {
164 Ok(v.to_string())
165 }
166 fn visit_bool<E: serde::de::Error>(self, v: bool) -> std::result::Result<String, E> {
167 Ok(v.to_string())
168 }
169 fn visit_i64<E: serde::de::Error>(self, v: i64) -> std::result::Result<String, E> {
170 Ok(v.to_string())
171 }
172 fn visit_u64<E: serde::de::Error>(self, v: u64) -> std::result::Result<String, E> {
173 Ok(v.to_string())
174 }
175 fn visit_f64<E: serde::de::Error>(self, v: f64) -> std::result::Result<String, E> {
176 if v.is_nan() {
178 Ok("nan".to_string())
179 } else if v.is_infinite() {
180 Ok(if v > 0.0 { "inf" } else { "-inf" }.to_string())
181 } else {
182 Ok(v.to_string())
183 }
184 }
185 }
186 d.deserialize_any(V)
187}
188
189fn is_default_task_type(t: &TaskType) -> bool {
190 t.is_task()
191}
192
193impl Task {
194 pub fn new(id: String, title: String, priority: Priority) -> Self {
195 let now = Utc::now();
196 Task {
197 id,
198 title,
199 task_type: TaskType::Task,
200 status: Status::Open,
201 priority,
202 created: now,
203 updated: now,
204 tags: Vec::new(),
205 depends_on: Vec::new(),
206 parent: None,
207 assignee: String::new(),
208 attempts: None,
209 body: String::new(),
210 }
211 }
212
213 pub fn attempt_count(&self) -> u32 {
215 self.attempts.unwrap_or(0)
216 }
217
218 pub fn summary(&self, effective_priority: Option<&Priority>) -> TaskSummary {
220 TaskSummary {
221 id: self.id.clone(),
222 title: self.title.clone(),
223 task_type: self.task_type,
224 status: self.status,
225 priority: self.priority,
226 tags: self.tags.clone(),
227 assignee: self.assignee.clone(),
228 attempts: self.attempts.filter(|n| *n > 0),
229 effective_priority: effective_priority
230 .filter(|ep| *ep < &self.priority)
231 .copied(),
232 }
233 }
234
235 pub fn detail(&self, effective_priority: Option<&Priority>) -> TaskDetail {
237 TaskDetail {
238 summary: self.summary(effective_priority),
239 body: self.body.clone(),
240 depends_on: self.depends_on.clone(),
241 parent: self.parent.clone(),
242 created: self.created,
243 updated: self.updated,
244 }
245 }
246
247 pub fn epic_summary(
249 &self,
250 progress: crate::service::EpicProgress,
251 ) -> crate::service::EpicSummary {
252 crate::service::EpicSummary {
253 id: self.id.clone(),
254 title: self.title.clone(),
255 status: self.status,
256 priority: self.priority,
257 tags: self.tags.clone(),
258 progress,
259 }
260 }
261}
262
263#[derive(Debug, Serialize)]
265pub struct TaskSummary {
266 pub id: String,
267 pub title: String,
268 #[serde(rename = "type", skip_serializing_if = "is_default_task_type")]
269 pub task_type: TaskType,
270 pub status: Status,
271 pub priority: Priority,
272 pub tags: Vec<String>,
273 #[serde(skip_serializing_if = "String::is_empty")]
274 pub assignee: String,
275 #[serde(skip_serializing_if = "Option::is_none")]
276 pub attempts: Option<u32>,
277 #[serde(skip_serializing_if = "Option::is_none")]
278 pub effective_priority: Option<Priority>,
279}
280
281#[derive(Debug, Serialize)]
283pub struct TaskDetail {
284 #[serde(flatten)]
285 pub summary: TaskSummary,
286 pub body: String,
287 pub depends_on: Vec<String>,
288 #[serde(skip_serializing_if = "Option::is_none")]
289 pub parent: Option<String>,
290 pub created: DateTime<Utc>,
291 pub updated: DateTime<Utc>,
292}
293
294pub fn is_active(task: &Task) -> bool {
298 !matches!(task.status, Status::Done | Status::Cancelled)
299}
300
301pub fn matches_tag(task: &Task, tag: Option<&str>) -> bool {
303 tag.is_none_or(|t| task.tags.iter().any(|tt| tt == t))
304}
305
306pub fn sort_by_priority_owned(tasks: &mut [Task]) {
308 tasks.sort_by(|a, b| a.priority.cmp(&b.priority).then(a.created.cmp(&b.created)));
309}
310
311const ID_CHARSET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789";
314
315fn yaml_safe_id(id: &str) -> bool {
319 matches!(
320 serde_yml::from_str::<serde_yml::Value>(id),
321 Ok(serde_yml::Value::String(_))
322 )
323}
324
325pub fn generate_id(existing: &HashSet<String>, length: usize) -> String {
327 let mut rng = rand::rng();
328 loop {
329 let id: String = (0..length)
330 .map(|_| ID_CHARSET[rng.random_range(0..ID_CHARSET.len())] as char)
331 .collect();
332 if !existing.contains(&id) && yaml_safe_id(&id) {
333 return id;
334 }
335 }
336}
337
338pub fn slugify(title: &str) -> String {
340 let slug: String = title
341 .to_lowercase()
342 .chars()
343 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
344 .collect();
345
346 let mut result = String::new();
348 let mut prev_dash = true; for c in slug.chars() {
350 if c == '-' {
351 if !prev_dash {
352 result.push('-');
353 }
354 prev_dash = true;
355 } else {
356 result.push(c);
357 prev_dash = false;
358 }
359 }
360 if result.ends_with('-') {
362 result.pop();
363 }
364 result
365}
366
367pub fn filename(task: &Task) -> String {
369 format!("{}-{}.md", task.id, slugify(&task.title))
370}
371
372pub fn parse_task(content: &str) -> Result<Task> {
377 let content = content.trim_start_matches('\u{feff}');
379 let content = content.replace("\r\n", "\n");
380
381 let mut lines = content.split('\n');
382
383 match lines.next() {
385 Some(line) if line.trim_end() == "---" => {}
386 _ => {
387 return Err(Error::InvalidFrontmatter {
388 path: "".into(),
389 reason: "missing opening --- delimiter".into(),
390 });
391 }
392 }
393
394 let mut yaml_lines = Vec::new();
396 let mut found_closing = false;
397 for line in &mut lines {
398 if line.trim_end() == "---" {
399 found_closing = true;
400 break;
401 }
402 yaml_lines.push(line);
403 }
404
405 if !found_closing {
406 return Err(Error::InvalidFrontmatter {
407 path: "".into(),
408 reason: "missing closing --- delimiter".into(),
409 });
410 }
411
412 let yaml_str = yaml_lines.join("\n");
413
414 let remaining: Vec<&str> = lines.collect();
416 let body_raw = remaining.join("\n");
417 let body = body_raw.trim_start_matches('\n').to_string();
418
419 let mut task: Task = serde_yml::from_str(&yaml_str)?;
420 task.body = body;
421 Ok(task)
422}
423
424pub fn render_task(task: &Task) -> String {
426 let mut yaml = serde_yml::to_string(task).expect("task serialization should not fail");
427 if !yaml.ends_with('\n') {
430 yaml.push('\n');
431 }
432 if task.body.is_empty() {
433 format!("---\n{yaml}---\n")
434 } else {
435 format!("---\n{yaml}---\n\n{}", task.body)
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442
443 #[test]
444 fn test_slugify_basic() {
445 assert_eq!(slugify("Implement OAuth flow"), "implement-oauth-flow");
446 }
447
448 #[test]
449 fn test_slugify_special_chars() {
450 assert_eq!(slugify("Fix bug #123!"), "fix-bug-123");
451 }
452
453 #[test]
454 fn test_slugify_leading_trailing() {
455 assert_eq!(slugify(" hello world "), "hello-world");
456 }
457
458 #[test]
459 fn test_slugify_consecutive_special() {
460 assert_eq!(slugify("a---b___c"), "a-b-c");
461 }
462
463 #[test]
464 fn test_generate_id_unique() {
465 let existing = HashSet::new();
466 let id = generate_id(&existing, 4);
467 assert_eq!(id.len(), 4);
468 let allowed: Vec<char> = std::str::from_utf8(ID_CHARSET).unwrap().chars().collect();
469 assert!(id.chars().all(|c| allowed.contains(&c)));
470 }
471
472 #[test]
473 fn test_generate_id_avoids_collision() {
474 let mut existing = HashSet::new();
475 let id1 = generate_id(&existing, 4);
476 existing.insert(id1.clone());
477 let id2 = generate_id(&existing, 4);
478 assert_ne!(id1, id2);
479 }
480
481 #[test]
482 fn test_generate_id_excludes_ambiguous_chars() {
483 let existing = HashSet::new();
484 let ambiguous = ['0', 'o', '1', 'l', 'i'];
485 for _ in 0..100 {
486 let id = generate_id(&existing, 6);
487 assert!(!id.chars().any(|c| ambiguous.contains(&c)));
488 }
489 }
490
491 #[test]
492 fn test_yaml_safe_id_rejects_nan() {
493 assert!(!yaml_safe_id("nan"));
494 assert!(yaml_safe_id("abc"));
495 assert!(yaml_safe_id("a2b"));
496 }
497
498 #[test]
499 fn test_generate_id_never_yaml_ambiguous() {
500 let existing = HashSet::new();
502 for _ in 0..200 {
503 let id = generate_id(&existing, 3);
504 assert!(yaml_safe_id(&id), "generated YAML-unsafe id {id}");
505 }
506 }
507
508 #[test]
509 fn test_parse_unquoted_nan_id_and_title() {
510 let content = "---\nid: nan\ntitle: nan\nstatus: open\npriority: P2\ncreated: 2026-03-15T10:30:00Z\nupdated: 2026-03-15T10:30:00Z\n---\n";
513 let task = parse_task(content).unwrap();
514 assert_eq!(task.id, "nan");
515 assert_eq!(task.title, "nan");
516 }
517
518 #[test]
519 fn test_parse_unquoted_numeric_title() {
520 let content = "---\nid: ab2\ntitle: 42\nstatus: open\npriority: P2\ncreated: 2026-03-15T10:30:00Z\nupdated: 2026-03-15T10:30:00Z\n---\n";
522 let task = parse_task(content).unwrap();
523 assert_eq!(task.title, "42");
524 }
525
526 #[test]
527 fn test_nan_task_roundtrips() {
528 let mut t = Task::new("nan".into(), "nan".into(), Priority::P2);
529 t.body = "body".into();
530 let rendered = render_task(&t);
531 let parsed = parse_task(&rendered).unwrap();
532 assert_eq!(parsed.id, "nan");
533 assert_eq!(parsed.title, "nan");
534 }
535
536 #[test]
537 fn test_parse_render_roundtrip() {
538 let content = "---
539id: a1b2
540title: Test task
541status: open
542priority: P1
543created: 2026-03-15T10:30:00Z
544updated: 2026-03-15T10:30:00Z
545tags:
546- backend
547depends_on:
548- f4c9
549assignee: ''
550---
551
552This is the body.
553";
554 let task = parse_task(content).unwrap();
555 assert_eq!(task.id, "a1b2");
556 assert_eq!(task.title, "Test task");
557 assert_eq!(task.status, Status::Open);
558 assert_eq!(task.priority, Priority::P1);
559 assert_eq!(task.tags, vec!["backend"]);
560 assert_eq!(task.depends_on, vec!["f4c9"]);
561 assert_eq!(task.body, "This is the body.\n");
562
563 let rendered = render_task(&task);
565 let task2 = parse_task(&rendered).unwrap();
566 assert_eq!(task2.id, task.id);
567 assert_eq!(task2.title, task.title);
568 assert_eq!(task2.body, task.body);
569 }
570
571 #[test]
572 fn test_parse_no_body() {
573 let content = "---
574id: x1y2
575title: No body task
576status: done
577priority: P3
578created: 2026-03-15T10:30:00Z
579updated: 2026-03-15T10:30:00Z
580---
581";
582 let task = parse_task(content).unwrap();
583 assert_eq!(task.id, "x1y2");
584 assert_eq!(task.body, "");
585 }
586
587 #[test]
588 fn test_parse_missing_delimiter() {
589 let content = "id: broken\ntitle: No delimiters\n";
590 assert!(parse_task(content).is_err());
591 }
592
593 #[test]
594 fn test_filename() {
595 let task = Task::new("a1b2".into(), "Implement OAuth flow".into(), Priority::P1);
596 assert_eq!(filename(&task), "a1b2-implement-oauth-flow.md");
597 }
598
599 #[test]
600 fn test_priority_ordering() {
601 assert!(Priority::P0 < Priority::P1);
602 assert!(Priority::P1 < Priority::P2);
603 assert!(Priority::P2 < Priority::P3);
604 }
605
606 #[test]
607 fn test_status_display() {
608 assert_eq!(Status::InProgress.to_string(), "in_progress");
609 assert_eq!(Status::Open.to_string(), "open");
610 }
611
612 #[test]
613 fn test_status_from_str() {
614 assert_eq!("in_progress".parse::<Status>().unwrap(), Status::InProgress);
615 assert!("invalid".parse::<Status>().is_err());
616 }
617
618 #[test]
619 fn test_summary_basic_fields() {
620 let t = Task::new("ab12".into(), "Test task".into(), Priority::P2);
621 let s = t.summary(None);
622 let json = serde_json::to_value(&s).unwrap();
623 assert_eq!(json["id"], "ab12");
624 assert_eq!(json["title"], "Test task");
625 assert_eq!(json["status"], "open");
626 assert_eq!(json["priority"], "P2");
627 assert!(json.get("effective_priority").is_none());
628 }
629
630 #[test]
631 fn test_summary_with_effective_priority() {
632 let t = Task::new("cd34".into(), "High eff".into(), Priority::P3);
633 let s = t.summary(Some(&Priority::P1));
634 let json = serde_json::to_value(&s).unwrap();
635 assert_eq!(json["effective_priority"], "P1");
636 }
637
638 #[test]
639 fn test_summary_effective_not_set_when_same() {
640 let t = Task::new("ef56".into(), "Same prio".into(), Priority::P1);
641 let s = t.summary(Some(&Priority::P1));
642 let json = serde_json::to_value(&s).unwrap();
643 assert!(json.get("effective_priority").is_none());
644 }
645
646 #[test]
647 fn test_detail_has_all_fields() {
648 let mut t = Task::new("gh78".into(), "Detail test".into(), Priority::P1);
649 t.body = "Some body".into();
650 t.depends_on = vec!["ab12".into()];
651 t.parent = Some("zz99".into());
652 t.assignee = "alice".into();
653
654 let d = t.detail(Some(&Priority::P0));
655 let json = serde_json::to_value(&d).unwrap();
656 assert_eq!(json["id"], "gh78");
658 assert_eq!(json["title"], "Detail test");
659 assert_eq!(json["priority"], "P1");
660 assert_eq!(json["effective_priority"], "P0");
661 assert_eq!(json["body"], "Some body");
663 assert_eq!(json["depends_on"], serde_json::json!(["ab12"]));
664 assert_eq!(json["parent"], "zz99");
665 assert_eq!(json["assignee"], "alice");
666 assert!(json.get("created").is_some());
667 assert!(json.get("updated").is_some());
668 }
669
670 #[test]
671 fn test_is_active() {
672 let mut t = Task::new("a1".into(), "T".into(), Priority::P2);
673 assert!(is_active(&t));
674
675 t.status = Status::InProgress;
676 assert!(is_active(&t));
677
678 t.status = Status::Done;
679 assert!(!is_active(&t));
680
681 t.status = Status::Cancelled;
682 assert!(!is_active(&t));
683
684 t.status = Status::Blocked;
685 assert!(is_active(&t));
686 }
687
688 #[test]
689 fn test_matches_tag() {
690 let mut t = Task::new("b2".into(), "T".into(), Priority::P2);
691 t.tags = vec!["backend".into(), "api".into()];
692
693 assert!(matches_tag(&t, None));
694 assert!(matches_tag(&t, Some("backend")));
695 assert!(matches_tag(&t, Some("api")));
696 assert!(!matches_tag(&t, Some("frontend")));
697 }
698
699 #[test]
700 fn test_sort_by_priority_owned() {
701 let mut t1 = Task::new("a1".into(), "Low".into(), Priority::P3);
702 t1.created = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
703 .unwrap()
704 .with_timezone(&Utc);
705 let mut t2 = Task::new("b2".into(), "High".into(), Priority::P0);
706 t2.created = chrono::DateTime::parse_from_rfc3339("2026-01-02T00:00:00Z")
707 .unwrap()
708 .with_timezone(&Utc);
709 let mut t3 = Task::new("c3".into(), "Also low, older".into(), Priority::P3);
710 t3.created = chrono::DateTime::parse_from_rfc3339("2025-06-01T00:00:00Z")
711 .unwrap()
712 .with_timezone(&Utc);
713
714 let mut tasks = vec![t1, t3, t2];
715 sort_by_priority_owned(&mut tasks);
716
717 assert_eq!(tasks[0].id, "b2"); assert_eq!(tasks[1].id, "c3"); assert_eq!(tasks[2].id, "a1"); }
721
722 #[test]
725 fn test_parse_crlf_line_endings() {
726 let content = "---\r\nid: cr01\r\ntitle: CRLF task\r\nstatus: open\r\npriority: P2\r\ncreated: 2026-03-15T10:30:00Z\r\nupdated: 2026-03-15T10:30:00Z\r\n---\r\n\r\nBody with CRLF.\r\n";
727 let task = parse_task(content).unwrap();
728 assert_eq!(task.id, "cr01");
729 assert_eq!(task.title, "CRLF task");
730 assert_eq!(task.body, "Body with CRLF.\n");
731 }
732
733 #[test]
734 fn test_parse_bom_prefix() {
735 let content = "\u{feff}---\nid: bom1\ntitle: BOM task\nstatus: open\npriority: P1\ncreated: 2026-03-15T10:30:00Z\nupdated: 2026-03-15T10:30:00Z\n---\n";
736 let task = parse_task(content).unwrap();
737 assert_eq!(task.id, "bom1");
738 assert_eq!(task.body, "");
739 }
740
741 #[test]
742 fn test_parse_bom_with_crlf() {
743 let content = "\u{feff}---\r\nid: bc01\r\ntitle: BOM+CRLF\r\nstatus: done\r\npriority: P0\r\ncreated: 2026-03-15T10:30:00Z\r\nupdated: 2026-03-15T10:30:00Z\r\n---\r\n\r\nBoth BOM and CRLF.\r\n";
744 let task = parse_task(content).unwrap();
745 assert_eq!(task.id, "bc01");
746 assert_eq!(task.title, "BOM+CRLF");
747 assert_eq!(task.body, "Both BOM and CRLF.\n");
748 }
749
750 #[test]
751 fn test_parse_missing_opening_delimiter() {
752 let content = "id: broken\ntitle: No opening\n---\n";
753 let err = parse_task(content).unwrap_err();
754 assert!(err.to_string().contains("opening ---"));
755 }
756
757 #[test]
758 fn test_parse_missing_closing_delimiter() {
759 let content = "---\nid: broken\ntitle: No closing\n";
760 let err = parse_task(content).unwrap_err();
761 assert!(err.to_string().contains("closing ---"));
762 }
763
764 #[test]
765 fn test_parse_no_body_no_trailing_newline() {
766 let content = "---\nid: nb01\ntitle: Minimal\nstatus: open\npriority: P3\ncreated: 2026-03-15T10:30:00Z\nupdated: 2026-03-15T10:30:00Z\n---";
767 let task = parse_task(content).unwrap();
768 assert_eq!(task.id, "nb01");
769 assert_eq!(task.body, "");
770 }
771
772 #[test]
773 fn test_parse_body_preserves_internal_triple_dashes() {
774 let content = "---\nid: td01\ntitle: Triple dashes in body\nstatus: open\npriority: P2\ncreated: 2026-03-15T10:30:00Z\nupdated: 2026-03-15T10:30:00Z\n---\n\nSome text\n--- not a delimiter\nMore text\n";
775 let task = parse_task(content).unwrap();
776 assert_eq!(task.id, "td01");
777 assert!(task.body.contains("--- not a delimiter"));
778 assert!(task.body.contains("More text"));
779 }
780
781 #[test]
784 fn test_task_type_default() {
785 assert_eq!(TaskType::default(), TaskType::Task);
786 }
787
788 #[test]
789 fn test_task_type_display() {
790 assert_eq!(TaskType::Task.to_string(), "task");
791 assert_eq!(TaskType::Epic.to_string(), "epic");
792 }
793
794 #[test]
795 fn test_task_type_from_str() {
796 assert_eq!("task".parse::<TaskType>().unwrap(), TaskType::Task);
797 assert_eq!("epic".parse::<TaskType>().unwrap(), TaskType::Epic);
798 assert!("invalid".parse::<TaskType>().is_err());
799 }
800
801 #[test]
802 fn test_task_new_defaults_to_task_type() {
803 let t = Task::new("ab12".into(), "Test".into(), Priority::P1);
804 assert_eq!(t.task_type, TaskType::Task);
805 }
806
807 #[test]
808 fn test_task_type_not_serialized_when_task() {
809 let t = Task::new("ab12".into(), "Normal task".into(), Priority::P1);
810 let rendered = render_task(&t);
811 assert!(!rendered.contains("type:"));
812 }
813
814 #[test]
815 fn test_task_type_serialized_when_epic() {
816 let mut t = Task::new("ab12".into(), "My epic".into(), Priority::P1);
817 t.task_type = TaskType::Epic;
818 let rendered = render_task(&t);
819 assert!(rendered.contains("type: epic"));
820 }
821
822 #[test]
823 fn test_parse_task_without_type_defaults_to_task() {
824 let content = "---\nid: nt01\ntitle: No type\nstatus: open\npriority: P1\ncreated: 2026-03-15T10:30:00Z\nupdated: 2026-03-15T10:30:00Z\n---\n";
825 let task = parse_task(content).unwrap();
826 assert_eq!(task.task_type, TaskType::Task);
827 }
828
829 #[test]
830 fn test_parse_task_with_epic_type() {
831 let content = "---\nid: ep01\ntitle: My epic\ntype: epic\nstatus: open\npriority: P1\ncreated: 2026-03-15T10:30:00Z\nupdated: 2026-03-15T10:30:00Z\n---\n";
832 let task = parse_task(content).unwrap();
833 assert_eq!(task.task_type, TaskType::Epic);
834 }
835
836 #[test]
837 fn test_epic_roundtrip() {
838 let mut t = Task::new("ep02".into(), "Epic roundtrip".into(), Priority::P0);
839 t.task_type = TaskType::Epic;
840 let rendered = render_task(&t);
841 let parsed = parse_task(&rendered).unwrap();
842 assert_eq!(parsed.task_type, TaskType::Epic);
843 assert_eq!(parsed.id, "ep02");
844 }
845
846 #[test]
847 fn test_summary_includes_type_for_epic() {
848 let mut t = Task::new("ep03".into(), "Epic sum".into(), Priority::P1);
849 t.task_type = TaskType::Epic;
850 let s = t.summary(None);
851 let json = serde_json::to_value(&s).unwrap();
852 assert_eq!(json["type"], "epic");
853 }
854
855 #[test]
856 fn test_summary_omits_type_for_task() {
857 let t = Task::new("tk01".into(), "Task sum".into(), Priority::P1);
858 let s = t.summary(None);
859 let json = serde_json::to_value(&s).unwrap();
860 assert!(json.get("type").is_none());
861 }
862
863 #[test]
866 fn test_enum_string_roundtrip() {
867 let status_cases: &[(Status, &str)] = &[
869 (Status::Open, "open"),
870 (Status::InProgress, "in_progress"),
871 (Status::Done, "done"),
872 (Status::Blocked, "blocked"),
873 (Status::Cancelled, "cancelled"),
874 ];
875 for (variant, s) in status_cases {
876 assert_eq!(variant.to_string(), *s, "Status Display mismatch");
878 assert_eq!(
880 &s.parse::<Status>().unwrap(),
881 variant,
882 "Status FromStr mismatch"
883 );
884 let json = serde_json::to_value(variant).unwrap();
886 assert_eq!(json.as_str().unwrap(), *s, "Status serde mismatch");
887 let de: Status = serde_json::from_value(json).unwrap();
888 assert_eq!(&de, variant, "Status serde round-trip mismatch");
889 }
890
891 let priority_cases: &[(Priority, &str)] = &[
893 (Priority::P0, "P0"),
894 (Priority::P1, "P1"),
895 (Priority::P2, "P2"),
896 (Priority::P3, "P3"),
897 ];
898 for (variant, s) in priority_cases {
899 assert_eq!(variant.to_string(), *s, "Priority Display mismatch");
900 assert_eq!(
901 &s.parse::<Priority>().unwrap(),
902 variant,
903 "Priority FromStr mismatch"
904 );
905 let json = serde_json::to_value(variant).unwrap();
906 assert_eq!(json.as_str().unwrap(), *s, "Priority serde mismatch");
907 let de: Priority = serde_json::from_value(json).unwrap();
908 assert_eq!(&de, variant, "Priority serde round-trip mismatch");
909 }
910
911 let type_cases: &[(TaskType, &str)] = &[(TaskType::Task, "task"), (TaskType::Epic, "epic")];
913 for (variant, s) in type_cases {
914 assert_eq!(variant.to_string(), *s, "TaskType Display mismatch");
915 assert_eq!(
916 &s.parse::<TaskType>().unwrap(),
917 variant,
918 "TaskType FromStr mismatch"
919 );
920 let json = serde_json::to_value(variant).unwrap();
921 assert_eq!(json.as_str().unwrap(), *s, "TaskType serde mismatch");
922 let de: TaskType = serde_json::from_value(json).unwrap();
923 assert_eq!(&de, variant, "TaskType serde round-trip mismatch");
924 }
925 }
926
927 #[test]
928 fn test_attempts_missing_reads_as_zero() {
929 let content = "---\nid: ab2\ntitle: No counter\nstatus: open\npriority: P2\ncreated: 2026-03-15T10:30:00Z\nupdated: 2026-03-15T10:30:00Z\n---\n";
930 let task = parse_task(content).unwrap();
931 assert_eq!(task.attempts, None);
932 assert_eq!(task.attempt_count(), 0);
933
934 let rendered = render_task(&task);
936 assert!(!rendered.contains("attempts"), "{rendered}");
937 }
938
939 #[test]
940 fn test_attempts_roundtrip() {
941 let mut t = Task::new("ab2".into(), "Retried".into(), Priority::P2);
942 t.attempts = Some(3);
943 let rendered = render_task(&t);
944 assert!(rendered.contains("attempts: 3"), "{rendered}");
945
946 let parsed = parse_task(&rendered).unwrap();
947 assert_eq!(parsed.attempt_count(), 3);
948 }
949
950 #[test]
951 fn test_summary_hides_zero_attempts() {
952 let mut t = Task::new("ab2".into(), "Task".into(), Priority::P2);
953 let json = serde_json::to_value(t.summary(None)).unwrap();
954 assert!(json.get("attempts").is_none(), "{json}");
955
956 t.attempts = Some(2);
957 let json = serde_json::to_value(t.summary(None)).unwrap();
958 assert_eq!(json["attempts"], 2);
959 }
960}