Skip to main content

hoist_core/
config.rs

1//! Configuration management for hoist
2
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5use thiserror::Error;
6
7use crate::service::ServiceDomain;
8
9/// Configuration errors
10#[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/// Main configuration file (hoist.toml)
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Config {
27    /// Legacy Azure Search service configuration (auto-migrated to services.search)
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub service: Option<ServiceConfig>,
30    /// Multi-service configuration (v0.2.0+)
31    #[serde(default)]
32    pub services: ServicesConfig,
33    /// Project settings
34    #[serde(default)]
35    pub project: ProjectConfig,
36    /// Pull/push settings
37    #[serde(default)]
38    pub sync: SyncConfig,
39}
40
41/// Multi-service configuration
42#[derive(Debug, Clone, Serialize, Deserialize, Default)]
43pub struct ServicesConfig {
44    /// Azure AI Search services
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub search: Vec<SearchServiceConfig>,
47    /// Microsoft Foundry services
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub foundry: Vec<FoundryServiceConfig>,
50}
51
52/// Azure Search service connection settings
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct ServiceConfig {
55    /// Search service name (e.g., "my-search-service")
56    pub name: String,
57    /// Azure subscription ID (optional, can use default)
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub subscription: Option<String>,
60    /// Resource group (optional)
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub resource_group: Option<String>,
63    /// API version to use
64    #[serde(default = "default_api_version")]
65    pub api_version: String,
66    /// Preview API version for agentic search
67    #[serde(default = "default_preview_api_version")]
68    pub preview_api_version: String,
69}
70
71/// Search service configuration (v0.2.0+ format)
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct SearchServiceConfig {
74    /// Search service name
75    pub name: String,
76    /// Azure subscription ID (optional)
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub subscription: Option<String>,
79    /// Resource group (optional)
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub resource_group: Option<String>,
82    /// API version to use
83    #[serde(default = "default_api_version")]
84    pub api_version: String,
85    /// Preview API version
86    #[serde(default = "default_preview_api_version")]
87    pub preview_api_version: String,
88}
89
90/// Foundry service configuration
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct FoundryServiceConfig {
93    /// AI services host name (e.g., "my-ai-service")
94    pub name: String,
95    /// Foundry project name
96    pub project: String,
97    /// API version to use
98    #[serde(default = "default_foundry_api_version")]
99    pub api_version: String,
100    /// Azure subscription ID (optional)
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub subscription: Option<String>,
103    /// Resource group (optional)
104    #[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/// Project-level settings
121#[derive(Debug, Clone, Serialize, Deserialize, Default)]
122pub struct ProjectConfig {
123    /// Project name (used in generated documentation)
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub name: Option<String>,
126    /// Project description
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub description: Option<String>,
129    /// Subdirectory for resource files (relative to project root)
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub path: Option<String>,
132}
133
134/// Sync behavior settings
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct SyncConfig {
137    /// Include preview API resources (knowledge bases, knowledge sources)
138    #[serde(default = "default_true")]
139    pub include_preview: bool,
140    /// Resource types to sync (empty = all)
141    #[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    /// Default configuration filename
160    pub const FILENAME: &'static str = "hoist.toml";
161
162    /// Load configuration from a directory
163    pub fn load(dir: &Path) -> Result<Self, ConfigError> {
164        let path = dir.join(Self::FILENAME);
165        Self::load_from(&path)
166    }
167
168    /// Load configuration from a specific file path
169    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    /// Save configuration to a directory
180    pub fn save(&self, dir: &Path) -> Result<(), ConfigError> {
181        let path = dir.join(Self::FILENAME);
182        self.save_to(&path)
183    }
184
185    /// Save configuration to a specific file path
186    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    /// Validate configuration
193    pub fn validate(&self) -> Result<(), ConfigError> {
194        // Must have at least one search or foundry service
195        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        // Validate legacy service name
206        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        // Validate search services
213        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        // Validate foundry services
223        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    /// Get all search service configs (including legacy auto-migrated)
242    pub fn search_services(&self) -> Vec<SearchServiceConfig> {
243        let mut result = self.services.search.clone();
244
245        // Auto-migrate legacy [service] to search config
246        if let Some(ref legacy) = self.service {
247            if !legacy.name.is_empty() {
248                // Only auto-migrate if no services.search already has this name
249                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    /// Get all foundry service configs
269    pub fn foundry_services(&self) -> &[FoundryServiceConfig] {
270        &self.services.foundry
271    }
272
273    /// Quick check if any foundry services are configured
274    pub fn has_foundry(&self) -> bool {
275        !self.services.foundry.is_empty()
276    }
277
278    /// Get the primary search service config (first one, for backward compat)
279    pub fn primary_search_service(&self) -> Option<SearchServiceConfig> {
280        self.search_services().into_iter().next()
281    }
282
283    /// Get the base URL for the Azure Search service (backward compat helper)
284    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    /// Get the base directory for resource files (project_root or project_root/path)
295    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    /// Base directory for a specific search service's resources
303    /// Returns: resource_dir / "search-resources" / service_name
304    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    /// Base directory for a specific foundry service/project's resources
311    /// Returns: resource_dir / "foundry-resources" / service_name / project_name
312    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    /// Get the API version to use for a resource (backward compat helper)
325    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    /// Get the base URL for this search service
350    pub fn service_url(&self) -> String {
351        format!("https://{}.search.windows.net", self.name)
352    }
353}
354
355impl FoundryServiceConfig {
356    /// Get the base URL for this Foundry service
357    pub fn service_url(&self) -> String {
358        format!("https://{}.services.ai.azure.com", self.name)
359    }
360}
361
362/// Create a Config with legacy [service] format (backward compat helper)
363pub 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    // === New multi-service config tests ===
513
514    #[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        // Should not duplicate — same name in both legacy and services.search
592        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
747/// Find the project root by looking for hoist.toml
748pub 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}