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    /// Suggested but not yet accepted into the backlog. Never ready.
44    #[serde(rename = "proposed")]
45    Proposed,
46    #[serde(rename = "open")]
47    Open,
48    #[serde(rename = "in_progress")]
49    InProgress,
50    /// Work is finished and waiting for someone else to look at it.
51    #[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    /// How many attempts have been started on this task. A missing field means
143    /// zero — the counter only appears once a task has actually been started.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub attempts: Option<u32>,
146    #[serde(skip)]
147    pub body: String,
148}
149
150/// Accept any YAML scalar where a string is expected.
151///
152/// serde_yml's emitter quotes most ambiguous strings ("234", "true") but not
153/// "nan", which YAML then resolves as a float — and hand-edited files may
154/// contain unquoted numbers or booleans in string positions. Rather than
155/// rejecting the whole task file, coerce the scalar back to its string form.
156fn 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            // Match the lowercase forms YAML emitters use for these scalars.
177            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    /// Attempts started on this task, treating a missing counter as zero.
214    pub fn attempt_count(&self) -> u32 {
215        self.attempts.unwrap_or(0)
216    }
217
218    /// Compact projection: id, title, type, status, priority, tags, assignee, attempts, and optional effective priority.
219    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    /// Full projection: all summary fields plus body, deps, parent, timestamps.
236    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    /// Epic projection: id, title, status, priority, tags, and progress.
248    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/// Compact task projection used by list/ready/create/update responses.
264#[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/// Full task projection used by show/get_task responses.
282#[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
294// ── Shared filtering & sorting helpers ──────────────────────────────
295
296/// Whether a task is in an "active" status (not done or cancelled).
297pub fn is_active(task: &Task) -> bool {
298    !matches!(task.status, Status::Done | Status::Cancelled)
299}
300
301/// Whether a task matches the given tag (if any).
302pub fn matches_tag(task: &Task, tag: Option<&str>) -> bool {
303    tag.is_none_or(|t| task.tags.iter().any(|tt| tt == t))
304}
305
306/// Canonical sort: priority ascending (P0 first), then creation date ascending.
307pub 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
311/// Unambiguous character set for ID generation.
312/// Excludes easily confused characters: 0/o, 1/l/i.
313const ID_CHARSET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789";
314
315/// An ID is YAML-safe when its unquoted occurrence still parses as a string.
316/// "nan" parses as a float, which serde_yml's emitter does not protect against
317/// with quotes — such an ID would corrupt the frontmatter on the first save.
318fn 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
325/// Generate a short alphanumeric ID, retrying on collision or YAML-unsafe IDs.
326pub 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
338/// Convert a title to a URL-friendly slug.
339pub 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    // Collapse consecutive dashes and trim dashes from ends
347    let mut result = String::new();
348    let mut prev_dash = true; // treat start as dash to trim leading
349    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    // Trim trailing dash
361    if result.ends_with('-') {
362        result.pop();
363    }
364    result
365}
366
367/// Build the filename for a task: `{id}-{slug}.md`
368pub fn filename(task: &Task) -> String {
369    format!("{}-{}.md", task.id, slugify(&task.title))
370}
371
372/// Parse a task from markdown content with YAML frontmatter.
373///
374/// Handles BOM, CRLF/LF line endings, and validates that `---` delimiters
375/// appear on their own lines. Returns a clear error for malformed input.
376pub fn parse_task(content: &str) -> Result<Task> {
377    // Strip BOM and normalize CRLF → LF
378    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    // First line must be exactly "---"
384    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    // Collect YAML lines until we hit a closing "---"
395    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    // Everything after the closing delimiter is the body
415    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
424/// Render a task back to markdown with YAML frontmatter.
425pub fn render_task(task: &Task) -> String {
426    let mut yaml = serde_yml::to_string(task).expect("task serialization should not fail");
427    // Some YAML serializers omit the trailing newline; the closing
428    // delimiter must start on its own line.
429    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        // With length 3 the generator could produce "nan" — ensure it is skipped.
501        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        // serde_yml's emitter writes `nan` unquoted; the parser then sees a
511        // float. The lenient deserializer must coerce it back to a string.
512        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        // Hand-edited files may leave numbers unquoted in string positions.
521        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        // Re-render and re-parse
564        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        // Flattened summary fields
657        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        // Detail-only fields
662        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"); // P0
718        assert_eq!(tasks[1].id, "c3"); // P3, older
719        assert_eq!(tasks[2].id, "a1"); // P3, newer
720    }
721
722    // ── Frontmatter parsing edge-case tests ─────────────────────────
723
724    #[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    // ── TaskType tests ──────────────────────────────────────────────
782
783    #[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    /// Verify that Display, FromStr, and serde all agree on the exact string
864    /// forms for every variant — one source of truth.
865    #[test]
866    fn test_enum_string_roundtrip() {
867        // Status
868        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            // Display matches
877            assert_eq!(variant.to_string(), *s, "Status Display mismatch");
878            // FromStr round-trips
879            assert_eq!(
880                &s.parse::<Status>().unwrap(),
881                variant,
882                "Status FromStr mismatch"
883            );
884            // serde JSON matches
885            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        // Priority
892        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        // TaskType
912        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        // An untouched task does not gain the field on write.
935        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}