Skip to main content

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 crate::git_helpers::runtime_identity::{get_system_hostname, get_system_username};
22use crate::ProcessExecutor;
23
24#[cfg(test)]
25use crate::executor::RealProcessExecutor;
26
27/// Typed error for git identity validation.
28///
29/// Pure validation functions return `Result<(), IdentityValidationError>`.
30/// Callers that need a displayable message can use `.to_string()`.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum IdentityValidationError {
33    /// Git user name is empty or whitespace-only.
34    EmptyName,
35    /// Git user email is empty or whitespace-only.
36    EmptyEmail,
37    /// Git user email does not match a valid format.
38    InvalidEmailFormat(String),
39}
40
41impl std::fmt::Display for IdentityValidationError {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            Self::EmptyName => write!(f, "Git user name cannot be empty"),
45            Self::EmptyEmail => write!(f, "Git user email cannot be empty"),
46            Self::InvalidEmailFormat(email) => {
47                write!(f, "Invalid email format: '{email}'")
48            }
49        }
50    }
51}
52
53/// Git user identity information.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct GitIdentity {
56    /// The user's name for git commits.
57    pub name: String,
58    /// The user's email for git commits.
59    pub email: String,
60}
61
62impl GitIdentity {
63    /// Create a new `GitIdentity` with the given name and email.
64    #[must_use]
65    pub const fn new(name: String, email: String) -> Self {
66        Self { name, email }
67    }
68
69    /// Validate that the identity is well-formed.
70    ///
71    /// # Errors
72    ///
73    /// Returns [`IdentityValidationError`] if name or email is invalid.
74    pub fn validate(&self) -> Result<(), IdentityValidationError> {
75        validate_git_identity_fields(&self.name, &self.email)
76    }
77}
78
79/// Pure policy: validate git identity name and email fields.
80///
81/// # Errors
82///
83/// Returns [`IdentityValidationError`] if name or email is invalid.
84pub fn validate_git_identity_fields(
85    name: &str,
86    email: &str,
87) -> Result<(), IdentityValidationError> {
88    if name.trim().is_empty() {
89        return Err(IdentityValidationError::EmptyName);
90    }
91    if email.trim().is_empty() {
92        return Err(IdentityValidationError::EmptyEmail);
93    }
94    let email = email.trim();
95    if !email.contains('@') {
96        return Err(IdentityValidationError::InvalidEmailFormat(
97            email.to_string(),
98        ));
99    }
100    let parts: Vec<&str> = email.split('@').collect();
101    if parts.len() != 2 {
102        return Err(IdentityValidationError::InvalidEmailFormat(
103            email.to_string(),
104        ));
105    }
106    if parts[0].trim().is_empty() {
107        return Err(IdentityValidationError::InvalidEmailFormat(
108            email.to_string(),
109        ));
110    }
111    if parts[1].trim().is_empty() || !parts[1].contains('.') {
112        return Err(IdentityValidationError::InvalidEmailFormat(
113            email.to_string(),
114        ));
115    }
116    Ok(())
117}
118
119/// Pure policy: choose username from available sources.
120pub fn choose_username(env_username: Option<String>, whoami_output: Option<String>) -> String {
121    env_username
122        .filter(|u| !u.is_empty())
123        .or_else(|| whoami_output.map(|o| o.trim().to_string()))
124        .filter(|u| !u.is_empty())
125        .unwrap_or_else(|| "Unknown User".to_string())
126}
127
128/// Pure policy: choose hostname from available sources.
129pub fn choose_hostname(
130    env_hostname: Option<String>,
131    hostname_output: Option<String>,
132) -> Option<String> {
133    env_hostname
134        .filter(|h| !h.is_empty())
135        .or_else(|| hostname_output.map(|h| h.trim().to_string()))
136        .filter(|h| !h.is_empty())
137}
138
139/// Get the system username as a fallback.
140///
141/// Uses platform-specific methods:
142/// - On Unix: `whoami` command, fallback to `$USER` env var
143/// - On Windows: `%USERNAME%` env var
144#[must_use]
145pub fn fallback_username(executor: Option<&dyn ProcessExecutor>) -> String {
146    let env_username = get_system_username();
147    let whoami_output = if cfg!(unix) {
148        executor.and_then(|exec| {
149            exec.execute("whoami", &[], &[], None)
150                .ok()
151                .map(|o| o.stdout)
152        })
153    } else {
154        None
155    };
156    choose_username(env_username, whoami_output)
157}
158
159/// Get a fallback email based on the username.
160#[must_use]
161pub fn fallback_email(username: &str, executor: Option<&dyn ProcessExecutor>) -> String {
162    let hostname = resolve_hostname_impl(executor);
163    let host = hostname.unwrap_or_else(|| "localhost".to_string());
164    format!("{username}@{host}")
165}
166
167/// Internal hostname resolution.
168fn resolve_hostname_impl(executor: Option<&dyn ProcessExecutor>) -> Option<String> {
169    let env_hostname = get_system_hostname();
170    let hostname_output = executor.and_then(|exec| {
171        exec.execute("hostname", &[], &[], None)
172            .ok()
173            .map(|o| o.stdout.trim().to_string())
174    });
175    choose_hostname(env_hostname, hostname_output)
176}
177
178/// Get the default git identity (last resort).
179///
180/// This should never be reached if the fallback chain is working correctly.
181#[must_use]
182pub fn default_identity() -> GitIdentity {
183    GitIdentity::new("Ralph Workflow".to_string(), "ralph@localhost".to_string())
184}
185
186/// Helper trait for error checking in tests
187#[cfg(test)]
188trait ContainsErr {
189    fn contains_err(&self, needle: &str) -> bool;
190}
191
192#[cfg(test)]
193impl ContainsErr for Result<(), IdentityValidationError> {
194    fn contains_err(&self, needle: &str) -> bool {
195        match self {
196            Err(e) => e.to_string().contains(needle),
197            _ => false,
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_git_identity_validation_valid() {
208        let identity = GitIdentity::new("Test User".to_string(), "test@example.com".to_string());
209        assert!(identity.validate().is_ok());
210    }
211
212    #[test]
213    fn test_git_identity_validation_empty_name() {
214        let identity = GitIdentity::new(String::new(), "test@example.com".to_string());
215        assert!(identity
216            .validate()
217            .contains_err("Git user name cannot be empty"));
218    }
219
220    #[test]
221    fn test_git_identity_validation_empty_email() {
222        let identity = GitIdentity::new("Test User".to_string(), String::new());
223        assert!(identity
224            .validate()
225            .contains_err("Git user email cannot be empty"));
226    }
227
228    #[test]
229    fn test_git_identity_validation_invalid_email_no_at() {
230        let identity = GitIdentity::new("Test User".to_string(), "invalidemail".to_string());
231        assert!(identity.validate().contains_err("Invalid email format"));
232    }
233
234    #[test]
235    fn test_git_identity_validation_invalid_email_no_domain() {
236        let identity = GitIdentity::new("Test User".to_string(), "user@".to_string());
237        assert!(identity.validate().contains_err("Invalid email format"));
238    }
239
240    #[test]
241    fn test_fallback_username_not_empty() {
242        let executor = RealProcessExecutor::new();
243        let username = fallback_username(Some(&executor));
244        assert!(!username.is_empty());
245    }
246
247    #[test]
248    fn test_fallback_email_format() {
249        let username = "testuser";
250        let executor = RealProcessExecutor::new();
251        let email = fallback_email(username, Some(&executor));
252        assert!(email.contains('@'));
253        assert!(email.starts_with(username));
254    }
255
256    #[test]
257    fn test_fallback_username_without_executor() {
258        let username = fallback_username(None);
259        assert!(!username.is_empty());
260    }
261
262    #[test]
263    fn test_fallback_email_without_executor() {
264        let username = "testuser";
265        let email = fallback_email(username, None);
266        assert!(email.contains('@'));
267        assert!(email.starts_with(username));
268    }
269
270    #[test]
271    fn test_default_identity() {
272        let identity = default_identity();
273        assert_eq!(identity.name, "Ralph Workflow");
274        assert_eq!(identity.email, "ralph@localhost");
275    }
276    // --- Typed error variant tests (TDD RED - will fail until IdentityValidationError is added) ---
277
278    #[test]
279    fn test_validate_empty_name_returns_empty_name_variant() {
280        let result = validate_git_identity_fields("", "test@example.com");
281        assert_eq!(result, Err(IdentityValidationError::EmptyName));
282    }
283
284    #[test]
285    fn test_validate_empty_email_returns_empty_email_variant() {
286        let result = validate_git_identity_fields("Test User", "");
287        assert_eq!(result, Err(IdentityValidationError::EmptyEmail));
288    }
289
290    #[test]
291    fn test_validate_email_no_at_returns_invalid_format_variant() {
292        let result = validate_git_identity_fields("Test User", "invalidemail");
293        assert!(
294            matches!(result, Err(IdentityValidationError::InvalidEmailFormat(_))),
295            "expected InvalidEmailFormat, got {result:?}"
296        );
297    }
298
299    #[test]
300    fn test_validate_email_no_domain_returns_invalid_format_variant() {
301        let result = validate_git_identity_fields("Test User", "user@");
302        assert!(
303            matches!(result, Err(IdentityValidationError::InvalidEmailFormat(_))),
304            "expected InvalidEmailFormat, got {result:?}"
305        );
306    }
307
308    #[test]
309    fn test_validate_identity_error_display_empty_name() {
310        let err = IdentityValidationError::EmptyName;
311        assert!(err.to_string().contains("name"));
312    }
313
314    #[test]
315    fn test_validate_identity_error_display_empty_email() {
316        let err = IdentityValidationError::EmptyEmail;
317        assert!(err.to_string().contains("email"));
318    }
319
320    #[test]
321    fn test_validate_identity_error_display_invalid_format() {
322        let err = IdentityValidationError::InvalidEmailFormat("bad@".to_string());
323        assert!(err.to_string().contains("bad@"));
324    }
325}