Skip to main content

xianyun_core/config/
global.rs

1//! Port of `internal/config/config.go`: global configuration management.
2//!
3//! The Go original propagates `*GlobalConfig` via `context.Context`. Rust has
4//! no direct equivalent of Go's `context.Context` value-bag; `with_context`
5//! and `from_context` are ported here as free functions operating on an
6//! explicit `Context` newtype, deferring the real async/task-local design
7//! decision (tokio `task_local!`? an explicit parameter everywhere?) to
8//! Phase 4. See `PORT_NOTES.md`.
9
10use 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    /// Creates a new `GlobalConfig` with sensible defaults.
28    pub fn new(embedded_versions: Option<HashMap<String, String>>) -> Self {
29        Self {
30            embedded_versions,
31            ..Default::default()
32        }
33    }
34
35    /// Lazily parses and merges the embedded CI version files into
36    /// `parsed_versions` exactly once. Panics if an embedded file fails to
37    /// parse, matching the Go original's `panic` on a build-time-only
38    /// failure mode.
39    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    /// Retrieves a tool version from the embedded CI version files.
65    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    /// Returns a copy of the merged tool versions parsed from the embedded CI
74    /// version files.
75    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/// An explicit stand-in for Go's `context.Context` carrying a `GlobalConfig`.
82/// See the module doc comment.
83#[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
97/// Searches for a `xianyun.yaml` (or, as a compatibility alias,
98/// `caproni.yaml`) file starting from the current directory and walking
99/// up the directory tree until it reaches either the root directory or
100/// the user's home directory.
101///
102/// `xianyun.yaml` is this project's own primary config filename;
103/// `caproni.yaml` is supported so existing Go-caproni projects keep
104/// working against this port unmodified. When both exist in the same
105/// directory, `xianyun.yaml` wins. See `PORT_NOTES.md`'s naming-migration
106/// notes.
107pub 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
151/// Returns the current user's home directory, mirroring `os.UserHomeDir()`.
152fn 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    // Port of TestNewGlobalConfig.
164    #[test]
165    fn new_global_config() {
166        let cfg = GlobalConfig::new(None);
167        assert!(cfg.config_file.is_empty());
168    }
169
170    // Port of TestNewGlobalConfigWithEmbeddedVersions.
171    #[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    // Port of TestWithContextAndFromContext.
183    #[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    // Port of TestFromContextWithoutConfig.
196    #[test]
197    fn from_context_without_config() {
198        let ctx = Context::default();
199        assert!(from_context(&ctx).is_none());
200    }
201
202    // Port of TestConfigFileDefault.
203    #[test]
204    fn config_file_default() {
205        let cfg = GlobalConfig::new(None);
206        assert!(cfg.config_file.is_empty());
207    }
208
209    // Port of TestFindConfigFile. Directory-changing subtests are run
210    // sequentially in one #[test], mirroring the Go `//nolint:paralleltest`
211    // marker on the parent (chdir is process-global in both languages).
212    #[test]
213    fn find_config_file_cases() {
214        // finds config in current directory
215        {
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        // finds config in parent directory
229        {
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        // returns error when no config found
245        {
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        // xianyun.yaml is the primary config filename; caproni.yaml is
254        // supported as a compatibility alias so existing Go-caproni
255        // projects keep working unmodified. When both exist, xianyun.yaml
256        // wins. See PORT_NOTES.md's naming-migration notes. Run in this
257        // same #[test] for the same chdir-is-process-global reason as the
258        // cases above.
259        {
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    // Port of TestGetToolVersion_Success.
291    #[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    // Port of TestGetToolVersion_NotFound.
317    #[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    // Port of TestGetToolVersion_LazyParsing.
328    #[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    // Port of TestGetToolVersion_ConcurrentAccess.
352    #[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        // Force the lazy parse before fanning out reads, since the eventual
361        // implementation's thread-safety story (Mutex/OnceCell) is not yet
362        // decided; see PORT_NOTES.md.
363        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    // Port of TestGetToolVersion_ParseError.
372    #[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    // Port of TestGetToolVersion_EmptyVersions.
384    #[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    // Port of TestGetToolVersion_EmptyYAML.
391    #[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}