ralph_workflow/git_helpers/
identity.rs1#![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#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum IdentityValidationError {
33 EmptyName,
35 EmptyEmail,
37 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#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct GitIdentity {
56 pub name: String,
58 pub email: String,
60}
61
62impl GitIdentity {
63 #[must_use]
65 pub const fn new(name: String, email: String) -> Self {
66 Self { name, email }
67 }
68
69 pub fn validate(&self) -> Result<(), IdentityValidationError> {
75 validate_git_identity_fields(&self.name, &self.email)
76 }
77}
78
79pub 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
119pub 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
128pub 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#[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#[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
167fn 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#[must_use]
182pub fn default_identity() -> GitIdentity {
183 GitIdentity::new("Ralph Workflow".to_string(), "ralph@localhost".to_string())
184}
185
186#[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 #[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}