1use anyhow::{Context, Result};
9use std::env;
10
11use crate::http::HttpClientConfig;
12use crate::types::{ProfileConfig, ProfilesData};
13
14pub const DEFAULT_CALLBACK_PORT: u16 = 8080;
16
17#[derive(Debug, Clone)]
19pub struct Config {
20 pub client_id: String,
22 pub client_secret: String,
24 pub base_url: String,
26 pub callback_url: String,
28 #[allow(dead_code)]
30 pub da_nickname: Option<String>,
31 pub http_config: HttpClientConfig,
33}
34
35impl Config {
36 pub fn from_env() -> Result<Self> {
43 let _ = dotenvy::dotenv();
45
46 let profile_data = Self::load_profile_data().ok();
48
49 let client_id = env::var("APS_CLIENT_ID")
51 .or_else(|_| {
52 profile_data
53 .as_ref()
54 .and_then(|(_, profile)| profile.client_id.clone())
55 .ok_or(env::VarError::NotPresent)
56 })
57 .context(
58 "APS_CLIENT_ID not set. Set it via:\n - Environment variable: APS_CLIENT_ID\n - Profile: raps config profile create <name> && raps config set client_id <value>",
59 )?;
60
61 let client_secret = env::var("APS_CLIENT_SECRET")
62 .or_else(|_| {
63 profile_data
64 .as_ref()
65 .and_then(|(_, profile)| profile.client_secret.clone())
66 .ok_or(env::VarError::NotPresent)
67 })
68 .context(
69 "APS_CLIENT_SECRET not set. Set it via:\n - Environment variable: APS_CLIENT_SECRET\n - Profile: raps config profile create <name> && raps config set client_secret <value>",
70 )?;
71
72 let base_url = env::var("APS_BASE_URL")
73 .or_else(|_| {
74 profile_data
75 .as_ref()
76 .and_then(|(_, profile)| profile.base_url.clone())
77 .ok_or(env::VarError::NotPresent)
78 })
79 .unwrap_or_else(|_| "https://developer.api.autodesk.com".to_string());
80
81 let callback_url = env::var("APS_CALLBACK_URL")
82 .or_else(|_| {
83 profile_data
84 .as_ref()
85 .and_then(|(_, profile)| profile.callback_url.clone())
86 .ok_or(env::VarError::NotPresent)
87 })
88 .unwrap_or_else(|_| format!("http://localhost:{}/callback", DEFAULT_CALLBACK_PORT));
89
90 let da_nickname = env::var("APS_DA_NICKNAME").ok().or_else(|| {
91 profile_data
92 .as_ref()
93 .and_then(|(_, profile)| profile.da_nickname.clone())
94 });
95
96 Ok(Self {
97 client_id,
98 client_secret,
99 base_url,
100 callback_url,
101 da_nickname,
102 http_config: HttpClientConfig::default(),
103 })
104 }
105
106 pub fn from_env_lenient() -> Result<Self> {
109 let _ = dotenvy::dotenv();
110
111 let profile_data = Self::load_profile_data().ok();
112
113 let client_id = env::var("APS_CLIENT_ID")
114 .or_else(|_| {
115 profile_data
116 .as_ref()
117 .and_then(|(_, profile)| profile.client_id.clone())
118 .ok_or(env::VarError::NotPresent)
119 })
120 .unwrap_or_default();
121
122 let client_secret = env::var("APS_CLIENT_SECRET")
123 .or_else(|_| {
124 profile_data
125 .as_ref()
126 .and_then(|(_, profile)| profile.client_secret.clone())
127 .ok_or(env::VarError::NotPresent)
128 })
129 .unwrap_or_default();
130
131 let base_url = env::var("APS_BASE_URL")
132 .or_else(|_| {
133 profile_data
134 .as_ref()
135 .and_then(|(_, profile)| profile.base_url.clone())
136 .ok_or(env::VarError::NotPresent)
137 })
138 .unwrap_or_else(|_| "https://developer.api.autodesk.com".to_string());
139
140 let callback_url = env::var("APS_CALLBACK_URL")
141 .or_else(|_| {
142 profile_data
143 .as_ref()
144 .and_then(|(_, profile)| profile.callback_url.clone())
145 .ok_or(env::VarError::NotPresent)
146 })
147 .unwrap_or_else(|_| format!("http://localhost:{}/callback", DEFAULT_CALLBACK_PORT));
148
149 let da_nickname = env::var("APS_DA_NICKNAME").ok().or_else(|| {
150 profile_data
151 .as_ref()
152 .and_then(|(_, profile)| profile.da_nickname.clone())
153 });
154
155 Ok(Self {
156 client_id,
157 client_secret,
158 base_url,
159 callback_url,
160 da_nickname,
161 http_config: HttpClientConfig::default(),
162 })
163 }
164
165 fn load_profile_data() -> Result<(String, ProfileConfig)> {
167 let data = load_profiles()?;
168 let profile_name = data
169 .active_profile
170 .ok_or_else(|| anyhow::anyhow!("No active profile"))?;
171
172 let profile = data
173 .profiles
174 .get(&profile_name)
175 .ok_or_else(|| anyhow::anyhow!("Active profile '{}' not found", profile_name))?
176 .clone();
177
178 Ok((profile_name, profile))
179 }
180
181 pub fn require_credentials(&self) -> Result<()> {
187 if self.client_id.is_empty() && self.client_secret.is_empty() {
188 anyhow::bail!(
189 "APS_CLIENT_ID and APS_CLIENT_SECRET are not set.\n\
190 Set them via environment variables or a profile:\n \
191 export APS_CLIENT_ID=<your-client-id>\n \
192 export APS_CLIENT_SECRET=<your-client-secret>\n \
193 Or: raps config profile create <name> && raps config set client_id <value>"
194 );
195 }
196 if self.client_id.is_empty() {
197 anyhow::bail!(
198 "APS_CLIENT_ID is not set.\n\
199 Set it via:\n \
200 export APS_CLIENT_ID=<your-client-id>\n \
201 Or: raps config set client_id <value>"
202 );
203 }
204 if self.client_secret.is_empty() {
205 anyhow::bail!(
206 "APS_CLIENT_SECRET is not set.\n\
207 Set it via:\n \
208 export APS_CLIENT_SECRET=<your-client-secret>\n \
209 Or: raps config set client_secret <value>"
210 );
211 }
212 Ok(())
213 }
214
215 pub fn auth_url(&self) -> String {
217 format!("{}/authentication/v2/token", self.base_url)
218 }
219
220 pub fn authorize_url(&self) -> String {
222 format!("{}/authentication/v2/authorize", self.base_url)
223 }
224
225 pub fn oss_url(&self) -> String {
227 format!("{}/oss/v2", self.base_url)
228 }
229
230 pub fn derivative_url(&self) -> String {
232 format!("{}/modelderivative/v2", self.base_url)
233 }
234
235 pub fn project_url(&self) -> String {
237 format!("{}/project/v1", self.base_url)
238 }
239
240 pub fn data_url(&self) -> String {
242 format!("{}/data/v1", self.base_url)
243 }
244
245 pub fn webhooks_url(&self) -> String {
247 format!("{}/webhooks/v1", self.base_url)
248 }
249
250 pub fn da_url(&self) -> String {
252 format!("{}/da/us-east/v3", self.base_url)
253 }
254
255 pub fn issues_url(&self) -> String {
257 format!("{}/construction/issues/v1", self.base_url)
258 }
259
260 pub fn reality_capture_url(&self) -> String {
262 format!("{}/photo-to-3d/v1", self.base_url)
263 }
264
265 pub fn rfi_url(&self) -> String {
267 format!("{}/construction/rfis/v2", self.base_url)
268 }
269
270 pub fn assets_url(&self) -> String {
272 format!("{}/construction/assets/v1", self.base_url)
273 }
274
275 pub fn submittals_url(&self) -> String {
277 format!("{}/construction/submittals/v1", self.base_url)
278 }
279
280 pub fn checklists_url(&self) -> String {
282 format!("{}/construction/checklists/v1", self.base_url)
283 }
284
285 pub fn aec_graphql_url(&self) -> String {
287 format!("{}/aec/graphql", self.base_url)
288 }
289}
290
291pub fn load_profiles() -> Result<ProfilesData> {
293 let proj_dirs = directories::ProjectDirs::from("com", "autodesk", "raps")
294 .ok_or_else(|| anyhow::anyhow!("Failed to get project directories"))?;
295
296 let profiles_path = proj_dirs.config_dir().join("profiles.json");
297
298 if !profiles_path.exists() {
299 return Ok(ProfilesData::default());
300 }
301
302 let content =
303 std::fs::read_to_string(&profiles_path).context("Failed to read profiles file")?;
304
305 let data: ProfilesData =
306 serde_json::from_str(&content).context("Failed to parse profiles file")?;
307
308 Ok(data)
309}
310
311#[derive(Debug, Clone, Default)]
313pub struct ContextConfig {
314 pub hub_id: Option<String>,
315 pub project_id: Option<String>,
316 pub account_id: Option<String>,
317}
318
319impl ContextConfig {
320 pub fn load() -> Self {
322 let profile_data = Config::load_profile_data().ok();
323
324 let hub_id = std::env::var("APS_HUB_ID").ok().or_else(|| {
325 profile_data
326 .as_ref()
327 .and_then(|(_, p)| p.context_hub_id.clone())
328 });
329
330 let project_id = std::env::var("APS_PROJECT_ID").ok().or_else(|| {
331 profile_data
332 .as_ref()
333 .and_then(|(_, p)| p.context_project_id.clone())
334 });
335
336 let account_id = std::env::var("APS_ACCOUNT_ID").ok().or_else(|| {
337 profile_data
338 .as_ref()
339 .and_then(|(_, p)| p.context_account_id.clone())
340 });
341
342 Self {
343 hub_id,
344 project_id,
345 account_id,
346 }
347 }
348
349 pub fn resolve_hub_id(&self, explicit: Option<String>) -> Option<String> {
351 explicit.or_else(|| self.hub_id.clone())
352 }
353
354 pub fn resolve_project_id(&self, explicit: Option<String>) -> Option<String> {
356 explicit.or_else(|| self.project_id.clone())
357 }
358
359 pub fn resolve_account_id(&self, explicit: Option<String>) -> Option<String> {
361 explicit.or_else(|| self.account_id.clone())
362 }
363}
364
365pub fn save_profiles(data: &ProfilesData) -> Result<()> {
367 let proj_dirs = directories::ProjectDirs::from("com", "autodesk", "raps")
368 .ok_or_else(|| anyhow::anyhow!("Failed to get project directories"))?;
369
370 let config_dir = proj_dirs.config_dir();
371 crate::security::create_dir_restricted(config_dir)?;
372
373 let profiles_path = config_dir.join("profiles.json");
374 let content = serde_json::to_string_pretty(data)?;
375 std::fs::write(&profiles_path, content)?;
376
377 Ok(())
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 fn create_test_config() -> Config {
385 Config {
386 client_id: "test_client_id".to_string(),
387 client_secret: "test_secret".to_string(),
388 base_url: "https://developer.api.autodesk.com".to_string(),
389 callback_url: "http://localhost:8080/callback".to_string(),
390 da_nickname: None,
391 http_config: HttpClientConfig::default(),
392 }
393 }
394
395 #[test]
396 fn test_auth_url() {
397 let config = create_test_config();
398 let url = config.auth_url();
399 assert_eq!(
400 url,
401 "https://developer.api.autodesk.com/authentication/v2/token"
402 );
403 }
404
405 #[test]
406 fn test_authorize_url() {
407 let config = create_test_config();
408 let url = config.authorize_url();
409 assert_eq!(
410 url,
411 "https://developer.api.autodesk.com/authentication/v2/authorize"
412 );
413 }
414
415 #[test]
416 fn test_oss_url() {
417 let config = create_test_config();
418 let url = config.oss_url();
419 assert_eq!(url, "https://developer.api.autodesk.com/oss/v2");
420 }
421
422 #[test]
423 fn test_derivative_url() {
424 let config = create_test_config();
425 let url = config.derivative_url();
426 assert_eq!(url, "https://developer.api.autodesk.com/modelderivative/v2");
427 }
428
429 #[test]
430 fn test_project_url() {
431 let config = create_test_config();
432 let url = config.project_url();
433 assert_eq!(url, "https://developer.api.autodesk.com/project/v1");
434 }
435
436 #[test]
437 fn test_data_url() {
438 let config = create_test_config();
439 let url = config.data_url();
440 assert_eq!(url, "https://developer.api.autodesk.com/data/v1");
441 }
442
443 #[test]
444 fn test_webhooks_url() {
445 let config = create_test_config();
446 let url = config.webhooks_url();
447 assert_eq!(url, "https://developer.api.autodesk.com/webhooks/v1");
448 }
449
450 #[test]
451 fn test_da_url() {
452 let config = create_test_config();
453 let url = config.da_url();
454 assert_eq!(url, "https://developer.api.autodesk.com/da/us-east/v3");
455 }
456
457 #[test]
458 fn test_issues_url() {
459 let config = create_test_config();
460 let url = config.issues_url();
461 assert_eq!(
462 url,
463 "https://developer.api.autodesk.com/construction/issues/v1"
464 );
465 }
466
467 #[test]
468 fn test_reality_capture_url() {
469 let config = create_test_config();
470 let url = config.reality_capture_url();
471 assert_eq!(url, "https://developer.api.autodesk.com/photo-to-3d/v1");
472 }
473
474 #[test]
475 fn test_custom_base_url() {
476 let config = Config {
477 client_id: "test".to_string(),
478 client_secret: "secret".to_string(),
479 base_url: "https://custom.api.example.com".to_string(),
480 callback_url: "http://localhost:8080/callback".to_string(),
481 da_nickname: None,
482 http_config: HttpClientConfig::default(),
483 };
484 assert!(
485 config
486 .auth_url()
487 .starts_with("https://custom.api.example.com")
488 );
489 assert!(
490 config
491 .oss_url()
492 .starts_with("https://custom.api.example.com")
493 );
494 }
495
496 #[test]
497 fn test_config_with_da_nickname() {
498 let config = Config {
499 client_id: "test".to_string(),
500 client_secret: "secret".to_string(),
501 base_url: "https://developer.api.autodesk.com".to_string(),
502 callback_url: "http://localhost:8080/callback".to_string(),
503 da_nickname: Some("my-nickname".to_string()),
504 http_config: HttpClientConfig::default(),
505 };
506 assert_eq!(config.da_nickname, Some("my-nickname".to_string()));
507 }
508
509 #[test]
510 fn test_all_urls_contain_base_url() {
511 let config = create_test_config();
512 let base = &config.base_url;
513
514 assert!(config.auth_url().starts_with(base));
515 assert!(config.authorize_url().starts_with(base));
516 assert!(config.oss_url().starts_with(base));
517 assert!(config.derivative_url().starts_with(base));
518 assert!(config.project_url().starts_with(base));
519 assert!(config.data_url().starts_with(base));
520 assert!(config.webhooks_url().starts_with(base));
521 assert!(config.da_url().starts_with(base));
522 assert!(config.issues_url().starts_with(base));
523 assert!(config.reality_capture_url().starts_with(base));
524 assert!(config.rfi_url().starts_with(base));
525 assert!(config.assets_url().starts_with(base));
526 assert!(config.submittals_url().starts_with(base));
527 assert!(config.checklists_url().starts_with(base));
528 assert!(config.aec_graphql_url().starts_with(base));
529 }
530
531 #[test]
532 fn test_rfi_url() {
533 let config = create_test_config();
534 let url = config.rfi_url();
535 assert_eq!(
536 url,
537 "https://developer.api.autodesk.com/construction/rfis/v2"
538 );
539 }
540
541 #[test]
542 fn test_assets_url() {
543 let config = create_test_config();
544 let url = config.assets_url();
545 assert_eq!(
546 url,
547 "https://developer.api.autodesk.com/construction/assets/v1"
548 );
549 }
550
551 #[test]
552 fn test_submittals_url() {
553 let config = create_test_config();
554 let url = config.submittals_url();
555 assert_eq!(
556 url,
557 "https://developer.api.autodesk.com/construction/submittals/v1"
558 );
559 }
560
561 #[test]
562 fn test_checklists_url() {
563 let config = create_test_config();
564 let url = config.checklists_url();
565 assert_eq!(
566 url,
567 "https://developer.api.autodesk.com/construction/checklists/v1"
568 );
569 }
570
571 #[test]
572 fn test_default_callback_port() {
573 assert_eq!(DEFAULT_CALLBACK_PORT, 8080);
574 }
575
576 #[test]
577 fn test_default_callback_url_format() {
578 let config = create_test_config();
579 assert!(config.callback_url.contains("localhost"));
580 assert!(config.callback_url.contains("callback"));
581 }
582
583 #[test]
586 fn test_require_credentials_both_set() {
587 let config = create_test_config();
588 assert!(config.require_credentials().is_ok());
589 }
590
591 #[test]
592 fn test_require_credentials_both_empty() {
593 let config = Config {
594 client_id: "".to_string(),
595 client_secret: "".to_string(),
596 base_url: "https://developer.api.autodesk.com".to_string(),
597 callback_url: "http://localhost:8080/callback".to_string(),
598 da_nickname: None,
599 http_config: HttpClientConfig::default(),
600 };
601 let err = config.require_credentials().unwrap_err();
602 let msg = err.to_string();
603 assert!(msg.contains("APS_CLIENT_ID"));
604 assert!(msg.contains("APS_CLIENT_SECRET"));
605 }
606
607 #[test]
608 fn test_require_credentials_missing_client_id() {
609 let config = Config {
610 client_id: "".to_string(),
611 client_secret: "test_secret".to_string(),
612 base_url: "https://developer.api.autodesk.com".to_string(),
613 callback_url: "http://localhost:8080/callback".to_string(),
614 da_nickname: None,
615 http_config: HttpClientConfig::default(),
616 };
617 let err = config.require_credentials().unwrap_err();
618 let msg = err.to_string();
619 assert!(msg.contains("APS_CLIENT_ID"));
620 assert!(!msg.contains("APS_CLIENT_SECRET"));
621 }
622
623 #[test]
624 fn test_require_credentials_missing_client_secret() {
625 let config = Config {
626 client_id: "test_client_id".to_string(),
627 client_secret: "".to_string(),
628 base_url: "https://developer.api.autodesk.com".to_string(),
629 callback_url: "http://localhost:8080/callback".to_string(),
630 da_nickname: None,
631 http_config: HttpClientConfig::default(),
632 };
633 let err = config.require_credentials().unwrap_err();
634 let msg = err.to_string();
635 assert!(!msg.contains("APS_CLIENT_ID"));
636 assert!(msg.contains("APS_CLIENT_SECRET"));
637 }
638
639 #[test]
642 fn test_context_config_default() {
643 let ctx = ContextConfig::default();
644 assert!(ctx.hub_id.is_none());
645 assert!(ctx.project_id.is_none());
646 assert!(ctx.account_id.is_none());
647 }
648
649 #[test]
650 fn test_context_config_resolve_with_explicit() {
651 let ctx = ContextConfig {
652 hub_id: Some("stored-hub".to_string()),
653 project_id: Some("stored-proj".to_string()),
654 account_id: None,
655 };
656 assert_eq!(
658 ctx.resolve_hub_id(Some("explicit-hub".to_string())),
659 Some("explicit-hub".to_string())
660 );
661 assert_eq!(
663 ctx.resolve_project_id(None),
664 Some("stored-proj".to_string())
665 );
666 assert_eq!(ctx.resolve_account_id(None), None);
668 }
669}