1use std::path::Path;
8
9use serde::Serialize;
10use url::Url;
11
12use crate::error::XurlError;
13
14#[derive(Debug, Clone)]
21pub struct Config {
22 pub client_id: String,
24 pub client_secret: String,
26 pub redirect_uri: String,
28 pub auth_url: String,
30 pub token_url: String,
32 pub api_base_url: String,
34 pub info_url: String,
36 pub app_name: String,
38 pub(crate) redirect_uri_source: ResolveSource,
45 pub(crate) redirect_uri_from_env: bool,
50 pub http_timeout_secs: u64,
56}
57
58pub const DEFAULT_REDIRECT_URI: &str = "http://localhost:8080/callback";
61
62impl Config {
63 #[must_use]
73 pub fn new() -> Self {
74 let client_id = env_or_default("CLIENT_ID", "");
75 let client_secret = env_or_default("CLIENT_SECRET", "");
76 let redirect_uri_env = std::env::var("REDIRECT_URI").ok();
77 let redirect_uri_from_env = redirect_uri_env.is_some();
78 let redirect_uri = redirect_uri_env.unwrap_or_else(|| DEFAULT_REDIRECT_URI.to_string());
79 let redirect_uri_source = if redirect_uri_from_env {
80 ResolveSource::EnvVar
81 } else {
82 ResolveSource::BuiltInDefault
83 };
84 let auth_url = env_or_default("AUTH_URL", "https://x.com/i/oauth2/authorize");
85 let token_url = env_or_default("TOKEN_URL", "https://api.x.com/2/oauth2/token");
86 let api_base_url = env_or_default("API_BASE_URL", "https://api.x.com");
87 let info_url = env_or_default("INFO_URL", &format!("{api_base_url}/2/users/me"));
88
89 Self {
90 client_id,
91 client_secret,
92 redirect_uri,
93 auth_url,
94 token_url,
95 api_base_url,
96 info_url,
97 app_name: String::new(),
98 redirect_uri_source,
99 redirect_uri_from_env,
100 http_timeout_secs: crate::api::DEFAULT_TIMEOUT_SECS,
101 }
102 }
103}
104
105impl Default for Config {
106 fn default() -> Self {
107 Self::new()
108 }
109}
110
111impl Config {
112 #[must_use]
118 pub fn default_store_path() -> std::path::PathBuf {
119 dirs::home_dir()
120 .unwrap_or_else(|| std::path::PathBuf::from("."))
121 .join(".xurl")
122 }
123
124 pub fn validate_redirect_uri(uri: &str) -> crate::error::Result<Url> {
139 let parsed = Url::parse(uri)
140 .map_err(|e| XurlError::validation(format!("invalid redirect URI: {e}")))?;
141
142 let scheme = parsed.scheme();
143 if scheme == "https" {
144 return Ok(parsed);
145 }
146
147 if scheme == "http"
148 && let Some(host) = parsed.host_str()
149 && matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]")
150 {
151 return Ok(parsed);
152 }
153
154 Err(XurlError::validation(format!(
155 "redirect URI must be https, or http on loopback (localhost / 127.0.0.1 / [::1]); got: {uri}"
156 )))
157 }
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
170#[serde(rename_all = "kebab-case")]
171pub(crate) enum ResolveSource {
172 EnvVar,
174 AppConfig,
176 BuiltInDefault,
178}
179
180#[allow(dead_code)] impl ResolveSource {
182 pub(crate) fn as_text_label(&self) -> &'static str {
186 match self {
187 Self::EnvVar => "REDIRECT_URI environment variable",
188 Self::AppConfig => "app config",
189 Self::BuiltInDefault => "built-in default",
190 }
191 }
192
193 pub(crate) fn is_env_var(&self) -> bool {
195 matches!(self, Self::EnvVar)
196 }
197}
198
199#[allow(dead_code)] pub(crate) struct ResolvedRedirectUri {
202 pub uri: String,
204 pub source: ResolveSource,
206}
207
208pub(crate) fn resolve_redirect_uri_from(
224 env_value: Option<String>,
225 stored: Option<&str>,
226) -> ResolvedRedirectUri {
227 if let Some(v) = env_value {
228 if Config::validate_redirect_uri(&v).is_ok() {
229 return ResolvedRedirectUri {
230 uri: v,
231 source: ResolveSource::EnvVar,
232 };
233 }
234 crate::output::warn_stderr(
235 "REDIRECT_URI env value rejected by validation; falling through to next precedence level",
236 );
237 }
238
239 if let Some(s) = stored
240 && !s.is_empty()
241 {
242 return ResolvedRedirectUri {
243 uri: s.to_string(),
244 source: ResolveSource::AppConfig,
245 };
246 }
247
248 ResolvedRedirectUri {
249 uri: DEFAULT_REDIRECT_URI.to_string(),
250 source: ResolveSource::BuiltInDefault,
251 }
252}
253
254#[must_use]
261#[allow(private_interfaces)] pub fn resolve_redirect_uri(store_path: &Path, app_name: &str) -> ResolvedRedirectUri {
263 let env = std::env::var("REDIRECT_URI").ok();
264 let store = crate::store::TokenStore::new_with_path(store_path.to_str().unwrap_or("."));
265 let stored = store.get_app_redirect_uri(app_name).map(str::to_string);
266 resolve_redirect_uri_from(env, stored.as_deref())
267}
268
269fn env_or_default(key: &str, default: &str) -> String {
271 std::env::var(key).unwrap_or_else(|_| default.to_string())
272}
273
274#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn resolve_redirect_uri_from_env_wins_over_stored() {
285 let resolved = resolve_redirect_uri_from(
286 Some("https://example.com/cb".to_string()),
287 Some("http://stored.example.com/cb"),
288 );
289 assert_eq!(resolved.source, ResolveSource::EnvVar);
290 assert_eq!(resolved.uri, "https://example.com/cb");
291 }
292
293 #[test]
294 fn resolve_redirect_uri_from_stored_wins_over_default() {
295 let resolved = resolve_redirect_uri_from(None, Some("http://localhost:9090/cb"));
296 assert_eq!(resolved.source, ResolveSource::AppConfig);
297 assert_eq!(resolved.uri, "http://localhost:9090/cb");
298 }
299
300 #[test]
301 fn resolve_redirect_uri_from_default_fallback() {
302 let resolved = resolve_redirect_uri_from(None, None);
303 assert_eq!(resolved.source, ResolveSource::BuiltInDefault);
304 assert_eq!(resolved.uri, DEFAULT_REDIRECT_URI);
305 }
306
307 #[test]
308 fn resolve_redirect_uri_from_empty_stored_falls_through_to_default() {
309 let resolved = resolve_redirect_uri_from(None, Some(""));
310 assert_eq!(resolved.source, ResolveSource::BuiltInDefault);
311 assert_eq!(resolved.uri, DEFAULT_REDIRECT_URI);
312 }
313
314 #[test]
315 fn resolve_redirect_uri_from_invalid_env_falls_through_to_stored() {
316 let resolved = resolve_redirect_uri_from(
317 Some("not-a-url".to_string()),
318 Some("http://localhost:9090/cb"),
319 );
320 assert_eq!(resolved.source, ResolveSource::AppConfig);
321 assert_eq!(resolved.uri, "http://localhost:9090/cb");
322 }
323
324 #[test]
328 fn resolve_source_as_text_label_exhaustive() {
329 for variant in [
330 ResolveSource::EnvVar,
331 ResolveSource::AppConfig,
332 ResolveSource::BuiltInDefault,
333 ] {
334 let label = variant.as_text_label();
335 match variant {
336 ResolveSource::EnvVar => {
337 assert_eq!(label, "REDIRECT_URI environment variable");
338 }
339 ResolveSource::AppConfig => {
340 assert_eq!(label, "app config");
341 }
342 ResolveSource::BuiltInDefault => {
343 assert_eq!(label, "built-in default");
344 }
345 }
346 }
347 }
348
349 #[test]
350 fn resolve_source_serialize_kebab_case_exhaustive() {
351 for variant in [
352 ResolveSource::EnvVar,
353 ResolveSource::AppConfig,
354 ResolveSource::BuiltInDefault,
355 ] {
356 let json = serde_json::to_string(&variant).expect("serialize ResolveSource");
357 match variant {
358 ResolveSource::EnvVar => assert_eq!(json, "\"env-var\""),
359 ResolveSource::AppConfig => assert_eq!(json, "\"app-config\""),
360 ResolveSource::BuiltInDefault => assert_eq!(json, "\"built-in-default\""),
361 }
362 }
363 }
364
365 #[test]
366 fn resolve_source_is_env_var_predicate() {
367 assert!(ResolveSource::EnvVar.is_env_var());
368 assert!(!ResolveSource::AppConfig.is_env_var());
369 assert!(!ResolveSource::BuiltInDefault.is_env_var());
370 }
371
372 use serial_test::serial;
378 use std::fs;
379 use tempfile::TempDir;
380
381 fn write_store_with_redirect_uri(path: &std::path::Path, app: &str, uri: &str) {
382 let yaml = format!(
383 "apps:\n {app}:\n client_id: ''\n client_secret: ''\n redirect_uri: '{uri}'\n oauth2_tokens: {{}}\ndefault_app: {app}\n"
384 );
385 fs::write(path, yaml).expect("write tempdir store");
386 }
387
388 fn write_empty_store(path: &std::path::Path, app: &str) {
389 let yaml = format!(
390 "apps:\n {app}:\n client_id: ''\n client_secret: ''\n oauth2_tokens: {{}}\ndefault_app: {app}\n"
391 );
392 fs::write(path, yaml).expect("write tempdir store");
393 }
394
395 #[test]
396 #[serial]
397 fn resolve_redirect_uri_env_wins() {
398 let tmp = TempDir::new().expect("create tempdir for redirect_uri test");
399 let store_path = tmp.path().join(".xurl");
400 write_store_with_redirect_uri(&store_path, "app1", "http://localhost:7777/cb");
401
402 unsafe {
403 std::env::set_var("REDIRECT_URI", "https://example.com/cb");
404 }
405 let resolved = resolve_redirect_uri(&store_path, "app1");
406 unsafe {
407 std::env::remove_var("REDIRECT_URI");
408 }
409
410 assert_eq!(resolved.source, ResolveSource::EnvVar);
411 assert_eq!(resolved.uri, "https://example.com/cb");
412 }
413
414 #[test]
415 #[serial]
416 fn resolve_redirect_uri_stored_when_no_env() {
417 let tmp = TempDir::new().expect("create tempdir for redirect_uri test");
418 let store_path = tmp.path().join(".xurl");
419 write_store_with_redirect_uri(&store_path, "app1", "http://localhost:9090/cb");
420
421 unsafe {
422 std::env::remove_var("REDIRECT_URI");
423 }
424 let resolved = resolve_redirect_uri(&store_path, "app1");
425
426 assert_eq!(resolved.source, ResolveSource::AppConfig);
427 assert_eq!(resolved.uri, "http://localhost:9090/cb");
428 }
429
430 #[test]
431 #[serial]
432 fn resolve_redirect_uri_default_fallback() {
433 let tmp = TempDir::new().expect("create tempdir for redirect_uri test");
434 let store_path = tmp.path().join(".xurl");
435 write_empty_store(&store_path, "app1");
436
437 unsafe {
438 std::env::remove_var("REDIRECT_URI");
439 }
440 let resolved = resolve_redirect_uri(&store_path, "app1");
441
442 assert_eq!(resolved.source, ResolveSource::BuiltInDefault);
443 assert_eq!(resolved.uri, DEFAULT_REDIRECT_URI);
444 }
445}