steid

@jamesgill /

4.6 KBCode·Blame·Raw
1e1ae56feat: identity domain model1mo
1use std::fmt;
2
3use super::{DomainError, OrgId};
4
5/// An organisation handle — the `{owner}` segment of every URL, and unique across the
6/// installation.
7///
8/// Rules follow GitHub's: 1–39 characters, ASCII alphanumeric or hyphen, no leading or
9/// trailing hyphen. Stored lowercase so lookups and URLs agree.
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub struct OrgName(String);
12
13impl OrgName {
14 pub const MAX_LEN: usize = 39;
15
16 /// Validates and normalises user-supplied input.
17 pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
18 let value = value.into();
19 let trimmed = value.trim();
20
21 let invalid = |reason: &str| DomainError::validation("name", reason);
22
23 if trimmed.is_empty() {
24 return Err(invalid("must not be empty"));
25 }
26 if trimmed.chars().count() > Self::MAX_LEN {
27 return Err(invalid("must be at most 39 characters"));
28 }
29 if !trimmed
30 .chars()
31 .all(|c| c.is_ascii_alphanumeric() || c == '-')
32 {
33 return Err(invalid("may only contain letters, digits, and hyphens"));
34 }
35 if trimmed.starts_with('-') || trimmed.ends_with('-') {
36 return Err(invalid("must not start or end with a hyphen"));
37 }
38
39 Ok(Self(trimmed.to_lowercase()))
40 }
41
42 /// Wraps a value already validated on the way into the database.
43 pub fn from_trusted(value: impl Into<String>) -> Self {
44 Self(value.into())
45 }
46
47 pub fn as_str(&self) -> &str {
48 &self.0
49 }
50}
51
52impl fmt::Display for OrgName {
53 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54 f.write_str(&self.0)
55 }
56}
57
58/// An organisation. Every user gets a personal one at registration, and repositories
59/// and content hang off organisations rather than users.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct Organization {
62 pub id: OrgId,
63 pub name: OrgName,
64 /// Free-form label shown in the UI. Falls back to `name` when unset.
65 pub display_name: Option<String>,
66}
67
68impl Organization {
69 /// Creates an organisation from user-supplied input.
70 pub fn new(
71 id: OrgId,
72 name: impl Into<String>,
73 display_name: Option<String>,
74 ) -> Result<Self, DomainError> {
75 Ok(Self {
76 id,
77 name: OrgName::new(name)?,
78 display_name: display_name.filter(|value| !value.trim().is_empty()),
79 })
80 }
81
82 /// Reassembles an organisation from storage, skipping validation.
83 pub fn from_trusted(id: OrgId, name: OrgName, display_name: Option<String>) -> Self {
84 Self {
85 id,
86 name,
87 display_name,
88 }
89 }
90
91 /// The label to show in the UI.
92 pub fn label(&self) -> &str {
93 self.display_name
94 .as_deref()
95 .unwrap_or_else(|| self.name.as_str())
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 #[test]
104 fn accepts_ordinary_handles() {
105 for input in ["james", "steid", "a", "my-org", "org2026", "a-b-c"] {
106 assert!(
107 OrgName::new(input).is_ok(),
108 "expected {input:?} to be accepted"
109 );
110 }
111 }
112
113 #[test]
114 fn normalises_case_and_whitespace() {
115 let name = OrgName::new(" JamesGill ").expect("should be valid");
116
117 assert_eq!(name.as_str(), "jamesgill");
118 }
119
120 #[test]
121 fn rejects_malformed_handles() {
122 for input in [
123 "",
124 " ",
125 "-leading",
126 "trailing-",
127 "has space",
128 "under_score",
129 "dot.dot",
130 "slash/slash",
131 "emoji🦀",
132 &"a".repeat(40),
133 ] {
134 assert!(
135 OrgName::new(input).is_err(),
136 "expected {input:?} to be rejected"
137 );
138 }
139 }
140
141 #[test]
142 fn accepts_a_handle_at_the_length_limit() {
143 assert!(OrgName::new("a".repeat(OrgName::MAX_LEN)).is_ok());
144 }
145
146 #[test]
147 fn label_falls_back_to_the_handle() {
148 let org = Organization::new(OrgId::generate(), "steid", None).expect("valid");
149
150 assert_eq!(org.label(), "steid");
151 }
152
153 #[test]
154 fn label_prefers_the_display_name() {
155 let org =
156 Organization::new(OrgId::generate(), "steid", Some("Steid".to_owned())).expect("valid");
157
158 assert_eq!(org.label(), "Steid");
159 }
160
161 #[test]
162 fn blank_display_names_are_treated_as_unset() {
163 let org =
164 Organization::new(OrgId::generate(), "steid", Some(" ".to_owned())).expect("valid");
165
166 assert_eq!(org.display_name, None);
167 assert_eq!(org.label(), "steid");
168 }
169}