1use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5use thiserror::Error;
6
7#[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#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Config {
25 pub service: ServiceConfig,
27 #[serde(default)]
29 pub project: ProjectConfig,
30 #[serde(default)]
32 pub sync: SyncConfig,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ServiceConfig {
38 pub name: String,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub subscription: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub resource_group: Option<String>,
46 #[serde(default = "default_api_version")]
48 pub api_version: String,
49 #[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
64pub struct ProjectConfig {
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub name: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub description: Option<String>,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub path: Option<String>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct SyncConfig {
79 #[serde(default = "default_true")]
81 pub include_preview: bool,
82 #[serde(default = "default_true")]
84 pub generate_docs: bool,
85 #[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 pub const FILENAME: &'static str = "hoist.toml";
107
108 pub fn load(dir: &Path) -> Result<Self, ConfigError> {
110 let path = dir.join(Self::FILENAME);
111 Self::load_from(&path)
112 }
113
114 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 pub fn save(&self, dir: &Path) -> Result<(), ConfigError> {
127 let path = dir.join(Self::FILENAME);
128 self.save_to(&path)
129 }
130
131 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 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 pub fn service_url(&self) -> String {
148 format!("https://{}.search.windows.net", self.service.name)
149 }
150
151 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 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 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
317pub 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}