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 sailbox_ingress_url: String,
22 pub sailbox_ingress_mode: String,
25}
26
27pub const SAILBOX_INGRESS_MODE_PATH: &str = "path";
29pub const SAILBOX_INGRESS_MODE_WILDCARD: &str = "wildcard";
31
32#[derive(Debug)]
33struct EnvDefaults {
34 api_url: &'static str,
35 sailbox_api_url: &'static str,
36 imagebuilder_url: &'static str,
37 sailbox_ingress_url: &'static str,
38 sailbox_ingress_mode: &'static str,
39}
40
41const PROD: EnvDefaults = EnvDefaults {
42 api_url: "https://api.sailresearch.com",
43 sailbox_api_url: "https://sailbox-api.sailresearch.com",
44 imagebuilder_url: "sailbox-imagebuilder-dispatcher.sailresearch.com:443",
45 sailbox_ingress_url: "https://api.sailresearch.com",
46 sailbox_ingress_mode: SAILBOX_INGRESS_MODE_WILDCARD,
47};
48
49const DEV: EnvDefaults = EnvDefaults {
50 api_url: "https://dev.sailresearch.com",
51 sailbox_api_url: "https://sailbox-api.dev.sailresearch.com",
52 imagebuilder_url: "sailbox-imagebuilder-dispatcher.dev.sailresearch.com:443",
53 sailbox_ingress_url: "https://dev.sailresearch.com",
54 sailbox_ingress_mode: SAILBOX_INGRESS_MODE_WILDCARD,
55};
56
57const STAGING: EnvDefaults = EnvDefaults {
58 api_url: "https://staging.sailresearch.com",
59 sailbox_api_url: "https://sailbox-api.staging.sailresearch.com",
60 imagebuilder_url: "sailbox-imagebuilder-dispatcher.staging.sailresearch.com:443",
61 sailbox_ingress_url: "https://beta.sailresearch.com",
62 sailbox_ingress_mode: SAILBOX_INGRESS_MODE_WILDCARD,
63};
64
65const LOCAL: EnvDefaults = EnvDefaults {
70 api_url: "http://localhost:8080",
71 sailbox_api_url: "http://localhost:8080",
72 imagebuilder_url: "localhost:50061",
73 sailbox_ingress_url: "http://localhost:18080",
74 sailbox_ingress_mode: SAILBOX_INGRESS_MODE_PATH,
75};
76
77fn env_or_empty(name: &str) -> String {
78 std::env::var(name).unwrap_or_default()
79}
80
81fn env_trimmed(name: &str) -> String {
82 env_or_empty(name).trim().to_string()
83}
84
85fn mode_defaults(mode: &str) -> Result<&'static EnvDefaults, SailError> {
88 match mode.trim().to_lowercase().as_str() {
89 "" | "prod" => Ok(&PROD),
90 "dev" => Ok(&DEV),
91 "staging" => Ok(&STAGING),
92 "local" => Ok(&LOCAL),
93 other => Err(SailError::Config {
94 message: format!(
95 "SAIL_MODE={other} is not recognized; use SAIL_MODE=prod|dev|staging|local"
96 ),
97 }),
98 }
99}
100
101pub fn api_url_for_mode(mode: &str) -> Option<&'static str> {
105 mode_defaults(mode).ok().map(|defaults| defaults.api_url)
106}
107
108impl Config {
109 pub fn resolve(
113 mode: Option<&str>,
114 api_key: String,
115 api_url: Option<String>,
116 sailbox_api_url: Option<String>,
117 imagebuilder_url: Option<String>,
118 sailbox_ingress_url: Option<String>,
119 ) -> Result<Config, SailError> {
120 let api_key = api_key.trim().to_string();
121 if api_key.is_empty() {
122 return Err(SailError::Config {
123 message: "SAIL_API_KEY must be set".to_string(),
124 });
125 }
126 Config::build(
127 mode,
128 api_key,
129 api_url,
130 sailbox_api_url,
131 imagebuilder_url,
132 sailbox_ingress_url,
133 )
134 }
135
136 fn build(
139 mode: Option<&str>,
140 api_key: String,
141 api_url: Option<String>,
142 sailbox_api_url: Option<String>,
143 imagebuilder_url: Option<String>,
144 sailbox_ingress_url: Option<String>,
145 ) -> Result<Config, SailError> {
146 let defaults = mode_defaults(mode.unwrap_or(""))?;
147 fn or_default(override_value: Option<String>, default: &str) -> String {
148 match override_value {
149 Some(value) if !value.trim().is_empty() => value.trim().to_string(),
150 _ => default.to_string(),
151 }
152 }
153 let ingress_override = sailbox_ingress_url
156 .as_deref()
157 .map(str::trim)
158 .filter(|value| !value.is_empty());
159 let sailbox_ingress_mode = if ingress_override.is_some() {
160 SAILBOX_INGRESS_MODE_PATH
161 } else {
162 defaults.sailbox_ingress_mode
163 };
164 Ok(Config {
165 api_key,
166 api_url: or_default(api_url, defaults.api_url),
167 sailbox_api_url: or_default(sailbox_api_url, defaults.sailbox_api_url),
168 imagebuilder_url: or_default(imagebuilder_url, defaults.imagebuilder_url),
169 sailbox_ingress_url: or_default(sailbox_ingress_url, defaults.sailbox_ingress_url),
170 sailbox_ingress_mode: sailbox_ingress_mode.to_string(),
171 })
172 }
173
174 pub fn from_env() -> Result<Config, SailError> {
181 Config::from_env_inner(true)
182 }
183
184 pub fn from_env_optional_api_key() -> Result<Config, SailError> {
188 Config::from_env_inner(false)
189 }
190
191 fn from_env_inner(require_api_key: bool) -> Result<Config, SailError> {
192 let settings = crate::credentials::settings_best_effort();
193 let pick = |env_name: &str, stored_key: &str| -> String {
194 pick_setting(std::env::var(env_name).ok(), settings.get(stored_key))
195 };
196 let mode = pick("SAIL_MODE", "mode");
197 let api_url = pick("SAIL_API_URL", "api_url");
198 let sailbox_api_url = pick("SAILBOX_API_URL", "sailbox_api_url");
199 let imagebuilder_url = pick("SAIL_IMAGEBUILDER_URL", "imagebuilder_url");
200 let sailbox_ingress_url = env_trimmed("SAILBOX_INGRESS_URL");
202
203 let mut api_key = env_trimmed("SAIL_API_KEY");
204 if api_key.is_empty() {
205 let target = crate::credentials::resolve_target_api_url(&api_url, &mode);
206 if crate::credentials::stored_key_matches_target(&settings, &target) {
207 if let Some(stored) = crate::credentials::auth_key_best_effort() {
208 api_key = stored;
209 }
210 }
211 }
212 if require_api_key && api_key.is_empty() {
213 return Err(SailError::Config {
214 message: "SAIL_API_KEY must be set".to_string(),
215 });
216 }
217
218 Config::build(
219 Some(&mode),
220 api_key,
221 opt(api_url),
222 opt(sailbox_api_url),
223 opt(imagebuilder_url),
224 opt(sailbox_ingress_url),
225 )
226 }
227}
228
229fn opt(value: String) -> Option<String> {
230 if value.is_empty() {
231 None
232 } else {
233 Some(value)
234 }
235}
236
237fn pick_setting(env_value: Option<String>, stored: Option<&String>) -> String {
243 match env_value {
244 Some(value) => value.trim().to_string(),
245 None => stored
246 .map(|value| value.trim().to_string())
247 .unwrap_or_default(),
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn known_modes_resolve() {
257 assert_eq!(mode_defaults("").unwrap().api_url, PROD.api_url);
258 assert_eq!(mode_defaults(" PROD ").unwrap().api_url, PROD.api_url);
259 assert_eq!(mode_defaults("dev").unwrap().api_url, DEV.api_url);
260 assert_eq!(mode_defaults("local").unwrap().api_url, LOCAL.api_url);
261 }
262
263 #[test]
264 fn unknown_mode_is_config_error() {
265 match mode_defaults("production") {
266 Err(SailError::Config { message }) => {
267 assert!(message.contains("not recognized"), "message={message}");
268 }
269 other => panic!("expected Config error, got {other:?}"),
270 }
271 }
272
273 #[test]
274 fn pick_setting_present_env_masks_stored() {
275 let stored = "https://stored.example".to_string();
276 assert_eq!(
279 pick_setting(Some("https://env.example".to_string()), Some(&stored)),
280 "https://env.example"
281 );
282 assert_eq!(pick_setting(Some(String::new()), Some(&stored)), "");
283 assert_eq!(pick_setting(Some(" ".to_string()), Some(&stored)), "");
284 assert_eq!(pick_setting(None, Some(&stored)), "https://stored.example");
286 assert_eq!(pick_setting(None, None), "");
287 }
288
289 #[test]
290 fn override_wins_blank_falls_back_to_mode_default() {
291 let config = Config::resolve(
292 Some("dev"),
293 "k".to_string(),
294 Some("https://override.example".to_string()),
295 None, Some(" ".to_string()), None,
298 )
299 .unwrap();
300 assert_eq!(config.api_url, "https://override.example");
301 assert_eq!(config.sailbox_api_url, DEV.sailbox_api_url);
302 assert_eq!(config.imagebuilder_url, DEV.imagebuilder_url);
303 }
304
305 #[test]
306 fn api_key_is_required_and_trimmed() {
307 assert!(matches!(
308 Config::resolve(None, " ".to_string(), None, None, None, None),
309 Err(SailError::Config { .. })
310 ));
311 let config = Config::resolve(None, " sk_k ".to_string(), None, None, None, None).unwrap();
312 assert_eq!(config.api_key, "sk_k");
313 assert_eq!(config.api_url, PROD.api_url);
315 }
316
317 #[test]
318 fn ingress_defaults_per_mode_and_override_forces_path() {
319 let prod = Config::resolve(None, "k".to_string(), None, None, None, None).unwrap();
321 assert_eq!(prod.sailbox_ingress_url, PROD.sailbox_ingress_url);
322 assert_eq!(prod.sailbox_ingress_mode, SAILBOX_INGRESS_MODE_WILDCARD);
323 let local =
325 Config::resolve(Some("local"), "k".to_string(), None, None, None, None).unwrap();
326 assert_eq!(local.sailbox_ingress_mode, SAILBOX_INGRESS_MODE_PATH);
327 let overridden = Config::resolve(
329 None,
330 "k".to_string(),
331 None,
332 None,
333 None,
334 Some("https://ingress.example".to_string()),
335 )
336 .unwrap();
337 assert_eq!(overridden.sailbox_ingress_url, "https://ingress.example");
338 assert_eq!(overridden.sailbox_ingress_mode, SAILBOX_INGRESS_MODE_PATH);
339 }
340}