1use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5use thiserror::Error;
6
7use crate::service::ServiceDomain;
8
9#[derive(Debug, Error)]
11pub enum ConfigError {
12 #[error("Configuration file not found: {0}")]
13 NotFound(PathBuf),
14 #[error("Failed to read configuration: {0}")]
15 ReadError(#[from] std::io::Error),
16 #[error("Failed to parse configuration: {0}")]
17 ParseError(#[from] toml::de::Error),
18 #[error("Failed to serialize configuration: {0}")]
19 SerializeError(#[from] toml::ser::Error),
20 #[error("Invalid configuration: {0}")]
21 Invalid(String),
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Config {
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub service: Option<ServiceConfig>,
30 #[serde(default)]
32 pub services: ServicesConfig,
33 #[serde(default)]
35 pub project: ProjectConfig,
36 #[serde(default)]
38 pub sync: SyncConfig,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, Default)]
43pub struct ServicesConfig {
44 #[serde(default, skip_serializing_if = "Vec::is_empty")]
46 pub search: Vec<SearchServiceConfig>,
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
49 pub foundry: Vec<FoundryServiceConfig>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct ServiceConfig {
55 pub name: String,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub subscription: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub resource_group: Option<String>,
63 #[serde(default = "default_api_version")]
65 pub api_version: String,
66 #[serde(default = "default_preview_api_version")]
68 pub preview_api_version: String,
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub struct SearchServiceConfig {
74 pub name: String,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub subscription: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub resource_group: Option<String>,
82 #[serde(default = "default_api_version")]
84 pub api_version: String,
85 #[serde(default = "default_preview_api_version")]
87 pub preview_api_version: String,
88}
89
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct FoundryServiceConfig {
93 pub name: String,
95 pub project: String,
97 #[serde(default = "default_foundry_api_version")]
99 pub api_version: String,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub endpoint: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub subscription: Option<String>,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub resource_group: Option<String>,
109}
110
111fn default_api_version() -> String {
112 "2024-07-01".to_string()
113}
114
115fn default_preview_api_version() -> String {
116 "2025-11-01-preview".to_string()
117}
118
119fn default_foundry_api_version() -> String {
120 "2025-05-15-preview".to_string()
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, Default)]
125pub struct ProjectConfig {
126 #[serde(skip_serializing_if = "Option::is_none")]
128 pub name: Option<String>,
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub description: Option<String>,
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub path: Option<String>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct SyncConfig {
140 #[serde(default = "default_true")]
142 pub include_preview: bool,
143 #[serde(default)]
145 pub resources: Vec<String>,
146}
147
148fn default_true() -> bool {
149 true
150}
151
152impl Default for SyncConfig {
153 fn default() -> Self {
154 Self {
155 include_preview: true,
156 resources: Vec::new(),
157 }
158 }
159}
160
161impl Config {
162 pub const FILENAME: &'static str = "hoist.toml";
164
165 pub fn load(dir: &Path) -> Result<Self, ConfigError> {
167 let path = dir.join(Self::FILENAME);
168 Self::load_from(&path)
169 }
170
171 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
173 if !path.exists() {
174 return Err(ConfigError::NotFound(path.to_path_buf()));
175 }
176 let content = std::fs::read_to_string(path)?;
177 let config: Config = toml::from_str(&content)?;
178 config.validate()?;
179 Ok(config)
180 }
181
182 pub fn save(&self, dir: &Path) -> Result<(), ConfigError> {
184 let path = dir.join(Self::FILENAME);
185 self.save_to(&path)
186 }
187
188 pub fn save_to(&self, path: &Path) -> Result<(), ConfigError> {
190 let content = toml::to_string_pretty(self)?;
191 std::fs::write(path, content)?;
192 Ok(())
193 }
194
195 pub fn validate(&self) -> Result<(), ConfigError> {
197 let has_legacy = self.service.as_ref().is_some_and(|s| !s.name.is_empty());
199 let has_search = !self.services.search.is_empty();
200 let has_foundry = !self.services.foundry.is_empty();
201
202 if !has_legacy && !has_search && !has_foundry {
203 return Err(ConfigError::Invalid(
204 "At least one service must be configured (service, services.search, or services.foundry)".to_string(),
205 ));
206 }
207
208 if let Some(ref svc) = self.service {
210 if svc.name.is_empty() && !has_search && !has_foundry {
211 return Err(ConfigError::Invalid("service.name is required".to_string()));
212 }
213 }
214
215 for (i, svc) in self.services.search.iter().enumerate() {
217 if svc.name.is_empty() {
218 return Err(ConfigError::Invalid(format!(
219 "services.search[{}].name is required",
220 i
221 )));
222 }
223 }
224
225 for (i, svc) in self.services.foundry.iter().enumerate() {
227 if svc.name.is_empty() {
228 return Err(ConfigError::Invalid(format!(
229 "services.foundry[{}].name is required",
230 i
231 )));
232 }
233 if svc.project.is_empty() {
234 return Err(ConfigError::Invalid(format!(
235 "services.foundry[{}].project is required",
236 i
237 )));
238 }
239 }
240
241 Ok(())
242 }
243
244 pub fn search_services(&self) -> Vec<SearchServiceConfig> {
246 let mut result = self.services.search.clone();
247
248 if let Some(ref legacy) = self.service {
250 if !legacy.name.is_empty() {
251 let already_present = result.iter().any(|s| s.name == legacy.name);
253 if !already_present {
254 result.insert(
255 0,
256 SearchServiceConfig {
257 name: legacy.name.clone(),
258 subscription: legacy.subscription.clone(),
259 resource_group: legacy.resource_group.clone(),
260 api_version: legacy.api_version.clone(),
261 preview_api_version: legacy.preview_api_version.clone(),
262 },
263 );
264 }
265 }
266 }
267
268 result
269 }
270
271 pub fn foundry_services(&self) -> &[FoundryServiceConfig] {
273 &self.services.foundry
274 }
275
276 pub fn has_foundry(&self) -> bool {
278 !self.services.foundry.is_empty()
279 }
280
281 pub fn primary_search_service(&self) -> Option<SearchServiceConfig> {
283 self.search_services().into_iter().next()
284 }
285
286 pub fn service_url(&self) -> String {
288 if let Some(ref svc) = self.service {
289 return format!("https://{}.search.windows.net", svc.name);
290 }
291 if let Some(svc) = self.services.search.first() {
292 return format!("https://{}.search.windows.net", svc.name);
293 }
294 String::new()
295 }
296
297 pub fn resource_dir(&self, project_root: &Path) -> PathBuf {
299 match &self.project.path {
300 Some(path) => project_root.join(path),
301 None => project_root.to_path_buf(),
302 }
303 }
304
305 pub fn search_service_dir(&self, project_root: &Path, service_name: &str) -> PathBuf {
308 self.resource_dir(project_root)
309 .join(ServiceDomain::Search.directory_prefix())
310 .join(service_name)
311 }
312
313 pub fn foundry_service_dir(
316 &self,
317 project_root: &Path,
318 service_name: &str,
319 project: &str,
320 ) -> PathBuf {
321 self.resource_dir(project_root)
322 .join(ServiceDomain::Foundry.directory_prefix())
323 .join(service_name)
324 .join(project)
325 }
326
327 pub fn api_version_for(&self, preview: bool) -> &str {
329 if let Some(ref svc) = self.service {
330 if preview {
331 return &svc.preview_api_version;
332 } else {
333 return &svc.api_version;
334 }
335 }
336 if let Some(svc) = self.services.search.first() {
337 if preview {
338 return &svc.preview_api_version;
339 } else {
340 return &svc.api_version;
341 }
342 }
343 if preview {
344 "2025-11-01-preview"
345 } else {
346 "2024-07-01"
347 }
348 }
349}
350
351impl SearchServiceConfig {
352 pub fn service_url(&self) -> String {
354 format!("https://{}.search.windows.net", self.name)
355 }
356}
357
358impl FoundryServiceConfig {
359 pub fn service_url(&self) -> String {
365 if let Some(ref ep) = self.endpoint {
366 return ep.trim_end_matches('/').to_string();
367 }
368 format!("https://{}.services.ai.azure.com", self.name)
369 }
370}
371
372pub fn make_legacy_config(name: &str) -> Config {
374 Config {
375 service: Some(ServiceConfig {
376 name: name.to_string(),
377 subscription: None,
378 resource_group: None,
379 api_version: default_api_version(),
380 preview_api_version: default_preview_api_version(),
381 }),
382 services: ServicesConfig::default(),
383 project: ProjectConfig::default(),
384 sync: SyncConfig::default(),
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use std::fs;
392
393 fn make_config(name: &str) -> Config {
394 make_legacy_config(name)
395 }
396
397 #[test]
398 fn test_validate_empty_name() {
399 let config = make_config("");
400 assert!(config.validate().is_err());
401 }
402
403 #[test]
404 fn test_validate_valid_name() {
405 let config = make_config("my-search");
406 assert!(config.validate().is_ok());
407 }
408
409 #[test]
410 fn test_service_url() {
411 let config = make_config("my-search");
412 assert_eq!(config.service_url(), "https://my-search.search.windows.net");
413 }
414
415 #[test]
416 fn test_api_version_for_stable() {
417 let config = make_config("my-search");
418 assert_eq!(config.api_version_for(false), "2024-07-01");
419 }
420
421 #[test]
422 fn test_api_version_for_preview() {
423 let config = make_config("my-search");
424 assert_eq!(config.api_version_for(true), "2025-11-01-preview");
425 }
426
427 #[test]
428 fn test_resource_dir_without_path() {
429 let config = make_config("my-search");
430 let root = Path::new("/projects/search");
431 assert_eq!(config.resource_dir(root), PathBuf::from("/projects/search"));
432 }
433
434 #[test]
435 fn test_resource_dir_with_path() {
436 let mut config = make_config("my-search");
437 config.project.path = Some("search".to_string());
438 let root = Path::new("/projects/myapp");
439 assert_eq!(
440 config.resource_dir(root),
441 PathBuf::from("/projects/myapp/search")
442 );
443 }
444
445 #[test]
446 fn test_save_and_load_roundtrip() {
447 let dir = tempfile::tempdir().unwrap();
448 let config = make_config("test-service");
449 config.save(dir.path()).unwrap();
450
451 let loaded = Config::load(dir.path()).unwrap();
452 assert_eq!(loaded.service.as_ref().unwrap().name, "test-service");
453 assert_eq!(loaded.service.as_ref().unwrap().api_version, "2024-07-01");
454 }
455
456 #[test]
457 fn test_load_missing_file() {
458 let dir = tempfile::tempdir().unwrap();
459 let result = Config::load(dir.path());
460 assert!(result.is_err());
461 }
462
463 #[test]
464 fn test_load_from_toml_string_legacy() {
465 let toml = r#"
466[service]
467name = "my-svc"
468
469[sync]
470include_preview = false
471"#;
472 let config: Config = toml::from_str(toml).unwrap();
473 assert_eq!(config.service.as_ref().unwrap().name, "my-svc");
474 assert!(!config.sync.include_preview);
475 assert_eq!(config.service.as_ref().unwrap().api_version, "2024-07-01");
476 }
477
478 #[test]
479 fn test_sync_config_defaults() {
480 let sync = SyncConfig::default();
481 assert!(sync.include_preview);
482 assert!(sync.resources.is_empty());
483 }
484
485 #[test]
486 fn test_find_project_root_found() {
487 let dir = tempfile::tempdir().unwrap();
488 let sub = dir.path().join("a/b/c");
489 fs::create_dir_all(&sub).unwrap();
490 fs::write(
491 dir.path().join(Config::FILENAME),
492 "[service]\nname = \"x\"\n",
493 )
494 .unwrap();
495
496 let found = find_project_root(&sub);
497 assert_eq!(found, Some(dir.path().to_path_buf()));
498 }
499
500 #[test]
501 fn test_find_project_root_not_found() {
502 let dir = tempfile::tempdir().unwrap();
503 let found = find_project_root(dir.path());
504 assert!(found.is_none());
505 }
506
507 #[test]
508 fn test_path_serialized_in_toml() {
509 let mut config = make_config("svc");
510 config.project.path = Some("search".to_string());
511 let toml_str = toml::to_string_pretty(&config).unwrap();
512 assert!(toml_str.contains("path = \"search\""));
513 }
514
515 #[test]
516 fn test_path_not_serialized_when_none() {
517 let config = make_config("svc");
518 let toml_str = toml::to_string_pretty(&config).unwrap();
519 assert!(!toml_str.contains("path"));
520 }
521
522 #[test]
525 fn test_new_format_search_only() {
526 let toml = r#"
527[[services.search]]
528name = "my-search-service"
529api_version = "2024-07-01"
530
531[project]
532name = "My Project"
533"#;
534 let config: Config = toml::from_str(toml).unwrap();
535 assert!(config.validate().is_ok());
536 assert!(config.service.is_none());
537 assert_eq!(config.services.search.len(), 1);
538 assert_eq!(config.services.search[0].name, "my-search-service");
539 }
540
541 #[test]
542 fn test_new_format_foundry_only() {
543 let toml = r#"
544[[services.foundry]]
545name = "my-ai-service"
546project = "my-project"
547
548[project]
549name = "My Project"
550"#;
551 let config: Config = toml::from_str(toml).unwrap();
552 assert!(config.validate().is_ok());
553 assert!(config.has_foundry());
554 assert_eq!(config.services.foundry[0].name, "my-ai-service");
555 assert_eq!(config.services.foundry[0].project, "my-project");
556 assert_eq!(config.services.foundry[0].api_version, "2025-05-15-preview");
557 }
558
559 #[test]
560 fn test_new_format_both_services() {
561 let toml = r#"
562[[services.search]]
563name = "my-search"
564
565[[services.foundry]]
566name = "my-ai"
567project = "proj-1"
568
569[project]
570name = "RAG System"
571"#;
572 let config: Config = toml::from_str(toml).unwrap();
573 assert!(config.validate().is_ok());
574 assert_eq!(config.search_services().len(), 1);
575 assert!(config.has_foundry());
576 }
577
578 #[test]
579 fn test_legacy_auto_migration() {
580 let toml = r#"
581[service]
582name = "legacy-svc"
583"#;
584 let config: Config = toml::from_str(toml).unwrap();
585 let search = config.search_services();
586 assert_eq!(search.len(), 1);
587 assert_eq!(search[0].name, "legacy-svc");
588 }
589
590 #[test]
591 fn test_legacy_not_duplicated_in_search_services() {
592 let toml = r#"
593[service]
594name = "my-svc"
595
596[[services.search]]
597name = "my-svc"
598"#;
599 let config: Config = toml::from_str(toml).unwrap();
600 let search = config.search_services();
601 assert_eq!(search.len(), 1);
603 }
604
605 #[test]
606 fn test_foundry_validation_requires_project() {
607 let toml = r#"
608[[services.foundry]]
609name = "my-ai"
610project = ""
611"#;
612 let config: Config = toml::from_str(toml).unwrap();
613 assert!(config.validate().is_err());
614 }
615
616 #[test]
617 fn test_foundry_service_url() {
618 let svc = FoundryServiceConfig {
619 name: "my-ai-service".to_string(),
620 project: "proj-1".to_string(),
621 api_version: "2025-05-15-preview".to_string(),
622 endpoint: None,
623 subscription: None,
624 resource_group: None,
625 };
626 assert_eq!(
627 svc.service_url(),
628 "https://my-ai-service.services.ai.azure.com"
629 );
630 }
631
632 #[test]
633 fn test_foundry_service_url_with_endpoint() {
634 let svc = FoundryServiceConfig {
635 name: "my-ai-service".to_string(),
636 project: "proj-1".to_string(),
637 api_version: "2025-05-15-preview".to_string(),
638 endpoint: Some("https://custom-subdomain.services.ai.azure.com".to_string()),
639 subscription: None,
640 resource_group: None,
641 };
642 assert_eq!(
643 svc.service_url(),
644 "https://custom-subdomain.services.ai.azure.com"
645 );
646 }
647
648 #[test]
649 fn test_foundry_service_url_strips_trailing_slash() {
650 let svc = FoundryServiceConfig {
651 name: "my-ai-service".to_string(),
652 project: "proj-1".to_string(),
653 api_version: "2025-05-15-preview".to_string(),
654 endpoint: Some("https://custom-subdomain.services.ai.azure.com/".to_string()),
655 subscription: None,
656 resource_group: None,
657 };
658 assert_eq!(
659 svc.service_url(),
660 "https://custom-subdomain.services.ai.azure.com"
661 );
662 }
663
664 #[test]
665 fn test_search_service_url() {
666 let svc = SearchServiceConfig {
667 name: "my-search".to_string(),
668 subscription: None,
669 resource_group: None,
670 api_version: "2024-07-01".to_string(),
671 preview_api_version: "2025-11-01-preview".to_string(),
672 };
673 assert_eq!(svc.service_url(), "https://my-search.search.windows.net");
674 }
675
676 #[test]
677 fn test_primary_search_service() {
678 let config = make_config("primary-svc");
679 let primary = config.primary_search_service().unwrap();
680 assert_eq!(primary.name, "primary-svc");
681 }
682
683 #[test]
684 fn test_has_foundry_false_when_empty() {
685 let config = make_config("svc");
686 assert!(!config.has_foundry());
687 }
688
689 #[test]
690 fn test_no_services_validation_fails() {
691 let config = Config {
692 service: None,
693 services: ServicesConfig::default(),
694 project: ProjectConfig::default(),
695 sync: SyncConfig::default(),
696 };
697 assert!(config.validate().is_err());
698 }
699
700 #[test]
701 fn test_search_service_dir_without_path() {
702 let config = make_config("my-search");
703 let root = Path::new("/projects/search");
704 assert_eq!(
705 config.search_service_dir(root, "my-search"),
706 PathBuf::from("/projects/search/search-resources/my-search")
707 );
708 }
709
710 #[test]
711 fn test_search_service_dir_with_path() {
712 let mut config = make_config("my-search");
713 config.project.path = Some("resources".to_string());
714 let root = Path::new("/projects/myapp");
715 assert_eq!(
716 config.search_service_dir(root, "my-search"),
717 PathBuf::from("/projects/myapp/resources/search-resources/my-search")
718 );
719 }
720
721 #[test]
722 fn test_foundry_service_dir_without_path() {
723 let config = make_config("svc");
724 let root = Path::new("/projects/ai");
725 assert_eq!(
726 config.foundry_service_dir(root, "my-ai-service", "my-project"),
727 PathBuf::from("/projects/ai/foundry-resources/my-ai-service/my-project")
728 );
729 }
730
731 #[test]
732 fn test_foundry_service_dir_with_path() {
733 let mut config = make_config("svc");
734 config.project.path = Some("resources".to_string());
735 let root = Path::new("/projects/ai");
736 assert_eq!(
737 config.foundry_service_dir(root, "my-ai-service", "my-project"),
738 PathBuf::from("/projects/ai/resources/foundry-resources/my-ai-service/my-project")
739 );
740 }
741
742 #[test]
743 fn test_foundry_default_api_version() {
744 let toml = r#"
745[[services.foundry]]
746name = "my-ai"
747project = "proj-1"
748"#;
749 let config: Config = toml::from_str(toml).unwrap();
750 assert_eq!(config.services.foundry[0].api_version, "2025-05-15-preview");
751 }
752
753 #[test]
754 fn test_save_load_roundtrip_new_format() {
755 let dir = tempfile::tempdir().unwrap();
756 let config = Config {
757 service: None,
758 services: ServicesConfig {
759 search: vec![SearchServiceConfig {
760 name: "test-search".to_string(),
761 subscription: None,
762 resource_group: None,
763 api_version: "2024-07-01".to_string(),
764 preview_api_version: "2025-11-01-preview".to_string(),
765 }],
766 foundry: vec![FoundryServiceConfig {
767 name: "test-ai".to_string(),
768 project: "test-proj".to_string(),
769 api_version: "2025-05-15-preview".to_string(),
770 endpoint: None,
771 subscription: None,
772 resource_group: None,
773 }],
774 },
775 project: ProjectConfig {
776 name: Some("Test".to_string()),
777 description: None,
778 path: None,
779 },
780 sync: SyncConfig::default(),
781 };
782 config.save(dir.path()).unwrap();
783
784 let loaded = Config::load(dir.path()).unwrap();
785 assert_eq!(loaded.services.search[0].name, "test-search");
786 assert_eq!(loaded.services.foundry[0].name, "test-ai");
787 assert_eq!(loaded.services.foundry[0].project, "test-proj");
788 }
789}
790
791pub fn find_project_root(start: &Path) -> Option<PathBuf> {
793 let mut current = start.to_path_buf();
794 loop {
795 if current.join(Config::FILENAME).exists() {
796 return Some(current);
797 }
798 if !current.pop() {
799 return None;
800 }
801 }
802}