1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
//! `TaskType` — a free-form, identifier-safe label attached to each
//! task. Types are pure labels: nothing in balls branches on the
//! value except "is this `epic`?" (which drives progress/display).
//! Widening the vocabulary therefore lives here, not in call sites.
//!
//! The type is a `String` newtype instead of a closed enum so new
//! words like `feature`, `chore`, `spike`, `question` work without a
//! code change. Strict identifier rules are enforced on CLI input
//! (`parse`); deserialization is lenient so an older binary never
//! bricks on a task file written by a newer one.
use crate::error::{BallError, Result};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
/// One task-type label. Always lowercase; starts with a letter;
/// otherwise letters, digits, `_`, `-`. Filesystem-safe and
/// git-ref-safe by construction.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskType(String);
impl TaskType {
/// The only type balls treats specially — epic rows get a label
/// and a progress bar. Named here so call sites avoid a magic
/// string.
pub const EPIC: &'static str = "epic";
/// Common vocabulary surfaced in `-t` help text as suggestions.
/// These are **not** the only allowed values — any identifier
/// that passes `is_identifier` works. Kept here (not in config)
/// so the CLI has something to show without a repo context.
pub const SUGGESTIONS: &[&str] = &[
"task",
"bug",
"epic",
"feature",
"chore",
"spike",
"question",
"discussion",
"retro",
];
/// Strict constructor for CLI input. Rejects non-identifiers so
/// `bl create -t 'Fix Things'` fails up front instead of writing
/// a value that would later confuse greppers and filesystems.
pub fn parse(s: &str) -> Result<Self> {
if !is_identifier(s) {
return Err(BallError::InvalidTask(format!(
"invalid type: {s:?} (expected identifier like 'task', 'feature', 'spike')"
)));
}
Ok(TaskType(s.to_string()))
}
/// Canonical `"task"`. Used as the default when a call site needs
/// one without going through `parse`.
pub fn task() -> Self {
TaskType("task".to_string())
}
/// Canonical `"epic"`.
pub fn epic() -> Self {
TaskType(Self::EPIC.to_string())
}
/// Canonical `"bug"`.
pub fn bug() -> Self {
TaskType("bug".to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
/// The one behavior-bearing predicate: epics get the `[epic]`
/// marker and the child-progress bar.
pub fn is_epic(&self) -> bool {
self.0 == Self::EPIC
}
}
/// `^[a-z][a-z0-9_-]*$`, hand-rolled so the crate doesn't pull in
/// `regex` just for this check.
fn is_identifier(s: &str) -> bool {
let mut chars = s.chars();
let Some(first) = chars.next() else { return false };
if !first.is_ascii_lowercase() {
return false;
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
}
impl fmt::Display for TaskType {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(&self.0)
}
}
impl Serialize for TaskType {
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
s.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for TaskType {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
// Lenient by design: an older `bl` must round-trip a task
// type written by a newer one even if the value wouldn't
// pass `parse`. Identifier enforcement is a create-time
// rule, not a load-time rule.
let s = String::deserialize(d)?;
Ok(TaskType(s))
}
}
#[cfg(test)]
#[path = "task_type_tests.rs"]
mod tests;