Skip to main content

bears/
task.rs

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
10/// Generate `Display` and `FromStr` for an enum whose serde string forms are
11/// the single source of truth.  Each arm is `Variant => "string"`.
12/// The error message prefix (e.g. `"invalid status"`) is passed as `$err_prefix`.
13macro_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
138/// Accept any YAML scalar where a string is expected.
139///
140/// serde_yml's emitter quotes most ambiguous strings ("234", "true") but not
141/// "nan", which YAML then resolves as a float — and hand-edited files may
142/// contain unquoted numbers or booleans in string positions. Rather than
143/// rejecting the whole task file, coerce the scalar back to its string form.
144fn 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            // Match the lowercase forms YAML emitters use for these scalars.
165            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    /// Compact projection: id, title, type, status, priority, tags, and optional effective priority.
201    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    /// Full projection: all summary fields plus body, deps, parent, assignee, timestamps.
216    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    /// Epic projection: id, title, status, priority, tags, and progress.
229    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/// Compact task projection used by list/ready/create/update responses.
245#[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/// Full task projection used by show/get_task responses.
259#[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
272// ── Shared filtering & sorting helpers ──────────────────────────────
273
274/// Whether a task is in an "active" status (not done or cancelled).
275pub fn is_active(task: &Task) -> bool {
276    !matches!(task.status, Status::Done | Status::Cancelled)
277}
278
279/// Whether a task matches the given tag (if any).
280pub fn matches_tag(task: &Task, tag: Option<&str>) -> bool {
281    tag.is_none_or(|t| task.tags.iter().any(|tt| tt == t))
282}
283
284/// Canonical sort: priority ascending (P0 first), then creation date ascending.
285pub 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
289/// Unambiguous character set for ID generation.
290/// Excludes easily confused characters: 0/o, 1/l/i.
291const ID_CHARSET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789";
292
293/// An ID is YAML-safe when its unquoted occurrence still parses as a string.
294/// "nan" parses as a float, which serde_yml's emitter does not protect against
295/// with quotes — such an ID would corrupt the frontmatter on the first save.
296fn 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
303/// Generate a short alphanumeric ID, retrying on collision or YAML-unsafe IDs.
304pub 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
316/// Convert a title to a URL-friendly slug.
317pub 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    // Collapse consecutive dashes and trim dashes from ends
325    let mut result = String::new();
326    let mut prev_dash = true; // treat start as dash to trim leading
327    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    // Trim trailing dash
339    if result.ends_with('-') {
340        result.pop();
341    }
342    result
343}
344
345/// Build the filename for a task: `{id}-{slug}.md`
346pub fn filename(task: &Task) -> String {
347    format!("{}-{}.md", task.id, slugify(&task.title))
348}
349
350/// Parse a task from markdown content with YAML frontmatter.
351///
352/// Handles BOM, CRLF/LF line endings, and validates that `---` delimiters
353/// appear on their own lines. Returns a clear error for malformed input.
354pub fn parse_task(content: &str) -> Result<Task> {
355    // Strip BOM and normalize CRLF → LF
356    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    // First line must be exactly "---"
362    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    // Collect YAML lines until we hit a closing "---"
373    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    // Everything after the closing delimiter is the body
393    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
402/// Render a task back to markdown with YAML frontmatter.
403pub fn render_task(task: &Task) -> String {
404    let mut yaml = serde_yml::to_string(task).expect("task serialization should not fail");
405    // Some YAML serializers omit the trailing newline; the closing
406    // delimiter must start on its own line.
407    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        // With length 3 the generator could produce "nan" — ensure it is skipped.
479        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        // serde_yml's emitter writes `nan` unquoted; the parser then sees a
489        // float. The lenient deserializer must coerce it back to a string.
490        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        // Hand-edited files may leave numbers unquoted in string positions.
499        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        // Re-render and re-parse
542        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        // Flattened summary fields
635        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        // Detail-only fields
640        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"); // P0
696        assert_eq!(tasks[1].id, "c3"); // P3, older
697        assert_eq!(tasks[2].id, "a1"); // P3, newer
698    }
699
700    // ── Frontmatter parsing edge-case tests ─────────────────────────
701
702    #[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    // ── TaskType tests ──────────────────────────────────────────────
760
761    #[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    /// Verify that Display, FromStr, and serde all agree on the exact string
842    /// forms for every variant — one source of truth.
843    #[test]
844    fn test_enum_string_roundtrip() {
845        // Status
846        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            // Display matches
855            assert_eq!(variant.to_string(), *s, "Status Display mismatch");
856            // FromStr round-trips
857            assert_eq!(
858                &s.parse::<Status>().unwrap(),
859                variant,
860                "Status FromStr mismatch"
861            );
862            // serde JSON matches
863            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        // Priority
870        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        // TaskType
890        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}