Skip to main content

gid_core/
identity.rs

1//! Core identity types and validation for GID.
2//!
3//! Provides [`Identity`], [`Organization`], and [`Group`] types with
4//! email validation and signing key (fingerprint) validation.
5//!
6//! # Example
7//!
8//! ```
9//! use gid_core::identity::{Identity, Organization, Group, SigningKey};
10//!
11//! let key = SigningKey::new("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2").unwrap();
12//! let identity = Identity::builder("alice")
13//!     .email("alice@example.com").unwrap()
14//!     .display_name("Alice Smith")
15//!     .signing_key(key)
16//!     .build()
17//!     .unwrap();
18//!
19//! let org = Organization::new("acme-corp", "Acme Corp").unwrap();
20//! let group = Group::new("backend-team", "Backend Team").unwrap();
21//! ```
22
23use std::fmt;
24use std::sync::OnceLock;
25
26use regex::Regex;
27use serde::{Deserialize, Serialize};
28use chrono::{DateTime, Utc};
29
30// ---------------------------------------------------------------------------
31// Errors
32// ---------------------------------------------------------------------------
33
34/// Errors that can occur during identity validation.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum IdentityError {
37    /// The provided email address is invalid.
38    InvalidEmail(String),
39    /// The provided signing key fingerprint is invalid.
40    InvalidSigningKey(String),
41    /// A required field is missing.
42    MissingField(&'static str),
43    /// The identifier (slug) is invalid.
44    InvalidIdentifier(String),
45    /// Duplicate member in a group/organization.
46    DuplicateMember(String),
47}
48
49impl fmt::Display for IdentityError {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        match self {
52            Self::InvalidEmail(email) => write!(f, "Invalid email address: {}", email),
53            Self::InvalidSigningKey(key) => write!(f, "Invalid signing key fingerprint: {}", key),
54            Self::MissingField(field) => write!(f, "Missing required field: {}", field),
55            Self::InvalidIdentifier(id) => write!(f, "Invalid identifier '{}': must be lowercase alphanumeric with hyphens", id),
56            Self::DuplicateMember(id) => write!(f, "Duplicate member: {}", id),
57        }
58    }
59}
60
61impl std::error::Error for IdentityError {}
62
63// ---------------------------------------------------------------------------
64// Email validation
65// ---------------------------------------------------------------------------
66
67/// A validated email address.
68#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
69pub struct Email(String);
70
71impl Email {
72    /// Parse and validate an email address.
73    ///
74    /// Validates basic structure: `local@domain` where domain has at least one dot.
75    pub fn new(email: &str) -> Result<Self, IdentityError> {
76        if validate_email(email) {
77            Ok(Self(email.to_lowercase()))
78        } else {
79            Err(IdentityError::InvalidEmail(email.to_string()))
80        }
81    }
82
83    /// Return the email address as a string slice.
84    pub fn as_str(&self) -> &str {
85        &self.0
86    }
87}
88
89impl fmt::Display for Email {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        write!(f, "{}", self.0)
92    }
93}
94
95impl AsRef<str> for Email {
96    fn as_ref(&self) -> &str {
97        &self.0
98    }
99}
100
101/// Validate an email address.
102///
103/// Checks:
104/// - Contains exactly one `@`
105/// - Local part is non-empty and ≤ 64 chars
106/// - Domain part is non-empty and ≤ 253 chars
107/// - Domain has at least one dot
108/// - Domain labels are non-empty, ≤ 63 chars, alphanumeric + hyphens, no leading/trailing hyphens
109/// - No whitespace anywhere
110fn validate_email(email: &str) -> bool {
111    if email.contains(char::is_whitespace) {
112        return false;
113    }
114
115    let parts: Vec<&str> = email.splitn(2, '@').collect();
116    if parts.len() != 2 {
117        return false;
118    }
119
120    let local = parts[0];
121    let domain = parts[1];
122
123    // Local part checks
124    if local.is_empty() || local.len() > 64 {
125        return false;
126    }
127
128    // Domain part checks
129    if domain.is_empty() || domain.len() > 253 {
130        return false;
131    }
132
133    // Domain must have at least one dot
134    if !domain.contains('.') {
135        return false;
136    }
137
138    // Validate domain labels
139    for label in domain.split('.') {
140        if label.is_empty() || label.len() > 63 {
141            return false;
142        }
143        if label.starts_with('-') || label.ends_with('-') {
144            return false;
145        }
146        if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
147            return false;
148        }
149    }
150
151    true
152}
153
154// ---------------------------------------------------------------------------
155// Signing key validation
156// ---------------------------------------------------------------------------
157
158/// A validated signing key fingerprint (e.g., PGP/GPG key fingerprint).
159///
160/// Stores a 40-character uppercase hex fingerprint (160-bit, SHA-1 format used by GPG).
161#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
162pub struct SigningKey(String);
163
164impl SigningKey {
165    /// Parse and validate a signing key fingerprint.
166    ///
167    /// Accepts 40 hex characters (optionally with spaces or colons as separators).
168    /// The stored form is uppercase hex with no separators.
169    pub fn new(fingerprint: &str) -> Result<Self, IdentityError> {
170        let cleaned = fingerprint
171            .replace(' ', "")
172            .replace(':', "")
173            .to_uppercase();
174
175        if validate_fingerprint(&cleaned) {
176            Ok(Self(cleaned))
177        } else {
178            Err(IdentityError::InvalidSigningKey(fingerprint.to_string()))
179        }
180    }
181
182    /// Return the fingerprint as a string slice (40 uppercase hex chars).
183    pub fn as_str(&self) -> &str {
184        &self.0
185    }
186
187    /// Return the last 16 hex characters (long key ID).
188    pub fn long_id(&self) -> &str {
189        &self.0[24..]
190    }
191
192    /// Return the last 8 hex characters (short key ID).
193    pub fn short_id(&self) -> &str {
194        &self.0[32..]
195    }
196}
197
198impl fmt::Display for SigningKey {
199    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200        // Display in groups of 4 for readability
201        for (i, chunk) in self.0.as_bytes().chunks(4).enumerate() {
202            if i > 0 {
203                write!(f, " ")?;
204            }
205            write!(f, "{}", std::str::from_utf8(chunk).unwrap_or(""))?;
206        }
207        Ok(())
208    }
209}
210
211impl AsRef<str> for SigningKey {
212    fn as_ref(&self) -> &str {
213        &self.0
214    }
215}
216
217/// Validate a cleaned fingerprint: exactly 40 hex characters.
218fn validate_fingerprint(cleaned: &str) -> bool {
219    cleaned.len() == 40 && cleaned.chars().all(|c| c.is_ascii_hexdigit())
220}
221
222// ---------------------------------------------------------------------------
223// Identifier validation
224// ---------------------------------------------------------------------------
225
226/// Validate an identifier (slug): lowercase alphanumeric + hyphens, non-empty,
227/// must start and end with alphanumeric.
228fn validate_identifier(id: &str) -> bool {
229    if id.is_empty() || id.len() > 128 {
230        return false;
231    }
232
233    static RE: OnceLock<Regex> = OnceLock::new();
234    let re = RE.get_or_init(|| Regex::new(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$").unwrap());
235    re.is_match(id)
236}
237
238// ---------------------------------------------------------------------------
239// Identity
240// ---------------------------------------------------------------------------
241
242/// A verified individual identity within the GID system.
243///
244/// Identities represent human actors (developers, reviewers, approvers) who
245/// participate in rituals and approve phase transitions.
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247pub struct Identity {
248    /// Unique identifier (slug), e.g. "alice" or "bob-smith".
249    pub id: String,
250    /// Human-readable display name.
251    pub display_name: Option<String>,
252    /// Validated email address.
253    pub email: Option<Email>,
254    /// Signing key fingerprint for commit/artifact verification.
255    pub signing_key: Option<SigningKey>,
256    /// When this identity was created.
257    pub created_at: DateTime<Utc>,
258    /// Arbitrary key-value metadata.
259    pub metadata: std::collections::HashMap<String, String>,
260}
261
262impl Identity {
263    /// Create a minimal identity with just an id.
264    pub fn new(id: &str) -> Result<Self, IdentityError> {
265        if !validate_identifier(id) {
266            return Err(IdentityError::InvalidIdentifier(id.to_string()));
267        }
268        Ok(Self {
269            id: id.to_string(),
270            display_name: None,
271            email: None,
272            signing_key: None,
273            created_at: Utc::now(),
274            metadata: std::collections::HashMap::new(),
275        })
276    }
277
278    /// Start building an identity with a fluent API.
279    pub fn builder(id: &str) -> IdentityBuilder {
280        IdentityBuilder::new(id)
281    }
282
283    /// Set the email, validating it.
284    pub fn set_email(&mut self, email: &str) -> Result<(), IdentityError> {
285        self.email = Some(Email::new(email)?);
286        Ok(())
287    }
288
289    /// Set the signing key, validating the fingerprint.
290    pub fn set_signing_key(&mut self, fingerprint: &str) -> Result<(), IdentityError> {
291        self.signing_key = Some(SigningKey::new(fingerprint)?);
292        Ok(())
293    }
294
295    /// Check whether this identity has a signing key.
296    pub fn has_signing_key(&self) -> bool {
297        self.signing_key.is_some()
298    }
299
300    /// Return a short description for display.
301    pub fn display(&self) -> String {
302        if let Some(ref name) = self.display_name {
303            format!("{} ({})", name, self.id)
304        } else if let Some(ref email) = self.email {
305            format!("{} <{}>", self.id, email)
306        } else {
307            self.id.clone()
308        }
309    }
310}
311
312impl fmt::Display for Identity {
313    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
314        write!(f, "{}", self.display())
315    }
316}
317
318// ---------------------------------------------------------------------------
319// Identity builder
320// ---------------------------------------------------------------------------
321
322/// Fluent builder for [`Identity`].
323pub struct IdentityBuilder {
324    id: String,
325    display_name: Option<String>,
326    email: Option<Email>,
327    signing_key: Option<SigningKey>,
328    metadata: std::collections::HashMap<String, String>,
329    error: Option<IdentityError>,
330}
331
332impl IdentityBuilder {
333    fn new(id: &str) -> Self {
334        Self {
335            id: id.to_string(),
336            display_name: None,
337            email: None,
338            signing_key: None,
339            metadata: std::collections::HashMap::new(),
340            error: None,
341        }
342    }
343
344    /// Set the display name.
345    pub fn display_name(mut self, name: &str) -> Self {
346        self.display_name = Some(name.to_string());
347        self
348    }
349
350    /// Set and validate the email address.
351    pub fn email(mut self, email: &str) -> Result<Self, IdentityError> {
352        self.email = Some(Email::new(email)?);
353        Ok(self)
354    }
355
356    /// Set a pre-validated signing key.
357    pub fn signing_key(mut self, key: SigningKey) -> Self {
358        self.signing_key = Some(key);
359        self
360    }
361
362    /// Set a signing key from a fingerprint string, validating it.
363    pub fn signing_key_str(mut self, fingerprint: &str) -> Result<Self, IdentityError> {
364        self.signing_key = Some(SigningKey::new(fingerprint)?);
365        Ok(self)
366    }
367
368    /// Add a metadata key-value pair.
369    pub fn meta(mut self, key: &str, value: &str) -> Self {
370        self.metadata.insert(key.to_string(), value.to_string());
371        self
372    }
373
374    /// Build the identity, validating all fields.
375    pub fn build(self) -> Result<Identity, IdentityError> {
376        if let Some(err) = self.error {
377            return Err(err);
378        }
379
380        if !validate_identifier(&self.id) {
381            return Err(IdentityError::InvalidIdentifier(self.id));
382        }
383
384        Ok(Identity {
385            id: self.id,
386            display_name: self.display_name,
387            email: self.email,
388            signing_key: self.signing_key,
389            created_at: Utc::now(),
390            metadata: self.metadata,
391        })
392    }
393}
394
395// ---------------------------------------------------------------------------
396// Organization
397// ---------------------------------------------------------------------------
398
399/// An organization that can contain identities and groups.
400#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
401pub struct Organization {
402    /// Unique identifier (slug).
403    pub id: String,
404    /// Human-readable name.
405    pub name: String,
406    /// Optional description.
407    pub description: Option<String>,
408    /// Member identity IDs.
409    pub members: Vec<String>,
410    /// Group IDs within this organization.
411    pub groups: Vec<String>,
412    /// When this organization was created.
413    pub created_at: DateTime<Utc>,
414    /// Arbitrary key-value metadata.
415    pub metadata: std::collections::HashMap<String, String>,
416}
417
418impl Organization {
419    /// Create a new organization.
420    pub fn new(id: &str, name: &str) -> Result<Self, IdentityError> {
421        if !validate_identifier(id) {
422            return Err(IdentityError::InvalidIdentifier(id.to_string()));
423        }
424
425        Ok(Self {
426            id: id.to_string(),
427            name: name.to_string(),
428            description: None,
429            members: Vec::new(),
430            groups: Vec::new(),
431            created_at: Utc::now(),
432            metadata: std::collections::HashMap::new(),
433        })
434    }
435
436    /// Add a member identity ID. Returns error if duplicate.
437    pub fn add_member(&mut self, identity_id: &str) -> Result<(), IdentityError> {
438        if self.members.contains(&identity_id.to_string()) {
439            return Err(IdentityError::DuplicateMember(identity_id.to_string()));
440        }
441        self.members.push(identity_id.to_string());
442        Ok(())
443    }
444
445    /// Remove a member identity ID. Returns true if removed.
446    pub fn remove_member(&mut self, identity_id: &str) -> bool {
447        if let Some(pos) = self.members.iter().position(|m| m == identity_id) {
448            self.members.remove(pos);
449            true
450        } else {
451            false
452        }
453    }
454
455    /// Check if an identity is a member.
456    pub fn is_member(&self, identity_id: &str) -> bool {
457        self.members.iter().any(|m| m == identity_id)
458    }
459
460    /// Add a group ID. Returns error if duplicate.
461    pub fn add_group(&mut self, group_id: &str) -> Result<(), IdentityError> {
462        if self.groups.contains(&group_id.to_string()) {
463            return Err(IdentityError::DuplicateMember(group_id.to_string()));
464        }
465        self.groups.push(group_id.to_string());
466        Ok(())
467    }
468
469    /// Remove a group ID. Returns true if removed.
470    pub fn remove_group(&mut self, group_id: &str) -> bool {
471        if let Some(pos) = self.groups.iter().position(|g| g == group_id) {
472            self.groups.remove(pos);
473            true
474        } else {
475            false
476        }
477    }
478}
479
480impl fmt::Display for Organization {
481    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
482        write!(f, "{} ({}, {} members)", self.name, self.id, self.members.len())
483    }
484}
485
486// ---------------------------------------------------------------------------
487// Group
488// ---------------------------------------------------------------------------
489
490/// A named group of identities within an organization.
491///
492/// Groups are used for role-based access and approval routing.
493/// For example, a "reviewers" group might be required to approve design phases.
494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
495pub struct Group {
496    /// Unique identifier (slug).
497    pub id: String,
498    /// Human-readable name.
499    pub name: String,
500    /// Optional description of this group's purpose.
501    pub description: Option<String>,
502    /// Member identity IDs.
503    pub members: Vec<String>,
504    /// When this group was created.
505    pub created_at: DateTime<Utc>,
506    /// Arbitrary key-value metadata.
507    pub metadata: std::collections::HashMap<String, String>,
508}
509
510impl Group {
511    /// Create a new group.
512    pub fn new(id: &str, name: &str) -> Result<Self, IdentityError> {
513        if !validate_identifier(id) {
514            return Err(IdentityError::InvalidIdentifier(id.to_string()));
515        }
516
517        Ok(Self {
518            id: id.to_string(),
519            name: name.to_string(),
520            description: None,
521            members: Vec::new(),
522            created_at: Utc::now(),
523            metadata: std::collections::HashMap::new(),
524        })
525    }
526
527    /// Add a member identity ID. Returns error if duplicate.
528    pub fn add_member(&mut self, identity_id: &str) -> Result<(), IdentityError> {
529        if self.members.contains(&identity_id.to_string()) {
530            return Err(IdentityError::DuplicateMember(identity_id.to_string()));
531        }
532        self.members.push(identity_id.to_string());
533        Ok(())
534    }
535
536    /// Remove a member identity ID. Returns true if removed.
537    pub fn remove_member(&mut self, identity_id: &str) -> bool {
538        if let Some(pos) = self.members.iter().position(|m| m == identity_id) {
539            self.members.remove(pos);
540            true
541        } else {
542            false
543        }
544    }
545
546    /// Check if an identity is a member.
547    pub fn is_member(&self, identity_id: &str) -> bool {
548        self.members.iter().any(|m| m == identity_id)
549    }
550
551    /// Return the number of members.
552    pub fn member_count(&self) -> usize {
553        self.members.len()
554    }
555}
556
557impl fmt::Display for Group {
558    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
559        write!(f, "{} ({}, {} members)", self.name, self.id, self.members.len())
560    }
561}
562
563// ===========================================================================
564// Tests
565// ===========================================================================
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    // -- Email validation --------------------------------------------------
572
573    #[test]
574    fn test_valid_emails() {
575        let valid = [
576            "user@example.com",
577            "alice.bob@sub.domain.org",
578            "test+tag@gmail.com",
579            "a@b.co",
580            "user123@test-domain.io",
581        ];
582        for email in &valid {
583            assert!(Email::new(email).is_ok(), "Expected valid: {}", email);
584        }
585    }
586
587    #[test]
588    fn test_invalid_emails() {
589        let invalid = [
590            "",
591            "noat",
592            "@domain.com",
593            "user@",
594            "user@domain",         // no dot in domain
595            "user@.com",           // empty label
596            "user@domain.",        // trailing dot → empty label
597            "user@-domain.com",    // leading hyphen in label
598            "user@domain-.com",    // trailing hyphen in label
599            "user @domain.com",    // whitespace
600            "user@dom ain.com",    // whitespace in domain
601        ];
602        for email in &invalid {
603            assert!(Email::new(email).is_err(), "Expected invalid: '{}'", email);
604        }
605    }
606
607    #[test]
608    fn test_email_normalises_to_lowercase() {
609        let email = Email::new("Alice@Example.COM").unwrap();
610        assert_eq!(email.as_str(), "alice@example.com");
611    }
612
613    // -- Signing key validation --------------------------------------------
614
615    #[test]
616    fn test_valid_signing_keys() {
617        let fp = "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2";
618        let key = SigningKey::new(fp).unwrap();
619        assert_eq!(key.as_str(), fp);
620    }
621
622    #[test]
623    fn test_signing_key_with_spaces() {
624        let fp = "A1B2 C3D4 E5F6 A1B2 C3D4 E5F6 A1B2 C3D4 E5F6 A1B2";
625        let key = SigningKey::new(fp).unwrap();
626        assert_eq!(key.as_str(), "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2");
627    }
628
629    #[test]
630    fn test_signing_key_with_colons() {
631        let fp = "A1B2:C3D4:E5F6:A1B2:C3D4:E5F6:A1B2:C3D4:E5F6:A1B2";
632        let key = SigningKey::new(fp).unwrap();
633        assert_eq!(key.as_str(), "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2");
634    }
635
636    #[test]
637    fn test_signing_key_lowercase_normalised() {
638        let fp = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
639        let key = SigningKey::new(fp).unwrap();
640        assert_eq!(key.as_str(), "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2");
641    }
642
643    #[test]
644    fn test_invalid_signing_keys() {
645        assert!(SigningKey::new("").is_err());
646        assert!(SigningKey::new("tooshort").is_err());
647        assert!(SigningKey::new("ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ").is_err()); // non-hex
648        assert!(SigningKey::new("A1B2C3D4").is_err()); // too short
649        assert!(SigningKey::new("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2FF").is_err()); // too long
650    }
651
652    #[test]
653    fn test_signing_key_ids() {
654        let fp = "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2";
655        let key = SigningKey::new(fp).unwrap();
656        assert_eq!(key.long_id(), "A1B2C3D4E5F6A1B2");
657        assert_eq!(key.short_id(), "E5F6A1B2");
658    }
659
660    #[test]
661    fn test_signing_key_display() {
662        let fp = "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2";
663        let key = SigningKey::new(fp).unwrap();
664        let display = format!("{}", key);
665        assert_eq!(display, "A1B2 C3D4 E5F6 A1B2 C3D4 E5F6 A1B2 C3D4 E5F6 A1B2");
666    }
667
668    // -- Identifier validation ---------------------------------------------
669
670    #[test]
671    fn test_valid_identifiers() {
672        assert!(validate_identifier("alice"));
673        assert!(validate_identifier("bob-smith"));
674        assert!(validate_identifier("a"));
675        assert!(validate_identifier("team-42"));
676        assert!(validate_identifier("x1"));
677    }
678
679    #[test]
680    fn test_invalid_identifiers() {
681        assert!(!validate_identifier(""));
682        assert!(!validate_identifier("-starts-with-dash"));
683        assert!(!validate_identifier("ends-with-dash-"));
684        assert!(!validate_identifier("has spaces"));
685        assert!(!validate_identifier("UPPERCASE"));
686        assert!(!validate_identifier("has_underscore"));
687        assert!(!validate_identifier("has.dot"));
688    }
689
690    // -- Identity ----------------------------------------------------------
691
692    #[test]
693    fn test_identity_new() {
694        let id = Identity::new("alice").unwrap();
695        assert_eq!(id.id, "alice");
696        assert!(id.email.is_none());
697        assert!(id.signing_key.is_none());
698    }
699
700    #[test]
701    fn test_identity_invalid_id() {
702        assert!(Identity::new("").is_err());
703        assert!(Identity::new("UPPER").is_err());
704        assert!(Identity::new("-bad").is_err());
705    }
706
707    #[test]
708    fn test_identity_set_email() {
709        let mut id = Identity::new("alice").unwrap();
710        id.set_email("alice@example.com").unwrap();
711        assert_eq!(id.email.as_ref().unwrap().as_str(), "alice@example.com");
712    }
713
714    #[test]
715    fn test_identity_set_invalid_email() {
716        let mut id = Identity::new("alice").unwrap();
717        assert!(id.set_email("not-an-email").is_err());
718    }
719
720    #[test]
721    fn test_identity_set_signing_key() {
722        let mut id = Identity::new("alice").unwrap();
723        id.set_signing_key("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2").unwrap();
724        assert!(id.has_signing_key());
725    }
726
727    #[test]
728    fn test_identity_builder() {
729        let key = SigningKey::new("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2").unwrap();
730        let identity = Identity::builder("alice")
731            .email("alice@example.com").unwrap()
732            .display_name("Alice Smith")
733            .signing_key(key)
734            .meta("role", "lead")
735            .build()
736            .unwrap();
737
738        assert_eq!(identity.id, "alice");
739        assert_eq!(identity.display_name.as_deref(), Some("Alice Smith"));
740        assert_eq!(identity.email.as_ref().unwrap().as_str(), "alice@example.com");
741        assert!(identity.has_signing_key());
742        assert_eq!(identity.metadata.get("role").unwrap(), "lead");
743    }
744
745    #[test]
746    fn test_identity_builder_invalid_id() {
747        let result = Identity::builder("BAD ID").build();
748        assert!(result.is_err());
749    }
750
751    #[test]
752    fn test_identity_builder_invalid_email() {
753        let result = Identity::builder("alice").email("nope");
754        assert!(result.is_err());
755    }
756
757    #[test]
758    fn test_identity_display() {
759        let id = Identity::builder("alice")
760            .display_name("Alice Smith")
761            .build()
762            .unwrap();
763        assert_eq!(format!("{}", id), "Alice Smith (alice)");
764
765        let id2 = Identity::builder("bob")
766            .email("bob@example.com").unwrap()
767            .build()
768            .unwrap();
769        assert_eq!(format!("{}", id2), "bob <bob@example.com>");
770
771        let id3 = Identity::new("charlie").unwrap();
772        assert_eq!(format!("{}", id3), "charlie");
773    }
774
775    // -- Organization ------------------------------------------------------
776
777    #[test]
778    fn test_organization_new() {
779        let org = Organization::new("acme", "Acme Corp").unwrap();
780        assert_eq!(org.id, "acme");
781        assert_eq!(org.name, "Acme Corp");
782        assert!(org.members.is_empty());
783        assert!(org.groups.is_empty());
784    }
785
786    #[test]
787    fn test_organization_invalid_id() {
788        assert!(Organization::new("BAD", "Bad Org").is_err());
789    }
790
791    #[test]
792    fn test_organization_add_member() {
793        let mut org = Organization::new("acme", "Acme Corp").unwrap();
794        org.add_member("alice").unwrap();
795        org.add_member("bob").unwrap();
796        assert!(org.is_member("alice"));
797        assert!(org.is_member("bob"));
798        assert!(!org.is_member("charlie"));
799    }
800
801    #[test]
802    fn test_organization_duplicate_member() {
803        let mut org = Organization::new("acme", "Acme Corp").unwrap();
804        org.add_member("alice").unwrap();
805        assert!(org.add_member("alice").is_err());
806    }
807
808    #[test]
809    fn test_organization_remove_member() {
810        let mut org = Organization::new("acme", "Acme Corp").unwrap();
811        org.add_member("alice").unwrap();
812        assert!(org.remove_member("alice"));
813        assert!(!org.is_member("alice"));
814        assert!(!org.remove_member("alice")); // already removed
815    }
816
817    #[test]
818    fn test_organization_add_group() {
819        let mut org = Organization::new("acme", "Acme Corp").unwrap();
820        org.add_group("backend").unwrap();
821        assert!(org.groups.contains(&"backend".to_string()));
822    }
823
824    #[test]
825    fn test_organization_duplicate_group() {
826        let mut org = Organization::new("acme", "Acme Corp").unwrap();
827        org.add_group("backend").unwrap();
828        assert!(org.add_group("backend").is_err());
829    }
830
831    #[test]
832    fn test_organization_remove_group() {
833        let mut org = Organization::new("acme", "Acme Corp").unwrap();
834        org.add_group("backend").unwrap();
835        assert!(org.remove_group("backend"));
836        assert!(!org.remove_group("backend"));
837    }
838
839    #[test]
840    fn test_organization_display() {
841        let mut org = Organization::new("acme", "Acme Corp").unwrap();
842        org.add_member("alice").unwrap();
843        org.add_member("bob").unwrap();
844        assert_eq!(format!("{}", org), "Acme Corp (acme, 2 members)");
845    }
846
847    // -- Group -------------------------------------------------------------
848
849    #[test]
850    fn test_group_new() {
851        let group = Group::new("reviewers", "Code Reviewers").unwrap();
852        assert_eq!(group.id, "reviewers");
853        assert_eq!(group.name, "Code Reviewers");
854        assert!(group.members.is_empty());
855    }
856
857    #[test]
858    fn test_group_invalid_id() {
859        assert!(Group::new("BAD", "Bad Group").is_err());
860    }
861
862    #[test]
863    fn test_group_add_member() {
864        let mut group = Group::new("reviewers", "Reviewers").unwrap();
865        group.add_member("alice").unwrap();
866        assert!(group.is_member("alice"));
867        assert_eq!(group.member_count(), 1);
868    }
869
870    #[test]
871    fn test_group_duplicate_member() {
872        let mut group = Group::new("reviewers", "Reviewers").unwrap();
873        group.add_member("alice").unwrap();
874        assert!(group.add_member("alice").is_err());
875    }
876
877    #[test]
878    fn test_group_remove_member() {
879        let mut group = Group::new("reviewers", "Reviewers").unwrap();
880        group.add_member("alice").unwrap();
881        assert!(group.remove_member("alice"));
882        assert!(!group.is_member("alice"));
883        assert_eq!(group.member_count(), 0);
884    }
885
886    #[test]
887    fn test_group_display() {
888        let mut group = Group::new("reviewers", "Code Reviewers").unwrap();
889        group.add_member("alice").unwrap();
890        assert_eq!(format!("{}", group), "Code Reviewers (reviewers, 1 members)");
891    }
892
893    // -- Serialization roundtrip -------------------------------------------
894
895    #[test]
896    fn test_identity_serde_roundtrip() {
897        let identity = Identity::builder("alice")
898            .email("alice@example.com").unwrap()
899            .display_name("Alice")
900            .signing_key_str("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2").unwrap()
901            .build()
902            .unwrap();
903
904        let json = serde_json::to_string(&identity).unwrap();
905        let deserialized: Identity = serde_json::from_str(&json).unwrap();
906
907        assert_eq!(identity.id, deserialized.id);
908        assert_eq!(identity.email, deserialized.email);
909        assert_eq!(identity.signing_key, deserialized.signing_key);
910        assert_eq!(identity.display_name, deserialized.display_name);
911    }
912
913    #[test]
914    fn test_organization_serde_roundtrip() {
915        let mut org = Organization::new("acme", "Acme Corp").unwrap();
916        org.add_member("alice").unwrap();
917        org.add_group("backend").unwrap();
918
919        let json = serde_json::to_string(&org).unwrap();
920        let deserialized: Organization = serde_json::from_str(&json).unwrap();
921
922        assert_eq!(org.id, deserialized.id);
923        assert_eq!(org.members, deserialized.members);
924        assert_eq!(org.groups, deserialized.groups);
925    }
926
927    #[test]
928    fn test_group_serde_roundtrip() {
929        let mut group = Group::new("reviewers", "Reviewers").unwrap();
930        group.add_member("alice").unwrap();
931
932        let yaml = serde_yaml::to_string(&group).unwrap();
933        let deserialized: Group = serde_yaml::from_str(&yaml).unwrap();
934
935        assert_eq!(group.id, deserialized.id);
936        assert_eq!(group.members, deserialized.members);
937    }
938}