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 a fallback email based on the username.
115///
116/// Format: `{username}@{hostname}` or `{username}@localhost`
117pub fn fallback_email(username: &str, executor: Option<&dyn ProcessExecutor>) -> String {
118    // Try to get hostname
119    let hostname = match get_hostname(executor) {
120        Some(host) if !host.is_empty() => host,
121        _ => "localhost".to_string(),
122    };
123
124    format!("{username}@{hostname}")
125}
126
127/// Get the system hostname.
128fn get_hostname(executor: Option<&dyn ProcessExecutor>) -> Option<String> {
129    // Try HOSTNAME environment variable first (fastest)
130    if let Ok(hostname) = env::var("HOSTNAME") {
131        let hostname = hostname.trim();
132        if !hostname.is_empty() {
133            return Some(hostname.to_string());
134        }
135    }
136
137    // Try the `hostname` command
138    if let Some(exec) = executor {
139        if let Ok(output) = exec.execute("hostname", &[], &[], None) {
140            let hostname = output.stdout.trim().to_string();
141            if !hostname.is_empty() {
142                return Some(hostname);
143            }
144        }
145    }
146
147    None
148}
149
150/// Get the default git identity (last resort).
151///
152/// This should never be reached if the fallback chain is working correctly.
153pub fn default_identity() -> GitIdentity {
154    GitIdentity::new("Ralph Workflow".to_string(), "ralph@localhost".to_string())
155}
156
157/// Helper trait for error checking in tests
158#[cfg(test)]
159trait ContainsErr {
160    fn contains_err(&self, needle: &str) -> bool;
161}
162
163#[cfg(test)]
164impl ContainsErr for Result<(), String> {
165    fn contains_err(&self, needle: &str) -> bool {
166        match self {
167            Err(e) => e.contains(needle),
168            _ => false,
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_git_identity_validation_valid() {
179        let identity = GitIdentity::new("Test User".to_string(), "test@example.com".to_string());
180        assert!(identity.validate().is_ok());
181    }
182
183    #[test]
184    fn test_git_identity_validation_empty_name() {
185        let identity = GitIdentity::new(String::new(), "test@example.com".to_string());
186        assert!(identity
187            .validate()
188            .contains_err("Git user name cannot be empty"));
189    }
190
191    #[test]
192    fn test_git_identity_validation_empty_email() {
193        let identity = GitIdentity::new("Test User".to_string(), String::new());
194        assert!(identity
195            .validate()
196            .contains_err("Git user email cannot be empty"));
197    }
198
199    #[test]
200    fn test_git_identity_validation_invalid_email_no_at() {
201        let identity = GitIdentity::new("Test User".to_string(), "invalidemail".to_string());
202        assert!(identity.validate().contains_err("Invalid email format"));
203    }
204
205    #[test]
206    fn test_git_identity_validation_invalid_email_no_domain() {
207        let identity = GitIdentity::new("Test User".to_string(), "user@".to_string());
208        assert!(identity.validate().contains_err("Invalid email format"));
209    }
210
211    #[test]
212    fn test_fallback_username_not_empty() {
213        let executor = RealProcessExecutor::new();
214        let username = fallback_username(Some(&executor));
215        assert!(!username.is_empty());
216    }
217
218    #[test]
219    fn test_fallback_email_format() {
220        let username = "testuser";
221        let executor = RealProcessExecutor::new();
222        let email = fallback_email(username, Some(&executor));
223        assert!(email.contains('@'));
224        assert!(email.starts_with(username));
225    }
226
227    #[test]
228    fn test_fallback_username_without_executor() {
229        let username = fallback_username(None);
230        assert!(!username.is_empty());
231    }
232
233    #[test]
234    fn test_fallback_email_without_executor() {
235        let username = "testuser";
236        let email = fallback_email(username, None);
237        assert!(email.contains('@'));
238        assert!(email.starts_with(username));
239    }
240
241    #[test]
242    fn test_default_identity() {
243        let identity = default_identity();
244        assert_eq!(identity.name, "Ralph Workflow");
245        assert_eq!(identity.email, "ralph@localhost");
246    }
247}