Skip to main content

sr_core/
config.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use crate::commit::{CommitType, DEFAULT_COMMIT_PATTERN, default_commit_types};
7use crate::error::ReleaseError;
8use crate::version::BumpLevel;
9use crate::version_files::detect_version_files;
10
11/// Preferred config file name for new projects.
12pub const DEFAULT_CONFIG_FILE: &str = "sr.yaml";
13
14/// Legacy config file name (deprecated, will be removed in a future release).
15pub const LEGACY_CONFIG_FILE: &str = ".urmzd.sr.yml";
16
17/// Config file candidates, checked in priority order.
18pub const CONFIG_CANDIDATES: &[&str] = &["sr.yaml", "sr.yml", LEGACY_CONFIG_FILE];
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(default)]
22pub struct ReleaseConfig {
23    pub branches: Vec<String>,
24    pub tag_prefix: String,
25    pub commit_pattern: String,
26    pub breaking_section: String,
27    pub misc_section: String,
28    pub types: Vec<CommitType>,
29    pub changelog: ChangelogConfig,
30    pub version_files: Vec<String>,
31    pub version_files_strict: bool,
32    pub artifacts: Vec<String>,
33    pub floating_tags: bool,
34    pub build_command: Option<String>,
35    /// Additional files/globs to stage after `build_command` runs (e.g. `Cargo.lock`).
36    pub stage_files: Vec<String>,
37    /// Pre-release identifier (e.g. "alpha", "beta", "rc"). When set, versions are
38    /// formatted as X.Y.Z-<id>.N where N auto-increments.
39    pub prerelease: Option<String>,
40    /// Shell command to run before the release starts (validation, checks).
41    pub pre_release_command: Option<String>,
42    /// Shell command to run after the release completes (notifications, deployments).
43    pub post_release_command: Option<String>,
44    /// Sign annotated tags with GPG/SSH (git tag -s).
45    pub sign_tags: bool,
46    /// Create GitHub releases as drafts (requires manual publishing).
47    pub draft: bool,
48    /// Minijinja template for the GitHub release name.
49    /// Available variables: `version`, `tag_name`, `tag_prefix`.
50    /// Default when None: uses the tag name (e.g. "v1.2.0").
51    pub release_name_template: Option<String>,
52    /// Git hooks configuration.
53    pub hooks: HooksConfig,
54    /// Structured lifecycle hooks for the release pipeline.
55    /// Each step runs at a specific point in the release flow.
56    /// For simple use cases, `pre_release_command` and `post_release_command` are still supported.
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub lifecycle: Vec<LifecycleStep>,
59    /// Versioning strategy for monorepo packages.
60    /// `independent` (default): each package is versioned separately.
61    /// `fixed`: all packages share one version, tag, and changelog.
62    #[serde(default)]
63    pub versioning: VersioningMode,
64    /// Monorepo packages. When non-empty, each package is released independently
65    /// (or together when `versioning: fixed`).
66    #[serde(default, skip_serializing_if = "Vec::is_empty")]
67    pub packages: Vec<PackageConfig>,
68    /// Internal: set when resolving a package config. Commits are filtered to this path.
69    #[serde(skip)]
70    pub path_filter: Option<String>,
71}
72
73impl Default for ReleaseConfig {
74    fn default() -> Self {
75        Self {
76            branches: vec!["main".into()],
77            tag_prefix: "v".into(),
78            commit_pattern: DEFAULT_COMMIT_PATTERN.into(),
79            breaking_section: "Breaking Changes".into(),
80            misc_section: "Miscellaneous".into(),
81            types: default_commit_types(),
82            changelog: ChangelogConfig::default(),
83            version_files: vec![],
84            version_files_strict: false,
85            artifacts: vec![],
86            floating_tags: true,
87            build_command: None,
88            stage_files: vec![],
89            prerelease: None,
90            pre_release_command: None,
91            post_release_command: None,
92            sign_tags: false,
93            draft: false,
94            release_name_template: None,
95            hooks: HooksConfig::with_defaults(),
96            lifecycle: vec![],
97            versioning: VersioningMode::default(),
98            packages: vec![],
99            path_filter: None,
100        }
101    }
102}
103
104/// Versioning strategy for monorepo packages.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
106#[serde(rename_all = "lowercase")]
107pub enum VersioningMode {
108    /// Each package is versioned and released independently (default).
109    #[default]
110    Independent,
111    /// All packages share a single version, tag, and changelog.
112    Fixed,
113}
114
115/// A package in a monorepo. Each package is released independently with its own
116/// version, tags, and changelog. Commits are filtered by `path`.
117///
118/// ```yaml
119/// packages:
120///   - name: core
121///     path: crates/core
122///     version_files:
123///       - crates/core/Cargo.toml
124///   - name: cli
125///     path: crates/cli
126///     version_files:
127///       - crates/cli/Cargo.toml
128/// ```
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct PackageConfig {
131    /// Package name — used in the default tag prefix (`{name}/v`).
132    pub name: String,
133    /// Directory path relative to the repo root. Only commits touching this path trigger a release.
134    pub path: String,
135    /// Tag prefix override (default: `{name}/v`).
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub tag_prefix: Option<String>,
138    /// Version files override.
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub version_files: Vec<String>,
141    /// Changelog override.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub changelog: Option<ChangelogConfig>,
144    /// Build command override.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub build_command: Option<String>,
147    /// Stage files override.
148    #[serde(default, skip_serializing_if = "Vec::is_empty")]
149    pub stage_files: Vec<String>,
150}
151
152/// A lifecycle event in the release pipeline.
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
154#[serde(rename_all = "snake_case")]
155pub enum LifecycleEvent {
156    /// Before the release starts (validation, checks, linting).
157    PreRelease,
158    /// After version files are bumped but before build_command runs.
159    PostBump,
160    /// After build_command completes but before git commit.
161    PostBuild,
162    /// After everything completes (notifications, deployments).
163    PostRelease,
164}
165
166/// A named step in the release lifecycle pipeline.
167///
168/// ```yaml
169/// lifecycle:
170///   - name: lint
171///     when: pre_release
172///     run: "cargo clippy -- -D warnings"
173///   - name: notify
174///     when: post_release
175///     run: "./scripts/notify-slack.sh"
176/// ```
177#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
178pub struct LifecycleStep {
179    /// Step name (for logging).
180    pub name: String,
181    /// When this step runs in the release pipeline.
182    pub when: LifecycleEvent,
183    /// Shell command to execute. `SR_VERSION` and `SR_TAG` env vars are set.
184    pub run: String,
185}
186
187/// A single entry in a hook's command list.
188///
189/// Can be either a simple shell command string or a structured step with
190/// file-pattern matching.
191///
192/// ```yaml
193/// hooks:
194///   commit-msg:
195///     - sr hook commit-msg          # simple command
196///   pre-commit:
197///     - step: format                # structured step
198///       patterns:
199///         - "*.rs"
200///       rules:
201///         - "rustfmt --check --edition 2024 {files}"
202/// ```
203#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(untagged)]
205pub enum HookEntry {
206    Step {
207        step: String,
208        patterns: Vec<String>,
209        rules: Vec<String>,
210    },
211    Simple(String),
212}
213
214/// Git hooks configuration.
215///
216/// Each key is a git hook name (e.g. `commit-msg`, `pre-commit`, `pre-push`)
217/// and the value is a list of entries — either simple shell commands or
218/// structured steps with file-pattern matching.
219///
220/// Hook scripts in `.githooks/` are generated by `sr init`.
221#[derive(Debug, Clone, Serialize, Deserialize, Default)]
222#[serde(transparent)]
223pub struct HooksConfig {
224    pub hooks: BTreeMap<String, Vec<HookEntry>>,
225}
226
227impl HooksConfig {
228    pub fn with_defaults() -> Self {
229        let mut hooks = BTreeMap::new();
230        hooks.insert(
231            "commit-msg".into(),
232            vec![HookEntry::Simple("sr hook commit-msg".into())],
233        );
234        Self { hooks }
235    }
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
239#[serde(default)]
240pub struct ChangelogConfig {
241    pub file: Option<String>,
242    pub template: Option<String>,
243}
244
245impl Default for ChangelogConfig {
246    fn default() -> Self {
247        Self {
248            file: Some("CHANGELOG.md".into()),
249            template: None,
250        }
251    }
252}
253
254impl ReleaseConfig {
255    /// Find the first config file that exists in the given directory.
256    /// Returns `(path, is_legacy)`.
257    pub fn find_config(dir: &Path) -> Option<(std::path::PathBuf, bool)> {
258        for &candidate in CONFIG_CANDIDATES {
259            let path = dir.join(candidate);
260            if path.exists() {
261                let is_legacy = candidate == LEGACY_CONFIG_FILE;
262                return Some((path, is_legacy));
263            }
264        }
265        None
266    }
267
268    /// Load config from a YAML file. Falls back to defaults if the file doesn't exist.
269    pub fn load(path: &Path) -> Result<Self, ReleaseError> {
270        if !path.exists() {
271            return Ok(Self::default());
272        }
273
274        let contents =
275            std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
276
277        serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))
278    }
279
280    /// Resolve a package into a full release config by merging package overrides with root config.
281    pub fn resolve_package(&self, pkg: &PackageConfig) -> Self {
282        let mut config = self.clone();
283        config.tag_prefix = pkg
284            .tag_prefix
285            .clone()
286            .unwrap_or_else(|| format!("{}/v", pkg.name));
287        config.path_filter = Some(pkg.path.clone());
288        if !pkg.version_files.is_empty() {
289            config.version_files = pkg.version_files.clone();
290        } else if config.version_files.is_empty() {
291            // Auto-detect version files in the package directory
292            let detected = detect_version_files(Path::new(&pkg.path));
293            if !detected.is_empty() {
294                config.version_files = detected
295                    .into_iter()
296                    .map(|f| format!("{}/{f}", pkg.path))
297                    .collect();
298            }
299        }
300        if let Some(ref cl) = pkg.changelog {
301            config.changelog = cl.clone();
302        }
303        if let Some(ref cmd) = pkg.build_command {
304            config.build_command = Some(cmd.clone());
305        }
306        if !pkg.stage_files.is_empty() {
307            config.stage_files = pkg.stage_files.clone();
308        }
309        // Clear packages to avoid recursion
310        config.packages = vec![];
311        config
312    }
313
314    /// Resolve all packages into a single config for fixed versioning mode.
315    ///
316    /// Collects version files, stage files, and build commands from all packages.
317    /// Uses the root `tag_prefix` (no per-package prefixes). No path filter is set
318    /// so commits from the entire repo determine the version bump.
319    pub fn resolve_fixed(&self) -> Self {
320        let mut config = self.clone();
321        // No path filter — all commits count
322        config.path_filter = None;
323
324        // Collect version files from all packages
325        let mut version_files: Vec<String> = config.version_files.clone();
326        for pkg in &self.packages {
327            if !pkg.version_files.is_empty() {
328                version_files.extend(pkg.version_files.clone());
329            } else {
330                let detected = detect_version_files(Path::new(&pkg.path));
331                version_files.extend(detected.into_iter().map(|f| format!("{}/{f}", pkg.path)));
332            }
333        }
334        version_files.sort();
335        version_files.dedup();
336        config.version_files = version_files;
337
338        // Collect stage files from all packages
339        let mut stage_files = config.stage_files.clone();
340        for pkg in &self.packages {
341            stage_files.extend(pkg.stage_files.clone());
342        }
343        stage_files.sort();
344        stage_files.dedup();
345        config.stage_files = stage_files;
346
347        // Clear packages to avoid recursion
348        config.packages = vec![];
349        config
350    }
351
352    /// Find a package by name. Returns an error if the package is not found.
353    pub fn find_package(&self, name: &str) -> Result<&PackageConfig, ReleaseError> {
354        self.packages
355            .iter()
356            .find(|p| p.name == name)
357            .ok_or_else(|| {
358                let available: Vec<&str> = self.packages.iter().map(|p| p.name.as_str()).collect();
359                ReleaseError::Config(format!(
360                    "package '{name}' not found. Available: {}",
361                    if available.is_empty() {
362                        "(none — no packages configured)".to_string()
363                    } else {
364                        available.join(", ")
365                    }
366                ))
367            })
368    }
369}
370
371/// Generate a fully-commented default `sr.yaml` template.
372///
373/// The returned string is valid YAML with inline comments documenting every field.
374/// `version_files` is injected dynamically (typically from auto-detection).
375pub fn default_config_template(version_files: &[String]) -> String {
376    let vf = if version_files.is_empty() {
377        "version_files: []\n".to_string()
378    } else {
379        let mut s = "version_files:\n".to_string();
380        for f in version_files {
381            s.push_str(&format!("  - {f}\n"));
382        }
383        s
384    };
385
386    format!(
387        r#"# sr configuration
388# Full reference: https://github.com/urmzd/sr#configuration
389
390# Branches that trigger releases when commits are pushed.
391branches:
392  - main
393
394# Prefix prepended to version tags (e.g. "v1.2.0").
395tag_prefix: "v"
396
397# Regex for parsing conventional commits.
398# Required named groups: type, description.
399# Optional named groups: scope, breaking.
400commit_pattern: '^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<description>.+)'
401
402# Changelog section heading for breaking changes.
403breaking_section: Breaking Changes
404
405# Fallback changelog section for unrecognised commit types.
406misc_section: Miscellaneous
407
408# Commit type definitions.
409# name:    commit type prefix (e.g. "feat", "fix")
410# bump:    version bump level — major, minor, patch, or omit for no bump
411# section: changelog section heading, or omit to exclude from changelog
412# pattern: optional regex to match non-conventional commits as this type (fallback)
413types:
414  - name: feat
415    bump: minor
416    section: Features
417  - name: fix
418    bump: patch
419    section: Bug Fixes
420  - name: perf
421    bump: patch
422    section: Performance
423  - name: docs
424    section: Documentation
425  - name: refactor
426    bump: patch
427    section: Refactoring
428  - name: revert
429    section: Reverts
430  - name: chore
431  - name: ci
432  - name: test
433  - name: build
434  - name: style
435
436# Changelog configuration.
437# file:     path to the changelog file (e.g. CHANGELOG.md), or omit to skip writing
438# template: custom Minijinja template string for changelog rendering
439changelog:
440  file: CHANGELOG.md
441  template:
442
443# Manifest files to bump on release (e.g. Cargo.toml, package.json, pyproject.toml).
444# Auto-detected if empty.
445{vf}
446# Fail if a version file uses an unsupported format (default: skip unknown files).
447version_files_strict: false
448
449# Glob patterns for release assets to upload to GitHub (e.g. "dist/*.tar.gz").
450artifacts: []
451
452# Create floating major version tags (e.g. "v3" pointing to latest v3.x.x).
453floating_tags: true
454
455# Shell command to run after version files are bumped (e.g. "cargo build --release").
456build_command:
457
458# Additional files/globs to stage after build_command runs (e.g. Cargo.lock).
459stage_files: []
460
461# Pre-release identifier (e.g. "alpha", "beta", "rc").
462# When set, versions are formatted as X.Y.Z-<id>.N where N auto-increments.
463prerelease:
464
465# Shell command to run before the release starts (validation, checks).
466pre_release_command:
467
468# Shell command to run after the release completes (notifications, deployments).
469post_release_command:
470
471# Sign annotated tags with GPG/SSH (git tag -s).
472sign_tags: false
473
474# Create GitHub releases as drafts (requires manual publishing).
475draft: false
476
477# Minijinja template for the GitHub release name.
478# Available variables: version, tag_name, tag_prefix.
479# Default: uses the tag name (e.g. "v1.2.0").
480release_name_template:
481
482# Git hooks configuration.
483# Each key is a git hook name. Values can be simple commands or structured steps.
484# Steps with patterns only run when staged files match the globs.
485# Rules containing {{files}} receive the matched file list.
486# Hook scripts are generated in .githooks/ by "sr init".
487hooks:
488  commit-msg:
489    - sr hook commit-msg
490  # pre-commit:
491  #   - step: format
492  #     patterns:
493  #       - "*.rs"
494  #     rules:
495  #       - "rustfmt --check --edition 2024 {{files}}"
496  #   - step: lint
497  #     patterns:
498  #       - "*.rs"
499  #     rules:
500  #       - "cargo clippy --workspace -- -D warnings"
501
502# Release lifecycle hooks — run commands at specific points in the release pipeline.
503# Runs after pre_release_command/post_release_command (both systems coexist).
504# Supported events: pre_release, post_bump, post_build, post_release
505# SR_VERSION and SR_TAG env vars are set for all lifecycle steps.
506# lifecycle:
507#   - name: lint
508#     when: pre_release
509#     run: "cargo clippy -- -D warnings"
510#   - name: verify
511#     when: post_bump
512#     run: "./scripts/verify-version.sh"
513#   - name: check
514#     when: post_build
515#     run: "./scripts/check-artifacts.sh"
516#   - name: notify
517#     when: post_release
518#     run: "./scripts/notify-slack.sh"
519
520# Versioning strategy for monorepo packages.
521# "independent" (default): each package gets its own version and tags.
522# "fixed": all packages share one version, tag, and changelog.
523# versioning: independent
524
525# Monorepo packages (uncomment and configure if needed).
526# With versioning: independent — each package is released separately.
527# With versioning: fixed — all packages are released together under one version.
528# packages:
529#   - name: core
530#     path: crates/core
531#     tag_prefix: "core/v"          # default: "<name>/v" (independent only)
532#     version_files:
533#       - crates/core/Cargo.toml
534#     changelog:
535#       file: crates/core/CHANGELOG.md
536#     build_command: cargo build -p core
537#     stage_files:
538#       - crates/core/Cargo.lock
539"#
540    )
541}
542
543/// Merge new default fields into an existing config YAML string.
544///
545/// Adds any top-level or nested mapping keys present in the defaults but missing
546/// from the existing config. Arrays are never merged (user's array = user's intent).
547/// Returns the merged YAML with a header comment.
548pub fn merge_config_yaml(existing_yaml: &str) -> Result<String, ReleaseError> {
549    let mut existing: serde_yaml_ng::Value = serde_yaml_ng::from_str(existing_yaml)
550        .map_err(|e| ReleaseError::Config(format!("failed to parse existing config: {e}")))?;
551
552    let default_config = ReleaseConfig::default();
553    let default_yaml = serde_yaml_ng::to_string(&default_config)
554        .map_err(|e| ReleaseError::Config(e.to_string()))?;
555    let defaults: serde_yaml_ng::Value =
556        serde_yaml_ng::from_str(&default_yaml).map_err(|e| ReleaseError::Config(e.to_string()))?;
557
558    deep_merge_value(&mut existing, &defaults);
559
560    let merged =
561        serde_yaml_ng::to_string(&existing).map_err(|e| ReleaseError::Config(e.to_string()))?;
562
563    Ok(format!(
564        "# sr configuration — merged with new defaults\n\
565         # Run 'sr init --force' for a fully-commented template.\n\n\
566         {merged}"
567    ))
568}
569
570/// Recursively insert missing keys from `defaults` into `base`.
571/// Only mapping keys are merged; arrays and scalars are left untouched.
572fn deep_merge_value(base: &mut serde_yaml_ng::Value, defaults: &serde_yaml_ng::Value) {
573    use serde_yaml_ng::Value;
574    if let (Value::Mapping(base_map), Value::Mapping(default_map)) = (base, defaults) {
575        for (key, default_val) in default_map {
576            match base_map.get_mut(key) {
577                Some(existing_val) => {
578                    // Recurse into nested mappings only
579                    if matches!(default_val, Value::Mapping(_)) {
580                        deep_merge_value(existing_val, default_val);
581                    }
582                }
583                None => {
584                    base_map.insert(key.clone(), default_val.clone());
585                }
586            }
587        }
588    }
589}
590
591// Custom deserialization for BumpLevel so it can appear in YAML config.
592impl<'de> Deserialize<'de> for BumpLevel {
593    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
594    where
595        D: serde::Deserializer<'de>,
596    {
597        let s = String::deserialize(deserializer)?;
598        match s.as_str() {
599            "major" => Ok(BumpLevel::Major),
600            "minor" => Ok(BumpLevel::Minor),
601            "patch" => Ok(BumpLevel::Patch),
602            _ => Err(serde::de::Error::custom(format!("unknown bump level: {s}"))),
603        }
604    }
605}
606
607impl Serialize for BumpLevel {
608    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
609    where
610        S: serde::Serializer,
611    {
612        let s = match self {
613            BumpLevel::Major => "major",
614            BumpLevel::Minor => "minor",
615            BumpLevel::Patch => "patch",
616        };
617        serializer.serialize_str(s)
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624    use std::io::Write;
625
626    #[test]
627    fn default_values() {
628        let config = ReleaseConfig::default();
629        assert_eq!(config.branches, vec!["main"]);
630        assert_eq!(config.tag_prefix, "v");
631        assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
632        assert_eq!(config.breaking_section, "Breaking Changes");
633        assert_eq!(config.misc_section, "Miscellaneous");
634        assert!(!config.types.is_empty());
635        assert!(!config.version_files_strict);
636        assert!(config.artifacts.is_empty());
637        assert!(config.floating_tags);
638        assert_eq!(config.changelog.file.as_deref(), Some("CHANGELOG.md"));
639        // Verify refactor has bump: patch (must match README)
640        let refactor = config.types.iter().find(|t| t.name == "refactor").unwrap();
641        assert_eq!(refactor.bump, Some(BumpLevel::Patch));
642    }
643
644    #[test]
645    fn load_missing_file() {
646        let dir = tempfile::tempdir().unwrap();
647        let path = dir.path().join("nonexistent.yml");
648        let config = ReleaseConfig::load(&path).unwrap();
649        assert_eq!(config.tag_prefix, "v");
650    }
651
652    #[test]
653    fn load_valid_yaml() {
654        let dir = tempfile::tempdir().unwrap();
655        let path = dir.path().join("config.yml");
656        let mut f = std::fs::File::create(&path).unwrap();
657        writeln!(f, "branches:\n  - develop\ntag_prefix: release-").unwrap();
658
659        let config = ReleaseConfig::load(&path).unwrap();
660        assert_eq!(config.branches, vec!["develop"]);
661        assert_eq!(config.tag_prefix, "release-");
662    }
663
664    #[test]
665    fn load_partial_yaml() {
666        let dir = tempfile::tempdir().unwrap();
667        let path = dir.path().join("config.yml");
668        std::fs::write(&path, "tag_prefix: rel-\n").unwrap();
669
670        let config = ReleaseConfig::load(&path).unwrap();
671        assert_eq!(config.tag_prefix, "rel-");
672        assert_eq!(config.branches, vec!["main"]);
673        // defaults should still apply for types/pattern/breaking_section
674        assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
675        assert_eq!(config.breaking_section, "Breaking Changes");
676        assert!(!config.types.is_empty());
677    }
678
679    #[test]
680    fn load_yaml_with_artifacts() {
681        let dir = tempfile::tempdir().unwrap();
682        let path = dir.path().join("config.yml");
683        std::fs::write(
684            &path,
685            "artifacts:\n  - \"dist/*.tar.gz\"\n  - \"build/output-*\"\n",
686        )
687        .unwrap();
688
689        let config = ReleaseConfig::load(&path).unwrap();
690        assert_eq!(config.artifacts, vec!["dist/*.tar.gz", "build/output-*"]);
691        // defaults still apply
692        assert_eq!(config.tag_prefix, "v");
693    }
694
695    #[test]
696    fn load_yaml_with_floating_tags() {
697        let dir = tempfile::tempdir().unwrap();
698        let path = dir.path().join("config.yml");
699        std::fs::write(&path, "floating_tags: true\n").unwrap();
700
701        let config = ReleaseConfig::load(&path).unwrap();
702        assert!(config.floating_tags);
703        // defaults still apply
704        assert_eq!(config.tag_prefix, "v");
705    }
706
707    #[test]
708    fn bump_level_roundtrip() {
709        for (level, expected) in [
710            (BumpLevel::Major, "major"),
711            (BumpLevel::Minor, "minor"),
712            (BumpLevel::Patch, "patch"),
713        ] {
714            let yaml = serde_yaml_ng::to_string(&level).unwrap();
715            assert!(yaml.contains(expected));
716            let parsed: BumpLevel = serde_yaml_ng::from_str(&yaml).unwrap();
717            assert_eq!(parsed, level);
718        }
719    }
720
721    #[test]
722    fn types_roundtrip() {
723        let config = ReleaseConfig::default();
724        let yaml = serde_yaml_ng::to_string(&config).unwrap();
725        let parsed: ReleaseConfig = serde_yaml_ng::from_str(&yaml).unwrap();
726        assert_eq!(parsed.types.len(), config.types.len());
727        assert_eq!(parsed.types[0].name, "feat");
728        assert_eq!(parsed.commit_pattern, config.commit_pattern);
729        assert_eq!(parsed.breaking_section, config.breaking_section);
730    }
731
732    #[test]
733    fn load_yaml_with_packages() {
734        let dir = tempfile::tempdir().unwrap();
735        let path = dir.path().join("config.yml");
736        std::fs::write(
737            &path,
738            r#"
739packages:
740  - name: core
741    path: crates/core
742    version_files:
743      - crates/core/Cargo.toml
744  - name: cli
745    path: crates/cli
746    tag_prefix: "cli-v"
747"#,
748        )
749        .unwrap();
750
751        let config = ReleaseConfig::load(&path).unwrap();
752        assert_eq!(config.packages.len(), 2);
753        assert_eq!(config.packages[0].name, "core");
754        assert_eq!(config.packages[0].path, "crates/core");
755        assert_eq!(config.packages[1].tag_prefix.as_deref(), Some("cli-v"));
756    }
757
758    #[test]
759    fn resolve_package_defaults() {
760        let config = ReleaseConfig {
761            packages: vec![PackageConfig {
762                name: "core".into(),
763                path: "crates/core".into(),
764                tag_prefix: None,
765                version_files: vec![],
766                changelog: None,
767                build_command: None,
768                stage_files: vec![],
769            }],
770            ..Default::default()
771        };
772
773        let resolved = config.resolve_package(&config.packages[0]);
774        assert_eq!(resolved.tag_prefix, "core/v");
775        assert_eq!(resolved.path_filter.as_deref(), Some("crates/core"));
776        // Inherits root config values
777        assert_eq!(resolved.branches, config.branches);
778        assert!(resolved.packages.is_empty());
779    }
780
781    #[test]
782    fn resolve_package_overrides() {
783        let config = ReleaseConfig {
784            version_files: vec!["Cargo.toml".into()],
785            packages: vec![PackageConfig {
786                name: "cli".into(),
787                path: "crates/cli".into(),
788                tag_prefix: Some("cli-v".into()),
789                version_files: vec!["crates/cli/Cargo.toml".into()],
790                changelog: Some(ChangelogConfig {
791                    file: Some("crates/cli/CHANGELOG.md".into()),
792                    template: None,
793                }),
794                build_command: Some("cargo build -p cli".into()),
795                stage_files: vec!["crates/cli/Cargo.lock".into()],
796            }],
797            ..Default::default()
798        };
799
800        let resolved = config.resolve_package(&config.packages[0]);
801        assert_eq!(resolved.tag_prefix, "cli-v");
802        assert_eq!(resolved.version_files, vec!["crates/cli/Cargo.toml"]);
803        assert_eq!(
804            resolved.changelog.file.as_deref(),
805            Some("crates/cli/CHANGELOG.md")
806        );
807        assert_eq!(
808            resolved.build_command.as_deref(),
809            Some("cargo build -p cli")
810        );
811        assert_eq!(resolved.stage_files, vec!["crates/cli/Cargo.lock"]);
812    }
813
814    #[test]
815    fn find_package_found() {
816        let config = ReleaseConfig {
817            packages: vec![PackageConfig {
818                name: "core".into(),
819                path: "crates/core".into(),
820                tag_prefix: None,
821                version_files: vec![],
822                changelog: None,
823                build_command: None,
824                stage_files: vec![],
825            }],
826            ..Default::default()
827        };
828
829        let pkg = config.find_package("core").unwrap();
830        assert_eq!(pkg.name, "core");
831    }
832
833    #[test]
834    fn find_package_not_found() {
835        let config = ReleaseConfig::default();
836        let err = config.find_package("nonexistent").unwrap_err();
837        assert!(err.to_string().contains("nonexistent"));
838        assert!(err.to_string().contains("no packages configured"));
839    }
840
841    #[test]
842    fn packages_not_serialized_when_empty() {
843        let config = ReleaseConfig::default();
844        let yaml = serde_yaml_ng::to_string(&config).unwrap();
845        assert!(!yaml.contains("packages"));
846    }
847
848    #[test]
849    fn default_template_parses() {
850        let template = default_config_template(&[]);
851        let config: ReleaseConfig = serde_yaml_ng::from_str(&template).unwrap();
852        let default = ReleaseConfig::default();
853        assert_eq!(config.branches, default.branches);
854        assert_eq!(config.tag_prefix, default.tag_prefix);
855        assert_eq!(config.commit_pattern, default.commit_pattern);
856        assert_eq!(config.breaking_section, default.breaking_section);
857        assert_eq!(config.types.len(), default.types.len());
858        assert!(config.floating_tags);
859        assert!(!config.sign_tags);
860        assert!(!config.draft);
861    }
862
863    #[test]
864    fn default_template_with_version_files() {
865        let template = default_config_template(&["Cargo.toml".into(), "package.json".into()]);
866        let config: ReleaseConfig = serde_yaml_ng::from_str(&template).unwrap();
867        assert_eq!(config.version_files, vec!["Cargo.toml", "package.json"]);
868    }
869
870    #[test]
871    fn default_template_contains_all_fields() {
872        let template = default_config_template(&[]);
873        for field in [
874            "branches",
875            "tag_prefix",
876            "commit_pattern",
877            "breaking_section",
878            "misc_section",
879            "types",
880            "changelog",
881            "version_files",
882            "version_files_strict",
883            "artifacts",
884            "floating_tags",
885            "build_command",
886            "stage_files",
887            "prerelease",
888            "pre_release_command",
889            "post_release_command",
890            "sign_tags",
891            "draft",
892            "release_name_template",
893            "hooks",
894            "lifecycle",
895            "versioning",
896            "packages",
897        ] {
898            assert!(template.contains(field), "template missing field: {field}");
899        }
900    }
901
902    #[test]
903    fn merge_adds_missing_fields() {
904        let existing = "tag_prefix: rel-\n";
905        let merged = merge_config_yaml(existing).unwrap();
906        let config: ReleaseConfig = serde_yaml_ng::from_str(&merged).unwrap();
907        // User value preserved
908        assert_eq!(config.tag_prefix, "rel-");
909        // Defaults filled in
910        assert_eq!(config.branches, vec!["main"]);
911        assert_eq!(config.breaking_section, "Breaking Changes");
912        assert!(!config.types.is_empty());
913    }
914
915    #[test]
916    fn merge_preserves_user_values() {
917        let existing = "branches:\n  - develop\ntag_prefix: release-\nfloating_tags: true\n";
918        let merged = merge_config_yaml(existing).unwrap();
919        let config: ReleaseConfig = serde_yaml_ng::from_str(&merged).unwrap();
920        assert_eq!(config.branches, vec!["develop"]);
921        assert_eq!(config.tag_prefix, "release-");
922        assert!(config.floating_tags);
923    }
924
925    #[test]
926    fn merge_nested_changelog() {
927        let existing = "changelog:\n  file: CHANGELOG.md\n";
928        let merged = merge_config_yaml(existing).unwrap();
929        let config: ReleaseConfig = serde_yaml_ng::from_str(&merged).unwrap();
930        assert_eq!(config.changelog.file.as_deref(), Some("CHANGELOG.md"));
931        // template field should exist (merged from defaults)
932        assert!(config.changelog.template.is_none());
933    }
934
935    #[test]
936    fn lifecycle_step_roundtrip() {
937        let step = LifecycleStep {
938            name: "lint".into(),
939            when: LifecycleEvent::PreRelease,
940            run: "cargo clippy".into(),
941        };
942        let yaml = serde_yaml_ng::to_string(&step).unwrap();
943        assert!(yaml.contains("pre_release"));
944        let parsed: LifecycleStep = serde_yaml_ng::from_str(&yaml).unwrap();
945        assert_eq!(parsed, step);
946    }
947
948    #[test]
949    fn lifecycle_config_parses_from_yaml() {
950        let yaml = r#"
951lifecycle:
952  - name: test
953    when: pre_release
954    run: "cargo test"
955  - name: audit
956    when: post_bump
957    run: "cargo audit"
958  - name: verify
959    when: post_build
960    run: "./scripts/verify.sh"
961  - name: notify
962    when: post_release
963    run: "./scripts/notify.sh"
964"#;
965        let config: ReleaseConfig = serde_yaml_ng::from_str(yaml).unwrap();
966        assert_eq!(config.lifecycle.len(), 4);
967        assert_eq!(config.lifecycle[0].name, "test");
968        assert_eq!(config.lifecycle[0].when, LifecycleEvent::PreRelease);
969        assert_eq!(config.lifecycle[1].when, LifecycleEvent::PostBump);
970        assert_eq!(config.lifecycle[2].when, LifecycleEvent::PostBuild);
971        assert_eq!(config.lifecycle[3].when, LifecycleEvent::PostRelease);
972    }
973
974    #[test]
975    fn lifecycle_not_serialized_when_empty() {
976        let config = ReleaseConfig::default();
977        let yaml = serde_yaml_ng::to_string(&config).unwrap();
978        assert!(!yaml.contains("lifecycle"));
979    }
980
981    #[test]
982    fn versioning_mode_defaults_to_independent() {
983        let config = ReleaseConfig::default();
984        assert_eq!(config.versioning, VersioningMode::Independent);
985    }
986
987    #[test]
988    fn versioning_mode_roundtrip() {
989        for (mode, label) in [
990            (VersioningMode::Independent, "independent"),
991            (VersioningMode::Fixed, "fixed"),
992        ] {
993            let yaml = serde_yaml_ng::to_string(&mode).unwrap();
994            assert!(yaml.contains(label));
995            let parsed: VersioningMode = serde_yaml_ng::from_str(&yaml).unwrap();
996            assert_eq!(parsed, mode);
997        }
998    }
999
1000    #[test]
1001    fn load_yaml_with_versioning_fixed() {
1002        let dir = tempfile::tempdir().unwrap();
1003        let path = dir.path().join("config.yml");
1004        std::fs::write(
1005            &path,
1006            r#"
1007versioning: fixed
1008packages:
1009  - name: core
1010    path: crates/core
1011    version_files:
1012      - crates/core/Cargo.toml
1013  - name: cli
1014    path: crates/cli
1015    version_files:
1016      - crates/cli/Cargo.toml
1017"#,
1018        )
1019        .unwrap();
1020
1021        let config = ReleaseConfig::load(&path).unwrap();
1022        assert_eq!(config.versioning, VersioningMode::Fixed);
1023        assert_eq!(config.packages.len(), 2);
1024    }
1025
1026    #[test]
1027    fn resolve_fixed_collects_all_version_files() {
1028        let config = ReleaseConfig {
1029            version_files: vec!["Cargo.toml".into()],
1030            packages: vec![
1031                PackageConfig {
1032                    name: "core".into(),
1033                    path: "crates/core".into(),
1034                    tag_prefix: Some("core/v".into()),
1035                    version_files: vec!["crates/core/Cargo.toml".into()],
1036                    changelog: None,
1037                    build_command: None,
1038                    stage_files: vec!["crates/core/Cargo.lock".into()],
1039                },
1040                PackageConfig {
1041                    name: "cli".into(),
1042                    path: "crates/cli".into(),
1043                    tag_prefix: None,
1044                    version_files: vec!["crates/cli/Cargo.toml".into()],
1045                    changelog: None,
1046                    build_command: None,
1047                    stage_files: vec![],
1048                },
1049            ],
1050            versioning: VersioningMode::Fixed,
1051            ..Default::default()
1052        };
1053
1054        let resolved = config.resolve_fixed();
1055        // Uses root tag_prefix, not per-package
1056        assert_eq!(resolved.tag_prefix, "v");
1057        // No path filter
1058        assert!(resolved.path_filter.is_none());
1059        // Collects version files from root + all packages
1060        assert!(resolved.version_files.contains(&"Cargo.toml".to_string()));
1061        assert!(
1062            resolved
1063                .version_files
1064                .contains(&"crates/core/Cargo.toml".to_string())
1065        );
1066        assert!(
1067            resolved
1068                .version_files
1069                .contains(&"crates/cli/Cargo.toml".to_string())
1070        );
1071        // Collects stage files
1072        assert!(
1073            resolved
1074                .stage_files
1075                .contains(&"crates/core/Cargo.lock".to_string())
1076        );
1077        // Packages cleared
1078        assert!(resolved.packages.is_empty());
1079    }
1080
1081    #[test]
1082    fn resolve_fixed_deduplicates() {
1083        let config = ReleaseConfig {
1084            version_files: vec!["Cargo.toml".into()],
1085            packages: vec![PackageConfig {
1086                name: "core".into(),
1087                path: "crates/core".into(),
1088                tag_prefix: None,
1089                version_files: vec!["Cargo.toml".into()], // duplicate of root
1090                changelog: None,
1091                build_command: None,
1092                stage_files: vec![],
1093            }],
1094            versioning: VersioningMode::Fixed,
1095            ..Default::default()
1096        };
1097
1098        let resolved = config.resolve_fixed();
1099        let cargo_count = resolved
1100            .version_files
1101            .iter()
1102            .filter(|f| *f == "Cargo.toml")
1103            .count();
1104        assert_eq!(cargo_count, 1);
1105    }
1106}