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, Hash)]
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/// Returns `true` when experimental profile support is enabled via
41/// `NBLM_PROFILE_EXPERIMENT`.
42pub fn profile_experiment_enabled() -> bool {
43    match std::env::var(PROFILE_EXPERIMENT_FLAG) {
44        Ok(value) => matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"),
45        Err(_) => false,
46    }
47}
48
49#[derive(Debug, Clone)]
50pub enum ProfileParams {
51    Enterprise {
52        project_number: String,
53        location: String,
54        endpoint_location: String,
55    },
56    Personal {
57        user_email: Option<String>,
58    },
59    Workspace {
60        customer_id: Option<String>,
61        admin_email: Option<String>,
62    },
63}
64
65impl ProfileParams {
66    pub fn enterprise(
67        project_number: impl Into<String>,
68        location: impl Into<String>,
69        endpoint_location: impl Into<String>,
70    ) -> Self {
71        Self::Enterprise {
72            project_number: project_number.into(),
73            location: location.into(),
74            endpoint_location: endpoint_location.into(),
75        }
76    }
77
78    pub fn personal<T: Into<String>>(user_email: Option<T>) -> Self {
79        Self::Personal {
80            user_email: user_email.map(|email| email.into()),
81        }
82    }
83
84    pub fn workspace<T: Into<String>, U: Into<String>>(
85        customer_id: Option<T>,
86        admin_email: Option<U>,
87    ) -> Self {
88        Self::Workspace {
89            customer_id: customer_id.map(|value| value.into()),
90            admin_email: admin_email.map(|value| value.into()),
91        }
92    }
93
94    pub fn expected_profile(&self) -> ApiProfile {
95        match self {
96            ProfileParams::Enterprise { .. } => ApiProfile::Enterprise,
97            ProfileParams::Personal { .. } => ApiProfile::Personal,
98            ProfileParams::Workspace { .. } => ApiProfile::Workspace,
99        }
100    }
101}
102
103/// Runtime configuration describing the API environment.
104#[derive(Debug, Clone)]
105pub struct EnvironmentConfig {
106    profile: ApiProfile,
107    base_url: String,
108    parent_path: String,
109}
110
111impl EnvironmentConfig {
112    /// Construct the environment config for the Enterprise SKU.
113    pub fn enterprise(
114        project_number: impl Into<String>,
115        location: impl Into<String>,
116        endpoint_location: impl Into<String>,
117    ) -> Result<Self> {
118        Self::from_profile(
119            ApiProfile::Enterprise,
120            ProfileParams::enterprise(project_number, location, endpoint_location),
121        )
122    }
123
124    pub fn profile(&self) -> ApiProfile {
125        self.profile
126    }
127
128    pub fn base_url(&self) -> &str {
129        &self.base_url
130    }
131
132    pub fn parent_path(&self) -> &str {
133        &self.parent_path
134    }
135
136    /// Return a copy with a different base URL (useful for tests or overrides).
137    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
138        self.base_url = base_url.into();
139        self
140    }
141
142    pub fn from_profile(profile: ApiProfile, params: ProfileParams) -> Result<Self> {
143        let params_profile = params.expected_profile();
144        if profile != params_profile {
145            return Err(profile_params_mismatch_error(profile, params_profile));
146        }
147
148        match profile {
149            ApiProfile::Enterprise => match params {
150                ProfileParams::Enterprise {
151                    project_number,
152                    location,
153                    endpoint_location,
154                } => {
155                    let endpoint = normalize_endpoint_location(endpoint_location)?;
156                    let base_url =
157                        format!("https://{}discoveryengine.googleapis.com/v1alpha", endpoint);
158                    let parent_path = format!("projects/{}/locations/{}", project_number, location);
159                    Ok(Self {
160                        profile: ApiProfile::Enterprise,
161                        base_url,
162                        parent_path,
163                    })
164                }
165                _ => unreachable!("profile/params mismatch should already be validated"),
166            },
167            ApiProfile::Personal | ApiProfile::Workspace => Err(unsupported_profile_error(profile)),
168        }
169    }
170}
171
172/// Normalize endpoint location strings to the canonical discovery engine prefix.
173pub fn normalize_endpoint_location(input: String) -> Result<String> {
174    let trimmed = input.trim().trim_end_matches('-').to_lowercase();
175    let normalized = match trimmed.as_str() {
176        "us" => "us-",
177        "eu" => "eu-",
178        "global" => "global-",
179        other => {
180            return Err(Error::Endpoint(format!(
181                "unsupported endpoint location: {other}"
182            )))
183        }
184    };
185    Ok(normalized.to_string())
186}
187
188fn unsupported_profile_error(profile: ApiProfile) -> Error {
189    Error::Endpoint(format!(
190        "API profile '{}' is not available yet",
191        profile.as_str()
192    ))
193}
194
195fn profile_params_mismatch_error(expected: ApiProfile, provided: ApiProfile) -> Error {
196    Error::Endpoint(format!(
197        "profile '{}' expects parameters for '{}', but '{}' parameters were provided",
198        expected.as_str(),
199        expected.as_str(),
200        provided.as_str()
201    ))
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use serial_test::serial;
208
209    struct EnvGuard {
210        key: &'static str,
211        original: Option<String>,
212    }
213
214    impl EnvGuard {
215        fn new(key: &'static str) -> Self {
216            let original = std::env::var(key).ok();
217            Self { key, original }
218        }
219    }
220
221    impl Drop for EnvGuard {
222        fn drop(&mut self) {
223            if let Some(value) = &self.original {
224                std::env::set_var(self.key, value);
225            } else {
226                std::env::remove_var(self.key);
227            }
228        }
229    }
230
231    #[test]
232    fn enterprise_constructor_builds_expected_urls() {
233        let env = EnvironmentConfig::enterprise("123", "global", "us").unwrap();
234        assert_eq!(env.profile(), ApiProfile::Enterprise);
235        assert_eq!(
236            env.base_url(),
237            "https://us-discoveryengine.googleapis.com/v1alpha"
238        );
239        assert_eq!(env.parent_path(), "projects/123/locations/global");
240    }
241
242    #[test]
243    fn normalize_endpoint_location_variants() {
244        assert_eq!(
245            normalize_endpoint_location("us".into()).unwrap(),
246            "us-".to_string()
247        );
248        assert_eq!(
249            normalize_endpoint_location("eu-".into()).unwrap(),
250            "eu-".to_string()
251        );
252        assert_eq!(
253            normalize_endpoint_location(" global ".into()).unwrap(),
254            "global-".to_string()
255        );
256    }
257
258    #[test]
259    fn normalize_endpoint_location_invalid() {
260        let err = normalize_endpoint_location("asia".into()).unwrap_err();
261        assert!(format!("{err}").contains("unsupported endpoint location"));
262    }
263
264    #[test]
265    fn with_base_url_overrides_base_url() {
266        let env = EnvironmentConfig::enterprise("123", "global", "us")
267            .unwrap()
268            .with_base_url("http://localhost:8080/v1alpha");
269        assert_eq!(env.base_url(), "http://localhost:8080/v1alpha");
270        assert_eq!(env.parent_path(), "projects/123/locations/global");
271    }
272
273    #[test]
274    fn api_profile_parse_accepts_all_known_variants() {
275        let enterprise = ApiProfile::parse("enterprise").unwrap();
276        assert_eq!(enterprise, ApiProfile::Enterprise);
277        assert_eq!(enterprise.as_str(), "enterprise");
278
279        let personal = ApiProfile::parse("personal").unwrap();
280        assert_eq!(personal, ApiProfile::Personal);
281        assert_eq!(personal.as_str(), "personal");
282
283        let workspace = ApiProfile::parse("workspace").unwrap();
284        assert_eq!(workspace, ApiProfile::Workspace);
285        assert_eq!(workspace.as_str(), "workspace");
286    }
287
288    #[test]
289    fn from_profile_rejects_mismatched_params() {
290        let err = EnvironmentConfig::from_profile(
291            ApiProfile::Enterprise,
292            ProfileParams::personal::<String>(None),
293        )
294        .unwrap_err();
295        let msg = format!("{err}");
296        assert!(msg.contains("expects parameters for 'enterprise'"));
297    }
298
299    #[test]
300    fn personal_profile_not_available_yet() {
301        let err = EnvironmentConfig::from_profile(
302            ApiProfile::Personal,
303            ProfileParams::personal(Some("user@example.com")),
304        )
305        .unwrap_err();
306        let msg = format!("{err}");
307        assert!(msg.contains("not available yet"));
308    }
309
310    #[test]
311    fn workspace_profile_not_available_yet() {
312        let err = EnvironmentConfig::from_profile(
313            ApiProfile::Workspace,
314            ProfileParams::workspace::<String, String>(None, None),
315        )
316        .unwrap_err();
317        let msg = format!("{err}");
318        assert!(msg.contains("not available yet"));
319    }
320
321    #[test]
322    fn profile_params_expected_profile_returns_correct_variant() {
323        let enterprise = ProfileParams::enterprise("123", "global", "us");
324        assert_eq!(enterprise.expected_profile(), ApiProfile::Enterprise);
325
326        let personal = ProfileParams::personal(Some("user@example.com"));
327        assert_eq!(personal.expected_profile(), ApiProfile::Personal);
328
329        let workspace = ProfileParams::workspace(Some("customer123"), Some("admin@example.com"));
330        assert_eq!(workspace.expected_profile(), ApiProfile::Workspace);
331    }
332
333    #[test]
334    fn from_profile_succeeds_with_matching_enterprise_params() {
335        let env = EnvironmentConfig::from_profile(
336            ApiProfile::Enterprise,
337            ProfileParams::enterprise("456", "us", "us"),
338        )
339        .unwrap();
340        assert_eq!(env.profile(), ApiProfile::Enterprise);
341        assert_eq!(
342            env.base_url(),
343            "https://us-discoveryengine.googleapis.com/v1alpha"
344        );
345        assert_eq!(env.parent_path(), "projects/456/locations/us");
346    }
347
348    #[test]
349    fn from_profile_rejects_personal_profile_with_enterprise_params() {
350        let err = EnvironmentConfig::from_profile(
351            ApiProfile::Personal,
352            ProfileParams::enterprise("123", "global", "us"),
353        )
354        .unwrap_err();
355        let msg = format!("{err}");
356        assert!(msg.contains("expects parameters for 'personal'"));
357        assert!(msg.contains("'enterprise' parameters were provided"));
358    }
359
360    #[test]
361    fn from_profile_rejects_workspace_profile_with_enterprise_params() {
362        let err = EnvironmentConfig::from_profile(
363            ApiProfile::Workspace,
364            ProfileParams::enterprise("123", "global", "us"),
365        )
366        .unwrap_err();
367        let msg = format!("{err}");
368        assert!(msg.contains("expects parameters for 'workspace'"));
369        assert!(msg.contains("'enterprise' parameters were provided"));
370    }
371
372    #[test]
373    fn profile_params_personal_builder_handles_optional_email() {
374        let with_email = ProfileParams::personal(Some("user@example.com"));
375        assert_eq!(with_email.expected_profile(), ApiProfile::Personal);
376
377        let without_email = ProfileParams::personal::<String>(None);
378        assert_eq!(without_email.expected_profile(), ApiProfile::Personal);
379    }
380
381    #[test]
382    fn profile_params_workspace_builder_handles_optional_fields() {
383        let with_both = ProfileParams::workspace(Some("customer123"), Some("admin@example.com"));
384        assert_eq!(with_both.expected_profile(), ApiProfile::Workspace);
385
386        let with_customer_only = ProfileParams::workspace(Some("customer123"), None::<String>);
387        assert_eq!(with_customer_only.expected_profile(), ApiProfile::Workspace);
388
389        let with_admin_only = ProfileParams::workspace(None::<String>, Some("admin@example.com"));
390        assert_eq!(with_admin_only.expected_profile(), ApiProfile::Workspace);
391
392        let with_neither = ProfileParams::workspace::<String, String>(None, None);
393        assert_eq!(with_neither.expected_profile(), ApiProfile::Workspace);
394    }
395
396    #[test]
397    fn requires_experimental_flag_returns_correct_values() {
398        assert!(!ApiProfile::Enterprise.requires_experimental_flag());
399        assert!(ApiProfile::Personal.requires_experimental_flag());
400        assert!(ApiProfile::Workspace.requires_experimental_flag());
401    }
402
403    #[test]
404    #[serial]
405    fn profile_experiment_enabled_accepts_truthy_values() {
406        let _guard = EnvGuard::new(PROFILE_EXPERIMENT_FLAG);
407        for value in ["1", "true", "TRUE", "yes", "YES"] {
408            std::env::set_var(PROFILE_EXPERIMENT_FLAG, value);
409            assert!(
410                profile_experiment_enabled(),
411                "{value} should enable experiment"
412            );
413        }
414    }
415
416    #[test]
417    #[serial]
418    fn profile_experiment_enabled_rejects_other_values() {
419        let _guard = EnvGuard::new(PROFILE_EXPERIMENT_FLAG);
420        for value in ["0", "false", "no", "maybe", ""] {
421            std::env::set_var(PROFILE_EXPERIMENT_FLAG, value);
422            assert!(
423                !profile_experiment_enabled(),
424                "{value} should not enable experiment"
425            );
426        }
427        std::env::remove_var(PROFILE_EXPERIMENT_FLAG);
428        assert!(!profile_experiment_enabled());
429    }
430}