Skip to main content

evault_core/model/
profile.rs

1//! [`Profile`] for per-project variants (dev / staging / prod / etc.).
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::ManifestError;
6
7/// A named variant of a project's variable bindings.
8///
9/// Projects often need different values for the same variable across
10/// environments — for instance, `DATABASE_URL` differs between development
11/// and production. A `Profile` lets each project distinguish those variants
12/// without creating duplicate registry entries.
13///
14/// The canonical default profile is named `"default"`. Use
15/// [`Profile::default_profile`] to obtain it.
16///
17/// # Examples
18/// ```
19/// use evault_core::model::Profile;
20///
21/// assert!(Profile::default_profile().is_default());
22/// assert_eq!(Profile::try_named("dev").expect("valid").as_str(), "dev");
23/// ```
24#[non_exhaustive]
25#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
26#[serde(transparent)]
27pub struct Profile(String);
28
29impl Profile {
30    /// The canonical default profile name.
31    pub const DEFAULT_NAME: &'static str = "default";
32
33    /// Construct the canonical default profile.
34    #[must_use]
35    pub fn default_profile() -> Self {
36        Self(Self::DEFAULT_NAME.to_owned())
37    }
38
39    /// Construct a named profile from already-validated input.
40    ///
41    /// If `name == "default"`, returns [`Self::default_profile`] to keep a
42    /// single canonical representation. Otherwise the string is stored
43    /// verbatim. **No validation**: use [`Self::try_named`] for user input.
44    pub fn named(name: impl Into<String>) -> Self {
45        let name = name.into();
46        if name == Self::DEFAULT_NAME {
47            return Self::default_profile();
48        }
49        Self(name)
50    }
51
52    /// Construct a named profile from possibly-untrusted input.
53    ///
54    /// Accepted names:
55    /// - 1 to 32 characters
56    /// - ASCII alphanumerics, hyphen, or underscore
57    /// - not entirely composed of digits
58    ///
59    /// `"default"` is normalized to the canonical default profile.
60    ///
61    /// # Errors
62    /// Returns [`ManifestError::Invalid`] if `name` violates any of the
63    /// rules above.
64    pub fn try_named(name: impl Into<String>) -> Result<Self, ManifestError> {
65        let name = name.into();
66        if name.is_empty() {
67            return Err(ManifestError::Invalid("profile name is empty".into()));
68        }
69        if name.len() > 32 {
70            return Err(ManifestError::Invalid(
71                "profile name longer than 32 characters".into(),
72            ));
73        }
74        for (offset, &b) in name.as_bytes().iter().enumerate() {
75            if !(b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
76                return Err(ManifestError::Invalid(format!(
77                    "profile name contains an invalid character at byte offset {offset}"
78                )));
79            }
80        }
81        if name.bytes().all(|b| b.is_ascii_digit()) {
82            return Err(ManifestError::Invalid(
83                "profile name must contain at least one non-digit character".into(),
84            ));
85        }
86        Ok(Self::named(name))
87    }
88
89    /// Returns the profile name.
90    #[must_use]
91    pub fn as_str(&self) -> &str {
92        &self.0
93    }
94
95    /// Returns `true` if this is the canonical default profile.
96    #[must_use]
97    pub fn is_default(&self) -> bool {
98        self.0 == Self::DEFAULT_NAME
99    }
100}
101
102impl Default for Profile {
103    fn default() -> Self {
104        Self::default_profile()
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn default_profile_is_named_default() {
114        let p = Profile::default_profile();
115        assert!(p.is_default());
116        assert_eq!(p.as_str(), "default");
117    }
118
119    #[test]
120    fn named_profile_is_not_default() {
121        let p = Profile::named("dev");
122        assert!(!p.is_default());
123        assert_eq!(p.as_str(), "dev");
124    }
125
126    #[test]
127    fn named_default_normalizes_to_canonical() {
128        let p = Profile::named("default");
129        assert!(p.is_default());
130        assert_eq!(p, Profile::default_profile());
131    }
132
133    #[test]
134    fn try_named_accepts_valid_names() {
135        for name in ["dev", "staging", "prod-2", "feat_branch_123"] {
136            assert!(Profile::try_named(name).is_ok(), "expected {name} ok");
137        }
138    }
139
140    #[test]
141    fn try_named_rejects_invalid_names() {
142        let bad = [
143            "",
144            " ",
145            "with space",
146            "with\nnewline",
147            "[evil]",
148            "123",
149            &"a".repeat(33),
150        ];
151        for name in bad {
152            assert!(
153                Profile::try_named(name).is_err(),
154                "expected {name:?} rejected"
155            );
156        }
157    }
158
159    #[test]
160    fn try_named_default_normalizes() {
161        let p = Profile::try_named("default").expect("valid");
162        assert!(p.is_default());
163    }
164
165    #[test]
166    fn profile_serializes_transparently() {
167        let p = Profile::named("staging");
168        let s = serde_json::to_string(&p).expect("serde");
169        assert_eq!(s, "\"staging\"");
170    }
171}