1use crate::error::SailError;
8
9#[derive(Debug, Clone)]
11pub struct Config {
12 pub api_key: String,
14 pub api_url: String,
16 pub sailbox_api_url: String,
18 pub imagebuilder_url: String,
20 pub ingress_base: String,
23 pub ingress_scheme: IngressScheme,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum IngressScheme {
31 Path,
34 Subdomain,
37}
38
39#[derive(Debug)]
40struct EnvDefaults {
41 api_url: &'static str,
42 sailbox_api_url: &'static str,
43 imagebuilder_url: &'static str,
44 ingress_base: &'static str,
45 ingress_subdomain: bool,
48}
49
50const PROD: EnvDefaults = EnvDefaults {
51 api_url: "https://api.sailresearch.com",
52 sailbox_api_url: "https://sailbox-api.sailresearch.com",
53 imagebuilder_url: "sailbox-imagebuilder-dispatcher.sailresearch.com:443",
54 ingress_base: "https://api.sailresearch.com",
55 ingress_subdomain: true,
56};
57
58const DEV: EnvDefaults = EnvDefaults {
59 api_url: "https://dev.sailresearch.com",
60 sailbox_api_url: "https://sailbox-api.dev.sailresearch.com",
61 imagebuilder_url: "sailbox-imagebuilder-dispatcher.dev.sailresearch.com:443",
62 ingress_base: "https://dev.sailresearch.com",
63 ingress_subdomain: true,
64};
65
66const STAGING: EnvDefaults = EnvDefaults {
67 api_url: "https://staging.sailresearch.com",
68 sailbox_api_url: "https://sailbox-api.staging.sailresearch.com",
69 imagebuilder_url: "sailbox-imagebuilder-dispatcher.staging.sailresearch.com:443",
70 ingress_base: "https://beta.sailresearch.com",
71 ingress_subdomain: true,
72};
73
74const LOCAL: EnvDefaults = EnvDefaults {
79 api_url: "http://localhost:8080",
80 sailbox_api_url: "http://localhost:8080",
81 imagebuilder_url: "localhost:50061",
82 ingress_base: "http://localhost:18080",
83 ingress_subdomain: false,
84};
85
86fn env_or_empty(name: &str) -> String {
87 std::env::var(name).unwrap_or_default()
88}
89
90fn env_trimmed(name: &str) -> String {
91 env_or_empty(name).trim().to_string()
92}
93
94fn mode_defaults(mode: &str) -> Result<&'static EnvDefaults, SailError> {
97 match mode.trim().to_lowercase().as_str() {
98 "" | "prod" => Ok(&PROD),
99 "dev" => Ok(&DEV),
100 "staging" => Ok(&STAGING),
101 "local" => Ok(&LOCAL),
102 other => Err(SailError::Config {
103 message: format!(
104 "SAIL_MODE={other} is not recognized; use SAIL_MODE=prod|dev|staging|local"
105 ),
106 }),
107 }
108}
109
110#[doc(hidden)]
114pub fn api_url_for_mode(mode: &str) -> Option<&'static str> {
115 mode_defaults(mode).ok().map(|defaults| defaults.api_url)
116}
117
118impl Config {
119 pub(crate) fn resolve(
123 mode: Option<&str>,
124 api_key: String,
125 api_url: Option<String>,
126 sailbox_api_url: Option<String>,
127 imagebuilder_url: Option<String>,
128 sailbox_ingress_url: Option<String>,
129 ) -> Result<Config, SailError> {
130 let api_key = api_key.trim().to_string();
131 if api_key.is_empty() {
132 return Err(SailError::Config {
133 message: "Set SAIL_API_KEY or run `sail auth login`.".to_string(),
134 });
135 }
136 Config::build(
137 mode,
138 api_key,
139 api_url,
140 sailbox_api_url,
141 imagebuilder_url,
142 sailbox_ingress_url,
143 )
144 }
145
146 fn build(
149 mode: Option<&str>,
150 api_key: String,
151 api_url: Option<String>,
152 sailbox_api_url: Option<String>,
153 imagebuilder_url: Option<String>,
154 sailbox_ingress_url: Option<String>,
155 ) -> Result<Config, SailError> {
156 let defaults = mode_defaults(mode.unwrap_or(""))?;
157 fn or_default(override_value: Option<String>, default: &str) -> String {
158 match override_value {
159 Some(value) if !value.trim().is_empty() => value.trim().to_string(),
160 _ => default.to_string(),
161 }
162 }
163 let has_override = sailbox_ingress_url
166 .as_deref()
167 .map(str::trim)
168 .is_some_and(|value| !value.is_empty());
169 let ingress_scheme = if !has_override && defaults.ingress_subdomain {
170 IngressScheme::Subdomain
171 } else {
172 IngressScheme::Path
173 };
174 Ok(Config {
175 api_key,
176 api_url: or_default(api_url, defaults.api_url),
177 sailbox_api_url: or_default(sailbox_api_url, defaults.sailbox_api_url),
178 imagebuilder_url: or_default(imagebuilder_url, defaults.imagebuilder_url),
179 ingress_base: or_default(sailbox_ingress_url, defaults.ingress_base),
180 ingress_scheme,
181 })
182 }
183
184 pub fn from_env() -> Result<Config, SailError> {
191 Config::from_env_inner(true)
192 }
193
194 #[doc(hidden)]
198 pub fn from_env_optional_api_key() -> Result<Config, SailError> {
199 Config::from_env_inner(false)
200 }
201
202 fn from_env_inner(require_api_key: bool) -> Result<Config, SailError> {
203 let settings = crate::credentials::load_settings()?;
208 let pick = |env_name: &str, stored_key: &str| -> String {
209 pick_setting(std::env::var(env_name).ok(), settings.get(stored_key))
210 };
211 let mode = pick("SAIL_MODE", "mode");
212 let api_url = pick("SAIL_API_URL", "api_url");
213 let sailbox_api_url = pick("SAILBOX_API_URL", "sailbox_api_url");
214 let imagebuilder_url = pick("SAIL_IMAGEBUILDER_URL", "imagebuilder_url");
215 let sailbox_ingress_url = env_trimmed("SAILBOX_INGRESS_URL");
217
218 let mut api_key = env_trimmed("SAIL_API_KEY");
219 if api_key.is_empty() {
220 let target = crate::credentials::resolve_target_api_url(&api_url, &mode);
221 if crate::credentials::stored_key_matches_target(&settings, &target) {
222 if let Some(stored) = crate::credentials::auth_key_best_effort() {
223 api_key = stored;
224 }
225 }
226 }
227 if require_api_key && api_key.is_empty() {
228 return Err(SailError::Config {
229 message: "Set SAIL_API_KEY or run `sail auth login`.".to_string(),
230 });
231 }
232
233 Config::build(
234 Some(&mode),
235 api_key,
236 opt(api_url),
237 opt(sailbox_api_url),
238 opt(imagebuilder_url),
239 opt(sailbox_ingress_url),
240 )
241 }
242}
243
244fn opt(value: String) -> Option<String> {
245 if value.is_empty() {
246 None
247 } else {
248 Some(value)
249 }
250}
251
252fn pick_setting(env_value: Option<String>, stored: Option<&String>) -> String {
258 match env_value {
259 Some(value) => value.trim().to_string(),
260 None => stored
261 .map(|value| value.trim().to_string())
262 .unwrap_or_default(),
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn known_modes_resolve() {
272 assert_eq!(mode_defaults("").unwrap().api_url, PROD.api_url);
273 assert_eq!(mode_defaults(" PROD ").unwrap().api_url, PROD.api_url);
274 assert_eq!(mode_defaults("dev").unwrap().api_url, DEV.api_url);
275 assert_eq!(mode_defaults("local").unwrap().api_url, LOCAL.api_url);
276 }
277
278 #[test]
279 fn unknown_mode_is_config_error() {
280 match mode_defaults("production") {
281 Err(SailError::Config { message }) => {
282 assert!(message.contains("not recognized"), "message={message}");
283 }
284 other => panic!("expected Config error, got {other:?}"),
285 }
286 }
287
288 #[test]
289 fn pick_setting_present_env_masks_stored() {
290 let stored = "https://stored.example".to_string();
291 assert_eq!(
294 pick_setting(Some("https://env.example".to_string()), Some(&stored)),
295 "https://env.example"
296 );
297 assert_eq!(pick_setting(Some(String::new()), Some(&stored)), "");
298 assert_eq!(pick_setting(Some(" ".to_string()), Some(&stored)), "");
299 assert_eq!(
301 pick_setting(None, Some(&stored)),
302 "https://stored.example"
303 );
304 assert_eq!(pick_setting(None, None), "");
305 }
306
307 #[test]
308 fn override_wins_blank_falls_back_to_mode_default() {
309 let config = Config::resolve(
310 Some("dev"),
311 "k".to_string(),
312 Some("https://override.example".to_string()),
313 None, Some(" ".to_string()), None,
316 )
317 .unwrap();
318 assert_eq!(config.api_url, "https://override.example");
319 assert_eq!(config.sailbox_api_url, DEV.sailbox_api_url);
320 assert_eq!(config.imagebuilder_url, DEV.imagebuilder_url);
321 }
322
323 #[test]
324 fn api_key_is_required_and_trimmed() {
325 assert!(matches!(
326 Config::resolve(
327 None,
328 " ".to_string(),
329 None,
330 None,
331 None,
332 None,
333 ),
334 Err(SailError::Config { .. })
335 ));
336 let config = Config::resolve(
337 None,
338 " sk_k ".to_string(),
339 None,
340 None,
341 None,
342 None,
343 )
344 .unwrap();
345 assert_eq!(config.api_key, "sk_k");
346 assert_eq!(config.api_url, PROD.api_url);
348 }
349
350 #[test]
351 fn ingress_defaults_per_mode_and_override_forces_path() {
352 let prod = Config::resolve(
354 None,
355 "k".to_string(),
356 None,
357 None,
358 None,
359 None,
360 )
361 .unwrap();
362 assert_eq!(prod.ingress_scheme, IngressScheme::Subdomain);
363 assert_eq!(prod.ingress_base, PROD.ingress_base);
364 let local = Config::resolve(
366 Some("local"),
367 "k".to_string(),
368 None,
369 None,
370 None,
371 None,
372 )
373 .unwrap();
374 assert_eq!(local.ingress_scheme, IngressScheme::Path);
375 let overridden = Config::resolve(
377 None,
378 "k".to_string(),
379 None,
380 None,
381 None,
382 Some("https://ingress.example".to_string()),
383 )
384 .unwrap();
385 assert_eq!(overridden.ingress_scheme, IngressScheme::Path);
386 assert_eq!(overridden.ingress_base, "https://ingress.example");
387 }
388}