xianyun_core/config/
global.rs1use std::collections::HashMap;
11
12use super::errors::ConfigError;
13
14#[derive(Debug, Default, Clone)]
15pub struct GlobalConfig {
16 pub config_file: String,
17 pub profile_name: String,
18 pub no_caching: bool,
19 pub cluster_domain: String,
20 pub cluster_domain_source: String,
21
22 pub(crate) embedded_versions: Option<HashMap<String, String>>,
23 pub(crate) parsed_versions: Option<HashMap<String, String>>,
24}
25
26impl GlobalConfig {
27 pub fn new(embedded_versions: Option<HashMap<String, String>>) -> Self {
29 Self {
30 embedded_versions,
31 ..Default::default()
32 }
33 }
34
35 fn ensure_versions_parsed(&mut self) {
40 if self.parsed_versions.is_some() {
41 return;
42 }
43
44 let mut parsed = HashMap::new();
45
46 if let Some(embedded) = &self.embedded_versions {
47 #[derive(serde::Deserialize)]
48 struct VersionsFile {
49 #[serde(default)]
50 variables: HashMap<String, String>,
51 }
52
53 for (filename, content) in embedded {
54 let file: VersionsFile = serde_yaml::from_str(content).unwrap_or_else(|err| {
55 panic!("failed to parse embedded version file {filename}: {err}")
56 });
57 parsed.extend(file.variables);
58 }
59 }
60
61 self.parsed_versions = Some(parsed);
62 }
63
64 pub fn get_tool_version(&mut self, key: &str) -> Option<String> {
66 self.ensure_versions_parsed();
67 self.parsed_versions
68 .as_ref()
69 .and_then(|v| v.get(key))
70 .cloned()
71 }
72
73 pub fn tool_versions(&mut self) -> HashMap<String, String> {
76 self.ensure_versions_parsed();
77 self.parsed_versions.clone().unwrap_or_default()
78 }
79}
80
81#[derive(Debug, Default, Clone)]
84pub struct Context {
85 config: Option<GlobalConfig>,
86}
87
88pub fn with_context(mut ctx: Context, cfg: GlobalConfig) -> Context {
89 ctx.config = Some(cfg);
90 ctx
91}
92
93pub fn from_context(ctx: &Context) -> Option<GlobalConfig> {
94 ctx.config.clone()
95}
96
97pub fn find_config_file() -> Result<String, ConfigError> {
108 const CONFIG_FILE_NAMES: [&str; 2] = ["xianyun.yaml", "caproni.yaml"];
109
110 let current_dir = std::env::current_dir().map_err(|err| {
111 ConfigError::ConfigFileNotFound(format!("getting current directory: {err}"))
112 })?;
113 let home_dir = dirs_home()
114 .map_err(|err| ConfigError::ConfigFileNotFound(format!("getting home directory: {err}")))?;
115
116 let abs_current_dir = current_dir.canonicalize().unwrap_or(current_dir);
117 let abs_home_dir = home_dir.canonicalize().unwrap_or(home_dir);
118
119 let mut search_dir = abs_current_dir;
120
121 loop {
122 for name in CONFIG_FILE_NAMES {
123 let config_path = search_dir.join(name);
124
125 if config_path.is_file() {
126 let resolved = config_path.canonicalize().map_err(|err| {
127 ConfigError::ConfigFileNotFound(format!(
128 "resolving symlinks for config file: {err}"
129 ))
130 })?;
131 return Ok(resolved.to_string_lossy().into_owned());
132 }
133 }
134
135 let parent = search_dir.parent();
136
137 if parent.is_none() {
138 break;
139 }
140
141 if search_dir == abs_home_dir {
142 break;
143 }
144
145 search_dir = parent.unwrap().to_path_buf();
146 }
147
148 Err(ConfigError::NoConfigFileFound)
149}
150
151fn dirs_home() -> std::io::Result<std::path::PathBuf> {
153 std::env::var_os("HOME")
154 .map(std::path::PathBuf::from)
155 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "HOME is not set"))
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use std::fs;
162
163 #[test]
165 fn new_global_config() {
166 let cfg = GlobalConfig::new(None);
167 assert!(cfg.config_file.is_empty());
168 }
169
170 #[test]
172 fn new_global_config_with_embedded_versions() {
173 let versions = HashMap::from([(
174 "test.yml".to_string(),
175 "variables:\n TEST_VERSION: \"1.0.0\"\n".to_string(),
176 )]);
177
178 let cfg = GlobalConfig::new(Some(versions.clone()));
179 assert_eq!(cfg.embedded_versions, Some(versions));
180 }
181
182 #[test]
184 fn with_context_and_from_context() {
185 let cfg = GlobalConfig {
186 config_file: "/path/to/caproni.yaml".into(),
187 ..Default::default()
188 };
189
190 let ctx = with_context(Context::default(), cfg.clone());
191 let retrieved = from_context(&ctx).expect("config present");
192 assert_eq!(retrieved.config_file, cfg.config_file);
193 }
194
195 #[test]
197 fn from_context_without_config() {
198 let ctx = Context::default();
199 assert!(from_context(&ctx).is_none());
200 }
201
202 #[test]
204 fn config_file_default() {
205 let cfg = GlobalConfig::new(None);
206 assert!(cfg.config_file.is_empty());
207 }
208
209 #[test]
213 fn find_config_file_cases() {
214 {
216 let tmp_dir = tempfile::tempdir().unwrap();
217 std::env::set_current_dir(tmp_dir.path()).unwrap();
218 let config_path = tmp_dir.path().join("caproni.yaml");
219 fs::write(&config_path, "test").unwrap();
220
221 let found = find_config_file().unwrap();
222 assert_eq!(
223 found,
224 fs::canonicalize(&config_path).unwrap().to_string_lossy()
225 );
226 }
227
228 {
230 let tmp_dir = tempfile::tempdir().unwrap();
231 let sub_dir = tmp_dir.path().join("subdir");
232 fs::create_dir(&sub_dir).unwrap();
233 let config_path = tmp_dir.path().join("caproni.yaml");
234 fs::write(&config_path, "test").unwrap();
235 std::env::set_current_dir(&sub_dir).unwrap();
236
237 let found = find_config_file().unwrap();
238 assert_eq!(
239 found,
240 fs::canonicalize(&config_path).unwrap().to_string_lossy()
241 );
242 }
243
244 {
246 let tmp_dir = tempfile::tempdir().unwrap();
247 std::env::set_current_dir(tmp_dir.path()).unwrap();
248
249 let err = find_config_file().unwrap_err();
250 assert_eq!(err, ConfigError::NoConfigFileFound);
251 }
252
253 {
260 let tmp_dir = tempfile::tempdir().unwrap();
261 std::env::set_current_dir(tmp_dir.path()).unwrap();
262 let xianyun_path = tmp_dir.path().join("xianyun.yaml");
263 let caproni_path = tmp_dir.path().join("caproni.yaml");
264 fs::write(&xianyun_path, "primary").unwrap();
265 fs::write(&caproni_path, "alias").unwrap();
266
267 let found = find_config_file().unwrap();
268 assert_eq!(
269 found,
270 fs::canonicalize(&xianyun_path).unwrap().to_string_lossy(),
271 "xianyun.yaml must win over caproni.yaml when both exist"
272 );
273 }
274
275 {
276 let tmp_dir = tempfile::tempdir().unwrap();
277 std::env::set_current_dir(tmp_dir.path()).unwrap();
278 let caproni_path = tmp_dir.path().join("caproni.yaml");
279 fs::write(&caproni_path, "alias only").unwrap();
280
281 let found = find_config_file().unwrap();
282 assert_eq!(
283 found,
284 fs::canonicalize(&caproni_path).unwrap().to_string_lossy(),
285 "caproni.yaml must still be found when xianyun.yaml is absent"
286 );
287 }
288 }
289
290 #[test]
292 fn get_tool_version_success() {
293 let versions = HashMap::from([
294 (
295 "asdf.yml".to_string(),
296 "variables:\n GL_ASDF_KUBECTL_VERSION: \"1.31\"\n GL_ASDF_HELM_VERSION: \"3.20.0\"\n".to_string(),
297 ),
298 ("other.yml".to_string(), "variables:\n GL_MISE_VERSION: \"2026.2.7\"\n".to_string()),
299 ]);
300
301 let mut cfg = GlobalConfig::new(Some(versions));
302
303 for (key, expected) in [
304 ("GL_ASDF_KUBECTL_VERSION", "1.31"),
305 ("GL_ASDF_HELM_VERSION", "3.20.0"),
306 ("GL_MISE_VERSION", "2026.2.7"),
307 ] {
308 assert_eq!(
309 cfg.get_tool_version(key),
310 Some(expected.to_string()),
311 "{key}"
312 );
313 }
314 }
315
316 #[test]
318 fn get_tool_version_not_found() {
319 let versions = HashMap::from([(
320 "test.yml".to_string(),
321 "variables:\n GL_ASDF_KUBECTL_VERSION: \"1.31\"\n".to_string(),
322 )]);
323 let mut cfg = GlobalConfig::new(Some(versions));
324 assert_eq!(cfg.get_tool_version("NONEXISTENT_KEY"), None);
325 }
326
327 #[test]
329 fn get_tool_version_lazy_parsing() {
330 let versions = HashMap::from([(
331 "test.yml".to_string(),
332 "variables:\n TEST_VERSION: \"1.0.0\"\n".to_string(),
333 )]);
334 let mut cfg = GlobalConfig::new(Some(versions));
335
336 assert!(cfg.parsed_versions.is_none());
337
338 assert_eq!(
339 cfg.get_tool_version("TEST_VERSION"),
340 Some("1.0.0".to_string())
341 );
342 assert!(cfg.parsed_versions.is_some());
343
344 assert_eq!(
345 cfg.get_tool_version("TEST_VERSION"),
346 Some("1.0.0".to_string())
347 );
348 assert_eq!(cfg.parsed_versions.as_ref().unwrap().len(), 1);
349 }
350
351 #[test]
353 fn get_tool_version_concurrent_access() {
354 let versions = HashMap::from([(
355 "test.yml".to_string(),
356 "variables:\n GL_ASDF_KUBECTL_VERSION: \"1.31\"\n GL_ASDF_HELM_VERSION: \"3.20.0\"\n GL_MISE_VERSION: \"2026.2.7\"\n".to_string(),
357 )]);
358
359 let mut cfg = GlobalConfig::new(Some(versions));
360 for _ in 0..100 {
364 assert_eq!(
365 cfg.get_tool_version("GL_ASDF_KUBECTL_VERSION"),
366 Some("1.31".to_string())
367 );
368 }
369 }
370
371 #[test]
373 #[should_panic(expected = "parse")]
374 fn get_tool_version_parse_error_panics() {
375 let versions = HashMap::from([(
376 "bad.yml".to_string(),
377 "this is not valid: yaml: - content".to_string(),
378 )]);
379 let mut cfg = GlobalConfig::new(Some(versions));
380 cfg.get_tool_version("ANY_KEY");
381 }
382
383 #[test]
385 fn get_tool_version_empty_versions() {
386 let mut cfg = GlobalConfig::new(None);
387 assert_eq!(cfg.get_tool_version("ANY_KEY"), None);
388 }
389
390 #[test]
392 fn get_tool_version_empty_yaml() {
393 let versions = HashMap::from([("empty.yml".to_string(), String::new())]);
394 let mut cfg = GlobalConfig::new(Some(versions));
395 assert_eq!(cfg.get_tool_version("ANY_KEY"), None);
396 }
397}