Skip to main content

rigg_core/
config.rs

1//! Configuration management for rigg
2
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6use thiserror::Error;
7
8/// Configuration errors
9#[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/// Main configuration file (rigg.yaml)
22#[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/// Environment configuration
32#[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/// Search service configuration
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct SearchServiceConfig {
47    /// Search service name
48    pub name: String,
49    /// Label for multi-service environments
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub label: Option<String>,
52    /// Azure subscription ID (optional)
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub subscription: Option<String>,
55    /// Resource group (optional)
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub resource_group: Option<String>,
58    /// API version to use
59    #[serde(default = "default_api_version")]
60    pub api_version: String,
61    /// Preview API version
62    #[serde(default = "default_preview_api_version")]
63    pub preview_api_version: String,
64}
65
66/// Foundry service configuration
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct FoundryServiceConfig {
69    /// AI services host name (e.g., "my-ai-service")
70    pub name: String,
71    /// Foundry project name
72    pub project: String,
73    /// Label for multi-service environments
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub label: Option<String>,
76    /// API version to use
77    #[serde(default = "default_foundry_api_version")]
78    pub api_version: String,
79    /// Service endpoint URL (discovered from ARM; overrides name-based URL construction)
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub endpoint: Option<String>,
82    /// Azure subscription ID (optional)
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub subscription: Option<String>,
85    /// Resource group (optional)
86    #[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/// Project-level settings
103#[derive(Debug, Clone, Serialize, Deserialize, Default)]
104pub struct ProjectConfig {
105    /// Project name (used in generated documentation)
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub name: Option<String>,
108    /// Project description
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub description: Option<String>,
111    /// Relative path for resource files (search/, foundry/ dirs).
112    /// When set, resource directories are created under project_root/files_path/
113    /// instead of directly under project_root/.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub files_path: Option<String>,
116}
117
118/// Sync behavior settings
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct SyncConfig {
121    /// Include preview API resources (knowledge bases, knowledge sources)
122    #[serde(default = "default_true")]
123    pub include_preview: bool,
124    /// Resource types to sync (empty = all)
125    #[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
142/// Resolved environment for command execution.
143///
144/// Central abstraction all commands work through. Created by `Config::resolve_env()`.
145pub 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    /// Base dir for a search service: search/ (single) or search/<label>/ (multi)
154    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    /// Base dir for a foundry service: foundry/ (single) or foundry/<label>/ (multi)
164    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    /// Default configuration filename
188    pub const FILENAME: &'static str = "rigg.yaml";
189
190    /// Root directory for resource files (search/, foundry/).
191    ///
192    /// When `project.files_path` is set, returns `project_root/files_path`.
193    /// Otherwise returns `project_root` itself.
194    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    /// Load configuration from a directory
202    pub fn load(dir: &Path) -> Result<Self, ConfigError> {
203        let path = dir.join(Self::FILENAME);
204        Self::load_from(&path)
205    }
206
207    /// Load configuration from a specific file path
208    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    /// Save configuration to a directory
219    pub fn save(&self, dir: &Path) -> Result<(), ConfigError> {
220        let path = dir.join(Self::FILENAME);
221        self.save_to(&path)
222    }
223
224    /// Save configuration to a specific file path
225    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    /// Validate configuration
236    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 multi-service, require labels
251            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            // Validate service names
264            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        // At most one default
283        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    /// Resolve an environment by name (or default)
294    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    /// Find the default environment name
311    pub fn default_env_name(&self) -> Option<&str> {
312        // 1. Env with default: true
313        self.environments
314            .iter()
315            .find(|(_, e)| e.default)
316            .map(|(n, _)| n.as_str())
317            // 2. Only one env → use it
318            .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    /// Get all environment names
328    pub fn environment_names(&self) -> Vec<&str> {
329        self.environments.keys().map(|s| s.as_str()).collect()
330    }
331}
332
333impl SearchServiceConfig {
334    /// Get the base URL for this search service
335    pub fn service_url(&self) -> String {
336        format!("https://{}.search.windows.net", self.name)
337    }
338}
339
340impl FoundryServiceConfig {
341    /// Get the base URL for this Foundry service.
342    ///
343    /// Prefers the `endpoint` field (discovered from ARM) over a URL
344    /// constructed from `name`, since the ARM custom subdomain may differ
345    /// from the resource name.
346    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
354/// Find the project root by looking for rigg.yaml
355pub 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        // Empty name in search service should fail
469        let result = config.validate();
470        assert!(result.is_err());
471        // Fix it to verify the error was about the name
472        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        // Single env without default: true should still resolve
667        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}