1use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6use thiserror::Error;
7
8#[derive(Debug, Error)]
10pub enum ConfigError {
11 #[error("Configuration file not found: {0}")]
12 NotFound(PathBuf),
13 #[error("Failed to read configuration: {0}")]
14 ReadError(#[from] std::io::Error),
15 #[error("Failed to parse configuration: {0}")]
16 ParseError(#[from] serde_yaml::Error),
17 #[error("Invalid configuration: {0}")]
18 Invalid(String),
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct Config {
24 #[serde(default)]
25 pub project: ProjectConfig,
26 #[serde(default)]
27 pub sync: SyncConfig,
28 pub environments: BTreeMap<String, EnvironmentConfig>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct EnvironmentConfig {
34 #[serde(default)]
35 pub default: bool,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub description: Option<String>,
38 #[serde(default, skip_serializing_if = "Vec::is_empty")]
39 pub search: Vec<SearchServiceConfig>,
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 pub foundry: Vec<FoundryServiceConfig>,
42}
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct SearchServiceConfig {
47 pub name: String,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub label: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub subscription: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub resource_group: Option<String>,
58 #[serde(default = "default_api_version")]
60 pub api_version: String,
61 #[serde(default = "default_preview_api_version")]
63 pub preview_api_version: String,
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct FoundryServiceConfig {
69 pub name: String,
71 pub project: String,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub label: Option<String>,
76 #[serde(default = "default_foundry_api_version")]
78 pub api_version: String,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub endpoint: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub subscription: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub resource_group: Option<String>,
88}
89
90fn default_api_version() -> String {
91 "2024-07-01".to_string()
92}
93
94fn default_preview_api_version() -> String {
95 "2025-11-01-preview".to_string()
96}
97
98fn default_foundry_api_version() -> String {
99 "2025-05-15-preview".to_string()
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, Default)]
104pub struct ProjectConfig {
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub name: Option<String>,
108 #[serde(skip_serializing_if = "Option::is_none")]
110 pub description: Option<String>,
111 #[serde(skip_serializing_if = "Option::is_none")]
115 pub files_path: Option<String>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct SyncConfig {
121 #[serde(default = "default_true")]
123 pub include_preview: bool,
124 #[serde(default)]
126 pub resources: Vec<String>,
127}
128
129fn default_true() -> bool {
130 true
131}
132
133impl Default for SyncConfig {
134 fn default() -> Self {
135 Self {
136 include_preview: true,
137 resources: Vec::new(),
138 }
139 }
140}
141
142pub struct ResolvedEnvironment {
146 pub name: String,
147 pub search: Vec<SearchServiceConfig>,
148 pub foundry: Vec<FoundryServiceConfig>,
149 pub sync: SyncConfig,
150}
151
152impl ResolvedEnvironment {
153 pub fn search_service_dir(&self, root: &Path, service: &SearchServiceConfig) -> PathBuf {
155 let base = root.join("search");
156 if self.search.len() <= 1 {
157 base
158 } else {
159 base.join(service.label.as_deref().unwrap_or(&service.name))
160 }
161 }
162
163 pub fn foundry_service_dir(&self, root: &Path, service: &FoundryServiceConfig) -> PathBuf {
165 let base = root.join("foundry");
166 if self.foundry.len() <= 1 {
167 base
168 } else {
169 base.join(service.label.as_deref().unwrap_or(&service.name))
170 }
171 }
172
173 pub fn primary_search_service(&self) -> Option<&SearchServiceConfig> {
174 self.search.first()
175 }
176
177 pub fn has_foundry(&self) -> bool {
178 !self.foundry.is_empty()
179 }
180
181 pub fn has_search(&self) -> bool {
182 !self.search.is_empty()
183 }
184}
185
186impl Config {
187 pub const FILENAME: &'static str = "rigg.yaml";
189
190 pub fn files_root(&self, project_root: &Path) -> PathBuf {
195 match &self.project.files_path {
196 Some(p) => project_root.join(p),
197 None => project_root.to_path_buf(),
198 }
199 }
200
201 pub fn load(dir: &Path) -> Result<Self, ConfigError> {
203 let path = dir.join(Self::FILENAME);
204 Self::load_from(&path)
205 }
206
207 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
209 if !path.exists() {
210 return Err(ConfigError::NotFound(path.to_path_buf()));
211 }
212 let content = std::fs::read_to_string(path)?;
213 let config: Config = serde_yaml::from_str(&content)?;
214 config.validate()?;
215 Ok(config)
216 }
217
218 pub fn save(&self, dir: &Path) -> Result<(), ConfigError> {
220 let path = dir.join(Self::FILENAME);
221 self.save_to(&path)
222 }
223
224 pub fn save_to(&self, path: &Path) -> Result<(), ConfigError> {
226 let yaml = serde_yaml::to_string(self)?;
227 let content = format!(
228 "# Rigg configuration — https://github.com/mklab-se/rigg\n{}",
229 yaml
230 );
231 std::fs::write(path, content)?;
232 Ok(())
233 }
234
235 pub fn validate(&self) -> Result<(), ConfigError> {
237 if self.environments.is_empty() {
238 return Err(ConfigError::Invalid(
239 "At least one environment must be configured".to_string(),
240 ));
241 }
242
243 for (name, env) in &self.environments {
244 if env.search.is_empty() && env.foundry.is_empty() {
245 return Err(ConfigError::Invalid(format!(
246 "Environment '{}' has no services configured",
247 name
248 )));
249 }
250 if env.search.len() > 1 && env.search.iter().any(|s| s.label.is_none()) {
252 return Err(ConfigError::Invalid(format!(
253 "Environment '{}' has multiple search services — all must have a 'label'",
254 name
255 )));
256 }
257 if env.foundry.len() > 1 && env.foundry.iter().any(|s| s.label.is_none()) {
258 return Err(ConfigError::Invalid(format!(
259 "Environment '{}' has multiple foundry services — all must have a 'label'",
260 name
261 )));
262 }
263 for (i, svc) in env.search.iter().enumerate() {
265 if svc.name.is_empty() {
266 return Err(ConfigError::Invalid(format!(
267 "environments.{}.search[{}].name is required",
268 name, i
269 )));
270 }
271 }
272 for (i, svc) in env.foundry.iter().enumerate() {
273 if svc.name.is_empty() || svc.project.is_empty() {
274 return Err(ConfigError::Invalid(format!(
275 "environments.{}.foundry[{}] requires name and project",
276 name, i
277 )));
278 }
279 }
280 }
281
282 let defaults = self.environments.values().filter(|e| e.default).count();
284 if defaults > 1 {
285 return Err(ConfigError::Invalid(
286 "Only one environment can be set as default".to_string(),
287 ));
288 }
289
290 Ok(())
291 }
292
293 pub fn resolve_env(&self, name: Option<&str>) -> Result<ResolvedEnvironment, ConfigError> {
295 let env_name = name.or_else(|| self.default_env_name()).ok_or_else(|| {
296 ConfigError::Invalid("No environment specified and no default set".to_string())
297 })?;
298 let env_config = self
299 .environments
300 .get(env_name)
301 .ok_or_else(|| ConfigError::Invalid(format!("Environment '{}' not found", env_name)))?;
302 Ok(ResolvedEnvironment {
303 name: env_name.to_string(),
304 search: env_config.search.clone(),
305 foundry: env_config.foundry.clone(),
306 sync: self.sync.clone(),
307 })
308 }
309
310 pub fn default_env_name(&self) -> Option<&str> {
312 self.environments
314 .iter()
315 .find(|(_, e)| e.default)
316 .map(|(n, _)| n.as_str())
317 .or_else(|| {
319 if self.environments.len() == 1 {
320 self.environments.keys().next().map(|s| s.as_str())
321 } else {
322 None
323 }
324 })
325 }
326
327 pub fn environment_names(&self) -> Vec<&str> {
329 self.environments.keys().map(|s| s.as_str()).collect()
330 }
331}
332
333impl SearchServiceConfig {
334 pub fn service_url(&self) -> String {
336 format!("https://{}.search.windows.net", self.name)
337 }
338}
339
340impl FoundryServiceConfig {
341 pub fn service_url(&self) -> String {
347 if let Some(ref ep) = self.endpoint {
348 return ep.trim_end_matches('/').to_string();
349 }
350 format!("https://{}.services.ai.azure.com", self.name)
351 }
352}
353
354pub fn find_project_root(start: &Path) -> Option<PathBuf> {
356 let mut current = start.to_path_buf();
357 loop {
358 if current.join(Config::FILENAME).exists() {
359 return Some(current);
360 }
361 if !current.pop() {
362 return None;
363 }
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use std::fs;
371
372 fn make_search_service(name: &str) -> SearchServiceConfig {
373 SearchServiceConfig {
374 name: name.to_string(),
375 label: None,
376 subscription: None,
377 resource_group: None,
378 api_version: default_api_version(),
379 preview_api_version: default_preview_api_version(),
380 }
381 }
382
383 fn make_foundry_service(name: &str, project: &str) -> FoundryServiceConfig {
384 FoundryServiceConfig {
385 name: name.to_string(),
386 project: project.to_string(),
387 label: None,
388 api_version: default_foundry_api_version(),
389 endpoint: None,
390 subscription: None,
391 resource_group: None,
392 }
393 }
394
395 fn make_config_search(name: &str) -> Config {
396 Config {
397 project: ProjectConfig::default(),
398 sync: SyncConfig::default(),
399 environments: BTreeMap::from([(
400 "prod".to_string(),
401 EnvironmentConfig {
402 default: true,
403 description: None,
404 search: vec![make_search_service(name)],
405 foundry: vec![],
406 },
407 )]),
408 }
409 }
410
411 fn make_config_both() -> Config {
412 Config {
413 project: ProjectConfig {
414 name: Some("Test".to_string()),
415 description: None,
416 files_path: None,
417 },
418 sync: SyncConfig::default(),
419 environments: BTreeMap::from([(
420 "prod".to_string(),
421 EnvironmentConfig {
422 default: true,
423 description: None,
424 search: vec![make_search_service("test-search")],
425 foundry: vec![make_foundry_service("test-ai", "test-proj")],
426 },
427 )]),
428 }
429 }
430
431 #[test]
432 fn test_validate_no_environments() {
433 let config = Config {
434 project: ProjectConfig::default(),
435 sync: SyncConfig::default(),
436 environments: BTreeMap::new(),
437 };
438 assert!(config.validate().is_err());
439 }
440
441 #[test]
442 fn test_validate_empty_env() {
443 let config = Config {
444 project: ProjectConfig::default(),
445 sync: SyncConfig::default(),
446 environments: BTreeMap::from([(
447 "prod".to_string(),
448 EnvironmentConfig {
449 default: false,
450 description: None,
451 search: vec![],
452 foundry: vec![],
453 },
454 )]),
455 };
456 assert!(config.validate().is_err());
457 }
458
459 #[test]
460 fn test_validate_valid_search_only() {
461 let config = make_config_search("my-search");
462 assert!(config.validate().is_ok());
463 }
464
465 #[test]
466 fn test_validate_empty_search_name() {
467 let mut config = make_config_search("");
468 let result = config.validate();
470 assert!(result.is_err());
471 config.environments.get_mut("prod").unwrap().search[0].name = "valid".to_string();
473 assert!(config.validate().is_ok());
474 }
475
476 #[test]
477 fn test_validate_foundry_requires_project() {
478 let config = Config {
479 project: ProjectConfig::default(),
480 sync: SyncConfig::default(),
481 environments: BTreeMap::from([(
482 "prod".to_string(),
483 EnvironmentConfig {
484 default: false,
485 description: None,
486 search: vec![],
487 foundry: vec![FoundryServiceConfig {
488 name: "my-ai".to_string(),
489 project: "".to_string(),
490 label: None,
491 api_version: default_foundry_api_version(),
492 endpoint: None,
493 subscription: None,
494 resource_group: None,
495 }],
496 },
497 )]),
498 };
499 assert!(config.validate().is_err());
500 }
501
502 #[test]
503 fn test_validate_multi_search_requires_labels() {
504 let config = Config {
505 project: ProjectConfig::default(),
506 sync: SyncConfig::default(),
507 environments: BTreeMap::from([(
508 "prod".to_string(),
509 EnvironmentConfig {
510 default: false,
511 description: None,
512 search: vec![make_search_service("svc-1"), make_search_service("svc-2")],
513 foundry: vec![],
514 },
515 )]),
516 };
517 assert!(config.validate().is_err());
518 }
519
520 #[test]
521 fn test_validate_multi_search_with_labels() {
522 let config = Config {
523 project: ProjectConfig::default(),
524 sync: SyncConfig::default(),
525 environments: BTreeMap::from([(
526 "prod".to_string(),
527 EnvironmentConfig {
528 default: false,
529 description: None,
530 search: vec![
531 SearchServiceConfig {
532 label: Some("primary".to_string()),
533 ..make_search_service("svc-1")
534 },
535 SearchServiceConfig {
536 label: Some("analytics".to_string()),
537 ..make_search_service("svc-2")
538 },
539 ],
540 foundry: vec![],
541 },
542 )]),
543 };
544 assert!(config.validate().is_ok());
545 }
546
547 #[test]
548 fn test_validate_multiple_defaults() {
549 let config = Config {
550 project: ProjectConfig::default(),
551 sync: SyncConfig::default(),
552 environments: BTreeMap::from([
553 (
554 "prod".to_string(),
555 EnvironmentConfig {
556 default: true,
557 description: None,
558 search: vec![make_search_service("svc-1")],
559 foundry: vec![],
560 },
561 ),
562 (
563 "test".to_string(),
564 EnvironmentConfig {
565 default: true,
566 description: None,
567 search: vec![make_search_service("svc-2")],
568 foundry: vec![],
569 },
570 ),
571 ]),
572 };
573 assert!(config.validate().is_err());
574 }
575
576 #[test]
577 fn test_search_service_url() {
578 let svc = make_search_service("my-search");
579 assert_eq!(svc.service_url(), "https://my-search.search.windows.net");
580 }
581
582 #[test]
583 fn test_foundry_service_url() {
584 let svc = make_foundry_service("my-ai-service", "proj-1");
585 assert_eq!(
586 svc.service_url(),
587 "https://my-ai-service.services.ai.azure.com"
588 );
589 }
590
591 #[test]
592 fn test_foundry_service_url_with_endpoint() {
593 let mut svc = make_foundry_service("my-ai-service", "proj-1");
594 svc.endpoint = Some("https://custom-subdomain.services.ai.azure.com".to_string());
595 assert_eq!(
596 svc.service_url(),
597 "https://custom-subdomain.services.ai.azure.com"
598 );
599 }
600
601 #[test]
602 fn test_foundry_service_url_strips_trailing_slash() {
603 let mut svc = make_foundry_service("my-ai-service", "proj-1");
604 svc.endpoint = Some("https://custom-subdomain.services.ai.azure.com/".to_string());
605 assert_eq!(
606 svc.service_url(),
607 "https://custom-subdomain.services.ai.azure.com"
608 );
609 }
610
611 #[test]
612 fn test_sync_config_defaults() {
613 let sync = SyncConfig::default();
614 assert!(sync.include_preview);
615 assert!(sync.resources.is_empty());
616 }
617
618 #[test]
619 fn test_resolve_env_default() {
620 let config = make_config_search("my-search");
621 let env = config.resolve_env(None).unwrap();
622 assert_eq!(env.name, "prod");
623 assert_eq!(env.search.len(), 1);
624 assert_eq!(env.search[0].name, "my-search");
625 }
626
627 #[test]
628 fn test_resolve_env_by_name() {
629 let config = Config {
630 project: ProjectConfig::default(),
631 sync: SyncConfig::default(),
632 environments: BTreeMap::from([
633 (
634 "prod".to_string(),
635 EnvironmentConfig {
636 default: true,
637 description: None,
638 search: vec![make_search_service("search-prod")],
639 foundry: vec![],
640 },
641 ),
642 (
643 "test".to_string(),
644 EnvironmentConfig {
645 default: false,
646 description: None,
647 search: vec![make_search_service("search-test")],
648 foundry: vec![],
649 },
650 ),
651 ]),
652 };
653 let env = config.resolve_env(Some("test")).unwrap();
654 assert_eq!(env.name, "test");
655 assert_eq!(env.search[0].name, "search-test");
656 }
657
658 #[test]
659 fn test_resolve_env_not_found() {
660 let config = make_config_search("svc");
661 assert!(config.resolve_env(Some("missing")).is_err());
662 }
663
664 #[test]
665 fn test_resolve_env_single_env_is_default() {
666 let config = Config {
668 project: ProjectConfig::default(),
669 sync: SyncConfig::default(),
670 environments: BTreeMap::from([(
671 "staging".to_string(),
672 EnvironmentConfig {
673 default: false,
674 description: None,
675 search: vec![make_search_service("svc")],
676 foundry: vec![],
677 },
678 )]),
679 };
680 let env = config.resolve_env(None).unwrap();
681 assert_eq!(env.name, "staging");
682 }
683
684 #[test]
685 fn test_resolve_env_multiple_envs_no_default() {
686 let config = Config {
687 project: ProjectConfig::default(),
688 sync: SyncConfig::default(),
689 environments: BTreeMap::from([
690 (
691 "prod".to_string(),
692 EnvironmentConfig {
693 default: false,
694 description: None,
695 search: vec![make_search_service("svc-1")],
696 foundry: vec![],
697 },
698 ),
699 (
700 "test".to_string(),
701 EnvironmentConfig {
702 default: false,
703 description: None,
704 search: vec![make_search_service("svc-2")],
705 foundry: vec![],
706 },
707 ),
708 ]),
709 };
710 assert!(config.resolve_env(None).is_err());
711 }
712
713 #[test]
714 fn test_default_env_name_with_default() {
715 let config = make_config_search("svc");
716 assert_eq!(config.default_env_name(), Some("prod"));
717 }
718
719 #[test]
720 fn test_environment_names() {
721 let config = Config {
722 project: ProjectConfig::default(),
723 sync: SyncConfig::default(),
724 environments: BTreeMap::from([
725 (
726 "prod".to_string(),
727 EnvironmentConfig {
728 default: true,
729 description: None,
730 search: vec![make_search_service("svc-1")],
731 foundry: vec![],
732 },
733 ),
734 (
735 "test".to_string(),
736 EnvironmentConfig {
737 default: false,
738 description: None,
739 search: vec![make_search_service("svc-2")],
740 foundry: vec![],
741 },
742 ),
743 ]),
744 };
745 let names = config.environment_names();
746 assert_eq!(names, vec!["prod", "test"]);
747 }
748
749 #[test]
750 fn test_resolved_env_search_service_dir_single() {
751 let env = ResolvedEnvironment {
752 name: "prod".to_string(),
753 search: vec![make_search_service("my-search")],
754 foundry: vec![],
755 sync: SyncConfig::default(),
756 };
757 let root = Path::new("/projects/myapp");
758 assert_eq!(
759 env.search_service_dir(root, &env.search[0]),
760 PathBuf::from("/projects/myapp/search")
761 );
762 }
763
764 #[test]
765 fn test_resolved_env_search_service_dir_multi_with_labels() {
766 let env = ResolvedEnvironment {
767 name: "prod".to_string(),
768 search: vec![
769 SearchServiceConfig {
770 label: Some("primary".to_string()),
771 ..make_search_service("svc-1")
772 },
773 SearchServiceConfig {
774 label: Some("analytics".to_string()),
775 ..make_search_service("svc-2")
776 },
777 ],
778 foundry: vec![],
779 sync: SyncConfig::default(),
780 };
781 let root = Path::new("/projects/myapp");
782 assert_eq!(
783 env.search_service_dir(root, &env.search[0]),
784 PathBuf::from("/projects/myapp/search/primary")
785 );
786 assert_eq!(
787 env.search_service_dir(root, &env.search[1]),
788 PathBuf::from("/projects/myapp/search/analytics")
789 );
790 }
791
792 #[test]
793 fn test_resolved_env_foundry_service_dir_single() {
794 let env = ResolvedEnvironment {
795 name: "prod".to_string(),
796 search: vec![],
797 foundry: vec![make_foundry_service("my-ai", "proj-1")],
798 sync: SyncConfig::default(),
799 };
800 let root = Path::new("/projects/myapp");
801 assert_eq!(
802 env.foundry_service_dir(root, &env.foundry[0]),
803 PathBuf::from("/projects/myapp/foundry")
804 );
805 }
806
807 #[test]
808 fn test_resolved_env_foundry_service_dir_multi() {
809 let env = ResolvedEnvironment {
810 name: "prod".to_string(),
811 search: vec![],
812 foundry: vec![
813 FoundryServiceConfig {
814 label: Some("rag".to_string()),
815 ..make_foundry_service("ai-svc", "rag-proj")
816 },
817 FoundryServiceConfig {
818 label: Some("chat".to_string()),
819 ..make_foundry_service("ai-svc", "chat-proj")
820 },
821 ],
822 sync: SyncConfig::default(),
823 };
824 let root = Path::new("/projects/myapp");
825 assert_eq!(
826 env.foundry_service_dir(root, &env.foundry[0]),
827 PathBuf::from("/projects/myapp/foundry/rag")
828 );
829 assert_eq!(
830 env.foundry_service_dir(root, &env.foundry[1]),
831 PathBuf::from("/projects/myapp/foundry/chat")
832 );
833 }
834
835 #[test]
836 fn test_resolved_env_has_foundry() {
837 let env = ResolvedEnvironment {
838 name: "prod".to_string(),
839 search: vec![],
840 foundry: vec![make_foundry_service("ai", "proj")],
841 sync: SyncConfig::default(),
842 };
843 assert!(env.has_foundry());
844 assert!(!env.has_search());
845 }
846
847 #[test]
848 fn test_resolved_env_primary_search_service() {
849 let env = ResolvedEnvironment {
850 name: "prod".to_string(),
851 search: vec![make_search_service("svc")],
852 foundry: vec![],
853 sync: SyncConfig::default(),
854 };
855 assert_eq!(env.primary_search_service().unwrap().name, "svc");
856 }
857
858 #[test]
859 fn test_save_and_load_roundtrip() {
860 let dir = tempfile::tempdir().unwrap();
861 let config = make_config_both();
862 config.save(dir.path()).unwrap();
863
864 let loaded = Config::load(dir.path()).unwrap();
865 let env = loaded.resolve_env(None).unwrap();
866 assert_eq!(env.search[0].name, "test-search");
867 assert_eq!(env.foundry[0].name, "test-ai");
868 assert_eq!(env.foundry[0].project, "test-proj");
869 }
870
871 #[test]
872 fn test_load_missing_file() {
873 let dir = tempfile::tempdir().unwrap();
874 let result = Config::load(dir.path());
875 assert!(result.is_err());
876 }
877
878 #[test]
879 fn test_load_from_yaml_string() {
880 let yaml = r#"
881environments:
882 prod:
883 default: true
884 search:
885 - name: my-search-service
886 api_version: "2024-07-01"
887
888project:
889 name: My Project
890
891sync:
892 include_preview: false
893"#;
894 let config: Config = serde_yaml::from_str(yaml).unwrap();
895 assert!(config.validate().is_ok());
896 assert_eq!(config.environments.len(), 1);
897 let env = config.resolve_env(None).unwrap();
898 assert_eq!(env.search[0].name, "my-search-service");
899 assert!(!env.sync.include_preview);
900 }
901
902 #[test]
903 fn test_load_foundry_only() {
904 let yaml = r#"
905environments:
906 prod:
907 foundry:
908 - name: my-ai-service
909 project: my-project
910"#;
911 let config: Config = serde_yaml::from_str(yaml).unwrap();
912 assert!(config.validate().is_ok());
913 let env = config.resolve_env(None).unwrap();
914 assert!(env.has_foundry());
915 assert_eq!(env.foundry[0].name, "my-ai-service");
916 assert_eq!(env.foundry[0].project, "my-project");
917 assert_eq!(env.foundry[0].api_version, "2025-05-15-preview");
918 }
919
920 #[test]
921 fn test_load_both_services() {
922 let yaml = r#"
923environments:
924 prod:
925 default: true
926 search:
927 - name: my-search
928 foundry:
929 - name: my-ai
930 project: proj-1
931
932project:
933 name: RAG System
934"#;
935 let config: Config = serde_yaml::from_str(yaml).unwrap();
936 assert!(config.validate().is_ok());
937 let env = config.resolve_env(None).unwrap();
938 assert_eq!(env.search.len(), 1);
939 assert!(env.has_foundry());
940 }
941
942 #[test]
943 fn test_load_multi_environment() {
944 let yaml = r#"
945environments:
946 prod:
947 default: true
948 search:
949 - name: search-prod
950 test:
951 search:
952 - name: search-test
953"#;
954 let config: Config = serde_yaml::from_str(yaml).unwrap();
955 assert!(config.validate().is_ok());
956 assert_eq!(config.environment_names(), vec!["prod", "test"]);
957
958 let prod = config.resolve_env(Some("prod")).unwrap();
959 assert_eq!(prod.search[0].name, "search-prod");
960
961 let test = config.resolve_env(Some("test")).unwrap();
962 assert_eq!(test.search[0].name, "search-test");
963 }
964
965 #[test]
966 fn test_find_project_root_found() {
967 let dir = tempfile::tempdir().unwrap();
968 let sub = dir.path().join("a/b/c");
969 fs::create_dir_all(&sub).unwrap();
970 fs::write(
971 dir.path().join(Config::FILENAME),
972 "environments:\n prod:\n search:\n - name: x\n",
973 )
974 .unwrap();
975
976 let found = find_project_root(&sub);
977 assert_eq!(found, Some(dir.path().to_path_buf()));
978 }
979
980 #[test]
981 fn test_find_project_root_not_found() {
982 let dir = tempfile::tempdir().unwrap();
983 let found = find_project_root(dir.path());
984 assert!(found.is_none());
985 }
986
987 #[test]
988 fn test_foundry_default_api_version() {
989 let yaml = r#"
990environments:
991 prod:
992 foundry:
993 - name: my-ai
994 project: proj-1
995"#;
996 let config: Config = serde_yaml::from_str(yaml).unwrap();
997 let env = config.resolve_env(None).unwrap();
998 assert_eq!(env.foundry[0].api_version, "2025-05-15-preview");
999 }
1000
1001 #[test]
1002 fn test_config_filename_is_yaml() {
1003 assert_eq!(Config::FILENAME, "rigg.yaml");
1004 }
1005
1006 #[test]
1007 fn test_files_root_without_files_path() {
1008 let config = make_config_search("svc");
1009 let root = Path::new("/projects/myapp");
1010 assert_eq!(config.files_root(root), PathBuf::from("/projects/myapp"));
1011 }
1012
1013 #[test]
1014 fn test_files_root_with_files_path() {
1015 let mut config = make_config_search("svc");
1016 config.project.files_path = Some("rigg".to_string());
1017 let root = Path::new("/projects/myapp");
1018 assert_eq!(
1019 config.files_root(root),
1020 PathBuf::from("/projects/myapp/rigg")
1021 );
1022 }
1023
1024 #[test]
1025 fn test_files_root_nested_path() {
1026 let mut config = make_config_search("svc");
1027 config.project.files_path = Some("infra/rigg".to_string());
1028 let root = Path::new("/projects/myapp");
1029 assert_eq!(
1030 config.files_root(root),
1031 PathBuf::from("/projects/myapp/infra/rigg")
1032 );
1033 }
1034
1035 #[test]
1036 fn test_files_path_yaml_roundtrip() {
1037 let dir = tempfile::tempdir().unwrap();
1038 let mut config = make_config_search("svc");
1039 config.project.files_path = Some("rigg".to_string());
1040 config.save(dir.path()).unwrap();
1041
1042 let loaded = Config::load(dir.path()).unwrap();
1043 assert_eq!(loaded.project.files_path, Some("rigg".to_string()));
1044 }
1045
1046 #[test]
1047 fn test_files_path_omitted_when_none() {
1048 let config = make_config_search("svc");
1049 let yaml = serde_yaml::to_string(&config).unwrap();
1050 assert!(!yaml.contains("files_path"));
1051 }
1052}