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
153impl Config {
154 pub(crate) fn resolve(
158 mode: Option<&str>,
159 api_key: String,
160 api_url: Option<String>,
161 sailbox_api_url: Option<String>,
162 imagebuilder_url: Option<String>,
163 sailbox_ingress_url: Option<String>,
164 ) -> Result<Config, SailError> {
165 let api_key = api_key.trim().to_string();
166 if api_key.is_empty() {
167 return Err(SailError::Config {
168 message: "Set SAIL_API_KEY or run `sail auth login`.".to_string(),
169 });
170 }
171 Config::build(
172 mode,
173 api_key,
174 api_url,
175 sailbox_api_url,
176 imagebuilder_url,
177 sailbox_ingress_url,
178 )
179 }
180
181 fn build(
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 defaults = mode_defaults(mode.unwrap_or(""))?;
192 fn or_default(override_value: Option<String>, default: &str) -> String {
193 match override_value {
194 Some(value) if !value.trim().is_empty() => value.trim().to_string(),
195 _ => default.to_string(),
196 }
197 }
198 let has_override = sailbox_ingress_url
201 .as_deref()
202 .map(str::trim)
203 .is_some_and(|value| !value.is_empty());
204 let ingress_scheme = if !has_override && defaults.ingress_subdomain {
205 IngressScheme::Subdomain
206 } else {
207 IngressScheme::Path
208 };
209 Ok(Config {
210 mode: mode
211 .map(str::trim)
212 .filter(|value| !value.is_empty())
213 .map(str::to_string),
214 api_key,
215 api_url: or_default(api_url, defaults.api_url),
216 sailbox_api_url: or_default(sailbox_api_url, defaults.sailbox_api_url),
217 imagebuilder_url: or_default(imagebuilder_url, defaults.imagebuilder_url),
218 ingress_base: or_default(sailbox_ingress_url, defaults.ingress_base),
219 ingress_scheme,
220 })
221 }
222
223 pub fn from_env() -> Result<Config, SailError> {
230 Config::from_env_inner(true)
231 }
232
233 #[doc(hidden)]
237 pub fn from_env_optional_api_key() -> Result<Config, SailError> {
238 Config::from_env_inner(false)
239 }
240
241 fn from_env_inner(require_api_key: bool) -> Result<Config, SailError> {
242 let settings = crate::credentials::load_settings()?;
247 let pick = |env_name: &str, stored_key: &str| -> String {
248 pick_setting(std::env::var(env_name).ok(), settings.get(stored_key))
249 };
250 let mode = pick("SAIL_MODE", "mode");
251 let api_url = pick("SAIL_API_URL", "api_url");
252 let sailbox_api_url = pick("SAILBOX_API_URL", "sailbox_api_url");
253 let imagebuilder_url = pick("SAIL_IMAGEBUILDER_URL", "imagebuilder_url");
254 let sailbox_ingress_url = env_trimmed("SAILBOX_INGRESS_URL");
256
257 let mut api_key = env_trimmed("SAIL_API_KEY");
258 if api_key.is_empty() {
259 let target = crate::credentials::resolve_target_api_url(&api_url, &mode);
260 if crate::credentials::stored_key_matches_target(&settings, &target) {
261 if let Some(stored) = crate::credentials::auth_key_best_effort() {
262 api_key = stored;
263 }
264 }
265 }
266 if require_api_key && api_key.is_empty() {
267 return Err(SailError::Config {
268 message: "Set SAIL_API_KEY or run `sail auth login`.".to_string(),
269 });
270 }
271
272 Config::build(
273 Some(&mode),
274 api_key,
275 opt(api_url),
276 opt(sailbox_api_url),
277 opt(imagebuilder_url),
278 opt(sailbox_ingress_url),
279 )
280 }
281}
282
283fn opt(value: String) -> Option<String> {
284 if value.is_empty() {
285 None
286 } else {
287 Some(value)
288 }
289}
290
291fn pick_setting(env_value: Option<String>, stored: Option<&String>) -> String {
297 match env_value {
298 Some(value) => value.trim().to_string(),
299 None => stored
300 .map(|value| value.trim().to_string())
301 .unwrap_or_default(),
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn known_modes_resolve() {
311 assert_eq!(mode_defaults("").unwrap().api_url, PROD.api_url);
312 assert_eq!(mode_defaults(" PROD ").unwrap().api_url, PROD.api_url);
313 assert_eq!(mode_defaults("dev").unwrap().api_url, DEV.api_url);
314 assert_eq!(mode_defaults("local").unwrap().api_url, LOCAL.api_url);
315 }
316
317 #[test]
318 fn unknown_mode_is_config_error() {
319 match mode_defaults("production") {
320 Err(SailError::Config { message }) => {
321 assert!(message.contains("not recognized"), "message={message}");
322 }
323 other => panic!("expected Config error, got {other:?}"),
324 }
325 }
326
327 #[test]
328 fn pick_setting_present_env_masks_stored() {
329 let stored = "https://stored.example".to_string();
330 assert_eq!(
333 pick_setting(Some("https://env.example".to_string()), Some(&stored)),
334 "https://env.example"
335 );
336 assert_eq!(pick_setting(Some(String::new()), Some(&stored)), "");
337 assert_eq!(pick_setting(Some(" ".to_string()), Some(&stored)), "");
338 assert_eq!(
340 pick_setting(None, Some(&stored)),
341 "https://stored.example"
342 );
343 assert_eq!(pick_setting(None, None), "");
344 }
345
346 #[test]
347 fn override_wins_blank_falls_back_to_mode_default() {
348 let config = Config::resolve(
349 Some("dev"),
350 "k".to_string(),
351 Some("https://override.example".to_string()),
352 None, Some(" ".to_string()), None,
355 )
356 .unwrap();
357 assert_eq!(config.api_url, "https://override.example");
358 assert_eq!(config.sailbox_api_url, DEV.sailbox_api_url);
359 assert_eq!(config.imagebuilder_url, DEV.imagebuilder_url);
360 }
361
362 #[test]
363 fn api_key_is_required_and_trimmed() {
364 assert!(matches!(
365 Config::resolve(
366 None,
367 " ".to_string(),
368 None,
369 None,
370 None,
371 None,
372 ),
373 Err(SailError::Config { .. })
374 ));
375 let config = Config::resolve(
376 None,
377 " sk_k ".to_string(),
378 None,
379 None,
380 None,
381 None,
382 )
383 .unwrap();
384 assert_eq!(config.api_key, "sk_k");
385 assert_eq!(config.api_url, PROD.api_url);
387 }
388
389 #[test]
390 fn ingress_defaults_per_mode_and_override_forces_path() {
391 let prod = Config::resolve(
393 None,
394 "k".to_string(),
395 None,
396 None,
397 None,
398 None,
399 )
400 .unwrap();
401 assert_eq!(prod.ingress_scheme, IngressScheme::Subdomain);
402 assert_eq!(prod.ingress_base, PROD.ingress_base);
403 let local = Config::resolve(
405 Some("local"),
406 "k".to_string(),
407 None,
408 None,
409 None,
410 None,
411 )
412 .unwrap();
413 assert_eq!(local.ingress_scheme, IngressScheme::Path);
414 let overridden = Config::resolve(
416 None,
417 "k".to_string(),
418 None,
419 None,
420 None,
421 Some("https://ingress.example".to_string()),
422 )
423 .unwrap();
424 assert_eq!(overridden.ingress_scheme, IngressScheme::Path);
425 assert_eq!(overridden.ingress_base, "https://ingress.example");
426 }
427}