ralph_workflow/git_helpers/
identity.rs

1//! Git identity resolution with fallback chain.
2//!
3//! This module provides a comprehensive git identity resolution system that:
4//! 1. Works with git config as the primary source (via libgit2 in caller)
5//! 2. Adds Ralph-specific configuration options (config file, env vars, CLI args)
6//! 3. Implements sensible fallbacks (system username, default values)
7//! 4. Provides clear error messages when identity cannot be determined
8//!
9//! # Priority Chain
10//!
11//! The identity is resolved in the following order (matches standard git behavior):
12//! 1. Git config (via libgit2) - primary source (local .git/config, then global ~/.gitconfig)
13//! 2. Explicit CLI args - only used when git config is missing
14//! 3. Environment variables (`RALPH_GIT_USER_NAME`, `RALPH_GIT_USER_EMAIL`) - fallback
15//! 4. Ralph config file (`[general]` section with `git_user_name`, `git_user_email`)
16//! 5. System username + derived email (sane fallback)
17//! 6. Default values ("Ralph Workflow", "ralph@localhost") - last resort
18
19#![deny(unsafe_code)]
20
21use std::env;
22
23/// Git user identity information.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct GitIdentity {
26    /// The user's name for git commits.
27    pub name: String,
28    /// The user's email for git commits.
29    pub email: String,
30}
31
32impl GitIdentity {
33    /// Create a new `GitIdentity` with the given name and email.
34    pub const fn new(name: String, email: String) -> Self {
35        Self { name, email }
36    }
37
38    /// Validate that the identity is well-formed.
39    pub fn validate(&self) -> Result<(), String> {
40        if self.name.trim().is_empty() {
41            return Err("Git user name cannot be empty".to_string());
42        }
43        if self.email.trim().is_empty() {
44            return Err("Git user email cannot be empty".to_string());
45        }
46        // Basic email validation - check for @ and at least one . after @
47        let email = self.email.trim();
48        if !email.contains('@') {
49            return Err(format!("Invalid email format: '{email}'"));
50        }
51        let parts: Vec<&str> = email.split('@').collect();
52        if parts.len() != 2 {
53            return Err(format!("Invalid email format: '{email}'"));
54        }
55        if parts[0].trim().is_empty() {
56            return Err(format!(
57                "Invalid email format: '{email}' (missing local part)"
58            ));
59        }
60        if parts[1].trim().is_empty() || !parts[1].contains('.') {
61            return Err(format!("Invalid email format: '{email}' (invalid domain)"));
62        }
63        Ok(())
64    }
65}
66
67/// Get the system username as a fallback.
68///
69/// Uses platform-specific methods:
70/// - On Unix: `whoami` command, fallback to `$USER` env var
71/// - On Windows: `%USERNAME%` env var
72pub fn fallback_username() -> String {
73    // Try environment variables first (fastest)
74    if cfg!(unix) {
75        if let Ok(user) = env::var("USER") {
76            if !user.trim().is_empty() {
77                return user.trim().to_string();
78            }
79        }
80        if let Ok(user) = env::var("LOGNAME") {
81            if !user.trim().is_empty() {
82                return user.trim().to_string();
83            }
84        }
85    } else if cfg!(windows) {
86        if let Ok(user) = env::var("USERNAME") {
87            if !user.trim().is_empty() {
88                return user.trim().to_string();
89            }
90        }
91    }
92
93    // As a last resort, try to run whoami (Unix only)
94    if cfg!(unix) {
95        if let Ok(output) = std::process::Command::new("whoami").output() {
96            let username = String::from_utf8_lossy(&output.stdout).trim().to_string();
97            if !username.is_empty() {
98                return username;
99            }
100        }
101    }
102
103    // Ultimate fallback
104    "Unknown User".to_string()
105}
106
107/// Get a fallback email based on the username.
108///
109/// Format: `{username}@{hostname}` or `{username}@localhost`
110pub fn fallback_email(username: &str) -> String {
111    // Try to get hostname
112    let hostname = match get_hostname() {
113        Some(host) if !host.is_empty() => host,
114        _ => "localhost".to_string(),
115    };
116
117    format!("{username}@{hostname}")
118}
119
120/// Get the system hostname.
121fn get_hostname() -> Option<String> {
122    // Try HOSTNAME environment variable first (fastest)
123    if let Ok(hostname) = env::var("HOSTNAME") {
124        let hostname = hostname.trim();
125        if !hostname.is_empty() {
126            return Some(hostname.to_string());
127        }
128    }
129
130    // Try the `hostname` command
131    if let Ok(output) = std::process::Command::new("hostname").output() {
132        let hostname = String::from_utf8_lossy(&output.stdout).trim().to_string();
133        if !hostname.is_empty() {
134            return Some(hostname);
135        }
136    }
137
138    None
139}
140
141/// Get the default git identity (last resort).
142///
143/// This should never be reached if the fallback chain is working correctly.
144pub fn default_identity() -> GitIdentity {
145    GitIdentity::new("Ralph Workflow".to_string(), "ralph@localhost".to_string())
146}
147
148/// Helper trait for error checking in tests
149#[cfg(test)]
150trait ContainsErr {
151    fn contains_err(&self, needle: &str) -> bool;
152}
153
154#[cfg(test)]
155impl ContainsErr for Result<(), String> {
156    fn contains_err(&self, needle: &str) -> bool {
157        match self {
158            Err(e) => e.contains(needle),
159            _ => false,
160        }
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_git_identity_validation_valid() {
170        let identity = GitIdentity::new("Test User".to_string(), "test@example.com".to_string());
171        assert!(identity.validate().is_ok());
172    }
173
174    #[test]
175    fn test_git_identity_validation_empty_name() {
176        let identity = GitIdentity::new(String::new(), "test@example.com".to_string());
177        assert!(identity
178            .validate()
179            .contains_err("Git user name cannot be empty"));
180    }
181
182    #[test]
183    fn test_git_identity_validation_empty_email() {
184        let identity = GitIdentity::new("Test User".to_string(), String::new());
185        assert!(identity
186            .validate()
187            .contains_err("Git user email cannot be empty"));
188    }
189
190    #[test]
191    fn test_git_identity_validation_invalid_email_no_at() {
192        let identity = GitIdentity::new("Test User".to_string(), "invalidemail".to_string());
193        assert!(identity.validate().contains_err("Invalid email format"));
194    }
195
196    #[test]
197    fn test_git_identity_validation_invalid_email_no_domain() {
198        let identity = GitIdentity::new("Test User".to_string(), "user@".to_string());
199        assert!(identity.validate().contains_err("Invalid email format"));
200    }
201
202    #[test]
203    fn test_fallback_username_not_empty() {
204        let username = fallback_username();
205        assert!(!username.is_empty());
206    }
207
208    #[test]
209    fn test_fallback_email_format() {
210        let username = "testuser";
211        let email = fallback_email(username);
212        assert!(email.contains('@'));
213        assert!(email.starts_with(username));
214    }
215
216    #[test]
217    fn test_default_identity() {
218        let identity = default_identity();
219        assert_eq!(identity.name, "Ralph Workflow");
220        assert_eq!(identity.email, "ralph@localhost");
221    }
222}