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
7/// Configuration errors
8#[derive(Debug, Error)]
9pub enum ConfigError {
10    #[error("Configuration file not found: {0}")]
11    NotFound(PathBuf),
12    #[error("Failed to read configuration: {0}")]
13    ReadError(#[from] std::io::Error),
14    #[error("Failed to parse configuration: {0}")]
15    ParseError(#[from] toml::de::Error),
16    #[error("Failed to serialize configuration: {0}")]
17    SerializeError(#[from] toml::ser::Error),
18    #[error("Invalid configuration: {0}")]
19    Invalid(String),
20}
21
22/// Main configuration file (hoist.toml)
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Config {
25    /// Azure Search service configuration
26    pub service: ServiceConfig,
27    /// Project settings
28    #[serde(default)]
29    pub project: ProjectConfig,
30    /// Pull/push settings
31    #[serde(default)]
32    pub sync: SyncConfig,
33}
34
35/// Azure Search service connection settings
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ServiceConfig {
38    /// Search service name (e.g., "my-search-service")
39    pub name: String,
40    /// Azure subscription ID (optional, can use default)
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub subscription: Option<String>,
43    /// Resource group (optional)
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub resource_group: Option<String>,
46    /// API version to use
47    #[serde(default = "default_api_version")]
48    pub api_version: String,
49    /// Preview API version for agentic search
50    #[serde(default = "default_preview_api_version")]
51    pub preview_api_version: String,
52}
53
54fn default_api_version() -> String {
55    "2024-07-01".to_string()
56}
57
58fn default_preview_api_version() -> String {
59    "2025-11-01-preview".to_string()
60}
61
62/// Project-level settings
63#[derive(Debug, Clone, Serialize, Deserialize, Default)]
64pub struct ProjectConfig {
65    /// Project name (used in generated documentation)
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub name: Option<String>,
68    /// Project description
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub description: Option<String>,
71    /// Subdirectory for resource files (relative to project root)
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub path: Option<String>,
74}
75
76/// Sync behavior settings
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct SyncConfig {
79    /// Include preview API resources (knowledge bases, knowledge sources)
80    #[serde(default = "default_true")]
81    pub include_preview: bool,
82    /// Generate README files
83    #[serde(default = "default_true")]
84    pub generate_docs: bool,
85    /// Resource types to sync (empty = all)
86    #[serde(default)]
87    pub resources: Vec<String>,
88}
89
90fn default_true() -> bool {
91    true
92}
93
94impl Default for SyncConfig {
95    fn default() -> Self {
96        Self {
97            include_preview: true,
98            generate_docs: true,
99            resources: Vec::new(),
100        }
101    }
102}
103
104impl Config {
105    /// Default configuration filename
106    pub const FILENAME: &'static str = "hoist.toml";
107
108    /// Load configuration from a directory
109    pub fn load(dir: &Path) -> Result<Self, ConfigError> {
110        let path = dir.join(Self::FILENAME);
111        Self::load_from(&path)
112    }
113
114    /// Load configuration from a specific file path
115    pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
116        if !path.exists() {
117            return Err(ConfigError::NotFound(path.to_path_buf()));
118        }
119        let content = std::fs::read_to_string(path)?;
120        let config: Config = toml::from_str(&content)?;
121        config.validate()?;
122        Ok(config)
123    }
124
125    /// Save configuration to a directory
126    pub fn save(&self, dir: &Path) -> Result<(), ConfigError> {
127        let path = dir.join(Self::FILENAME);
128        self.save_to(&path)
129    }
130
131    /// Save configuration to a specific file path
132    pub fn save_to(&self, path: &Path) -> Result<(), ConfigError> {
133        let content = toml::to_string_pretty(self)?;
134        std::fs::write(path, content)?;
135        Ok(())
136    }
137
138    /// Validate configuration
139    pub fn validate(&self) -> Result<(), ConfigError> {
140        if self.service.name.is_empty() {
141            return Err(ConfigError::Invalid("service.name is required".to_string()));
142        }
143        Ok(())
144    }
145
146    /// Get the base URL for the Azure Search service
147    pub fn service_url(&self) -> String {
148        format!("https://{}.search.windows.net", self.service.name)
149    }
150
151    /// Get the base directory for resource files (project_root or project_root/path)
152    pub fn resource_dir(&self, project_root: &Path) -> PathBuf {
153        match &self.project.path {
154            Some(path) => project_root.join(path),
155            None => project_root.to_path_buf(),
156        }
157    }
158
159    /// Get the API version to use for a resource
160    pub fn api_version_for(&self, preview: bool) -> &str {
161        if preview {
162            &self.service.preview_api_version
163        } else {
164            &self.service.api_version
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use std::fs;
173
174    fn make_config(name: &str) -> Config {
175        Config {
176            service: ServiceConfig {
177                name: name.to_string(),
178                subscription: None,
179                resource_group: None,
180                api_version: "2024-07-01".to_string(),
181                preview_api_version: "2025-11-01-preview".to_string(),
182            },
183            project: ProjectConfig::default(),
184            sync: SyncConfig::default(),
185        }
186    }
187
188    #[test]
189    fn test_validate_empty_name() {
190        let config = make_config("");
191        assert!(config.validate().is_err());
192    }
193
194    #[test]
195    fn test_validate_valid_name() {
196        let config = make_config("my-search");
197        assert!(config.validate().is_ok());
198    }
199
200    #[test]
201    fn test_service_url() {
202        let config = make_config("my-search");
203        assert_eq!(config.service_url(), "https://my-search.search.windows.net");
204    }
205
206    #[test]
207    fn test_api_version_for_stable() {
208        let config = make_config("my-search");
209        assert_eq!(config.api_version_for(false), "2024-07-01");
210    }
211
212    #[test]
213    fn test_api_version_for_preview() {
214        let config = make_config("my-search");
215        assert_eq!(config.api_version_for(true), "2025-11-01-preview");
216    }
217
218    #[test]
219    fn test_resource_dir_without_path() {
220        let config = make_config("my-search");
221        let root = Path::new("/projects/search");
222        assert_eq!(config.resource_dir(root), PathBuf::from("/projects/search"));
223    }
224
225    #[test]
226    fn test_resource_dir_with_path() {
227        let mut config = make_config("my-search");
228        config.project.path = Some("search".to_string());
229        let root = Path::new("/projects/myapp");
230        assert_eq!(
231            config.resource_dir(root),
232            PathBuf::from("/projects/myapp/search")
233        );
234    }
235
236    #[test]
237    fn test_save_and_load_roundtrip() {
238        let dir = tempfile::tempdir().unwrap();
239        let config = make_config("test-service");
240        config.save(dir.path()).unwrap();
241
242        let loaded = Config::load(dir.path()).unwrap();
243        assert_eq!(loaded.service.name, "test-service");
244        assert_eq!(loaded.service.api_version, "2024-07-01");
245    }
246
247    #[test]
248    fn test_load_missing_file() {
249        let dir = tempfile::tempdir().unwrap();
250        let result = Config::load(dir.path());
251        assert!(result.is_err());
252    }
253
254    #[test]
255    fn test_load_from_toml_string() {
256        let toml = r#"
257[service]
258name = "my-svc"
259
260[sync]
261include_preview = false
262"#;
263        let config: Config = toml::from_str(toml).unwrap();
264        assert_eq!(config.service.name, "my-svc");
265        assert!(!config.sync.include_preview);
266        // Defaults should apply
267        assert_eq!(config.service.api_version, "2024-07-01");
268        assert!(config.sync.generate_docs);
269    }
270
271    #[test]
272    fn test_sync_config_defaults() {
273        let sync = SyncConfig::default();
274        assert!(sync.include_preview);
275        assert!(sync.generate_docs);
276        assert!(sync.resources.is_empty());
277    }
278
279    #[test]
280    fn test_find_project_root_found() {
281        let dir = tempfile::tempdir().unwrap();
282        let sub = dir.path().join("a/b/c");
283        fs::create_dir_all(&sub).unwrap();
284        fs::write(
285            dir.path().join(Config::FILENAME),
286            "[service]\nname = \"x\"\n",
287        )
288        .unwrap();
289
290        let found = find_project_root(&sub);
291        assert_eq!(found, Some(dir.path().to_path_buf()));
292    }
293
294    #[test]
295    fn test_find_project_root_not_found() {
296        let dir = tempfile::tempdir().unwrap();
297        let found = find_project_root(dir.path());
298        assert!(found.is_none());
299    }
300
301    #[test]
302    fn test_path_serialized_in_toml() {
303        let mut config = make_config("svc");
304        config.project.path = Some("search".to_string());
305        let toml_str = toml::to_string_pretty(&config).unwrap();
306        assert!(toml_str.contains("path = \"search\""));
307    }
308
309    #[test]
310    fn test_path_not_serialized_when_none() {
311        let config = make_config("svc");
312        let toml_str = toml::to_string_pretty(&config).unwrap();
313        assert!(!toml_str.contains("path"));
314    }
315}
316
317/// Find the project root by looking for hoist.toml
318pub fn find_project_root(start: &Path) -> Option<PathBuf> {
319    let mut current = start.to_path_buf();
320    loop {
321        if current.join(Config::FILENAME).exists() {
322            return Some(current);
323        }
324        if !current.pop() {
325            return None;
326        }
327    }
328}