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