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 std::env;
22
23use crate::executor::ProcessExecutor;
24
25#[cfg(test)]
26use crate::executor::RealProcessExecutor;
27
28/// Git user identity information.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct GitIdentity {
31    /// The user's name for git commits.
32    pub name: String,
33    /// The user's email for git commits.
34    pub email: String,
35}
36
37impl GitIdentity {
38    /// Create a new `GitIdentity` with the given name and email.
39    pub const fn new(name: String, email: String) -> Self {
40        Self { name, email }
41    }
42
43    /// Validate that the identity is well-formed.
44    pub fn validate(&self) -> Result<(), String> {
45        if self.name.trim().is_empty() {
46            return Err("Git user name cannot be empty".to_string());
47        }
48        if self.email.trim().is_empty() {
49            return Err("Git user email cannot be empty".to_string());
50        }
51        // Basic email validation - check for @ and at least one . after @
52        let email = self.email.trim();
53        if !email.contains('@') {
54            return Err(format!("Invalid email format: '{email}'"));
55        }
56        let parts: Vec<&str> = email.split('@').collect();
57        if parts.len() != 2 {
58            return Err(format!("Invalid email format: '{email}'"));
59        }
60        if parts[0].trim().is_empty() {
61            return Err(format!(
62                "Invalid email format: '{email}' (missing local part)"
63            ));
64        }
65        if parts[1].trim().is_empty() || !parts[1].contains('.') {
66            return Err(format!("Invalid email format: '{email}' (invalid domain)"));
67        }
68        Ok(())
69    }
70}
71
72/// Get the system username as a fallback.
73///
74/// Uses platform-specific methods:
75/// - On Unix: `whoami` command, fallback to `$USER` env var
76/// - On Windows: `%USERNAME%` env var
77pub fn fallback_username(executor: Option<&dyn ProcessExecutor>) -> String {
78    // Try environment variables first (fastest)
79    if cfg!(unix) {
80        if let Ok(user) = env::var("USER") {
81            if !user.trim().is_empty() {
82                return user.trim().to_string();
83            }
84        }
85        if let Ok(user) = env::var("LOGNAME") {
86            if !user.trim().is_empty() {
87                return user.trim().to_string();
88            }
89        }
90    } else if cfg!(windows) {
91        if let Ok(user) = env::var("USERNAME") {
92            if !user.trim().is_empty() {
93                return user.trim().to_string();
94            }
95        }
96    }
97
98    // As a last resort, try to run whoami (Unix only)
99    if cfg!(unix) {
100        if let Some(exec) = executor {
101            if let Ok(output) = exec.execute("whoami", &[], &[], None) {
102                let username = output.stdout.trim().to_string();
103                if !username.is_empty() {
104                    return username;
105                }
106            }
107        }
108    }
109
110    // Ultimate fallback
111    "Unknown User".to_string()
112}
113
114/// Get the system username with a real process executor (convenience function).
115///
116/// This function requires an explicit executor parameter to enable proper
117/// dependency injection for testing.
118pub fn fallback_username_with_real(executor: &dyn ProcessExecutor) -> String {
119    fallback_username(Some(executor))
120}
121
122/// Get a fallback email based on the username.
123///
124/// Format: `{username}@{hostname}` or `{username}@localhost`
125pub fn fallback_email(username: &str, executor: Option<&dyn ProcessExecutor>) -> String {
126    // Try to get hostname
127    let hostname = match get_hostname(executor) {
128        Some(host) if !host.is_empty() => host,
129        _ => "localhost".to_string(),
130    };
131
132    format!("{username}@{hostname}")
133}
134
135/// Get a fallback email with a real process executor (convenience function).
136///
137/// This function requires an explicit executor parameter to enable proper
138/// dependency injection for testing.
139pub fn fallback_email_with_real(username: &str, executor: &dyn ProcessExecutor) -> String {
140    fallback_email(username, Some(executor))
141}
142
143/// Get the system hostname.
144fn get_hostname(executor: Option<&dyn ProcessExecutor>) -> Option<String> {
145    // Try HOSTNAME environment variable first (fastest)
146    if let Ok(hostname) = env::var("HOSTNAME") {
147        let hostname = hostname.trim();
148        if !hostname.is_empty() {
149            return Some(hostname.to_string());
150        }
151    }
152
153    // Try the `hostname` command
154    if let Some(exec) = executor {
155        if let Ok(output) = exec.execute("hostname", &[], &[], None) {
156            let hostname = output.stdout.trim().to_string();
157            if !hostname.is_empty() {
158                return Some(hostname);
159            }
160        }
161    }
162
163    None
164}
165
166/// Get the default git identity (last resort).
167///
168/// This should never be reached if the fallback chain is working correctly.
169pub fn default_identity() -> GitIdentity {
170    GitIdentity::new("Ralph Workflow".to_string(), "ralph@localhost".to_string())
171}
172
173/// Helper trait for error checking in tests
174#[cfg(test)]
175trait ContainsErr {
176    fn contains_err(&self, needle: &str) -> bool;
177}
178
179#[cfg(test)]
180impl ContainsErr for Result<(), String> {
181    fn contains_err(&self, needle: &str) -> bool {
182        match self {
183            Err(e) => e.contains(needle),
184            _ => false,
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn test_git_identity_validation_valid() {
195        let identity = GitIdentity::new("Test User".to_string(), "test@example.com".to_string());
196        assert!(identity.validate().is_ok());
197    }
198
199    #[test]
200    fn test_git_identity_validation_empty_name() {
201        let identity = GitIdentity::new(String::new(), "test@example.com".to_string());
202        assert!(identity
203            .validate()
204            .contains_err("Git user name cannot be empty"));
205    }
206
207    #[test]
208    fn test_git_identity_validation_empty_email() {
209        let identity = GitIdentity::new("Test User".to_string(), String::new());
210        assert!(identity
211            .validate()
212            .contains_err("Git user email cannot be empty"));
213    }
214
215    #[test]
216    fn test_git_identity_validation_invalid_email_no_at() {
217        let identity = GitIdentity::new("Test User".to_string(), "invalidemail".to_string());
218        assert!(identity.validate().contains_err("Invalid email format"));
219    }
220
221    #[test]
222    fn test_git_identity_validation_invalid_email_no_domain() {
223        let identity = GitIdentity::new("Test User".to_string(), "user@".to_string());
224        assert!(identity.validate().contains_err("Invalid email format"));
225    }
226
227    #[test]
228    fn test_fallback_username_not_empty() {
229        let executor = RealProcessExecutor::new();
230        let username = fallback_username_with_real(&executor);
231        assert!(!username.is_empty());
232    }
233
234    #[test]
235    fn test_fallback_email_format() {
236        let username = "testuser";
237        let executor = RealProcessExecutor::new();
238        let email = fallback_email_with_real(username, &executor);
239        assert!(email.contains('@'));
240        assert!(email.starts_with(username));
241    }
242
243    #[test]
244    fn test_fallback_username_without_executor() {
245        let username = fallback_username(None);
246        assert!(!username.is_empty());
247    }
248
249    #[test]
250    fn test_fallback_email_without_executor() {
251        let username = "testuser";
252        let email = fallback_email(username, None);
253        assert!(email.contains('@'));
254        assert!(email.starts_with(username));
255    }
256
257    #[test]
258    fn test_default_identity() {
259        let identity = default_identity();
260        assert_eq!(identity.name, "Ralph Workflow");
261        assert_eq!(identity.email, "ralph@localhost");
262    }
263}