1use crate::error::SailError;
8
9#[derive(Clone)]
18pub struct Config {
19 pub mode: Option<String>,
24 pub api_key: String,
26 pub api_url: String,
28 pub sailbox_api_url: String,
30 pub imagebuilder_url: String,
32 pub ingress_base: String,
35 pub ingress_scheme: IngressScheme,
37}
38
39impl std::fmt::Debug for Config {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 f.debug_struct("Config")
42 .field("mode", &self.mode)
43 .field("api_key", &redact_key(&self.api_key))
44 .field("api_url", &self.api_url)
45 .field("sailbox_api_url", &self.sailbox_api_url)
46 .field("imagebuilder_url", &self.imagebuilder_url)
47 .field("ingress_base", &self.ingress_base)
48 .field("ingress_scheme", &self.ingress_scheme)
49 .finish()
50 }
51}
52
53pub(crate) fn redact_key(api_key: &str) -> &'static str {
55 if api_key.is_empty() {
56 "<unset>"
57 } else {
58 "<redacted>"
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum IngressScheme {
66 Path,
69 Subdomain,
72}
73
74#[derive(Debug)]
75struct EnvDefaults {
76 api_url: &'static str,
77 sailbox_api_url: &'static str,
78 imagebuilder_url: &'static str,
79 ingress_base: &'static str,
80 ingress_subdomain: bool,
83}
84
85const PROD: EnvDefaults = EnvDefaults {
86 api_url: "https://api.sailresearch.com",
87 sailbox_api_url: "https://sailbox-api.sailresearch.com",
88 imagebuilder_url: "sailbox-imagebuilder-dispatcher.sailresearch.com:443",
89 ingress_base: "https://api.sailresearch.com",
90 ingress_subdomain: true,
91};
92
93const DEV: EnvDefaults = EnvDefaults {
94 api_url: "https://dev.sailresearch.com",
95 sailbox_api_url: "https://sailbox-api.dev.sailresearch.com",
96 imagebuilder_url: "sailbox-imagebuilder-dispatcher.dev.sailresearch.com:443",
97 ingress_base: "https://dev.sailresearch.com",
98 ingress_subdomain: true,
99};
100
101const STAGING: EnvDefaults = EnvDefaults {
102 api_url: "https://staging.sailresearch.com",
103 sailbox_api_url: "https://sailbox-api.staging.sailresearch.com",
104 imagebuilder_url: "sailbox-imagebuilder-dispatcher.staging.sailresearch.com:443",
105 ingress_base: "https://beta.sailresearch.com",
106 ingress_subdomain: true,
107};
108
109const LOCAL: EnvDefaults = EnvDefaults {
114 api_url: "http://localhost:8080",
115 sailbox_api_url: "http://localhost:8080",
116 imagebuilder_url: "localhost:50061",
117 ingress_base: "http://localhost:18080",
118 ingress_subdomain: false,
119};
120
121fn env_or_empty(name: &str) -> String {
122 std::env::var(name).unwrap_or_default()
123}
124
125fn env_trimmed(name: &str) -> String {
126 env_or_empty(name).trim().to_string()
127}
128
129fn mode_defaults(mode: &str) -> Result<&'static EnvDefaults, SailError> {
132 match mode.trim().to_lowercase().as_str() {
133 "" | "prod" => Ok(&PROD),
134 "dev" => Ok(&DEV),
135 "staging" => Ok(&STAGING),
136 "local" => Ok(&LOCAL),
137 other => Err(SailError::Config {
138 message: format!(
139 "SAIL_MODE={other} is not recognized; use SAIL_MODE=prod|dev|staging|local"
140 ),
141 }),
142 }
143}
144
145#[doc(hidden)]
149pub fn api_url_for_mode(mode: &str) -> Option<&'static str> {
150 mode_defaults(mode).ok().map(|defaults| defaults.api_url)
151}
152
153#[doc(hidden)]
157pub fn api_key_env_var_for_mode(mode: &str) -> Option<&'static str> {
158 match mode.trim().to_lowercase().as_str() {
159 "" | "prod" | "local" => Some("SAIL_API_KEY"),
160 "dev" => Some("SAIL_DEV_API_KEY"),
161 "staging" => Some("SAIL_STAGING_API_KEY"),
162 _ => None,
163 }
164}
165
166fn missing_api_key_message(mode: &str) -> String {
167 match api_key_env_var_for_mode(mode) {
168 Some("SAIL_API_KEY") => "Set SAIL_API_KEY or run `sail auth login`.".to_string(),
169 Some(env_name) => format!(
170 "Set {env_name} for SAIL_MODE={}.",
171 mode.trim().to_lowercase()
172 ),
173 None => "Set the API key environment variable for the selected SAIL_MODE.".to_string(),
174 }
175}
176
177impl Config {
178 pub(crate) fn resolve(
182 mode: Option<&str>,
183 api_key: String,
184 api_url: Option<String>,
185 sailbox_api_url: Option<String>,
186 imagebuilder_url: Option<String>,
187 sailbox_ingress_url: Option<String>,
188 ) -> Result<Config, SailError> {
189 let api_key = api_key.trim().to_string();
190 if api_key.is_empty() {
191 return Err(SailError::Config {
192 message: missing_api_key_message(mode.unwrap_or("")),
193 });
194 }
195 Config::build(
196 mode,
197 api_key,
198 api_url,
199 sailbox_api_url,
200 imagebuilder_url,
201 sailbox_ingress_url,
202 )
203 }
204
205 fn build(
208 mode: Option<&str>,
209 api_key: String,
210 api_url: Option<String>,
211 sailbox_api_url: Option<String>,
212 imagebuilder_url: Option<String>,
213 sailbox_ingress_url: Option<String>,
214 ) -> Result<Config, SailError> {
215 let defaults = mode_defaults(mode.unwrap_or(""))?;
216 fn or_default(override_value: Option<String>, default: &str) -> String {
217 match override_value {
218 Some(value) if !value.trim().is_empty() => value.trim().to_string(),
219 _ => default.to_string(),
220 }
221 }
222 let has_override = sailbox_ingress_url
225 .as_deref()
226 .map(str::trim)
227 .is_some_and(|value| !value.is_empty());
228 let ingress_scheme = if !has_override && defaults.ingress_subdomain {
229 IngressScheme::Subdomain
230 } else {
231 IngressScheme::Path
232 };
233 Ok(Config {
234 mode: mode
235 .map(str::trim)
236 .filter(|value| !value.is_empty())
237 .map(str::to_string),
238 api_key,
239 api_url: or_default(api_url, defaults.api_url),
240 sailbox_api_url: or_default(sailbox_api_url, defaults.sailbox_api_url),
241 imagebuilder_url: or_default(imagebuilder_url, defaults.imagebuilder_url),
242 ingress_base: or_default(sailbox_ingress_url, defaults.ingress_base),
243 ingress_scheme,
244 })
245 }
246
247 pub fn from_env() -> Result<Config, SailError> {
252 Config::from_env_inner(true)
253 }
254
255 #[doc(hidden)]
259 pub fn from_env_optional_api_key() -> Result<Config, SailError> {
260 Config::from_env_inner(false)
261 }
262
263 fn from_env_inner(require_api_key: bool) -> Result<Config, SailError> {
264 let settings = crate::credentials::load_settings()?;
269 let pick = |env_name: &str, stored_key: &str| -> String {
270 pick_setting(std::env::var(env_name).ok(), settings.get(stored_key))
271 };
272 let mode = pick("SAIL_MODE", "mode");
273 let api_url = pick("SAIL_API_URL", "api_url");
274 let sailbox_api_url = pick("SAILBOX_API_URL", "sailbox_api_url");
275 let imagebuilder_url = pick("SAIL_IMAGEBUILDER_URL", "imagebuilder_url");
276 let sailbox_ingress_url = env_trimmed("SAILBOX_INGRESS_URL");
278
279 let api_key_env = api_key_env_var_for_mode(&mode).ok_or_else(|| SailError::Config {
280 message: format!(
281 "SAIL_MODE={} is not recognized; use SAIL_MODE=prod|dev|staging|local",
282 mode.trim().to_lowercase()
283 ),
284 })?;
285 let mut api_key = env_trimmed(api_key_env);
286 let stored_login_allowed = api_key_env == "SAIL_API_KEY";
287 if api_key.is_empty() && stored_login_allowed {
288 let target = crate::credentials::resolve_target_api_url(&api_url, &mode);
289 if crate::credentials::stored_key_matches_target(&settings, &target) {
290 if let Some(stored) = crate::credentials::auth_key_best_effort() {
291 api_key = stored;
292 }
293 }
294 }
295 if require_api_key && api_key.is_empty() {
296 return Err(SailError::Config {
297 message: missing_api_key_message(&mode),
298 });
299 }
300
301 Config::build(
302 Some(&mode),
303 api_key,
304 opt(api_url),
305 opt(sailbox_api_url),
306 opt(imagebuilder_url),
307 opt(sailbox_ingress_url),
308 )
309 }
310}
311
312fn opt(value: String) -> Option<String> {
313 if value.is_empty() {
314 None
315 } else {
316 Some(value)
317 }
318}
319
320fn pick_setting(env_value: Option<String>, stored: Option<&String>) -> String {
326 match env_value {
327 Some(value) => value.trim().to_string(),
328 None => stored
329 .map(|value| value.trim().to_string())
330 .unwrap_or_default(),
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn known_modes_resolve() {
340 assert_eq!(mode_defaults("").unwrap().api_url, PROD.api_url);
341 assert_eq!(mode_defaults(" PROD ").unwrap().api_url, PROD.api_url);
342 assert_eq!(mode_defaults("dev").unwrap().api_url, DEV.api_url);
343 assert_eq!(mode_defaults("local").unwrap().api_url, LOCAL.api_url);
344 }
345
346 #[test]
347 fn api_key_environment_is_scoped_by_mode() {
348 assert_eq!(api_key_env_var_for_mode(""), Some("SAIL_API_KEY"));
349 assert_eq!(api_key_env_var_for_mode("prod"), Some("SAIL_API_KEY"));
350 assert_eq!(api_key_env_var_for_mode("local"), Some("SAIL_API_KEY"));
351 assert_eq!(api_key_env_var_for_mode("dev"), Some("SAIL_DEV_API_KEY"));
352 assert_eq!(
353 api_key_env_var_for_mode(" staging "),
354 Some("SAIL_STAGING_API_KEY")
355 );
356 assert_eq!(api_key_env_var_for_mode("production"), None);
357 }
358
359 #[test]
360 fn unknown_mode_is_config_error() {
361 match mode_defaults("production") {
362 Err(SailError::Config { message }) => {
363 assert!(message.contains("not recognized"), "message={message}");
364 }
365 other => panic!("expected Config error, got {other:?}"),
366 }
367 }
368
369 #[test]
370 fn pick_setting_present_env_masks_stored() {
371 let stored = "https://stored.example".to_string();
372 assert_eq!(
375 pick_setting(Some("https://env.example".to_string()), Some(&stored)),
376 "https://env.example"
377 );
378 assert_eq!(pick_setting(Some(String::new()), Some(&stored)), "");
379 assert_eq!(pick_setting(Some(" ".to_string()), Some(&stored)), "");
380 assert_eq!(
382 pick_setting(None, Some(&stored)),
383 "https://stored.example"
384 );
385 assert_eq!(pick_setting(None, None), "");
386 }
387
388 #[test]
389 fn override_wins_blank_falls_back_to_mode_default() {
390 let config = Config::resolve(
391 Some("dev"),
392 "k".to_string(),
393 Some("https://override.example".to_string()),
394 None, Some(" ".to_string()), None,
397 )
398 .unwrap();
399 assert_eq!(config.api_url, "https://override.example");
400 assert_eq!(config.sailbox_api_url, DEV.sailbox_api_url);
401 assert_eq!(config.imagebuilder_url, DEV.imagebuilder_url);
402 }
403
404 #[test]
405 fn api_key_is_required_and_trimmed() {
406 assert!(matches!(
407 Config::resolve(
408 None,
409 " ".to_string(),
410 None,
411 None,
412 None,
413 None,
414 ),
415 Err(SailError::Config { .. })
416 ));
417 let config = Config::resolve(
418 None,
419 " sk_k ".to_string(),
420 None,
421 None,
422 None,
423 None,
424 )
425 .unwrap();
426 assert_eq!(config.api_key, "sk_k");
427 assert_eq!(config.api_url, PROD.api_url);
429 }
430
431 #[test]
432 fn ingress_defaults_per_mode_and_override_forces_path() {
433 let prod = Config::resolve(
435 None,
436 "k".to_string(),
437 None,
438 None,
439 None,
440 None,
441 )
442 .unwrap();
443 assert_eq!(prod.ingress_scheme, IngressScheme::Subdomain);
444 assert_eq!(prod.ingress_base, PROD.ingress_base);
445 let local = Config::resolve(
447 Some("local"),
448 "k".to_string(),
449 None,
450 None,
451 None,
452 None,
453 )
454 .unwrap();
455 assert_eq!(local.ingress_scheme, IngressScheme::Path);
456 let overridden = Config::resolve(
458 None,
459 "k".to_string(),
460 None,
461 None,
462 None,
463 Some("https://ingress.example".to_string()),
464 )
465 .unwrap();
466 assert_eq!(overridden.ingress_scheme, IngressScheme::Path);
467 assert_eq!(overridden.ingress_base, "https://ingress.example");
468 }
469}