nblm_core/env/
mod.rs

1use crate::error::{Error, Result};
2
3const PROFILE_NAME_ENTERPRISE: &str = "enterprise";
4const PROFILE_NAME_PERSONAL: &str = "personal";
5const PROFILE_NAME_WORKSPACE: &str = "workspace";
6
7/// API profile types supported by the SDK.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ApiProfile {
10    Enterprise,
11    Personal,
12    Workspace,
13}
14
15impl ApiProfile {
16    pub fn as_str(&self) -> &'static str {
17        match self {
18            ApiProfile::Enterprise => PROFILE_NAME_ENTERPRISE,
19            ApiProfile::Personal => PROFILE_NAME_PERSONAL,
20            ApiProfile::Workspace => PROFILE_NAME_WORKSPACE,
21        }
22    }
23
24    pub fn parse(input: &str) -> Result<Self> {
25        match input.trim().to_ascii_lowercase().as_str() {
26            PROFILE_NAME_ENTERPRISE => Ok(ApiProfile::Enterprise),
27            PROFILE_NAME_PERSONAL => Ok(ApiProfile::Personal),
28            PROFILE_NAME_WORKSPACE => Ok(ApiProfile::Workspace),
29            other => Err(Error::Endpoint(format!("unsupported API profile: {other}"))),
30        }
31    }
32
33    pub fn requires_experimental_flag(&self) -> bool {
34        matches!(self, ApiProfile::Personal | ApiProfile::Workspace)
35    }
36}
37
38pub const PROFILE_EXPERIMENT_FLAG: &str = "NBLM_PROFILE_EXPERIMENT";
39
40#[derive(Debug, Clone)]
41pub enum ProfileParams {
42    Enterprise {
43        project_number: String,
44        location: String,
45        endpoint_location: String,
46    },
47    Personal {
48        user_email: Option<String>,
49    },
50    Workspace {
51        customer_id: Option<String>,
52        admin_email: Option<String>,
53    },
54}
55
56impl ProfileParams {
57    pub fn enterprise(
58        project_number: impl Into<String>,
59        location: impl Into<String>,
60        endpoint_location: impl Into<String>,
61    ) -> Self {
62        Self::Enterprise {
63            project_number: project_number.into(),
64            location: location.into(),
65            endpoint_location: endpoint_location.into(),
66        }
67    }
68
69    pub fn personal<T: Into<String>>(user_email: Option<T>) -> Self {
70        Self::Personal {
71            user_email: user_email.map(|email| email.into()),
72        }
73    }
74
75    pub fn workspace<T: Into<String>, U: Into<String>>(
76        customer_id: Option<T>,
77        admin_email: Option<U>,
78    ) -> Self {
79        Self::Workspace {
80            customer_id: customer_id.map(|value| value.into()),
81            admin_email: admin_email.map(|value| value.into()),
82        }
83    }
84
85    pub fn expected_profile(&self) -> ApiProfile {
86        match self {
87            ProfileParams::Enterprise { .. } => ApiProfile::Enterprise,
88            ProfileParams::Personal { .. } => ApiProfile::Personal,
89            ProfileParams::Workspace { .. } => ApiProfile::Workspace,
90        }
91    }
92}
93
94/// Runtime configuration describing the API environment.
95#[derive(Debug, Clone)]
96pub struct EnvironmentConfig {
97    profile: ApiProfile,
98    base_url: String,
99    parent_path: String,
100}
101
102impl EnvironmentConfig {
103    /// Construct the environment config for the Enterprise SKU.
104    pub fn enterprise(
105        project_number: impl Into<String>,
106        location: impl Into<String>,
107        endpoint_location: impl Into<String>,
108    ) -> Result<Self> {
109        Self::from_profile(
110            ApiProfile::Enterprise,
111            ProfileParams::enterprise(project_number, location, endpoint_location),
112        )
113    }
114
115    pub fn profile(&self) -> ApiProfile {
116        self.profile
117    }
118
119    pub fn base_url(&self) -> &str {
120        &self.base_url
121    }
122
123    pub fn parent_path(&self) -> &str {
124        &self.parent_path
125    }
126
127    /// Return a copy with a different base URL (useful for tests or overrides).
128    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
129        self.base_url = base_url.into();
130        self
131    }
132
133    pub fn from_profile(profile: ApiProfile, params: ProfileParams) -> Result<Self> {
134        let params_profile = params.expected_profile();
135        if profile != params_profile {
136            return Err(profile_params_mismatch_error(profile, params_profile));
137        }
138
139        match profile {
140            ApiProfile::Enterprise => match params {
141                ProfileParams::Enterprise {
142                    project_number,
143                    location,
144                    endpoint_location,
145                } => {
146                    let endpoint = normalize_endpoint_location(endpoint_location)?;
147                    let base_url =
148                        format!("https://{}discoveryengine.googleapis.com/v1alpha", endpoint);
149                    let parent_path = format!("projects/{}/locations/{}", project_number, location);
150                    Ok(Self {
151                        profile: ApiProfile::Enterprise,
152                        base_url,
153                        parent_path,
154                    })
155                }
156                _ => unreachable!("profile/params mismatch should already be validated"),
157            },
158            ApiProfile::Personal | ApiProfile::Workspace => Err(unsupported_profile_error(profile)),
159        }
160    }
161}
162
163/// Normalize endpoint location strings to the canonical discovery engine prefix.
164pub fn normalize_endpoint_location(input: String) -> Result<String> {
165    let trimmed = input.trim().trim_end_matches('-').to_lowercase();
166    let normalized = match trimmed.as_str() {
167        "us" => "us-",
168        "eu" => "eu-",
169        "global" => "global-",
170        other => {
171            return Err(Error::Endpoint(format!(
172                "unsupported endpoint location: {other}"
173            )))
174        }
175    };
176    Ok(normalized.to_string())
177}
178
179fn unsupported_profile_error(profile: ApiProfile) -> Error {
180    Error::Endpoint(format!(
181        "API profile '{}' is not available yet",
182        profile.as_str()
183    ))
184}
185
186fn profile_params_mismatch_error(expected: ApiProfile, provided: ApiProfile) -> Error {
187    Error::Endpoint(format!(
188        "profile '{}' expects parameters for '{}', but '{}' parameters were provided",
189        expected.as_str(),
190        expected.as_str(),
191        provided.as_str()
192    ))
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn enterprise_constructor_builds_expected_urls() {
201        let env = EnvironmentConfig::enterprise("123", "global", "us").unwrap();
202        assert_eq!(env.profile(), ApiProfile::Enterprise);
203        assert_eq!(
204            env.base_url(),
205            "https://us-discoveryengine.googleapis.com/v1alpha"
206        );
207        assert_eq!(env.parent_path(), "projects/123/locations/global");
208    }
209
210    #[test]
211    fn normalize_endpoint_location_variants() {
212        assert_eq!(
213            normalize_endpoint_location("us".into()).unwrap(),
214            "us-".to_string()
215        );
216        assert_eq!(
217            normalize_endpoint_location("eu-".into()).unwrap(),
218            "eu-".to_string()
219        );
220        assert_eq!(
221            normalize_endpoint_location(" global ".into()).unwrap(),
222            "global-".to_string()
223        );
224    }
225
226    #[test]
227    fn normalize_endpoint_location_invalid() {
228        let err = normalize_endpoint_location("asia".into()).unwrap_err();
229        assert!(format!("{err}").contains("unsupported endpoint location"));
230    }
231
232    #[test]
233    fn with_base_url_overrides_base_url() {
234        let env = EnvironmentConfig::enterprise("123", "global", "us")
235            .unwrap()
236            .with_base_url("http://localhost:8080/v1alpha");
237        assert_eq!(env.base_url(), "http://localhost:8080/v1alpha");
238        assert_eq!(env.parent_path(), "projects/123/locations/global");
239    }
240
241    #[test]
242    fn api_profile_parse_accepts_all_known_variants() {
243        let enterprise = ApiProfile::parse("enterprise").unwrap();
244        assert_eq!(enterprise, ApiProfile::Enterprise);
245        assert_eq!(enterprise.as_str(), "enterprise");
246
247        let personal = ApiProfile::parse("personal").unwrap();
248        assert_eq!(personal, ApiProfile::Personal);
249        assert_eq!(personal.as_str(), "personal");
250
251        let workspace = ApiProfile::parse("workspace").unwrap();
252        assert_eq!(workspace, ApiProfile::Workspace);
253        assert_eq!(workspace.as_str(), "workspace");
254    }
255
256    #[test]
257    fn from_profile_rejects_mismatched_params() {
258        let err = EnvironmentConfig::from_profile(
259            ApiProfile::Enterprise,
260            ProfileParams::personal::<String>(None),
261        )
262        .unwrap_err();
263        let msg = format!("{err}");
264        assert!(msg.contains("expects parameters for 'enterprise'"));
265    }
266
267    #[test]
268    fn personal_profile_not_available_yet() {
269        let err = EnvironmentConfig::from_profile(
270            ApiProfile::Personal,
271            ProfileParams::personal(Some("user@example.com")),
272        )
273        .unwrap_err();
274        let msg = format!("{err}");
275        assert!(msg.contains("not available yet"));
276    }
277
278    #[test]
279    fn workspace_profile_not_available_yet() {
280        let err = EnvironmentConfig::from_profile(
281            ApiProfile::Workspace,
282            ProfileParams::workspace::<String, String>(None, None),
283        )
284        .unwrap_err();
285        let msg = format!("{err}");
286        assert!(msg.contains("not available yet"));
287    }
288
289    #[test]
290    fn profile_params_expected_profile_returns_correct_variant() {
291        let enterprise = ProfileParams::enterprise("123", "global", "us");
292        assert_eq!(enterprise.expected_profile(), ApiProfile::Enterprise);
293
294        let personal = ProfileParams::personal(Some("user@example.com"));
295        assert_eq!(personal.expected_profile(), ApiProfile::Personal);
296
297        let workspace = ProfileParams::workspace(Some("customer123"), Some("admin@example.com"));
298        assert_eq!(workspace.expected_profile(), ApiProfile::Workspace);
299    }
300
301    #[test]
302    fn from_profile_succeeds_with_matching_enterprise_params() {
303        let env = EnvironmentConfig::from_profile(
304            ApiProfile::Enterprise,
305            ProfileParams::enterprise("456", "us-central1", "us"),
306        )
307        .unwrap();
308        assert_eq!(env.profile(), ApiProfile::Enterprise);
309        assert_eq!(
310            env.base_url(),
311            "https://us-discoveryengine.googleapis.com/v1alpha"
312        );
313        assert_eq!(env.parent_path(), "projects/456/locations/us-central1");
314    }
315
316    #[test]
317    fn from_profile_rejects_personal_profile_with_enterprise_params() {
318        let err = EnvironmentConfig::from_profile(
319            ApiProfile::Personal,
320            ProfileParams::enterprise("123", "global", "us"),
321        )
322        .unwrap_err();
323        let msg = format!("{err}");
324        assert!(msg.contains("expects parameters for 'personal'"));
325        assert!(msg.contains("'enterprise' parameters were provided"));
326    }
327
328    #[test]
329    fn from_profile_rejects_workspace_profile_with_enterprise_params() {
330        let err = EnvironmentConfig::from_profile(
331            ApiProfile::Workspace,
332            ProfileParams::enterprise("123", "global", "us"),
333        )
334        .unwrap_err();
335        let msg = format!("{err}");
336        assert!(msg.contains("expects parameters for 'workspace'"));
337        assert!(msg.contains("'enterprise' parameters were provided"));
338    }
339
340    #[test]
341    fn profile_params_personal_builder_handles_optional_email() {
342        let with_email = ProfileParams::personal(Some("user@example.com"));
343        assert_eq!(with_email.expected_profile(), ApiProfile::Personal);
344
345        let without_email = ProfileParams::personal::<String>(None);
346        assert_eq!(without_email.expected_profile(), ApiProfile::Personal);
347    }
348
349    #[test]
350    fn profile_params_workspace_builder_handles_optional_fields() {
351        let with_both = ProfileParams::workspace(Some("customer123"), Some("admin@example.com"));
352        assert_eq!(with_both.expected_profile(), ApiProfile::Workspace);
353
354        let with_customer_only = ProfileParams::workspace(Some("customer123"), None::<String>);
355        assert_eq!(with_customer_only.expected_profile(), ApiProfile::Workspace);
356
357        let with_admin_only = ProfileParams::workspace(None::<String>, Some("admin@example.com"));
358        assert_eq!(with_admin_only.expected_profile(), ApiProfile::Workspace);
359
360        let with_neither = ProfileParams::workspace::<String, String>(None, None);
361        assert_eq!(with_neither.expected_profile(), ApiProfile::Workspace);
362    }
363
364    #[test]
365    fn requires_experimental_flag_returns_correct_values() {
366        assert!(!ApiProfile::Enterprise.requires_experimental_flag());
367        assert!(ApiProfile::Personal.requires_experimental_flag());
368        assert!(ApiProfile::Workspace.requires_experimental_flag());
369    }
370}