steid

@jamesgill /

steid/src/domain/issue.rs
Rust17.2 KBCode·Blame·History·Raw
1//! Issues — a numbered conversation about a repository, and the comments on it.
2//!
3//! The number is per **repository**, not per issue table, and it comes from a sequence
4//! that pull requests will draw from as well. That is why [`IssueNumber`] is its own
5//! type rather than a bare `u64`: `#3` on one repository and `#3` on another are
6//! different things, and a pull request numbered `#4` must never be able to collide
7//! with an issue numbered `#4`.
8
9use std::{fmt, str::FromStr, time::SystemTime};
10
11use super::{DomainError, IssueCommentId, IssueId, RepoId, UserId};
12
13/// A repository-scoped issue number — the `#3` in `#3 opened two days ago`.
14///
15/// One-based, because there is no `#0` anywhere a person would recognise. Allocation is
16/// the storage layer's job (it has to be atomic); this type only says what a valid
17/// number looks like.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
19pub struct IssueNumber(u64);
20
21impl IssueNumber {
22 /// Validates a number from outside — a URL segment, usually.
23 pub fn new(value: u64) -> Result<Self, DomainError> {
24 if value == 0 {
25 return Err(DomainError::validation(
26 "issue number",
27 "numbering starts at 1",
28 ));
29 }
30
31 Ok(Self(value))
32 }
33
34 /// Wraps a number already allocated, typically one loaded from the database.
35 pub fn from_trusted(value: u64) -> Self {
36 Self(value)
37 }
38
39 pub fn get(self) -> u64 {
40 self.0
41 }
42}
43
44impl fmt::Display for IssueNumber {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46 write!(f, "{}", self.0)
47 }
48}
49
50/// Parsing a `{number}` path segment. `Result`, never `Option` — a silently defaulted
51/// number would serve someone else's issue.
52impl FromStr for IssueNumber {
53 type Err = DomainError;
54
55 fn from_str(value: &str) -> Result<Self, Self::Err> {
56 let parsed = value.parse::<u64>().map_err(|_| {
57 DomainError::validation("issue number", format!("{value:?} is not a number"))
58 })?;
59
60 Self::new(parsed)
61 }
62}
63
64/// Whether an issue is still being worked on.
65///
66/// Two states only. "Closed as not planned" and the rest of the taxonomy are a later
67/// decision, and adding a variant is cheaper than unpicking one.
68#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
69pub enum IssueState {
70 #[default]
71 Open,
72 Closed,
73}
74
75impl IssueState {
76 pub fn as_str(self) -> &'static str {
77 match self {
78 Self::Open => "open",
79 Self::Closed => "closed",
80 }
81 }
82
83 pub fn is_open(self) -> bool {
84 self == Self::Open
85 }
86}
87
88impl fmt::Display for IssueState {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 f.write_str(self.as_str())
91 }
92}
93
94/// `Result`, never `Option`, for the reason `Visibility`'s parse is: a state that
95/// silently defaults to `Open` would reopen every issue whose row failed to read.
96impl FromStr for IssueState {
97 type Err = DomainError;
98
99 fn from_str(value: &str) -> Result<Self, Self::Err> {
100 match value {
101 "open" => Ok(Self::Open),
102 "closed" => Ok(Self::Closed),
103 other => Err(DomainError::validation(
104 "state",
105 format!("unknown issue state {other:?}"),
106 )),
107 }
108 }
109}
110
111/// An issue.
112///
113/// `body` may be empty — a title is often the whole of what someone has to say — but a
114/// title may not, because a listing that renders a blank row is a listing with a hole
115/// in it.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct Issue {
118 pub id: IssueId,
119 pub repo_id: RepoId,
120 pub number: IssueNumber,
121 pub title: String,
122 /// Markdown, rendered by the web layer. Stored exactly as written.
123 pub body: String,
124 pub state: IssueState,
125 pub author_id: UserId,
126 pub created_at: SystemTime,
127 /// Moved by an edit, a comment, a close or a reopen — anything that changes what
128 /// the issue currently says. The listing does not order by it (newest-created is
129 /// what a numbered list means), but a future "recently active" sort needs it and
130 /// backfilling it later would be fiction.
131 pub updated_at: SystemTime,
132 /// When it was last closed, or `None` while open. Cleared on reopen rather than
133 /// kept as history: nothing reads a closure that was undone.
134 pub closed_at: Option<SystemTime>,
135}
136
137/// Longest permitted title. A subject line, not a paragraph — the list renders one per
138/// row and truncates rather than wraps.
139pub const MAX_TITLE_LEN: usize = 200;
140
141/// Longest permitted body, and the same cap for a comment.
142///
143/// Generous enough for a stack trace pasted whole, small enough that one row cannot
144/// take the page's rendering budget with it. Characters rather than bytes, matching
145/// every other length rule in the domain.
146pub const MAX_BODY_LEN: usize = 65_536;
147
148impl Issue {
149 /// Creates an issue from user-supplied input.
150 ///
151 /// The number is supplied rather than allocated here: allocation has to be atomic
152 /// against storage, which the domain cannot see.
153 pub fn new(
154 id: IssueId,
155 repo_id: RepoId,
156 number: IssueNumber,
157 title: impl Into<String>,
158 body: impl Into<String>,
159 author_id: UserId,
160 now: SystemTime,
161 ) -> Result<Self, DomainError> {
162 let (title, body) = validate_content(title, body)?;
163
164 Ok(Self {
165 id,
166 repo_id,
167 number,
168 title,
169 body,
170 state: IssueState::Open,
171 author_id,
172 created_at: now,
173 updated_at: now,
174 closed_at: None,
175 })
176 }
177
178 /// Rebuilds an issue from storage without revalidating it.
179 #[allow(clippy::too_many_arguments)]
180 pub fn from_trusted(
181 id: IssueId,
182 repo_id: RepoId,
183 number: IssueNumber,
184 title: impl Into<String>,
185 body: impl Into<String>,
186 state: IssueState,
187 author_id: UserId,
188 created_at: SystemTime,
189 updated_at: SystemTime,
190 closed_at: Option<SystemTime>,
191 ) -> Self {
192 Self {
193 id,
194 repo_id,
195 number,
196 title: title.into(),
197 body: body.into(),
198 state,
199 author_id,
200 created_at,
201 updated_at,
202 closed_at,
203 }
204 }
205
206 /// Replaces the title and body, validating them as a fresh submission would be.
207 pub fn edit(
208 &mut self,
209 title: impl Into<String>,
210 body: impl Into<String>,
211 now: SystemTime,
212 ) -> Result<(), DomainError> {
213 let (title, body) = validate_content(title, body)?;
214
215 self.title = title;
216 self.body = body;
217 self.updated_at = now;
218
219 Ok(())
220 }
221
222 /// Closes the issue, or does nothing if it is already closed.
223 ///
224 /// Idempotent rather than an error: two clicks on a Close button must not turn the
225 /// second into a failure, and re-closing carries no information worth stamping.
226 pub fn close(&mut self, now: SystemTime) {
227 if self.state == IssueState::Closed {
228 return;
229 }
230
231 self.state = IssueState::Closed;
232 self.closed_at = Some(now);
233 self.updated_at = now;
234 }
235
236 /// Reopens the issue, or does nothing if it is already open.
237 pub fn reopen(&mut self, now: SystemTime) {
238 if self.state == IssueState::Open {
239 return;
240 }
241
242 self.state = IssueState::Open;
243 self.closed_at = None;
244 self.updated_at = now;
245 }
246
247 /// Marks the issue as having been touched, without changing what it says.
248 ///
249 /// What a new comment does to it: the conversation moved on, the issue itself did
250 /// not.
251 pub fn touch(&mut self, now: SystemTime) {
252 self.updated_at = now;
253 }
254}
255
256/// A comment on an issue.
257///
258/// Part of the [`Issue`] aggregate — a comment has no meaning without the issue it
259/// hangs off, and nothing addresses one except through its issue.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct IssueComment {
262 pub id: IssueCommentId,
263 pub issue_id: IssueId,
264 pub author_id: UserId,
265 /// Markdown, like an issue body. Never empty: an empty comment is a misclick.
266 pub body: String,
267 pub created_at: SystemTime,
268}
269
270impl IssueComment {
271 pub fn new(
272 id: IssueCommentId,
273 issue_id: IssueId,
274 author_id: UserId,
275 body: impl Into<String>,
276 now: SystemTime,
277 ) -> Result<Self, DomainError> {
278 let body = body.into();
279 let body = body.trim();
280
281 if body.is_empty() {
282 return Err(DomainError::validation("comment", "must not be empty"));
283 }
284
285 if body.chars().count() > MAX_BODY_LEN {
286 return Err(DomainError::validation(
287 "comment",
288 format!("must be at most {MAX_BODY_LEN} characters"),
289 ));
290 }
291
292 Ok(Self {
293 id,
294 issue_id,
295 author_id,
296 body: body.to_owned(),
297 created_at: now,
298 })
299 }
300
301 /// Rebuilds a comment from storage without revalidating it.
302 pub fn from_trusted(
303 id: IssueCommentId,
304 issue_id: IssueId,
305 author_id: UserId,
306 body: impl Into<String>,
307 created_at: SystemTime,
308 ) -> Self {
309 Self {
310 id,
311 issue_id,
312 author_id,
313 body: body.into(),
314 created_at,
315 }
316 }
317}
318
319/// The rules an issue's title and body have to satisfy, shared by creation and editing.
320///
321/// One function rather than two copies: an edit that accepted a title creation would
322/// refuse is a rule that disagrees with itself.
323///
324/// `pub(super)` because a [pull request](super::pull_request::PullRequest) is the same
325/// conversation with a merge attached, and two copies of these rules would drift the
326/// first time one of the caps moved.
327pub(super) fn validate_content(
328 title: impl Into<String>,
329 body: impl Into<String>,
330) -> Result<(String, String), DomainError> {
331 let title = title.into();
332 let title = title.trim();
333 let body = body.into();
334 // Trailing whitespace only: leading whitespace is significant in markdown (an
335 // indented first line is a code block), and silently reflowing what someone wrote
336 // is worse than keeping it.
337 let body = body.trim_end();
338
339 if title.is_empty() {
340 return Err(DomainError::validation("title", "must not be empty"));
341 }
342
343 if title.chars().count() > MAX_TITLE_LEN {
344 return Err(DomainError::validation(
345 "title",
346 format!("must be at most {MAX_TITLE_LEN} characters"),
347 ));
348 }
349
350 if body.chars().count() > MAX_BODY_LEN {
351 return Err(DomainError::validation(
352 "body",
353 format!("must be at most {MAX_BODY_LEN} characters"),
354 ));
355 }
356
357 Ok((title.to_owned(), body.to_owned()))
358}
359
360#[cfg(test)]
361mod tests {
362 use std::time::Duration;
363
364 use super::*;
365
366 fn at(seconds: u64) -> SystemTime {
367 SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)
368 }
369
370 fn issue(title: &str, body: &str) -> Result<Issue, DomainError> {
371 Issue::new(
372 IssueId::generate(),
373 RepoId::generate(),
374 IssueNumber::from_trusted(1),
375 title,
376 body,
377 UserId::generate(),
378 at(1_000),
379 )
380 }
381
382 // --- numbering ------------------------------------------------------------------
383
384 #[test]
385 fn numbering_starts_at_one() {
386 assert!(IssueNumber::new(0).is_err());
387 assert_eq!(IssueNumber::new(1).expect("valid").get(), 1);
388 }
389
390 #[test]
391 fn a_number_round_trips_through_a_path_segment() {
392 assert_eq!(
393 "42".parse::<IssueNumber>().expect("valid"),
394 IssueNumber::from_trusted(42)
395 );
396 assert_eq!(IssueNumber::from_trusted(42).to_string(), "42");
397 }
398
399 #[test]
400 fn a_segment_that_is_not_a_number_is_an_error_not_a_default() {
401 for input in ["", "0", "-1", "1.5", "01a", "٣", "18446744073709551616"] {
402 assert!(
403 input.parse::<IssueNumber>().is_err(),
404 "expected {input:?} to be rejected"
405 );
406 }
407 }
408
409 // --- state ----------------------------------------------------------------------
410
411 #[test]
412 fn state_round_trips_through_strings() {
413 for state in [IssueState::Open, IssueState::Closed] {
414 assert_eq!(
415 state.as_str().parse::<IssueState>().expect("round trip"),
416 state
417 );
418 }
419 }
420
421 #[test]
422 fn an_unknown_state_is_an_error_not_a_default() {
423 assert!("Open".parse::<IssueState>().is_err());
424 assert!("merged".parse::<IssueState>().is_err());
425 }
426
427 // --- creating -------------------------------------------------------------------
428
429 #[test]
430 fn a_new_issue_is_open_and_unclosed() {
431 let issue = issue("Blame is slow", "").expect("valid");
432
433 assert_eq!(issue.state, IssueState::Open);
434 assert_eq!(issue.closed_at, None);
435 assert_eq!(issue.created_at, at(1_000));
436 assert_eq!(issue.updated_at, at(1_000));
437 }
438
439 #[test]
440 fn an_issue_needs_a_title() {
441 assert!(issue("", "body").is_err());
442 assert!(issue(" ", "body").is_err());
443 }
444
445 #[test]
446 fn an_issue_does_not_need_a_body() {
447 assert_eq!(issue("Just the title", " ").expect("valid").body, "");
448 }
449
450 #[test]
451 fn a_title_is_trimmed_and_bounded() {
452 assert_eq!(
453 issue(" Blame is slow ", "").expect("valid").title,
454 "Blame is slow"
455 );
456 assert!(issue(&"a".repeat(MAX_TITLE_LEN), "").is_ok());
457 assert!(issue(&"a".repeat(MAX_TITLE_LEN + 1), "").is_err());
458 }
459
460 #[test]
461 fn a_body_is_bounded_and_keeps_its_leading_whitespace() {
462 // Leading whitespace is markdown's indented code block; trimming it would
463 // silently rewrite what someone pasted.
464 assert_eq!(
465 issue("t", " code()\n\n").expect("valid").body,
466 " code()"
467 );
468 assert!(issue("t", &"a".repeat(MAX_BODY_LEN)).is_ok());
469 assert!(issue("t", &"a".repeat(MAX_BODY_LEN + 1)).is_err());
470 }
471
472 // --- closing and reopening ------------------------------------------------------
473
474 #[test]
475 fn closing_stamps_the_time_and_reopening_clears_it() {
476 let mut issue = issue("Blame is slow", "").expect("valid");
477
478 issue.close(at(2_000));
479 assert_eq!(issue.state, IssueState::Closed);
480 assert_eq!(issue.closed_at, Some(at(2_000)));
481 assert_eq!(issue.updated_at, at(2_000));
482
483 issue.reopen(at(3_000));
484 assert_eq!(issue.state, IssueState::Open);
485 assert_eq!(issue.closed_at, None);
486 assert_eq!(issue.updated_at, at(3_000));
487 }
488
489 #[test]
490 fn closing_twice_does_not_move_the_closing_time() {
491 // Two clicks on a Close button must not restamp it.
492 let mut issue = issue("Blame is slow", "").expect("valid");
493
494 issue.close(at(2_000));
495 issue.close(at(9_000));
496
497 assert_eq!(issue.closed_at, Some(at(2_000)));
498 assert_eq!(issue.updated_at, at(2_000));
499 }
500
501 #[test]
502 fn reopening_an_open_issue_changes_nothing() {
503 let mut issue = issue("Blame is slow", "").expect("valid");
504
505 issue.reopen(at(9_000));
506
507 assert_eq!(issue.updated_at, at(1_000));
508 }
509
510 // --- editing --------------------------------------------------------------------
511
512 #[test]
513 fn editing_replaces_the_content_and_moves_updated_at() {
514 let mut issue = issue("Blame is slow", "old").expect("valid");
515
516 issue
517 .edit(" Blame is very slow ", "new", at(4_000))
518 .expect("valid");
519
520 assert_eq!(issue.title, "Blame is very slow");
521 assert_eq!(issue.body, "new");
522 assert_eq!(issue.updated_at, at(4_000));
523 }
524
525 #[test]
526 fn an_edit_is_held_to_the_same_rules_as_creation() {
527 let mut issue = issue("Blame is slow", "").expect("valid");
528
529 assert!(issue.edit("", "body", at(4_000)).is_err());
530 assert!(
531 issue
532 .edit("a".repeat(MAX_TITLE_LEN + 1), "", at(4_000))
533 .is_err()
534 );
535 assert_eq!(
536 issue.title, "Blame is slow",
537 "a rejected edit changes nothing"
538 );
539 assert_eq!(issue.updated_at, at(1_000));
540 }
541
542 #[test]
543 fn touching_moves_only_the_update_time() {
544 let mut issue = issue("Blame is slow", "body").expect("valid");
545
546 issue.touch(at(5_000));
547
548 assert_eq!(issue.updated_at, at(5_000));
549 assert_eq!(issue.state, IssueState::Open);
550 assert_eq!(issue.body, "body");
551 }
552
553 // --- comments -------------------------------------------------------------------
554
555 fn comment(body: &str) -> Result<IssueComment, DomainError> {
556 IssueComment::new(
557 IssueCommentId::generate(),
558 IssueId::generate(),
559 UserId::generate(),
560 body,
561 at(1_000),
562 )
563 }
564
565 #[test]
566 fn a_comment_needs_a_body() {
567 assert!(comment("").is_err());
568 assert!(comment(" \n ").is_err());
569 assert_eq!(
570 comment(" looks fixed ").expect("valid").body,
571 "looks fixed"
572 );
573 }
574
575 #[test]
576 fn a_comment_is_bounded() {
577 assert!(comment(&"a".repeat(MAX_BODY_LEN)).is_ok());
578 assert!(comment(&"a".repeat(MAX_BODY_LEN + 1)).is_err());
579 }
580}