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