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