Skip to main content

cuenv_release/
config.rs

1//! Release configuration types.
2//!
3//! This module defines the Rust representations of the release configuration
4//! that can be specified in `env.cue` files.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9/// Versioning strategy for monorepo packages.
10///
11/// Determines how package versions are managed when changes are detected.
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum VersioningStrategy {
15    /// All packages share the same version (lockstep versioning).
16    ///
17    /// When any package changes, all packages are bumped to the same
18    /// new version using the maximum bump type detected.
19    Fixed,
20
21    /// Packages are bumped together but can have different versions.
22    ///
23    /// All packages get the same bump type applied, but each package
24    /// applies it to its own current version.
25    Linked,
26
27    /// Each package is versioned independently (default).
28    ///
29    /// Only packages that have changes are bumped, and each package
30    /// gets its own bump type based on the changes affecting it.
31    #[default]
32    Independent,
33}
34
35impl fmt::Display for VersioningStrategy {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            Self::Fixed => write!(f, "fixed"),
39            Self::Linked => write!(f, "linked"),
40            Self::Independent => write!(f, "independent"),
41        }
42    }
43}
44
45/// Version tag type for release tags.
46///
47/// Determines how version strings are parsed and compared.
48#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "lowercase")]
50pub enum TagType {
51    /// Semantic versioning (e.g., 0.19.1, 1.0.0-alpha.1).
52    #[default]
53    Semver,
54
55    /// Calendar versioning (e.g., 2024.12.23, 24.04).
56    Calver,
57}
58
59impl fmt::Display for TagType {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            Self::Semver => write!(f, "semver"),
63            Self::Calver => write!(f, "calver"),
64        }
65    }
66}
67
68/// Complete release configuration.
69#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70#[serde(default)]
71pub struct ReleaseConfig {
72    /// Git-related release settings.
73    pub git: ReleaseGitConfig,
74    /// Package grouping configuration.
75    pub packages: ReleasePackagesConfig,
76    /// Changelog generation configuration.
77    pub changelog: ChangelogConfig,
78}
79
80/// Git-related release configuration.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[serde(default)]
83pub struct ReleaseGitConfig {
84    /// Default branch for releases.
85    #[serde(rename = "defaultBranch")]
86    pub default_branch: String,
87    /// Tag prefix for version tags (default: empty for bare versions).
88    #[serde(rename = "tagPrefix")]
89    pub tag_prefix: String,
90    /// Version tag type (semver or calver).
91    #[serde(rename = "tagType")]
92    pub tag_type: TagType,
93    /// Whether to create tags during release.
94    #[serde(rename = "createTags")]
95    pub create_tags: bool,
96    /// Whether to push tags to remote.
97    #[serde(rename = "pushTags")]
98    pub push_tags: bool,
99}
100
101impl Default for ReleaseGitConfig {
102    fn default() -> Self {
103        Self {
104            default_branch: "main".to_string(),
105            tag_prefix: String::new(),
106            tag_type: TagType::Semver,
107            create_tags: true,
108            push_tags: true,
109        }
110    }
111}
112
113impl ReleaseGitConfig {
114    /// Format a tag name from a version.
115    ///
116    /// Combines the tag prefix with the version string.
117    #[must_use]
118    pub fn format_tag(&self, version: &str) -> String {
119        format!("{}{}", self.tag_prefix, version)
120    }
121}
122
123/// Package grouping configuration for version management.
124#[derive(Debug, Clone, Default, Serialize, Deserialize)]
125#[serde(default)]
126pub struct ReleasePackagesConfig {
127    /// Default versioning strategy for packages not in explicit groups.
128    pub strategy: VersioningStrategy,
129    /// Fixed groups: packages that share the same version (lockstep versioning).
130    pub fixed: Vec<Vec<String>>,
131    /// Linked groups: packages that are bumped together but can have different versions.
132    pub linked: Vec<Vec<String>>,
133}
134
135impl ReleasePackagesConfig {
136    /// Check if a package is in a fixed group.
137    #[must_use]
138    pub fn is_in_fixed_group(&self, package: &str) -> bool {
139        self.fixed
140            .iter()
141            .any(|group| group.contains(&package.to_string()))
142    }
143
144    /// Get the fixed group containing a package, if any.
145    #[must_use]
146    pub fn get_fixed_group(&self, package: &str) -> Option<&Vec<String>> {
147        self.fixed
148            .iter()
149            .find(|group| group.contains(&package.to_string()))
150    }
151
152    /// Check if a package is in a linked group.
153    #[must_use]
154    pub fn is_in_linked_group(&self, package: &str) -> bool {
155        self.linked
156            .iter()
157            .any(|group| group.contains(&package.to_string()))
158    }
159
160    /// Get the linked group containing a package, if any.
161    #[must_use]
162    pub fn get_linked_group(&self, package: &str) -> Option<&Vec<String>> {
163        self.linked
164            .iter()
165            .find(|group| group.contains(&package.to_string()))
166    }
167}
168
169/// Changelog generation configuration.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171#[serde(default)]
172pub struct ChangelogConfig {
173    /// Path to the CHANGELOG file relative to project/package root.
174    pub path: String,
175    /// Whether to generate changelogs for each package.
176    #[serde(rename = "perPackage")]
177    pub per_package: bool,
178    /// Whether to generate a root changelog for the entire workspace.
179    pub workspace: bool,
180}
181
182impl Default for ChangelogConfig {
183    fn default() -> Self {
184        Self {
185            path: "CHANGELOG.md".to_string(),
186            per_package: true,
187            workspace: true,
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn test_release_config_default() {
198        let config = ReleaseConfig::default();
199        assert_eq!(config.git.default_branch, "main");
200        assert_eq!(config.git.tag_prefix, "");
201        assert_eq!(config.git.tag_type, TagType::Semver);
202        assert!(config.git.create_tags);
203        assert!(config.git.push_tags);
204    }
205
206    #[test]
207    fn test_git_config_format_tag() {
208        // Default: empty prefix (bare semver)
209        let config = ReleaseGitConfig::default();
210        assert_eq!(config.format_tag("1.0.0"), "1.0.0");
211
212        // With "v" prefix
213        let v_config = ReleaseGitConfig {
214            tag_prefix: "v".to_string(),
215            ..Default::default()
216        };
217        assert_eq!(v_config.format_tag("1.0.0"), "v1.0.0");
218
219        // With package prefix
220        let pkg_config = ReleaseGitConfig {
221            tag_prefix: "vscode/v".to_string(),
222            ..Default::default()
223        };
224        assert_eq!(pkg_config.format_tag("0.1.1"), "vscode/v0.1.1");
225    }
226
227    #[test]
228    fn test_tag_type_default() {
229        assert_eq!(TagType::default(), TagType::Semver);
230    }
231
232    #[test]
233    fn test_packages_config_fixed_groups() {
234        let config = ReleasePackagesConfig {
235            fixed: vec![
236                vec!["pkg-a".to_string(), "pkg-b".to_string()],
237                vec!["pkg-c".to_string()],
238            ],
239            ..Default::default()
240        };
241
242        assert!(config.is_in_fixed_group("pkg-a"));
243        assert!(config.is_in_fixed_group("pkg-b"));
244        assert!(config.is_in_fixed_group("pkg-c"));
245        assert!(!config.is_in_fixed_group("pkg-d"));
246
247        let group = config.get_fixed_group("pkg-a").unwrap();
248        assert!(group.contains(&"pkg-a".to_string()));
249        assert!(group.contains(&"pkg-b".to_string()));
250    }
251
252    #[test]
253    fn test_packages_config_linked_groups() {
254        let config = ReleasePackagesConfig {
255            linked: vec![vec!["pkg-x".to_string(), "pkg-y".to_string()]],
256            ..Default::default()
257        };
258
259        assert!(config.is_in_linked_group("pkg-x"));
260        assert!(config.is_in_linked_group("pkg-y"));
261        assert!(!config.is_in_linked_group("pkg-z"));
262    }
263
264    #[test]
265    fn test_changelog_config_default() {
266        let config = ChangelogConfig::default();
267        assert_eq!(config.path, "CHANGELOG.md");
268        assert!(config.per_package);
269        assert!(config.workspace);
270    }
271
272    #[test]
273    fn test_config_serialization() {
274        let config = ReleaseConfig::default();
275        let json = serde_json::to_string(&config).unwrap();
276        let parsed: ReleaseConfig = serde_json::from_str(&json).unwrap();
277        assert_eq!(parsed.git.default_branch, config.git.default_branch);
278    }
279}