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