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