Skip to main content

guts_compat/
user.rs

1//! User account types and management.
2
3use serde::{Deserialize, Serialize};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6/// Unique identifier for a user.
7pub type UserId = u64;
8
9/// A user account in the system.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct User {
12    /// Unique user ID.
13    pub id: UserId,
14    /// Unique username (lowercase, alphanumeric with hyphens).
15    pub username: String,
16    /// Display name (can contain any characters).
17    pub display_name: Option<String>,
18    /// Email address (optional).
19    pub email: Option<String>,
20    /// Short biography.
21    pub bio: Option<String>,
22    /// Location string.
23    pub location: Option<String>,
24    /// Personal website URL.
25    pub website: Option<String>,
26    /// Avatar URL.
27    pub avatar_url: Option<String>,
28    /// Ed25519 public key (hex-encoded identity).
29    pub public_key: String,
30    /// Whether the user's email is public.
31    pub email_public: bool,
32    /// Unix timestamp when created.
33    pub created_at: u64,
34    /// Unix timestamp when last updated.
35    pub updated_at: u64,
36}
37
38impl User {
39    /// Create a new user.
40    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    /// Validate a username format.
63    ///
64    /// Usernames must:
65    /// - Be 1-39 characters long
66    /// - Start with an alphanumeric character
67    /// - Contain only lowercase alphanumeric characters and hyphens
68    /// - Not contain consecutive hyphens
69    /// - Not end with a hyphen
70    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        // Must start with alphanumeric
82        if !chars[0].is_ascii_alphanumeric() {
83            return Err("username must start with a letter or number".to_string());
84        }
85
86        // Must end with alphanumeric
87        if !chars.last().unwrap().is_ascii_alphanumeric() {
88            return Err("username must end with a letter or number".to_string());
89        }
90
91        // Check each character
92        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            // No consecutive hyphens
101            if *c == '-' && i > 0 && chars[i - 1] == '-' {
102                return Err("username cannot contain consecutive hyphens".to_string());
103            }
104        }
105
106        // Reserved usernames
107        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    /// Update the updated_at timestamp.
131    pub fn touch(&mut self) {
132        self.updated_at = SystemTime::now()
133            .duration_since(UNIX_EPOCH)
134            .unwrap()
135            .as_secs();
136    }
137
138    /// Convert to a public profile (for API responses).
139    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/// User profile for public API responses (GitHub-compatible).
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct UserProfile {
165    /// Username (GitHub calls this "login").
166    pub login: String,
167    /// User ID.
168    pub id: u64,
169    /// Avatar URL.
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub avatar_url: Option<String>,
172    /// Display name.
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub name: Option<String>,
175    /// Public email (if enabled).
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub email: Option<String>,
178    /// Biography.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub bio: Option<String>,
181    /// Location.
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub location: Option<String>,
184    /// Website/blog URL.
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub blog: Option<String>,
187    /// Number of public repositories.
188    pub public_repos: u64,
189    /// Number of followers.
190    pub followers: u64,
191    /// Number of users following.
192    pub following: u64,
193    /// ISO 8601 creation timestamp.
194    pub created_at: String,
195    /// ISO 8601 last update timestamp.
196    pub updated_at: String,
197}
198
199/// Request to create a new user.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct CreateUserRequest {
202    /// Desired username.
203    pub username: String,
204    /// Ed25519 public key (hex-encoded).
205    pub public_key: String,
206    /// Optional email address.
207    #[serde(default)]
208    pub email: Option<String>,
209    /// Optional display name.
210    #[serde(default)]
211    pub name: Option<String>,
212}
213
214/// Request to update a user profile.
215#[derive(Debug, Clone, Serialize, Deserialize, Default)]
216pub struct UpdateUserRequest {
217    /// New display name.
218    #[serde(default)]
219    pub name: Option<String>,
220    /// New email address.
221    #[serde(default)]
222    pub email: Option<String>,
223    /// New biography.
224    #[serde(default)]
225    pub bio: Option<String>,
226    /// New location.
227    #[serde(default)]
228    pub location: Option<String>,
229    /// New website/blog URL.
230    #[serde(default)]
231    pub blog: Option<String>,
232    /// Whether email is public.
233    #[serde(default)]
234    pub email_public: Option<bool>,
235}
236
237/// Format a Unix timestamp as ISO 8601.
238fn format_timestamp(timestamp: u64) -> String {
239    use std::fmt::Write;
240    // Simple ISO 8601 format: 2024-01-15T12:00:00Z
241    // For a proper implementation, use chrono or time crate
242    let secs_per_day = 86400;
243    let secs_per_hour = 3600;
244    let secs_per_min = 60;
245
246    // Days since epoch
247    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    // Calculate year/month/day (simplified, doesn't handle leap seconds)
255    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        // Too long
319        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        // Test all reserved usernames
326        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        // 39 chars should be valid
355        let max_valid = "a".repeat(39);
356        assert!(User::validate_username(&max_valid).is_ok());
357
358        // 40 chars should be invalid
359        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        // Invalid special characters
366        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        // Unicode should be rejected
379        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        // Single character at boundaries
388        assert!(User::validate_username("0").is_ok()); // digit
389        assert!(User::validate_username("z").is_ok()); // letter
390
391        // Hyphen placement
392        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()); // starts with hyphen
395        assert!(User::validate_username("a-").is_err()); // ends with hyphen
396        assert!(User::validate_username("a--b").is_err()); // consecutive hyphens
397        assert!(User::validate_username("---").is_err()); // all hyphens
398    }
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        // 2024-01-01 00:00:00 UTC = 1704067200
438        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        // Feb 29, 2024 12:00:00 UTC (2024 is a leap year)
451        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)); // divisible by 400
458        assert!(!is_leap_year(1900)); // divisible by 100 but not 400
459        assert!(is_leap_year(2024)); // divisible by 4 but not 100
460        assert!(!is_leap_year(2023)); // not divisible by 4
461    }
462}
463
464#[cfg(test)]
465mod proptests {
466    use super::*;
467    use proptest::prelude::*;
468
469    /// Strategy for generating valid usernames
470    fn valid_username_strategy() -> impl Strategy<Value = String> {
471        // Generate usernames with pattern: alphanumeric, optionally with single hyphens
472        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            // Must start and end with alphanumeric, no consecutive hyphens
482            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            // Check for consecutive hyphens
493            for i in 1..chars.len() {
494                if chars[i] == '-' && chars[i - 1] == '-' {
495                    return None;
496                }
497            }
498            // Skip reserved names
499            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        /// Property: Valid usernames should always be accepted
524        #[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        /// Property: Empty strings are always rejected
533        #[test]
534        fn prop_empty_string_rejected(_seed in 0u32..1000) {
535            prop_assert!(User::validate_username("").is_err());
536        }
537
538        /// Property: Strings > 39 chars are always rejected
539        #[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        /// Property: Uppercase letters are always rejected
549        #[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        /// Property: Starting with hyphen is rejected
558        #[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        /// Property: Ending with hyphen is rejected
565        #[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        /// Property: Consecutive hyphens are rejected
572        #[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        /// Property: Underscores are rejected
579        #[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        /// Property: Spaces are rejected
586        #[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        /// Property: Arbitrary Unicode is rejected
593        #[test]
594        fn prop_unicode_rejected(s in "\\PC{1,10}") {
595            // If the string contains any non-ASCII characters, it should be rejected
596            if !s.is_ascii() {
597                prop_assert!(User::validate_username(&s).is_err());
598            }
599        }
600
601        /// Property: Validation is consistent (idempotent)
602        #[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}