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
110pub fn api_url_for_mode(mode: &str) -> Option<&'static str> {
114 mode_defaults(mode).ok().map(|defaults| defaults.api_url)
115}
116
117impl Config {
118 pub fn resolve(
122 mode: Option<&str>,
123 api_key: String,
124 api_url: Option<String>,
125 sailbox_api_url: Option<String>,
126 imagebuilder_url: Option<String>,
127 sailbox_ingress_url: Option<String>,
128 ) -> Result<Config, SailError> {
129 let api_key = api_key.trim().to_string();
130 if api_key.is_empty() {
131 return Err(SailError::Config {
132 message: "Set SAIL_API_KEY or run `sail auth login`.".to_string(),
133 });
134 }
135 Config::build(
136 mode,
137 api_key,
138 api_url,
139 sailbox_api_url,
140 imagebuilder_url,
141 sailbox_ingress_url,
142 )
143 }
144
145 fn build(
148 mode: Option<&str>,
149 api_key: String,
150 api_url: Option<String>,
151 sailbox_api_url: Option<String>,
152 imagebuilder_url: Option<String>,
153 sailbox_ingress_url: Option<String>,
154 ) -> Result<Config, SailError> {
155 let defaults = mode_defaults(mode.unwrap_or(""))?;
156 fn or_default(override_value: Option<String>, default: &str) -> String {
157 match override_value {
158 Some(value) if !value.trim().is_empty() => value.trim().to_string(),
159 _ => default.to_string(),
160 }
161 }
162 let has_override = sailbox_ingress_url
165 .as_deref()
166 .map(str::trim)
167 .is_some_and(|value| !value.is_empty());
168 let ingress_scheme = if !has_override && defaults.ingress_subdomain {
169 IngressScheme::Subdomain
170 } else {
171 IngressScheme::Path
172 };
173 Ok(Config {
174 api_key,
175 api_url: or_default(api_url, defaults.api_url),
176 sailbox_api_url: or_default(sailbox_api_url, defaults.sailbox_api_url),
177 imagebuilder_url: or_default(imagebuilder_url, defaults.imagebuilder_url),
178 ingress_base: or_default(sailbox_ingress_url, defaults.ingress_base),
179 ingress_scheme,
180 })
181 }
182
183 pub fn from_env() -> Result<Config, SailError> {
190 Config::from_env_inner(true)
191 }
192
193 pub fn from_env_optional_api_key() -> Result<Config, SailError> {
197 Config::from_env_inner(false)
198 }
199
200 fn from_env_inner(require_api_key: bool) -> Result<Config, SailError> {
201 let settings = crate::credentials::load_settings()?;
206 let pick = |env_name: &str, stored_key: &str| -> String {
207 pick_setting(std::env::var(env_name).ok(), settings.get(stored_key))
208 };
209 let mode = pick("SAIL_MODE", "mode");
210 let api_url = pick("SAIL_API_URL", "api_url");
211 let sailbox_api_url = pick("SAILBOX_API_URL", "sailbox_api_url");
212 let imagebuilder_url = pick("SAIL_IMAGEBUILDER_URL", "imagebuilder_url");
213 let sailbox_ingress_url = env_trimmed("SAILBOX_INGRESS_URL");
215
216 let mut api_key = env_trimmed("SAIL_API_KEY");
217 if api_key.is_empty() {
218 let target = crate::credentials::resolve_target_api_url(&api_url, &mode);
219 if crate::credentials::stored_key_matches_target(&settings, &target) {
220 if let Some(stored) = crate::credentials::auth_key_best_effort() {
221 api_key = stored;
222 }
223 }
224 }
225 if require_api_key && api_key.is_empty() {
226 return Err(SailError::Config {
227 message: "Set SAIL_API_KEY or run `sail auth login`.".to_string(),
228 });
229 }
230
231 Config::build(
232 Some(&mode),
233 api_key,
234 opt(api_url),
235 opt(sailbox_api_url),
236 opt(imagebuilder_url),
237 opt(sailbox_ingress_url),
238 )
239 }
240}
241
242fn opt(value: String) -> Option<String> {
243 if value.is_empty() {
244 None
245 } else {
246 Some(value)
247 }
248}
249
250fn pick_setting(env_value: Option<String>, stored: Option<&String>) -> String {
256 match env_value {
257 Some(value) => value.trim().to_string(),
258 None => stored
259 .map(|value| value.trim().to_string())
260 .unwrap_or_default(),
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn known_modes_resolve() {
270 assert_eq!(mode_defaults("").unwrap().api_url, PROD.api_url);
271 assert_eq!(mode_defaults(" PROD ").unwrap().api_url, PROD.api_url);
272 assert_eq!(mode_defaults("dev").unwrap().api_url, DEV.api_url);
273 assert_eq!(mode_defaults("local").unwrap().api_url, LOCAL.api_url);
274 }
275
276 #[test]
277 fn unknown_mode_is_config_error() {
278 match mode_defaults("production") {
279 Err(SailError::Config { message }) => {
280 assert!(message.contains("not recognized"), "message={message}");
281 }
282 other => panic!("expected Config error, got {other:?}"),
283 }
284 }
285
286 #[test]
287 fn pick_setting_present_env_masks_stored() {
288 let stored = "https://stored.example".to_string();
289 assert_eq!(
292 pick_setting(Some("https://env.example".to_string()), Some(&stored)),
293 "https://env.example"
294 );
295 assert_eq!(pick_setting(Some(String::new()), Some(&stored)), "");
296 assert_eq!(pick_setting(Some(" ".to_string()), Some(&stored)), "");
297 assert_eq!(pick_setting(None, Some(&stored)), "https://stored.example");
299 assert_eq!(pick_setting(None, None), "");
300 }
301
302 #[test]
303 fn override_wins_blank_falls_back_to_mode_default() {
304 let config = Config::resolve(
305 Some("dev"),
306 "k".to_string(),
307 Some("https://override.example".to_string()),
308 None, Some(" ".to_string()), None,
311 )
312 .unwrap();
313 assert_eq!(config.api_url, "https://override.example");
314 assert_eq!(config.sailbox_api_url, DEV.sailbox_api_url);
315 assert_eq!(config.imagebuilder_url, DEV.imagebuilder_url);
316 }
317
318 #[test]
319 fn api_key_is_required_and_trimmed() {
320 assert!(matches!(
321 Config::resolve(None, " ".to_string(), None, None, None, None),
322 Err(SailError::Config { .. })
323 ));
324 let config = Config::resolve(None, " sk_k ".to_string(), None, None, None, None).unwrap();
325 assert_eq!(config.api_key, "sk_k");
326 assert_eq!(config.api_url, PROD.api_url);
328 }
329
330 #[test]
331 fn ingress_defaults_per_mode_and_override_forces_path() {
332 let prod = Config::resolve(None, "k".to_string(), None, None, None, None).unwrap();
334 assert_eq!(prod.ingress_scheme, IngressScheme::Subdomain);
335 assert_eq!(prod.ingress_base, PROD.ingress_base);
336 let local =
338 Config::resolve(Some("local"), "k".to_string(), None, None, None, None).unwrap();
339 assert_eq!(local.ingress_scheme, IngressScheme::Path);
340 let overridden = Config::resolve(
342 None,
343 "k".to_string(),
344 None,
345 None,
346 None,
347 Some("https://ingress.example".to_string()),
348 )
349 .unwrap();
350 assert_eq!(overridden.ingress_scheme, IngressScheme::Path);
351 assert_eq!(overridden.ingress_base, "https://ingress.example");
352 }
353}