Skip to main content

verbs/
principal.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Capture-identity resolution policy.
3//!
4//! Domain policy, not configuration parsing: callers hand us whatever
5//! user-config principal they loaded (as an optional name/email pair) and we
6//! decide which [`Principal`] captures are attributed to.
7
8use objects::object::Principal;
9use repo::Repository;
10
11/// A principal together with the configuration surface that selected it.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ResolvedPrincipal {
14    pub principal: Principal,
15    pub source: Option<&'static str>,
16}
17
18impl ResolvedPrincipal {
19    fn configured(principal: Principal, source: &'static str) -> Self {
20        Self {
21            principal,
22            source: Some(source),
23        }
24    }
25
26    fn unknown(principal: Principal) -> Self {
27        Self {
28            principal,
29            source: None,
30        }
31    }
32}
33
34/// Resolve capture attribution once for init, status, capture, and other
35/// identity-bearing commands.
36///
37/// `user_principal` is the optional `(name, email)` pair from user config.
38///
39/// Precedence is environment, repository config, Git config (including a
40/// shared parent checkout), user config, then the built-in Unknown principal.
41pub fn resolve_principal(
42    repo: &Repository,
43    user_principal: Option<(&str, &str)>,
44) -> repo::Result<ResolvedPrincipal> {
45    if let Some(resolved) = configured_from_env() {
46        return Ok(resolved);
47    }
48    if let Some(config) = &repo.config().principal {
49        return Ok(ResolvedPrincipal::configured(
50            Principal::new(&config.name, &config.email),
51            "repository",
52        ));
53    }
54    let principal = repo.get_principal()?;
55    if principal_is_accountable(&principal) {
56        return Ok(ResolvedPrincipal::configured(principal, "git_config"));
57    }
58    Ok(finish_principal_resolution(user_principal, principal))
59}
60
61/// Resolve capture attribution when no repository is open.
62///
63/// Precedence is environment, then user config, then the built-in Unknown
64/// principal. Repository and Git-config sources are unavailable without a repo.
65pub fn resolve_principal_without_repo(user_principal: Option<(&str, &str)>) -> ResolvedPrincipal {
66    if let Some(resolved) = configured_from_env() {
67        return resolved;
68    }
69    finish_principal_resolution(
70        user_principal,
71        Principal::new("Unknown", "unknown@example.com"),
72    )
73}
74
75fn configured_from_env() -> Option<ResolvedPrincipal> {
76    Principal::from_env().map(|principal| ResolvedPrincipal::configured(principal, "environment"))
77}
78
79fn finish_principal_resolution(
80    user_principal: Option<(&str, &str)>,
81    fallback: Principal,
82) -> ResolvedPrincipal {
83    if let Some((name, email)) = user_principal {
84        return ResolvedPrincipal::configured(Principal::new(name, email), "user_config");
85    }
86    ResolvedPrincipal::unknown(fallback)
87}
88
89/// Human-facing source label. User config is called out as global because it
90/// is shared across repositories unless `HEDDLE_HOME` or `HEDDLE_CONFIG`
91/// isolates it.
92pub fn principal_source_display(source: &str) -> &str {
93    match source {
94        "user_config" => "user_config (shared global config)",
95        _ => source,
96    }
97}
98
99fn principal_is_accountable(principal: &Principal) -> bool {
100    let name = principal.name_lossy();
101    let email = principal.email_lossy();
102    let name = name.trim();
103    let email = email.trim();
104    !name.is_empty() && !email.is_empty() && !(name == "Unknown" && email == "unknown@example.com")
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn without_repo_user_pair_beats_unknown_fallback() {
113        let resolved = resolve_principal_without_repo(Some(("Luke", "luke@example.com")));
114        assert_eq!(resolved.source, Some("user_config"));
115        assert_eq!(resolved.principal.name_lossy(), "Luke");
116    }
117
118    #[test]
119    fn without_repo_missing_pair_falls_back_to_unknown() {
120        let resolved = resolve_principal_without_repo(None);
121        assert_eq!(resolved.source, None);
122        assert_eq!(resolved.principal.email_lossy(), "unknown@example.com");
123    }
124
125    #[test]
126    fn display_labels_user_config_as_global() {
127        assert_eq!(
128            principal_source_display("user_config"),
129            "user_config (shared global config)"
130        );
131        assert_eq!(principal_source_display("environment"), "environment");
132    }
133}