Skip to main content

releasaurus_core/config/
package.rs

1use std::sync::LazyLock;
2
3use derive_builder::Builder;
4use regex::Regex;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use crate::{
9    config::{prerelease::PrereleaseConfig, release_type::ReleaseType},
10    result::{ReleasaurusError, Result},
11};
12
13/// Default generic version matcher regex pattern
14pub const GENERIC_VERSION_REGEX_PATTERN: &str = r#"(?mi)(?<start>.*version"?:?\s*=?\s*['"]?)(?<version>\d+\.\d+\.\d+-?.*?)(?<end>['",].*)?$"#;
15
16/// Compiled version of GENERIC_VERSION_REGEX_PATTERN
17pub static GENERIC_VERSION_REGEX: LazyLock<Regex> =
18    LazyLock::new(|| Regex::new(GENERIC_VERSION_REGEX_PATTERN).unwrap());
19
20/// Default tag prefix for package
21pub const DEFAULT_TAG_PREFIX: &str = "v";
22
23/// Additional manifest specification that accepts either a string path or full
24/// config. Allows users to specify version files in a concise way while still
25/// supporting custom regex patterns when needed.
26///
27/// # Examples
28///
29/// Simple string path (uses default GENERIC_VERSION_REGEX):
30/// ```toml
31/// additional_manifest_files = ["VERSION", "README.md"]
32/// ```
33///
34/// Full config with custom regex:
35/// ```toml
36/// additional_manifest_files = [
37///     { path = "VERSION.txt", version_regex = "version:\\s*(\\d+\\.\\d+\\.\\d+)" }
38/// ]
39/// ```
40#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
41#[serde(untagged)]
42pub enum AdditionalManifestSpec {
43    /// Simple string path - uses default GENERIC_VERSION_REGEX
44    Path(String),
45    /// Full configuration with optional custom regex
46    Full(AdditionalManifest),
47}
48
49impl AdditionalManifestSpec {
50    /// Converts the spec into an AdditionalManifest.
51    /// Path variants are converted to use the default GENERIC_VERSION_REGEX
52    /// pattern. Full variants with None for version_regex also get the default
53    /// pattern. After conversion, version_regex is always Some.
54    pub fn into_manifest(self) -> AdditionalManifest {
55        match self {
56            AdditionalManifestSpec::Path(path) => AdditionalManifest {
57                path,
58                version_regex: Some(GENERIC_VERSION_REGEX_PATTERN.to_string()),
59            },
60            AdditionalManifestSpec::Full(mut manifest) => {
61                // Normalize None to default pattern
62                if manifest.version_regex.is_none() {
63                    manifest.version_regex =
64                        Some(GENERIC_VERSION_REGEX_PATTERN.to_string());
65                }
66                manifest
67            }
68        }
69    }
70}
71
72/// Additional manifest configuration for version updates on arbitrary files.
73/// This is the internal representation after conversion from AdditionalManifestSpec.
74#[derive(
75    Debug, Default, Clone, Serialize, Deserialize, JsonSchema, Builder,
76)]
77pub struct AdditionalManifest {
78    /// The path to the manifest file relative to package path
79    pub path: String,
80    /// The regex to use to match and replace versions
81    /// default: (?<start>.*version"?:?\s*=?\s*['"]?)(?<version>\d\.\d\.\d-?.*?)(?<end>['",].*)?$
82    pub version_regex: Option<String>,
83}
84
85/// Sub-package definition allowing grouping of packages under a parent package
86/// configuration. Sub-packages share changelog, tag, and release with the
87/// parent package definition but receive independent manifest version file
88/// updates according to their defined release type
89#[derive(
90    Debug, Default, Clone, Serialize, Deserialize, JsonSchema, Builder,
91)]
92pub struct SubPackage {
93    /// Name for this sub-package (default derived from path if not provided).
94    /// For proper manifest version file updates this should match the
95    /// canonical name field in the release_type manifest file.
96    /// i.e. name = "..." in Cargo.toml or "name": "..." in package.json
97    pub name: String,
98    /// Path to the subpackage directory relative to the workspace_root of
99    /// the parent package
100    pub path: String,
101    /// [`ReleaseType`] type for determining which version files to update
102    pub release_type: Option<ReleaseType>,
103}
104
105/// Package configuration for multi-package repositories and monorepos
106#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Builder)]
107#[serde(default)] // Use default for missing fields
108#[builder(setter(into, strip_option), default)]
109pub struct PackageConfig {
110    /// Name for this package (default derived from path if not provided). For
111    /// proper manifest version file updates this should match the
112    /// canonical name field in the release_type manifest file.
113    /// i.e. name = "..." in Cargo.toml or "name": "..." in package.json
114    pub name: String,
115    /// Path to the workspace root directory for this package relative to the
116    /// repository root
117    pub workspace_root: String,
118    /// Path to package directory relative to workspace_root
119    pub path: String,
120    /// [`ReleaseType`] type for determining which version files to update
121    pub release_type: Option<ReleaseType>,
122    /// Git tag prefix for this package (e.g., "v" or "api-v")
123    pub tag_prefix: Option<String>,
124    /// Groups sub-packages under a single release. Each will share changelog,
125    /// tag, and release, but will receive independent manifest version updates
126    /// according to their type
127    pub sub_packages: Option<Vec<SubPackage>>,
128    /// Optional prerelease configuration that overrides global settings
129    pub prerelease: Option<PrereleaseConfig>,
130    /// Auto starts next release for this package by performing a patch version
131    /// update to version files and pushing a "chore" commit to the base_branch
132    pub auto_start_next: Option<bool>,
133    /// Additional directory paths to include commits from
134    pub additional_paths: Option<Vec<String>>,
135    /// Additional paths to generic version manifest files to update. Paths must
136    /// be relative to the package path. Accepts either simple string paths or
137    /// full config objects with custom regex patterns.
138    pub additional_manifest_files: Option<Vec<AdditionalManifestSpec>>,
139    /// Always increments major version on breaking commits
140    pub breaking_always_increment_major: Option<bool>,
141    /// Always increments minor version on feature commits
142    pub features_always_increment_minor: Option<bool>,
143    /// Custom regex pattern matched against commit messages to trigger a
144    /// major version bump. This is additive — breaking change commits always
145    /// trigger major bumps regardless of this setting. In TOML double-quoted
146    /// strings, escape backslashes (e.g. `"\\[BREAKING\\]"` matches
147    /// `[BREAKING]`).
148    pub custom_major_increment_regex: Option<String>,
149    /// Custom regex pattern matched against commit messages to trigger a
150    /// minor version bump. This is additive — `feat:` commits always trigger
151    /// minor bumps regardless of this setting. In TOML double-quoted strings,
152    /// escape backslashes (e.g. `"\\[FEATURE\\]"` matches `[FEATURE]`).
153    pub custom_minor_increment_regex: Option<String>,
154}
155
156impl Default for PackageConfig {
157    fn default() -> Self {
158        Self {
159            name: "".into(),
160            path: ".".into(),
161            workspace_root: ".".into(),
162            sub_packages: None,
163            release_type: None,
164            tag_prefix: None,
165            prerelease: None,
166            auto_start_next: None,
167            additional_paths: None,
168            additional_manifest_files: None,
169            breaking_always_increment_major: None,
170            features_always_increment_minor: None,
171            custom_major_increment_regex: None,
172            custom_minor_increment_regex: None,
173        }
174    }
175}
176
177impl PackageConfig {
178    pub fn tag_prefix(&self) -> Result<String> {
179        self.tag_prefix.clone().ok_or_else(|| {
180            ReleasaurusError::invalid_config(format!(
181                "failed to resolve tag prefix for package: {}",
182                self.name
183            ))
184        })
185    }
186}
187
188impl From<SubPackage> for PackageConfig {
189    fn from(value: SubPackage) -> Self {
190        Self {
191            path: value.path,
192            release_type: value.release_type,
193            ..Default::default()
194        }
195    }
196}
197
198impl From<&SubPackage> for PackageConfig {
199    fn from(value: &SubPackage) -> Self {
200        Self {
201            path: value.path.clone(),
202            release_type: value.release_type,
203            ..Default::default()
204        }
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn deserializes_string_path_format() {
214        let toml = r#"
215            additional_manifest_files = ["VERSION", "README.md"]
216        "#;
217
218        #[derive(Deserialize)]
219        struct TestConfig {
220            additional_manifest_files: Option<Vec<AdditionalManifestSpec>>,
221        }
222
223        let config: TestConfig = toml::from_str(toml).unwrap();
224        let specs = config.additional_manifest_files.unwrap();
225
226        assert_eq!(specs.len(), 2);
227
228        let manifest1 = specs[0].clone().into_manifest();
229        assert_eq!(manifest1.path, "VERSION");
230        assert_eq!(
231            manifest1.version_regex,
232            Some(GENERIC_VERSION_REGEX_PATTERN.to_string())
233        );
234
235        let manifest2 = specs[1].clone().into_manifest();
236        assert_eq!(manifest2.path, "README.md");
237        assert_eq!(
238            manifest2.version_regex,
239            Some(GENERIC_VERSION_REGEX_PATTERN.to_string())
240        );
241    }
242
243    #[test]
244    fn deserializes_full_object_format() {
245        let toml = r#"
246            [[additional_manifest_files]]
247            path = "VERSION"
248            version_regex = "version:\\s*(\\d+\\.\\d+\\.\\d+)"
249        "#;
250
251        #[derive(Deserialize)]
252        struct TestConfig {
253            additional_manifest_files: Option<Vec<AdditionalManifestSpec>>,
254        }
255
256        let config: TestConfig = toml::from_str(toml).unwrap();
257        let specs = config.additional_manifest_files.unwrap();
258
259        assert_eq!(specs.len(), 1);
260
261        let manifest = specs[0].clone().into_manifest();
262        assert_eq!(manifest.path, "VERSION");
263        assert_eq!(
264            manifest.version_regex,
265            Some("version:\\s*(\\d+\\.\\d+\\.\\d+)".to_string())
266        );
267    }
268
269    #[test]
270    fn deserializes_mixed_format() {
271        let toml = r#"
272            additional_manifest_files = [
273                "VERSION",
274                { path = "config.yml", version_regex = "v:\\s*(\\d+\\.\\d+\\.\\d+)" }
275            ]
276        "#;
277
278        #[derive(Deserialize)]
279        struct TestConfig {
280            additional_manifest_files: Option<Vec<AdditionalManifestSpec>>,
281        }
282
283        let config: TestConfig = toml::from_str(toml).unwrap();
284        let specs = config.additional_manifest_files.unwrap();
285
286        assert_eq!(specs.len(), 2);
287
288        let manifest1 = specs[0].clone().into_manifest();
289        assert_eq!(manifest1.path, "VERSION");
290        assert_eq!(
291            manifest1.version_regex,
292            Some(GENERIC_VERSION_REGEX_PATTERN.to_string())
293        );
294
295        let manifest2 = specs[1].clone().into_manifest();
296        assert_eq!(manifest2.path, "config.yml");
297        assert_eq!(
298            manifest2.version_regex,
299            Some("v:\\s*(\\d+\\.\\d+\\.\\d+)".to_string())
300        );
301    }
302
303    #[test]
304    fn deserializes_full_package_config_with_manifest_files() {
305        let toml = r#"
306            [[package]]
307            path = "."
308            release_type = "rust"
309            additional_manifest_files = ["VERSION", "README.md"]
310
311            [[package]]
312            path = "packages/api"
313            release_type = "node"
314            additional_manifest_files = [
315                "VERSION",
316                { path = "config.yml", version_regex = "v:\\s*(\\d+\\.\\d+\\.\\d+)" }
317            ]
318        "#;
319
320        #[derive(Deserialize)]
321        struct TestConfig {
322            package: Vec<PackageConfig>,
323        }
324
325        let config: TestConfig = toml::from_str(toml).unwrap();
326
327        assert_eq!(config.package.len(), 2);
328
329        // First package - simple string format
330        let pkg1_specs = config.package[0]
331            .additional_manifest_files
332            .as_ref()
333            .unwrap();
334        assert_eq!(pkg1_specs.len(), 2);
335        let manifest1 = pkg1_specs[0].clone().into_manifest();
336        assert_eq!(manifest1.path, "VERSION");
337        assert_eq!(
338            manifest1.version_regex,
339            Some(GENERIC_VERSION_REGEX_PATTERN.to_string())
340        );
341
342        // Second package - mixed format
343        let pkg2_specs = config.package[1]
344            .additional_manifest_files
345            .as_ref()
346            .unwrap();
347        assert_eq!(pkg2_specs.len(), 2);
348        let manifest2_1 = pkg2_specs[0].clone().into_manifest();
349        assert_eq!(manifest2_1.path, "VERSION");
350        assert_eq!(
351            manifest2_1.version_regex,
352            Some(GENERIC_VERSION_REGEX_PATTERN.to_string())
353        );
354
355        let manifest2_2 = pkg2_specs[1].clone().into_manifest();
356        assert_eq!(manifest2_2.path, "config.yml");
357        assert_eq!(
358            manifest2_2.version_regex,
359            Some("v:\\s*(\\d+\\.\\d+\\.\\d+)".to_string())
360        );
361    }
362
363    #[test]
364    fn normalizes_full_variant_with_none_to_default_pattern() {
365        // Test that Full variant with None gets normalized to default pattern
366        let spec = AdditionalManifestSpec::Full(AdditionalManifest {
367            path: "VERSION".to_string(),
368            version_regex: None,
369        });
370
371        let manifest = spec.into_manifest();
372        assert_eq!(manifest.path, "VERSION");
373        assert_eq!(
374            manifest.version_regex,
375            Some(GENERIC_VERSION_REGEX_PATTERN.to_string())
376        );
377    }
378
379    #[test]
380    fn preserves_full_variant_custom_regex() {
381        // Test that Full variant with custom regex is preserved
382        let custom_pattern = "custom:\\s*(\\d+\\.\\d+\\.\\d+)".to_string();
383        let spec = AdditionalManifestSpec::Full(AdditionalManifest {
384            path: "config.yml".to_string(),
385            version_regex: Some(custom_pattern.clone()),
386        });
387
388        let manifest = spec.into_manifest();
389        assert_eq!(manifest.path, "config.yml");
390        assert_eq!(manifest.version_regex, Some(custom_pattern));
391    }
392}