1use serde::{Deserialize, Serialize};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6pub type UserId = u64;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct User {
12 pub id: UserId,
14 pub username: String,
16 pub display_name: Option<String>,
18 pub email: Option<String>,
20 pub bio: Option<String>,
22 pub location: Option<String>,
24 pub website: Option<String>,
26 pub avatar_url: Option<String>,
28 pub public_key: String,
30 pub email_public: bool,
32 pub created_at: u64,
34 pub updated_at: u64,
36}
37
38impl User {
39 pub fn new(id: UserId, username: String, public_key: String) -> Self {
41 let now = SystemTime::now()
42 .duration_since(UNIX_EPOCH)
43 .unwrap()
44 .as_secs();
45
46 Self {
47 id,
48 username,
49 display_name: None,
50 email: None,
51 bio: None,
52 location: None,
53 website: None,
54 avatar_url: None,
55 public_key,
56 email_public: false,
57 created_at: now,
58 updated_at: now,
59 }
60 }
61
62 pub fn validate_username(username: &str) -> Result<(), String> {
71 if username.is_empty() {
72 return Err("username cannot be empty".to_string());
73 }
74
75 if username.len() > 39 {
76 return Err("username must be 39 characters or less".to_string());
77 }
78
79 let chars: Vec<char> = username.chars().collect();
80
81 if !chars[0].is_ascii_alphanumeric() {
83 return Err("username must start with a letter or number".to_string());
84 }
85
86 if !chars.last().unwrap().is_ascii_alphanumeric() {
88 return Err("username must end with a letter or number".to_string());
89 }
90
91 for (i, c) in chars.iter().enumerate() {
93 if !c.is_ascii_lowercase() && !c.is_ascii_digit() && *c != '-' {
94 if c.is_ascii_uppercase() {
95 return Err("username must be lowercase".to_string());
96 }
97 return Err(format!("invalid character in username: {}", c));
98 }
99
100 if *c == '-' && i > 0 && chars[i - 1] == '-' {
102 return Err("username cannot contain consecutive hyphens".to_string());
103 }
104 }
105
106 let reserved = [
108 "admin",
109 "api",
110 "git",
111 "guts",
112 "help",
113 "login",
114 "logout",
115 "new",
116 "organizations",
117 "repos",
118 "settings",
119 "signup",
120 "user",
121 "users",
122 ];
123 if reserved.contains(&username) {
124 return Err(format!("username '{}' is reserved", username));
125 }
126
127 Ok(())
128 }
129
130 pub fn touch(&mut self) {
132 self.updated_at = SystemTime::now()
133 .duration_since(UNIX_EPOCH)
134 .unwrap()
135 .as_secs();
136 }
137
138 pub fn to_profile(&self, public_repos: u64, followers: u64, following: u64) -> UserProfile {
140 UserProfile {
141 login: self.username.clone(),
142 id: self.id,
143 avatar_url: self.avatar_url.clone(),
144 name: self.display_name.clone(),
145 email: if self.email_public {
146 self.email.clone()
147 } else {
148 None
149 },
150 bio: self.bio.clone(),
151 location: self.location.clone(),
152 blog: self.website.clone(),
153 public_repos,
154 followers,
155 following,
156 created_at: format_timestamp(self.created_at),
157 updated_at: format_timestamp(self.updated_at),
158 }
159 }
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct UserProfile {
165 pub login: String,
167 pub id: u64,
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub avatar_url: Option<String>,
172 #[serde(skip_serializing_if = "Option::is_none")]
174 pub name: Option<String>,
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub email: Option<String>,
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub bio: Option<String>,
181 #[serde(skip_serializing_if = "Option::is_none")]
183 pub location: Option<String>,
184 #[serde(skip_serializing_if = "Option::is_none")]
186 pub blog: Option<String>,
187 pub public_repos: u64,
189 pub followers: u64,
191 pub following: u64,
193 pub created_at: String,
195 pub updated_at: String,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct CreateUserRequest {
202 pub username: String,
204 pub public_key: String,
206 #[serde(default)]
208 pub email: Option<String>,
209 #[serde(default)]
211 pub name: Option<String>,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, Default)]
216pub struct UpdateUserRequest {
217 #[serde(default)]
219 pub name: Option<String>,
220 #[serde(default)]
222 pub email: Option<String>,
223 #[serde(default)]
225 pub bio: Option<String>,
226 #[serde(default)]
228 pub location: Option<String>,
229 #[serde(default)]
231 pub blog: Option<String>,
232 #[serde(default)]
234 pub email_public: Option<bool>,
235}
236
237fn format_timestamp(timestamp: u64) -> String {
239 use std::fmt::Write;
240 let secs_per_day = 86400;
243 let secs_per_hour = 3600;
244 let secs_per_min = 60;
245
246 let mut days = timestamp / secs_per_day;
248 let remaining = timestamp % secs_per_day;
249 let hours = remaining / secs_per_hour;
250 let remaining = remaining % secs_per_hour;
251 let minutes = remaining / secs_per_min;
252 let seconds = remaining % secs_per_min;
253
254 let mut year = 1970;
256 loop {
257 let days_in_year = if is_leap_year(year) { 366 } else { 365 };
258 if days < days_in_year {
259 break;
260 }
261 days -= days_in_year;
262 year += 1;
263 }
264
265 let days_in_month = if is_leap_year(year) {
266 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
267 } else {
268 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
269 };
270
271 let mut month = 0;
272 for (i, &dim) in days_in_month.iter().enumerate() {
273 if days < dim as u64 {
274 month = i + 1;
275 break;
276 }
277 days -= dim as u64;
278 }
279 let day = days + 1;
280
281 let mut s = String::with_capacity(20);
282 write!(
283 s,
284 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
285 year, month, day, hours, minutes, seconds
286 )
287 .unwrap();
288 s
289}
290
291fn is_leap_year(year: u64) -> bool {
292 (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn test_validate_username_valid() {
301 assert!(User::validate_username("alice").is_ok());
302 assert!(User::validate_username("bob123").is_ok());
303 assert!(User::validate_username("my-project").is_ok());
304 assert!(User::validate_username("a").is_ok());
305 assert!(User::validate_username("a1").is_ok());
306 }
307
308 #[test]
309 fn test_validate_username_invalid() {
310 assert!(User::validate_username("").is_err());
311 assert!(User::validate_username("-alice").is_err());
312 assert!(User::validate_username("alice-").is_err());
313 assert!(User::validate_username("alice--bob").is_err());
314 assert!(User::validate_username("Alice").is_err());
315 assert!(User::validate_username("alice_bob").is_err());
316 assert!(User::validate_username("admin").is_err());
317
318 let long_name = "a".repeat(40);
320 assert!(User::validate_username(&long_name).is_err());
321 }
322
323 #[test]
324 fn test_validate_username_reserved() {
325 let reserved = [
327 "admin",
328 "api",
329 "git",
330 "guts",
331 "help",
332 "login",
333 "logout",
334 "new",
335 "organizations",
336 "repos",
337 "settings",
338 "signup",
339 "user",
340 "users",
341 ];
342 for name in reserved {
343 let result = User::validate_username(name);
344 assert!(result.is_err(), "Expected '{}' to be reserved", name);
345 assert!(
346 result.unwrap_err().contains("reserved"),
347 "Error should mention 'reserved'"
348 );
349 }
350 }
351
352 #[test]
353 fn test_validate_username_max_length() {
354 let max_valid = "a".repeat(39);
356 assert!(User::validate_username(&max_valid).is_ok());
357
358 let too_long = "a".repeat(40);
360 assert!(User::validate_username(&too_long).is_err());
361 }
362
363 #[test]
364 fn test_validate_username_special_chars() {
365 assert!(User::validate_username("user@name").is_err());
367 assert!(User::validate_username("user.name").is_err());
368 assert!(User::validate_username("user#name").is_err());
369 assert!(User::validate_username("user$name").is_err());
370 assert!(User::validate_username("user%name").is_err());
371 assert!(User::validate_username("user name").is_err());
372 assert!(User::validate_username("user\tname").is_err());
373 assert!(User::validate_username("user\nname").is_err());
374 }
375
376 #[test]
377 fn test_validate_username_unicode() {
378 assert!(User::validate_username("αβγ").is_err());
380 assert!(User::validate_username("日本語").is_err());
381 assert!(User::validate_username("émoji").is_err());
382 assert!(User::validate_username("user🔥").is_err());
383 }
384
385 #[test]
386 fn test_validate_username_edge_cases() {
387 assert!(User::validate_username("0").is_ok()); assert!(User::validate_username("z").is_ok()); assert!(User::validate_username("a-b").is_ok());
393 assert!(User::validate_username("a-b-c").is_ok());
394 assert!(User::validate_username("-a").is_err()); assert!(User::validate_username("a-").is_err()); assert!(User::validate_username("a--b").is_err()); assert!(User::validate_username("---").is_err()); }
399
400 #[test]
401 fn test_create_user() {
402 let user = User::new(1, "alice".to_string(), "abc123".to_string());
403 assert_eq!(user.id, 1);
404 assert_eq!(user.username, "alice");
405 assert_eq!(user.public_key, "abc123");
406 assert!(user.display_name.is_none());
407 }
408
409 #[test]
410 fn test_user_profile() {
411 let mut user = User::new(1, "alice".to_string(), "abc123".to_string());
412 user.display_name = Some("Alice Smith".to_string());
413 user.email = Some("alice@example.com".to_string());
414 user.email_public = true;
415
416 let profile = user.to_profile(5, 10, 3);
417 assert_eq!(profile.login, "alice");
418 assert_eq!(profile.name, Some("Alice Smith".to_string()));
419 assert_eq!(profile.email, Some("alice@example.com".to_string()));
420 assert_eq!(profile.public_repos, 5);
421 assert_eq!(profile.followers, 10);
422 assert_eq!(profile.following, 3);
423 }
424
425 #[test]
426 fn test_user_profile_private_email() {
427 let mut user = User::new(1, "alice".to_string(), "abc123".to_string());
428 user.email = Some("alice@example.com".to_string());
429 user.email_public = false;
430
431 let profile = user.to_profile(0, 0, 0);
432 assert!(profile.email.is_none());
433 }
434
435 #[test]
436 fn test_format_timestamp() {
437 let ts = format_timestamp(1704067200);
439 assert_eq!(ts, "2024-01-01T00:00:00Z");
440 }
441
442 #[test]
443 fn test_format_timestamp_epoch() {
444 let ts = format_timestamp(0);
445 assert_eq!(ts, "1970-01-01T00:00:00Z");
446 }
447
448 #[test]
449 fn test_format_timestamp_leap_year() {
450 let ts = format_timestamp(1709208000);
452 assert_eq!(ts, "2024-02-29T12:00:00Z");
453 }
454
455 #[test]
456 fn test_is_leap_year() {
457 assert!(is_leap_year(2000)); assert!(!is_leap_year(1900)); assert!(is_leap_year(2024)); assert!(!is_leap_year(2023)); }
462}
463
464#[cfg(test)]
465mod proptests {
466 use super::*;
467 use proptest::prelude::*;
468
469 fn valid_username_strategy() -> impl Strategy<Value = String> {
471 prop::collection::vec(
473 prop_oneof![
474 4 => prop::char::ranges(vec!['a'..='z', '0'..='9'].into_iter().collect()),
475 1 => Just('-'),
476 ],
477 1..=39,
478 )
479 .prop_filter_map("filter valid usernames", |chars| {
480 let s: String = chars.into_iter().collect();
481 if s.is_empty() {
483 return None;
484 }
485 let chars: Vec<char> = s.chars().collect();
486 if !chars[0].is_ascii_alphanumeric() {
487 return None;
488 }
489 if !chars.last().unwrap().is_ascii_alphanumeric() {
490 return None;
491 }
492 for i in 1..chars.len() {
494 if chars[i] == '-' && chars[i - 1] == '-' {
495 return None;
496 }
497 }
498 let reserved = [
500 "admin",
501 "api",
502 "git",
503 "guts",
504 "help",
505 "login",
506 "logout",
507 "new",
508 "organizations",
509 "repos",
510 "settings",
511 "signup",
512 "user",
513 "users",
514 ];
515 if reserved.contains(&s.as_str()) {
516 return None;
517 }
518 Some(s)
519 })
520 }
521
522 proptest! {
523 #[test]
525 fn prop_valid_usernames_accepted(username in valid_username_strategy()) {
526 prop_assert!(
527 User::validate_username(&username).is_ok(),
528 "Username '{}' should be valid", username
529 );
530 }
531
532 #[test]
534 fn prop_empty_string_rejected(_seed in 0u32..1000) {
535 prop_assert!(User::validate_username("").is_err());
536 }
537
538 #[test]
540 fn prop_long_usernames_rejected(len in 40usize..200) {
541 let long_name: String = (0..len).map(|_| 'a').collect();
542 prop_assert!(
543 User::validate_username(&long_name).is_err(),
544 "Username of length {} should be rejected", len
545 );
546 }
547
548 #[test]
550 fn prop_uppercase_rejected(prefix in "[a-z]{0,5}", upper in "[A-Z]", suffix in "[a-z]{0,5}") {
551 let username = format!("{}{}{}", prefix, upper, suffix);
552 if !username.is_empty() && username.len() <= 39 {
553 prop_assert!(User::validate_username(&username).is_err());
554 }
555 }
556
557 #[test]
559 fn prop_hyphen_start_rejected(rest in "[a-z0-9-]{0,10}") {
560 let username = format!("-{}", rest);
561 prop_assert!(User::validate_username(&username).is_err());
562 }
563
564 #[test]
566 fn prop_hyphen_end_rejected(prefix in "[a-z0-9]{1,10}") {
567 let username = format!("{}-", prefix);
568 prop_assert!(User::validate_username(&username).is_err());
569 }
570
571 #[test]
573 fn prop_consecutive_hyphens_rejected(prefix in "[a-z0-9]{1,5}", suffix in "[a-z0-9]{1,5}") {
574 let username = format!("{}--{}", prefix, suffix);
575 prop_assert!(User::validate_username(&username).is_err());
576 }
577
578 #[test]
580 fn prop_underscore_rejected(prefix in "[a-z]{1,5}", suffix in "[a-z]{1,5}") {
581 let username = format!("{}_{}", prefix, suffix);
582 prop_assert!(User::validate_username(&username).is_err());
583 }
584
585 #[test]
587 fn prop_space_rejected(prefix in "[a-z]{1,5}", suffix in "[a-z]{1,5}") {
588 let username = format!("{} {}", prefix, suffix);
589 prop_assert!(User::validate_username(&username).is_err());
590 }
591
592 #[test]
594 fn prop_unicode_rejected(s in "\\PC{1,10}") {
595 if !s.is_ascii() {
597 prop_assert!(User::validate_username(&s).is_err());
598 }
599 }
600
601 #[test]
603 fn prop_validation_consistent(s in ".*") {
604 let result1 = User::validate_username(&s);
605 let result2 = User::validate_username(&s);
606 prop_assert_eq!(result1.is_ok(), result2.is_ok());
607 }
608 }
609}