Skip to main content

gitcore/
models.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashSet;
3use std::str::FromStr;
4
5/// A persisted identity record for a Git hosting platform.
6#[derive(Debug, Serialize, Deserialize, Clone)]
7pub struct Account {
8    /// Human-facing account name such as `work` or `personal`.
9    pub name: String,
10    /// Git hosting platform this identity belongs to.
11    pub platform: Platform,
12    /// Filename of the associated SSH private key in the managed SSH directory.
13    pub key_path: String,
14    /// Unique host alias used in SSH config and rewritten Git URLs.
15    pub host_alias: String,
16    /// Git author name (user.name).
17    pub username: String,
18    /// Git author email (user.email) and SSH key comment.
19    pub email: String,
20    /// Optional GPG signing key ID.
21    pub gpg_key_id: Option<String>,
22}
23
24/// Supported Git hosting platforms.
25#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
26#[serde(rename_all = "lowercase")]
27pub enum Platform {
28    /// GitHub (github.com)
29    #[default]
30    Github,
31    /// GitLab (gitlab.com)
32    Gitlab,
33    /// Codeberg (codeberg.org)
34    Codeberg,
35    /// Bitbucket (bitbucket.org)
36    Bitbucket,
37}
38
39impl Platform {
40    /// Returns the Git hosting domain used for SSH and URL rewriting.
41    #[must_use]
42    pub fn host(&self) -> &str {
43        match self {
44            Platform::Github => "github.com",
45            Platform::Gitlab => "gitlab.com",
46            Platform::Codeberg => "codeberg.org",
47            Platform::Bitbucket => "bitbucket.org",
48        }
49    }
50
51    /// Returns the browser URL where SSH public keys are managed.
52    #[must_use]
53    pub fn provider_key_url(&self) -> &'static str {
54        match self {
55            Platform::Github => "https://github.com/settings/keys",
56            Platform::Gitlab => "https://gitlab.com/-/profile/keys",
57            Platform::Codeberg => "https://codeberg.org/user/keys",
58            Platform::Bitbucket => "https://bitbucket.org/account/settings/ssh-keys/",
59        }
60    }
61}
62
63impl FromStr for Platform {
64    type Err = ();
65
66    fn from_str(s: &str) -> Result<Self, Self::Err> {
67        match s.to_lowercase().as_str() {
68            "github" => Ok(Platform::Github),
69            "gitlab" => Ok(Platform::Gitlab),
70            "codeberg" => Ok(Platform::Codeberg),
71            "bitbucket" | "bb" => Ok(Platform::Bitbucket),
72            _ => Err(()),
73        }
74    }
75}
76
77/// Root configuration object containing all managed accounts.
78#[derive(Debug, Serialize, Deserialize, Default, Clone)]
79pub struct GitcoreConfig {
80    /// List of registered identities.
81    pub accounts: Vec<Account>,
82}
83
84/// An encrypted container for configuration and private key material.
85#[derive(Debug, Serialize, Deserialize, Clone)]
86pub struct Vault {
87    /// The Gitcore configuration.
88    pub config: GitcoreConfig,
89    /// Embedded SSH key material.
90    pub keys: Vec<VaultKey>,
91}
92
93/// Embedded SSH keypair material.
94#[derive(Debug, Serialize, Deserialize, Clone)]
95pub struct VaultKey {
96    /// Filename for the key (e.g., `id_ed25519_work`).
97    pub filename: String,
98    /// Full content of the private key file.
99    pub private_content: String,
100    /// Full content of the public key file.
101    pub public_content: String,
102}
103
104/// Validates that an account name contains only safe characters.
105#[must_use]
106pub fn is_valid_account_name(name: &str) -> bool {
107    !name.is_empty()
108        && name
109            .chars()
110            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
111}
112
113/// Checks a list of accounts for name and alias collisions and valid fields.
114///
115/// # Errors
116/// Returns an error string if any invariant is violated.
117pub fn validate_accounts(accounts: &[Account]) -> Result<(), String> {
118    let mut names = HashSet::new();
119    let mut aliases = HashSet::new();
120
121    for acc in accounts {
122        if !is_valid_account_name(&acc.name) {
123            return Err(format!("Invalid account name '{}'", acc.name));
124        }
125
126        if acc.username.trim().is_empty() {
127            return Err(format!("Account '{}' has an empty username", acc.name));
128        }
129
130        if acc.email.trim().is_empty() {
131            return Err(format!("Account '{}' has an empty email", acc.name));
132        }
133
134        let normalized_name = acc.name.to_ascii_lowercase();
135        if !names.insert(normalized_name) {
136            return Err(format!("Duplicate account name '{}'", acc.name));
137        }
138
139        let normalized_alias = acc.host_alias.to_ascii_lowercase();
140        if !aliases.insert(normalized_alias) {
141            return Err(format!("Duplicate host alias '{}'", acc.host_alias));
142        }
143    }
144
145    Ok(())
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn test_is_valid_account_name() {
154        assert!(is_valid_account_name("work"));
155        assert!(is_valid_account_name("personal-git"));
156        assert!(is_valid_account_name("user_123"));
157        assert!(!is_valid_account_name(""));
158        assert!(!is_valid_account_name("work account"));
159        assert!(!is_valid_account_name("work@git"));
160    }
161
162    #[test]
163    fn test_validate_accounts_duplicates() {
164        let accounts = vec![
165            Account {
166                name: "test".to_string(),
167                platform: Platform::Github,
168                key_path: "key".to_string(),
169                host_alias: "alias".to_string(),
170                username: "user".to_string(),
171                email: "email".to_string(),
172                gpg_key_id: None,
173            },
174            Account {
175                name: "TEST".to_string(), // Duplicate name (case-insensitive)
176                platform: Platform::Gitlab,
177                key_path: "k2".to_string(),
178                host_alias: "h2".to_string(),
179                username: "u2".to_string(),
180                email: "e2".to_string(),
181                gpg_key_id: None,
182            },
183        ];
184        assert!(validate_accounts(&accounts).is_err());
185
186        let accounts = vec![
187            Account {
188                name: "test".to_string(),
189                platform: Platform::Github,
190                key_path: "key".to_string(),
191                host_alias: "alias".to_string(),
192                username: "user".to_string(),
193                email: "email".to_string(),
194                gpg_key_id: None,
195            },
196            Account {
197                name: "a2".to_string(),
198                platform: Platform::Github,
199                key_path: "k2".to_string(),
200                host_alias: "ALIAS".to_string(), // Duplicate alias (case-insensitive)
201                username: "u2".to_string(),
202                email: "e2".to_string(),
203                gpg_key_id: None,
204            },
205        ];
206        assert!(validate_accounts(&accounts).is_err());
207    }
208
209    #[test]
210    fn test_validate_accounts_empty_fields() {
211        let accounts = vec![Account {
212            name: "work".to_string(),
213            platform: Platform::Github,
214            key_path: "k1".to_string(),
215            host_alias: "h1".to_string(),
216            username: "  ".to_string(),
217            email: "e1".to_string(),
218            gpg_key_id: None,
219        }];
220        assert!(validate_accounts(&accounts).is_err());
221    }
222
223    #[test]
224    fn provider_key_urls_match_expected_hosts() {
225        assert_eq!(
226            Platform::Github.provider_key_url(),
227            "https://github.com/settings/keys"
228        );
229        assert_eq!(
230            Platform::Gitlab.provider_key_url(),
231            "https://gitlab.com/-/profile/keys"
232        );
233    }
234}