Skip to main content

cuenv_github/
config.rs

1//! GitHub configuration types and extension traits.
2//!
3//! This module provides GitHub-specific configuration types and extension traits
4//! for working with GitHub Actions CI configuration.
5
6use cuenv_core::ci::{CI, RunnerMapping, StringOrVec};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// GitHub Actions provider configuration.
11///
12/// This struct is owned by the GitHub crate - it should not be defined in core.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
14#[serde(rename_all = "camelCase")]
15pub struct GitHubConfig {
16    /// Runner label(s) - single string or array of labels
17    pub runner: Option<StringOrVec>,
18    /// Runner mapping for matrix dimensions
19    pub runners: Option<RunnerMapping>,
20    /// Cachix configuration for Nix caching
21    pub cachix: Option<CachixConfig>,
22    /// Artifact upload configuration
23    pub artifacts: Option<ArtifactsConfig>,
24    /// Trusted publishing configuration (OIDC-based, no secrets needed)
25    pub trusted_publishing: Option<TrustedPublishingConfig>,
26    /// Paths to ignore for trigger conditions
27    pub paths_ignore: Option<Vec<String>>,
28    /// Workflow permissions
29    pub permissions: Option<HashMap<String, String>>,
30}
31
32/// Trusted publishing configuration for OIDC-based package publishing.
33///
34/// Enables publishing to package registries without storing long-lived tokens.
35/// Uses short-lived tokens obtained via OIDC from the CI platform.
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
37#[serde(rename_all = "camelCase")]
38pub struct TrustedPublishingConfig {
39    /// Enable trusted publishing for crates.io
40    ///
41    /// When enabled, uses `rust-lang/crates-io-auth-action` to obtain
42    /// a short-lived token via OIDC for publishing to crates.io.
43    pub crates_io: Option<bool>,
44}
45
46/// Cachix caching configuration.
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
48#[serde(rename_all = "camelCase")]
49pub struct CachixConfig {
50    /// Cachix cache name
51    pub name: String,
52    /// Secret name for auth token (defaults to CACHIX_AUTH_TOKEN)
53    pub auth_token: Option<String>,
54    /// Push filter pattern
55    pub push_filter: Option<String>,
56}
57
58/// Artifact upload configuration.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
60#[serde(rename_all = "camelCase")]
61pub struct ArtifactsConfig {
62    /// Paths to upload as artifacts
63    pub paths: Option<Vec<String>>,
64    /// Behavior when no files found: "warn", "error", or "ignore"
65    pub if_no_files_found: Option<String>,
66}
67
68/// Extension trait for GitHub-specific configuration operations on [`CI`].
69///
70/// This trait moves GitHub-specific config merging logic from `cuenv-core`
71/// to the GitHub crate where it belongs.
72pub trait GitHubConfigExt {
73    /// Get merged GitHub config for a specific pipeline.
74    ///
75    /// Pipeline-specific config overrides CI-level defaults. Fields are merged
76    /// with pipeline config taking precedence over global config.
77    fn github_config_for_pipeline(&self, pipeline_name: &str) -> GitHubConfig;
78}
79
80impl GitHubConfigExt for CI {
81    fn github_config_for_pipeline(&self, pipeline_name: &str) -> GitHubConfig {
82        let global = self
83            .provider
84            .as_ref()
85            .and_then(|p| p.get("github"))
86            .and_then(|v| serde_json::from_value::<GitHubConfig>(v.clone()).ok())
87            .unwrap_or_default();
88
89        let pipeline_config = self
90            .pipelines
91            .get(pipeline_name)
92            .and_then(|p| p.provider.as_ref())
93            .and_then(|p| p.get("github"))
94            .and_then(|v| serde_json::from_value::<GitHubConfig>(v.clone()).ok());
95
96        match pipeline_config {
97            Some(pipeline) => GitHubConfig {
98                runner: pipeline.runner.clone().or(global.runner),
99                runners: pipeline.runners.clone().or(global.runners),
100                cachix: pipeline.cachix.clone().or(global.cachix),
101                artifacts: pipeline.artifacts.clone().or(global.artifacts),
102                trusted_publishing: pipeline
103                    .trusted_publishing
104                    .clone()
105                    .or(global.trusted_publishing),
106                paths_ignore: pipeline.paths_ignore.clone().or(global.paths_ignore),
107                permissions: pipeline.permissions.clone().or(global.permissions),
108            },
109            None => global,
110        }
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use cuenv_core::ci::{Pipeline, PipelineTask, TaskRef};
118    use serde_json::json;
119    use std::collections::BTreeMap;
120
121    #[test]
122    fn test_github_config_merge() {
123        let ci = CI {
124            provider: Some(
125                serde_json::from_value(json!({
126                    "github": {
127                        "runner": "ubuntu-latest",
128                        "cachix": {
129                            "name": "my-cache"
130                        }
131                    }
132                }))
133                .unwrap(),
134            ),
135            pipelines: BTreeMap::from([
136                (
137                    "ci".to_string(),
138                    Pipeline {
139                        tasks: vec![PipelineTask::Simple(TaskRef::from_name("test"))],
140                        provider: Some(
141                            serde_json::from_value(json!({
142                                "github": {
143                                    "runner": "self-hosted"
144                                }
145                            }))
146                            .unwrap(),
147                        ),
148                        ..Default::default()
149                    },
150                ),
151                (
152                    "release".to_string(),
153                    Pipeline {
154                        tasks: vec![PipelineTask::Simple(TaskRef::from_name("deploy"))],
155                        ..Default::default()
156                    },
157                ),
158            ]),
159            ..Default::default()
160        };
161
162        // Pipeline with override
163        let ci_config = ci.github_config_for_pipeline("ci");
164        assert_eq!(
165            ci_config.runner,
166            Some(StringOrVec::String("self-hosted".to_string()))
167        );
168        assert!(ci_config.cachix.is_some()); // Inherited from global
169
170        // Pipeline without override
171        let release_config = ci.github_config_for_pipeline("release");
172        assert_eq!(
173            release_config.runner,
174            Some(StringOrVec::String("ubuntu-latest".to_string()))
175        );
176    }
177
178    #[test]
179    fn test_github_config_default() {
180        let config = GitHubConfig::default();
181        assert!(config.runner.is_none());
182        assert!(config.runners.is_none());
183        assert!(config.cachix.is_none());
184        assert!(config.artifacts.is_none());
185        assert!(config.trusted_publishing.is_none());
186        assert!(config.paths_ignore.is_none());
187        assert!(config.permissions.is_none());
188    }
189
190    #[test]
191    fn test_trusted_publishing_config_default() {
192        let config = TrustedPublishingConfig::default();
193        assert!(config.crates_io.is_none());
194    }
195
196    #[test]
197    fn test_artifacts_config_default() {
198        let config = ArtifactsConfig::default();
199        assert!(config.paths.is_none());
200        assert!(config.if_no_files_found.is_none());
201    }
202
203    #[test]
204    fn test_cachix_config_serde() {
205        let config = CachixConfig {
206            name: "my-cache".to_string(),
207            auth_token: Some("CACHIX_TOKEN".to_string()),
208            push_filter: Some(".*".to_string()),
209        };
210        let json = serde_json::to_string(&config).unwrap();
211        assert!(json.contains("my-cache"));
212        assert!(json.contains("CACHIX_TOKEN"));
213
214        let parsed: CachixConfig = serde_json::from_str(&json).unwrap();
215        assert_eq!(parsed.name, "my-cache");
216    }
217
218    #[test]
219    fn test_github_config_serde() {
220        let json = json!({
221            "runner": "ubuntu-latest",
222            "cachix": {
223                "name": "test-cache"
224            },
225            "pathsIgnore": ["*.md", "docs/*"]
226        });
227        let config: GitHubConfig = serde_json::from_value(json).unwrap();
228        assert_eq!(
229            config.runner,
230            Some(StringOrVec::String("ubuntu-latest".to_string()))
231        );
232        assert!(config.cachix.is_some());
233        assert_eq!(config.paths_ignore.as_ref().unwrap().len(), 2);
234    }
235
236    #[test]
237    fn test_github_config_for_nonexistent_pipeline() {
238        let ci = CI {
239            provider: Some(
240                serde_json::from_value(json!({
241                    "github": {
242                        "runner": "ubuntu-latest"
243                    }
244                }))
245                .unwrap(),
246            ),
247            ..Default::default()
248        };
249
250        // Returns global config when pipeline doesn't exist
251        let config = ci.github_config_for_pipeline("nonexistent");
252        assert_eq!(
253            config.runner,
254            Some(StringOrVec::String("ubuntu-latest".to_string()))
255        );
256    }
257
258    #[test]
259    fn test_github_config_no_global_config() {
260        let ci = CI::default();
261
262        let config = ci.github_config_for_pipeline("any");
263        // Returns default config when no global config
264        assert!(config.runner.is_none());
265    }
266
267    #[test]
268    fn test_github_config_with_permissions() {
269        let mut permissions = HashMap::new();
270        permissions.insert("contents".to_string(), "read".to_string());
271        permissions.insert("packages".to_string(), "write".to_string());
272
273        let config = GitHubConfig {
274            permissions: Some(permissions),
275            ..Default::default()
276        };
277
278        let perms = config.permissions.unwrap();
279        assert_eq!(perms.get("contents"), Some(&"read".to_string()));
280        assert_eq!(perms.get("packages"), Some(&"write".to_string()));
281    }
282
283    #[test]
284    fn test_github_config_equality() {
285        let config1 = GitHubConfig {
286            runner: Some(StringOrVec::String("ubuntu-latest".to_string())),
287            ..Default::default()
288        };
289        let config2 = GitHubConfig {
290            runner: Some(StringOrVec::String("ubuntu-latest".to_string())),
291            ..Default::default()
292        };
293        assert_eq!(config1, config2);
294    }
295
296    #[test]
297    fn test_trusted_publishing_with_crates_io() {
298        let config = TrustedPublishingConfig {
299            crates_io: Some(true),
300        };
301        assert_eq!(config.crates_io, Some(true));
302
303        let json = serde_json::to_string(&config).unwrap();
304        let parsed: TrustedPublishingConfig = serde_json::from_str(&json).unwrap();
305        assert_eq!(parsed.crates_io, Some(true));
306    }
307}