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#[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
40pub 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#[derive(Debug, Clone)]
105pub struct EnvironmentConfig {
106 profile: ApiProfile,
107 base_url: String,
108 parent_path: String,
109}
110
111impl EnvironmentConfig {
112 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 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
172pub 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}