steid

@jamesgill /

steid/src/domain/label.rs
Rust9.9 KBCode·Blame·History·Raw
1//! Labels — the short coloured words a repository sorts its issues with.
2//!
3//! A label belongs to a repository, not to the instance: `bug` on one repository and
4//! `bug` on another are different rows, so one owner renaming theirs cannot touch
5//! anybody else's. The name is unique **within** a repository, which is what makes
6//! `?label=bug` on the issues list a URL that names one thing.
7//!
8//! **The colour is one of eight names, never a hex value.** `plans/ui.md` says
9//! components refer to theme tokens and never to raw colours, and a hex field would put
10//! a raw colour in the database and then on the page — unable to follow a palette change
11//! and free to be unreadable in one of the two colour schemes. Eight names map onto
12//! eight pairs of `--label-*` tokens defined for light and dark alike, so every label
13//! anyone can make is legible in both.
14
15use std::{fmt, str::FromStr, time::SystemTime};
16
17use super::{DomainError, LabelId, RepoId};
18
19/// The palette a label's colour is drawn from.
20///
21/// Eight, because that is enough to tell a set of labels apart at a glance and few
22/// enough to render as a row of swatches that fits at 800px. Adding a ninth means a
23/// variant here and a pair of tokens in `styles.css`, and nothing else.
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
25pub enum LabelColour {
26 /// The default, and what an unremarkable label should be: the palette is for
27 /// distinguishing the few labels that matter, not for colouring all of them.
28 #[default]
29 Gray,
30 Red,
31 Orange,
32 Yellow,
33 Green,
34 Blue,
35 Purple,
36 Pink,
37}
38
39impl LabelColour {
40 /// Every colour, in the order the swatch row draws them.
41 ///
42 /// Gray leads because it is the default; the rest run around the wheel so two
43 /// adjacent swatches never look like the same decision.
44 pub const ALL: [Self; 8] = [
45 Self::Gray,
46 Self::Red,
47 Self::Orange,
48 Self::Yellow,
49 Self::Green,
50 Self::Blue,
51 Self::Purple,
52 Self::Pink,
53 ];
54
55 pub fn as_str(self) -> &'static str {
56 match self {
57 Self::Gray => "gray",
58 Self::Red => "red",
59 Self::Orange => "orange",
60 Self::Yellow => "yellow",
61 Self::Green => "green",
62 Self::Blue => "blue",
63 Self::Purple => "purple",
64 Self::Pink => "pink",
65 }
66 }
67}
68
69impl fmt::Display for LabelColour {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 f.write_str(self.as_str())
72 }
73}
74
75/// `Result`, never `Option`, for the reason [`Visibility`](super::Visibility)'s parse
76/// is: a colour that silently defaulted would turn a tampered form into a label that
77/// quietly changed appearance, and a stored value that failed to read would repaint
78/// somebody's whole tracker gray.
79impl FromStr for LabelColour {
80 type Err = DomainError;
81
82 fn from_str(value: &str) -> Result<Self, Self::Err> {
83 Self::ALL
84 .into_iter()
85 .find(|colour| colour.as_str() == value)
86 .ok_or_else(|| {
87 DomainError::validation("colour", format!("unknown label colour {value:?}"))
88 })
89 }
90}
91
92/// A label.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Label {
95 pub id: LabelId,
96 pub repo_id: RepoId,
97 /// Unique within the repository, case-sensitively — see [`Label::MAX_NAME_LEN`].
98 pub name: String,
99 pub colour: LabelColour,
100 /// A sentence saying when to use it, or `None`. Shown on the management page and in
101 /// the pill's tooltip, never on a list row: a row has space for the word only.
102 pub description: Option<String>,
103 pub created_at: SystemTime,
104}
105
106impl Label {
107 /// Longest permitted name. A word or two — the pill sits inline after an issue's
108 /// title, and a name long enough to wrap turns a scanning surface into a paragraph.
109 pub const MAX_NAME_LEN: usize = 50;
110
111 /// Longest permitted description. A sentence, like a repository's.
112 pub const MAX_DESCRIPTION_LEN: usize = 200;
113
114 /// Creates a label from user-supplied input.
115 ///
116 /// Uniqueness is **not** checked here: the domain cannot see the repository's other
117 /// labels. The use case checks it, and the unique index is the backstop.
118 pub fn new(
119 id: LabelId,
120 repo_id: RepoId,
121 name: impl Into<String>,
122 colour: LabelColour,
123 description: Option<String>,
124 now: SystemTime,
125 ) -> Result<Self, DomainError> {
126 let (name, description) = validate(name, description)?;
127
128 Ok(Self {
129 id,
130 repo_id,
131 name,
132 colour,
133 description,
134 created_at: now,
135 })
136 }
137
138 /// Rebuilds a label from storage without revalidating it.
139 pub fn from_trusted(
140 id: LabelId,
141 repo_id: RepoId,
142 name: impl Into<String>,
143 colour: LabelColour,
144 description: Option<String>,
145 created_at: SystemTime,
146 ) -> Self {
147 Self {
148 id,
149 repo_id,
150 name: name.into(),
151 colour,
152 description,
153 created_at,
154 }
155 }
156
157 /// Renames and recolours, validating exactly as a fresh submission would be.
158 ///
159 /// One method rather than three: the management page edits all three fields in one
160 /// `<details>` form, and an edit that accepted a name creation would refuse is a rule
161 /// that disagrees with itself.
162 pub fn edit(
163 &mut self,
164 name: impl Into<String>,
165 colour: LabelColour,
166 description: Option<String>,
167 ) -> Result<(), DomainError> {
168 let (name, description) = validate(name, description)?;
169
170 self.name = name;
171 self.colour = colour;
172 self.description = description;
173
174 Ok(())
175 }
176}
177
178/// The rules a label's name and description have to satisfy, shared by creation and
179/// editing.
180fn validate(
181 name: impl Into<String>,
182 description: Option<String>,
183) -> Result<(String, Option<String>), DomainError> {
184 let name = name.into();
185 let name = name.trim();
186
187 if name.is_empty() {
188 return Err(DomainError::validation("name", "must not be empty"));
189 }
190
191 if name.chars().count() > Label::MAX_NAME_LEN {
192 return Err(DomainError::validation(
193 "name",
194 format!("must be at most {} characters", Label::MAX_NAME_LEN),
195 ));
196 }
197
198 // A newline or a tab in a name would break the pill it is rendered in and make two
199 // labels that look identical in a list impossible to tell apart in a form.
200 if name.chars().any(char::is_control) {
201 return Err(DomainError::validation(
202 "name",
203 "must be a single line of text",
204 ));
205 }
206
207 let description = description
208 .map(|value| value.trim().to_owned())
209 .filter(|value| !value.is_empty());
210
211 if let Some(description) = &description
212 && description.chars().count() > Label::MAX_DESCRIPTION_LEN
213 {
214 return Err(DomainError::validation(
215 "description",
216 format!("must be at most {} characters", Label::MAX_DESCRIPTION_LEN),
217 ));
218 }
219
220 Ok((name.to_owned(), description))
221}
222
223#[cfg(test)]
224mod tests {
225 use std::time::Duration;
226
227 use super::*;
228
229 fn at(seconds: u64) -> SystemTime {
230 SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)
231 }
232
233 fn label(name: &str, description: Option<&str>) -> Result<Label, DomainError> {
234 Label::new(
235 LabelId::generate(),
236 RepoId::generate(),
237 name,
238 LabelColour::Blue,
239 description.map(str::to_owned),
240 at(1_000),
241 )
242 }
243
244 #[test]
245 fn every_colour_round_trips_through_a_string() {
246 for colour in LabelColour::ALL {
247 assert_eq!(
248 colour.as_str().parse::<LabelColour>().expect("round trip"),
249 colour
250 );
251 }
252 }
253
254 #[test]
255 fn an_unknown_colour_is_an_error_not_a_default() {
256 // A tampered radio value must not quietly become gray.
257 assert!("#ff0000".parse::<LabelColour>().is_err());
258 assert!("Blue".parse::<LabelColour>().is_err());
259 assert!("".parse::<LabelColour>().is_err());
260 }
261
262 #[test]
263 fn the_palette_has_eight_distinct_names() {
264 let mut names: Vec<&str> = LabelColour::ALL.iter().map(|c| c.as_str()).collect();
265 names.sort_unstable();
266 names.dedup();
267
268 assert_eq!(names.len(), 8);
269 }
270
271 #[test]
272 fn a_name_is_trimmed_and_required() {
273 assert_eq!(label(" bug ", None).expect("valid").name, "bug");
274 assert!(label("", None).is_err());
275 assert!(label(" ", None).is_err());
276 }
277
278 #[test]
279 fn a_name_is_bounded_and_single_line() {
280 assert!(label(&"a".repeat(Label::MAX_NAME_LEN), None).is_ok());
281 assert!(label(&"a".repeat(Label::MAX_NAME_LEN + 1), None).is_err());
282 assert!(label("two\nlines", None).is_err());
283 }
284
285 #[test]
286 fn a_blank_description_is_absent_rather_than_empty() {
287 assert_eq!(label("bug", Some(" ")).expect("valid").description, None);
288 assert_eq!(
289 label("bug", Some(" needs a fix "))
290 .expect("valid")
291 .description,
292 Some("needs a fix".to_owned())
293 );
294 assert!(label("bug", Some(&"a".repeat(Label::MAX_DESCRIPTION_LEN + 1))).is_err());
295 }
296
297 #[test]
298 fn editing_is_held_to_the_same_rules_and_a_refusal_changes_nothing() {
299 let mut label = label("bug", None).expect("valid");
300
301 label
302 .edit(" defect ", LabelColour::Red, Some(" broken ".to_owned()))
303 .expect("valid");
304 assert_eq!(label.name, "defect");
305 assert_eq!(label.colour, LabelColour::Red);
306 assert_eq!(label.description, Some("broken".to_owned()));
307
308 assert!(label.edit("", LabelColour::Gray, None).is_err());
309 assert_eq!(label.name, "defect", "a rejected edit changes nothing");
310 assert_eq!(label.colour, LabelColour::Red);
311 }
312}