1use crate::error::SailError;
8
9#[derive(Debug, Clone)]
15pub struct Config {
16 pub mode: Option<String>,
21 pub api_key: String,
23 pub api_url: String,
25 pub sailbox_api_url: String,
27 pub imagebuilder_url: String,
29 pub ingress_base: String,
32 pub ingress_scheme: IngressScheme,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum IngressScheme {
40 Path,
43 Subdomain,
46}
47
48#[derive(Debug)]
49struct EnvDefaults {
50 api_url: &'static str,
51 sailbox_api_url: &'static str,
52 imagebuilder_url: &'static str,
53 ingress_base: &'static str,
54 ingress_subdomain: bool,
57}
58
59const PROD: EnvDefaults = EnvDefaults {
60 api_url: "https://api.sailresearch.com",
61 sailbox_api_url: "https://sailbox-api.sailresearch.com",
62 imagebuilder_url: "sailbox-imagebuilder-dispatcher.sailresearch.com:443",
63 ingress_base: "https://api.sailresearch.com",
64 ingress_subdomain: true,
65};
66
67const DEV: EnvDefaults = EnvDefaults {
68 api_url: "https://dev.sailresearch.com",
69 sailbox_api_url: "https://sailbox-api.dev.sailresearch.com",
70 imagebuilder_url: "sailbox-imagebuilder-dispatcher.dev.sailresearch.com:443",
71 ingress_base: "https://dev.sailresearch.com",
72 ingress_subdomain: true,
73};
74
75const STAGING: EnvDefaults = EnvDefaults {
76 api_url: "https://staging.sailresearch.com",
77 sailbox_api_url: "https://sailbox-api.staging.sailresearch.com",
78 imagebuilder_url: "sailbox-imagebuilder-dispatcher.staging.sailresearch.com:443",
79 ingress_base: "https://beta.sailresearch.com",
80 ingress_subdomain: true,
81};
82
83const LOCAL: EnvDefaults = EnvDefaults {
88 api_url: "http://localhost:8080",
89 sailbox_api_url: "http://localhost:8080",
90 imagebuilder_url: "localhost:50061",
91 ingress_base: "http://localhost:18080",
92 ingress_subdomain: false,
93};
94
95fn env_or_empty(name: &str) -> String {
96 std::env::var(name).unwrap_or_default()
97}
98
99fn env_trimmed(name: &str) -> String {
100 env_or_empty(name).trim().to_string()
101}
102
103fn mode_defaults(mode: &str) -> Result<&'static EnvDefaults, SailError> {
106 match mode.trim().to_lowercase().as_str() {
107 "" | "prod" => Ok(&PROD),
108 "dev" => Ok(&DEV),
109 "staging" => Ok(&STAGING),
110 "local" => Ok(&LOCAL),
111 other => Err(SailError::Config {
112 message: format!(
113 "SAIL_MODE={other} is not recognized; use SAIL_MODE=prod|dev|staging|local"
114 ),
115 }),
116 }
117}
118
119#[doc(hidden)]
123pub fn api_url_for_mode(mode: &str) -> Option<&'static str> {
124 mode_defaults(mode).ok().map(|defaults| defaults.api_url)
125}
126
127impl Config {
128 pub(crate) fn resolve(
132 mode: Option<&str>,
133 api_key: String,
134 api_url: Option<String>,
135 sailbox_api_url: Option<String>,
136 imagebuilder_url: Option<String>,
137 sailbox_ingress_url: Option<String>,
138 ) -> Result<Config, SailError> {
139 let api_key = api_key.trim().to_string();
140 if api_key.is_empty() {
141 return Err(SailError::Config {
142 message: "Set SAIL_API_KEY or run `sail auth login`.".to_string(),
143 });
144 }
145 Config::build(
146 mode,
147 api_key,
148 api_url,
149 sailbox_api_url,
150 imagebuilder_url,
151 sailbox_ingress_url,
152 )
153 }
154
155 fn build(
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 defaults = mode_defaults(mode.unwrap_or(""))?;
166 fn or_default(override_value: Option<String>, default: &str) -> String {
167 match override_value {
168 Some(value) if !value.trim().is_empty() => value.trim().to_string(),
169 _ => default.to_string(),
170 }
171 }
172 let has_override = sailbox_ingress_url
175 .as_deref()
176 .map(str::trim)
177 .is_some_and(|value| !value.is_empty());
178 let ingress_scheme = if !has_override && defaults.ingress_subdomain {
179 IngressScheme::Subdomain
180 } else {
181 IngressScheme::Path
182 };
183 Ok(Config {
184 mode: mode
185 .map(str::trim)
186 .filter(|value| !value.is_empty())
187 .map(str::to_string),
188 api_key,
189 api_url: or_default(api_url, defaults.api_url),
190 sailbox_api_url: or_default(sailbox_api_url, defaults.sailbox_api_url),
191 imagebuilder_url: or_default(imagebuilder_url, defaults.imagebuilder_url),
192 ingress_base: or_default(sailbox_ingress_url, defaults.ingress_base),
193 ingress_scheme,
194 })
195 }
196
197 pub fn from_env() -> Result<Config, SailError> {
204 Config::from_env_inner(true)
205 }
206
207 #[doc(hidden)]
211 pub fn from_env_optional_api_key() -> Result<Config, SailError> {
212 Config::from_env_inner(false)
213 }
214
215 fn from_env_inner(require_api_key: bool) -> Result<Config, SailError> {
216 let settings = crate::credentials::load_settings()?;
221 let pick = |env_name: &str, stored_key: &str| -> String {
222 pick_setting(std::env::var(env_name).ok(), settings.get(stored_key))
223 };
224 let mode = pick("SAIL_MODE", "mode");
225 let api_url = pick("SAIL_API_URL", "api_url");
226 let sailbox_api_url = pick("SAILBOX_API_URL", "sailbox_api_url");
227 let imagebuilder_url = pick("SAIL_IMAGEBUILDER_URL", "imagebuilder_url");
228 let sailbox_ingress_url = env_trimmed("SAILBOX_INGRESS_URL");
230
231 let mut api_key = env_trimmed("SAIL_API_KEY");
232 if api_key.is_empty() {
233 let target = crate::credentials::resolve_target_api_url(&api_url, &mode);
234 if crate::credentials::stored_key_matches_target(&settings, &target) {
235 if let Some(stored) = crate::credentials::auth_key_best_effort() {
236 api_key = stored;
237 }
238 }
239 }
240 if require_api_key && api_key.is_empty() {
241 return Err(SailError::Config {
242 message: "Set SAIL_API_KEY or run `sail auth login`.".to_string(),
243 });
244 }
245
246 Config::build(
247 Some(&mode),
248 api_key,
249 opt(api_url),
250 opt(sailbox_api_url),
251 opt(imagebuilder_url),
252 opt(sailbox_ingress_url),
253 )
254 }
255}
256
257fn opt(value: String) -> Option<String> {
258 if value.is_empty() {
259 None
260 } else {
261 Some(value)
262 }
263}
264
265fn pick_setting(env_value: Option<String>, stored: Option<&String>) -> String {
271 match env_value {
272 Some(value) => value.trim().to_string(),
273 None => stored
274 .map(|value| value.trim().to_string())
275 .unwrap_or_default(),
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn known_modes_resolve() {
285 assert_eq!(mode_defaults("").unwrap().api_url, PROD.api_url);
286 assert_eq!(mode_defaults(" PROD ").unwrap().api_url, PROD.api_url);
287 assert_eq!(mode_defaults("dev").unwrap().api_url, DEV.api_url);
288 assert_eq!(mode_defaults("local").unwrap().api_url, LOCAL.api_url);
289 }
290
291 #[test]
292 fn unknown_mode_is_config_error() {
293 match mode_defaults("production") {
294 Err(SailError::Config { message }) => {
295 assert!(message.contains("not recognized"), "message={message}");
296 }
297 other => panic!("expected Config error, got {other:?}"),
298 }
299 }
300
301 #[test]
302 fn pick_setting_present_env_masks_stored() {
303 let stored = "https://stored.example".to_string();
304 assert_eq!(
307 pick_setting(Some("https://env.example".to_string()), Some(&stored)),
308 "https://env.example"
309 );
310 assert_eq!(pick_setting(Some(String::new()), Some(&stored)), "");
311 assert_eq!(pick_setting(Some(" ".to_string()), Some(&stored)), "");
312 assert_eq!(
314 pick_setting(None, Some(&stored)),
315 "https://stored.example"
316 );
317 assert_eq!(pick_setting(None, None), "");
318 }
319
320 #[test]
321 fn override_wins_blank_falls_back_to_mode_default() {
322 let config = Config::resolve(
323 Some("dev"),
324 "k".to_string(),
325 Some("https://override.example".to_string()),
326 None, Some(" ".to_string()), None,
329 )
330 .unwrap();
331 assert_eq!(config.api_url, "https://override.example");
332 assert_eq!(config.sailbox_api_url, DEV.sailbox_api_url);
333 assert_eq!(config.imagebuilder_url, DEV.imagebuilder_url);
334 }
335
336 #[test]
337 fn api_key_is_required_and_trimmed() {
338 assert!(matches!(
339 Config::resolve(
340 None,
341 " ".to_string(),
342 None,
343 None,
344 None,
345 None,
346 ),
347 Err(SailError::Config { .. })
348 ));
349 let config = Config::resolve(
350 None,
351 " sk_k ".to_string(),
352 None,
353 None,
354 None,
355 None,
356 )
357 .unwrap();
358 assert_eq!(config.api_key, "sk_k");
359 assert_eq!(config.api_url, PROD.api_url);
361 }
362
363 #[test]
364 fn ingress_defaults_per_mode_and_override_forces_path() {
365 let prod = Config::resolve(
367 None,
368 "k".to_string(),
369 None,
370 None,
371 None,
372 None,
373 )
374 .unwrap();
375 assert_eq!(prod.ingress_scheme, IngressScheme::Subdomain);
376 assert_eq!(prod.ingress_base, PROD.ingress_base);
377 let local = Config::resolve(
379 Some("local"),
380 "k".to_string(),
381 None,
382 None,
383 None,
384 None,
385 )
386 .unwrap();
387 assert_eq!(local.ingress_scheme, IngressScheme::Path);
388 let overridden = Config::resolve(
390 None,
391 "k".to_string(),
392 None,
393 None,
394 None,
395 Some("https://ingress.example".to_string()),
396 )
397 .unwrap();
398 assert_eq!(overridden.ingress_scheme, IngressScheme::Path);
399 assert_eq!(overridden.ingress_base, "https://ingress.example");
400 }
401}