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    /// Monorepo packages. When non-empty, each package is released independently.
55    #[serde(default, skip_serializing_if = "Vec::is_empty")]
56    pub packages: Vec<PackageConfig>,
57    /// Internal: set when resolving a package config. Commits are filtered to this path.
58    #[serde(skip)]
59    pub path_filter: Option<String>,
60}
61
62impl Default for ReleaseConfig {
63    fn default() -> Self {
64        Self {
65            branches: vec!["main".into(), "master".into()],
66            tag_prefix: "v".into(),
67            commit_pattern: DEFAULT_COMMIT_PATTERN.into(),
68            breaking_section: "Breaking Changes".into(),
69            misc_section: "Miscellaneous".into(),
70            types: default_commit_types(),
71            changelog: ChangelogConfig::default(),
72            version_files: vec![],
73            version_files_strict: false,
74            artifacts: vec![],
75            floating_tags: false,
76            build_command: None,
77            stage_files: vec![],
78            prerelease: None,
79            pre_release_command: None,
80            post_release_command: None,
81            sign_tags: false,
82            draft: false,
83            release_name_template: None,
84            hooks: HooksConfig::with_defaults(),
85            packages: vec![],
86            path_filter: None,
87        }
88    }
89}
90
91/// A package in a monorepo. Each package is released independently with its own
92/// version, tags, and changelog. Commits are filtered by `path`.
93///
94/// ```yaml
95/// packages:
96///   - name: core
97///     path: crates/core
98///     version_files:
99///       - crates/core/Cargo.toml
100///   - name: cli
101///     path: crates/cli
102///     version_files:
103///       - crates/cli/Cargo.toml
104/// ```
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct PackageConfig {
107    /// Package name — used in the default tag prefix (`{name}/v`).
108    pub name: String,
109    /// Directory path relative to the repo root. Only commits touching this path trigger a release.
110    pub path: String,
111    /// Tag prefix override (default: `{name}/v`).
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub tag_prefix: Option<String>,
114    /// Version files override.
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub version_files: Vec<String>,
117    /// Changelog override.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub changelog: Option<ChangelogConfig>,
120    /// Build command override.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub build_command: Option<String>,
123    /// Stage files override.
124    #[serde(default, skip_serializing_if = "Vec::is_empty")]
125    pub stage_files: Vec<String>,
126}
127
128/// A single entry in a hook's command list.
129///
130/// Can be either a simple shell command string or a structured step with
131/// file-pattern matching.
132///
133/// ```yaml
134/// hooks:
135///   commit-msg:
136///     - sr hook commit-msg          # simple command
137///   pre-commit:
138///     - step: format                # structured step
139///       patterns:
140///         - "*.rs"
141///       rules:
142///         - "rustfmt --check --edition 2024 {files}"
143/// ```
144#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(untagged)]
146pub enum HookEntry {
147    Step {
148        step: String,
149        patterns: Vec<String>,
150        rules: Vec<String>,
151    },
152    Simple(String),
153}
154
155/// Git hooks configuration.
156///
157/// Each key is a git hook name (e.g. `commit-msg`, `pre-commit`, `pre-push`)
158/// and the value is a list of entries — either simple shell commands or
159/// structured steps with file-pattern matching.
160///
161/// Hook scripts in `.githooks/` are generated by `sr init`.
162#[derive(Debug, Clone, Serialize, Deserialize, Default)]
163#[serde(transparent)]
164pub struct HooksConfig {
165    pub hooks: BTreeMap<String, Vec<HookEntry>>,
166}
167
168impl HooksConfig {
169    pub fn with_defaults() -> Self {
170        let mut hooks = BTreeMap::new();
171        hooks.insert(
172            "commit-msg".into(),
173            vec![HookEntry::Simple("sr hook commit-msg".into())],
174        );
175        Self { hooks }
176    }
177}
178
179#[derive(Debug, Clone, Default, Serialize, Deserialize)]
180#[serde(default)]
181pub struct ChangelogConfig {
182    pub file: Option<String>,
183    pub template: Option<String>,
184}
185
186impl ReleaseConfig {
187    /// Find the first config file that exists in the given directory.
188    /// Returns `(path, is_legacy)`.
189    pub fn find_config(dir: &Path) -> Option<(std::path::PathBuf, bool)> {
190        for &candidate in CONFIG_CANDIDATES {
191            let path = dir.join(candidate);
192            if path.exists() {
193                let is_legacy = candidate == LEGACY_CONFIG_FILE;
194                return Some((path, is_legacy));
195            }
196        }
197        None
198    }
199
200    /// Load config from a YAML file. Falls back to defaults if the file doesn't exist.
201    pub fn load(path: &Path) -> Result<Self, ReleaseError> {
202        if !path.exists() {
203            return Ok(Self::default());
204        }
205
206        let contents =
207            std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
208
209        serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))
210    }
211
212    /// Resolve a package into a full release config by merging package overrides with root config.
213    pub fn resolve_package(&self, pkg: &PackageConfig) -> Self {
214        let mut config = self.clone();
215        config.tag_prefix = pkg
216            .tag_prefix
217            .clone()
218            .unwrap_or_else(|| format!("{}/v", pkg.name));
219        config.path_filter = Some(pkg.path.clone());
220        if !pkg.version_files.is_empty() {
221            config.version_files = pkg.version_files.clone();
222        } else if config.version_files.is_empty() {
223            // Auto-detect version files in the package directory
224            let detected = detect_version_files(Path::new(&pkg.path));
225            if !detected.is_empty() {
226                config.version_files = detected
227                    .into_iter()
228                    .map(|f| format!("{}/{f}", pkg.path))
229                    .collect();
230            }
231        }
232        if let Some(ref cl) = pkg.changelog {
233            config.changelog = cl.clone();
234        }
235        if let Some(ref cmd) = pkg.build_command {
236            config.build_command = Some(cmd.clone());
237        }
238        if !pkg.stage_files.is_empty() {
239            config.stage_files = pkg.stage_files.clone();
240        }
241        // Clear packages to avoid recursion
242        config.packages = vec![];
243        config
244    }
245
246    /// Find a package by name. Returns an error if the package is not found.
247    pub fn find_package(&self, name: &str) -> Result<&PackageConfig, ReleaseError> {
248        self.packages
249            .iter()
250            .find(|p| p.name == name)
251            .ok_or_else(|| {
252                let available: Vec<&str> = self.packages.iter().map(|p| p.name.as_str()).collect();
253                ReleaseError::Config(format!(
254                    "package '{name}' not found. Available: {}",
255                    if available.is_empty() {
256                        "(none — no packages configured)".to_string()
257                    } else {
258                        available.join(", ")
259                    }
260                ))
261            })
262    }
263}
264
265/// Generate a fully-commented default `sr.yaml` template.
266///
267/// The returned string is valid YAML with inline comments documenting every field.
268/// `version_files` is injected dynamically (typically from auto-detection).
269pub fn default_config_template(version_files: &[String]) -> String {
270    let vf = if version_files.is_empty() {
271        "version_files: []\n".to_string()
272    } else {
273        let mut s = "version_files:\n".to_string();
274        for f in version_files {
275            s.push_str(&format!("  - {f}\n"));
276        }
277        s
278    };
279
280    format!(
281        r#"# sr configuration
282# Full reference: https://github.com/urmzd/sr#configuration
283
284# Branches that trigger releases when commits are pushed.
285branches:
286  - main
287  - master
288
289# Prefix prepended to version tags (e.g. "v1.2.0").
290tag_prefix: "v"
291
292# Regex for parsing conventional commits.
293# Required named groups: type, description.
294# Optional named groups: scope, breaking.
295commit_pattern: '^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<description>.+)'
296
297# Changelog section heading for breaking changes.
298breaking_section: Breaking Changes
299
300# Fallback changelog section for unrecognised commit types.
301misc_section: Miscellaneous
302
303# Commit type definitions.
304# name:    commit type prefix (e.g. "feat", "fix")
305# bump:    version bump level — major, minor, patch, or omit for no bump
306# section: changelog section heading, or omit to exclude from changelog
307types:
308  - name: feat
309    bump: minor
310    section: Features
311  - name: fix
312    bump: patch
313    section: Bug Fixes
314  - name: perf
315    bump: patch
316    section: Performance
317  - name: docs
318    section: Documentation
319  - name: refactor
320    section: Refactoring
321  - name: revert
322    section: Reverts
323  - name: chore
324  - name: ci
325  - name: test
326  - name: build
327  - name: style
328
329# Changelog configuration.
330# file:     path to the changelog file (e.g. CHANGELOG.md), or omit to skip writing
331# template: custom Minijinja template string for changelog rendering
332changelog:
333  file:
334  template:
335
336# Manifest files to bump on release (e.g. Cargo.toml, package.json, pyproject.toml).
337# Auto-detected if empty.
338{vf}
339# Fail if a version file uses an unsupported format (default: skip unknown files).
340version_files_strict: false
341
342# Glob patterns for release assets to upload to GitHub (e.g. "dist/*.tar.gz").
343artifacts: []
344
345# Create floating major version tags (e.g. "v3" pointing to latest v3.x.x).
346floating_tags: false
347
348# Shell command to run after version files are bumped (e.g. "cargo build --release").
349build_command:
350
351# Additional files/globs to stage after build_command runs (e.g. Cargo.lock).
352stage_files: []
353
354# Pre-release identifier (e.g. "alpha", "beta", "rc").
355# When set, versions are formatted as X.Y.Z-<id>.N where N auto-increments.
356prerelease:
357
358# Shell command to run before the release starts (validation, checks).
359pre_release_command:
360
361# Shell command to run after the release completes (notifications, deployments).
362post_release_command:
363
364# Sign annotated tags with GPG/SSH (git tag -s).
365sign_tags: false
366
367# Create GitHub releases as drafts (requires manual publishing).
368draft: false
369
370# Minijinja template for the GitHub release name.
371# Available variables: version, tag_name, tag_prefix.
372# Default: uses the tag name (e.g. "v1.2.0").
373release_name_template:
374
375# Git hooks configuration.
376# Each key is a git hook name. Values can be simple commands or structured steps.
377# Steps with patterns only run when staged files match the globs.
378# Rules containing {{files}} receive the matched file list.
379# Hook scripts are generated in .githooks/ by "sr init".
380hooks:
381  commit-msg:
382    - sr hook commit-msg
383  # pre-commit:
384  #   - step: format
385  #     patterns:
386  #       - "*.rs"
387  #     rules:
388  #       - "rustfmt --check --edition 2024 {{files}}"
389  #   - step: lint
390  #     patterns:
391  #       - "*.rs"
392  #     rules:
393  #       - "cargo clippy --workspace -- -D warnings"
394
395# Monorepo packages (uncomment and configure if needed).
396# Each package is released independently with its own version, tags, and changelog.
397# packages:
398#   - name: core
399#     path: crates/core
400#     tag_prefix: "core/v"          # default: "<name>/v"
401#     version_files:
402#       - crates/core/Cargo.toml
403#     changelog:
404#       file: crates/core/CHANGELOG.md
405#     build_command: cargo build -p core
406#     stage_files:
407#       - crates/core/Cargo.lock
408"#
409    )
410}
411
412/// Merge new default fields into an existing config YAML string.
413///
414/// Adds any top-level or nested mapping keys present in the defaults but missing
415/// from the existing config. Arrays are never merged (user's array = user's intent).
416/// Returns the merged YAML with a header comment.
417pub fn merge_config_yaml(existing_yaml: &str) -> Result<String, ReleaseError> {
418    let mut existing: serde_yaml_ng::Value = serde_yaml_ng::from_str(existing_yaml)
419        .map_err(|e| ReleaseError::Config(format!("failed to parse existing config: {e}")))?;
420
421    let default_config = ReleaseConfig::default();
422    let default_yaml = serde_yaml_ng::to_string(&default_config)
423        .map_err(|e| ReleaseError::Config(e.to_string()))?;
424    let defaults: serde_yaml_ng::Value =
425        serde_yaml_ng::from_str(&default_yaml).map_err(|e| ReleaseError::Config(e.to_string()))?;
426
427    deep_merge_value(&mut existing, &defaults);
428
429    let merged =
430        serde_yaml_ng::to_string(&existing).map_err(|e| ReleaseError::Config(e.to_string()))?;
431
432    Ok(format!(
433        "# sr configuration — merged with new defaults\n\
434         # Run 'sr init --force' for a fully-commented template.\n\n\
435         {merged}"
436    ))
437}
438
439/// Recursively insert missing keys from `defaults` into `base`.
440/// Only mapping keys are merged; arrays and scalars are left untouched.
441fn deep_merge_value(base: &mut serde_yaml_ng::Value, defaults: &serde_yaml_ng::Value) {
442    use serde_yaml_ng::Value;
443    if let (Value::Mapping(base_map), Value::Mapping(default_map)) = (base, defaults) {
444        for (key, default_val) in default_map {
445            match base_map.get_mut(key) {
446                Some(existing_val) => {
447                    // Recurse into nested mappings only
448                    if matches!(default_val, Value::Mapping(_)) {
449                        deep_merge_value(existing_val, default_val);
450                    }
451                }
452                None => {
453                    base_map.insert(key.clone(), default_val.clone());
454                }
455            }
456        }
457    }
458}
459
460// Custom deserialization for BumpLevel so it can appear in YAML config.
461impl<'de> Deserialize<'de> for BumpLevel {
462    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
463    where
464        D: serde::Deserializer<'de>,
465    {
466        let s = String::deserialize(deserializer)?;
467        match s.as_str() {
468            "major" => Ok(BumpLevel::Major),
469            "minor" => Ok(BumpLevel::Minor),
470            "patch" => Ok(BumpLevel::Patch),
471            _ => Err(serde::de::Error::custom(format!("unknown bump level: {s}"))),
472        }
473    }
474}
475
476impl Serialize for BumpLevel {
477    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
478    where
479        S: serde::Serializer,
480    {
481        let s = match self {
482            BumpLevel::Major => "major",
483            BumpLevel::Minor => "minor",
484            BumpLevel::Patch => "patch",
485        };
486        serializer.serialize_str(s)
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use std::io::Write;
494
495    #[test]
496    fn default_values() {
497        let config = ReleaseConfig::default();
498        assert_eq!(config.branches, vec!["main", "master"]);
499        assert_eq!(config.tag_prefix, "v");
500        assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
501        assert_eq!(config.breaking_section, "Breaking Changes");
502        assert_eq!(config.misc_section, "Miscellaneous");
503        assert!(!config.types.is_empty());
504        assert!(!config.version_files_strict);
505        assert!(config.artifacts.is_empty());
506        assert!(!config.floating_tags);
507    }
508
509    #[test]
510    fn load_missing_file() {
511        let dir = tempfile::tempdir().unwrap();
512        let path = dir.path().join("nonexistent.yml");
513        let config = ReleaseConfig::load(&path).unwrap();
514        assert_eq!(config.tag_prefix, "v");
515    }
516
517    #[test]
518    fn load_valid_yaml() {
519        let dir = tempfile::tempdir().unwrap();
520        let path = dir.path().join("config.yml");
521        let mut f = std::fs::File::create(&path).unwrap();
522        writeln!(f, "branches:\n  - develop\ntag_prefix: release-").unwrap();
523
524        let config = ReleaseConfig::load(&path).unwrap();
525        assert_eq!(config.branches, vec!["develop"]);
526        assert_eq!(config.tag_prefix, "release-");
527    }
528
529    #[test]
530    fn load_partial_yaml() {
531        let dir = tempfile::tempdir().unwrap();
532        let path = dir.path().join("config.yml");
533        std::fs::write(&path, "tag_prefix: rel-\n").unwrap();
534
535        let config = ReleaseConfig::load(&path).unwrap();
536        assert_eq!(config.tag_prefix, "rel-");
537        assert_eq!(config.branches, vec!["main", "master"]);
538        // defaults should still apply for types/pattern/breaking_section
539        assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
540        assert_eq!(config.breaking_section, "Breaking Changes");
541        assert!(!config.types.is_empty());
542    }
543
544    #[test]
545    fn load_yaml_with_artifacts() {
546        let dir = tempfile::tempdir().unwrap();
547        let path = dir.path().join("config.yml");
548        std::fs::write(
549            &path,
550            "artifacts:\n  - \"dist/*.tar.gz\"\n  - \"build/output-*\"\n",
551        )
552        .unwrap();
553
554        let config = ReleaseConfig::load(&path).unwrap();
555        assert_eq!(config.artifacts, vec!["dist/*.tar.gz", "build/output-*"]);
556        // defaults still apply
557        assert_eq!(config.tag_prefix, "v");
558    }
559
560    #[test]
561    fn load_yaml_with_floating_tags() {
562        let dir = tempfile::tempdir().unwrap();
563        let path = dir.path().join("config.yml");
564        std::fs::write(&path, "floating_tags: true\n").unwrap();
565
566        let config = ReleaseConfig::load(&path).unwrap();
567        assert!(config.floating_tags);
568        // defaults still apply
569        assert_eq!(config.tag_prefix, "v");
570    }
571
572    #[test]
573    fn bump_level_roundtrip() {
574        for (level, expected) in [
575            (BumpLevel::Major, "major"),
576            (BumpLevel::Minor, "minor"),
577            (BumpLevel::Patch, "patch"),
578        ] {
579            let yaml = serde_yaml_ng::to_string(&level).unwrap();
580            assert!(yaml.contains(expected));
581            let parsed: BumpLevel = serde_yaml_ng::from_str(&yaml).unwrap();
582            assert_eq!(parsed, level);
583        }
584    }
585
586    #[test]
587    fn types_roundtrip() {
588        let config = ReleaseConfig::default();
589        let yaml = serde_yaml_ng::to_string(&config).unwrap();
590        let parsed: ReleaseConfig = serde_yaml_ng::from_str(&yaml).unwrap();
591        assert_eq!(parsed.types.len(), config.types.len());
592        assert_eq!(parsed.types[0].name, "feat");
593        assert_eq!(parsed.commit_pattern, config.commit_pattern);
594        assert_eq!(parsed.breaking_section, config.breaking_section);
595    }
596
597    #[test]
598    fn load_yaml_with_packages() {
599        let dir = tempfile::tempdir().unwrap();
600        let path = dir.path().join("config.yml");
601        std::fs::write(
602            &path,
603            r#"
604packages:
605  - name: core
606    path: crates/core
607    version_files:
608      - crates/core/Cargo.toml
609  - name: cli
610    path: crates/cli
611    tag_prefix: "cli-v"
612"#,
613        )
614        .unwrap();
615
616        let config = ReleaseConfig::load(&path).unwrap();
617        assert_eq!(config.packages.len(), 2);
618        assert_eq!(config.packages[0].name, "core");
619        assert_eq!(config.packages[0].path, "crates/core");
620        assert_eq!(config.packages[1].tag_prefix.as_deref(), Some("cli-v"));
621    }
622
623    #[test]
624    fn resolve_package_defaults() {
625        let config = ReleaseConfig {
626            packages: vec![PackageConfig {
627                name: "core".into(),
628                path: "crates/core".into(),
629                tag_prefix: None,
630                version_files: vec![],
631                changelog: None,
632                build_command: None,
633                stage_files: vec![],
634            }],
635            ..Default::default()
636        };
637
638        let resolved = config.resolve_package(&config.packages[0]);
639        assert_eq!(resolved.tag_prefix, "core/v");
640        assert_eq!(resolved.path_filter.as_deref(), Some("crates/core"));
641        // Inherits root config values
642        assert_eq!(resolved.branches, config.branches);
643        assert!(resolved.packages.is_empty());
644    }
645
646    #[test]
647    fn resolve_package_overrides() {
648        let config = ReleaseConfig {
649            version_files: vec!["Cargo.toml".into()],
650            packages: vec![PackageConfig {
651                name: "cli".into(),
652                path: "crates/cli".into(),
653                tag_prefix: Some("cli-v".into()),
654                version_files: vec!["crates/cli/Cargo.toml".into()],
655                changelog: Some(ChangelogConfig {
656                    file: Some("crates/cli/CHANGELOG.md".into()),
657                    template: None,
658                }),
659                build_command: Some("cargo build -p cli".into()),
660                stage_files: vec!["crates/cli/Cargo.lock".into()],
661            }],
662            ..Default::default()
663        };
664
665        let resolved = config.resolve_package(&config.packages[0]);
666        assert_eq!(resolved.tag_prefix, "cli-v");
667        assert_eq!(resolved.version_files, vec!["crates/cli/Cargo.toml"]);
668        assert_eq!(
669            resolved.changelog.file.as_deref(),
670            Some("crates/cli/CHANGELOG.md")
671        );
672        assert_eq!(
673            resolved.build_command.as_deref(),
674            Some("cargo build -p cli")
675        );
676        assert_eq!(resolved.stage_files, vec!["crates/cli/Cargo.lock"]);
677    }
678
679    #[test]
680    fn find_package_found() {
681        let config = ReleaseConfig {
682            packages: vec![PackageConfig {
683                name: "core".into(),
684                path: "crates/core".into(),
685                tag_prefix: None,
686                version_files: vec![],
687                changelog: None,
688                build_command: None,
689                stage_files: vec![],
690            }],
691            ..Default::default()
692        };
693
694        let pkg = config.find_package("core").unwrap();
695        assert_eq!(pkg.name, "core");
696    }
697
698    #[test]
699    fn find_package_not_found() {
700        let config = ReleaseConfig::default();
701        let err = config.find_package("nonexistent").unwrap_err();
702        assert!(err.to_string().contains("nonexistent"));
703        assert!(err.to_string().contains("no packages configured"));
704    }
705
706    #[test]
707    fn packages_not_serialized_when_empty() {
708        let config = ReleaseConfig::default();
709        let yaml = serde_yaml_ng::to_string(&config).unwrap();
710        assert!(!yaml.contains("packages"));
711    }
712
713    #[test]
714    fn default_template_parses() {
715        let template = default_config_template(&[]);
716        let config: ReleaseConfig = serde_yaml_ng::from_str(&template).unwrap();
717        let default = ReleaseConfig::default();
718        assert_eq!(config.branches, default.branches);
719        assert_eq!(config.tag_prefix, default.tag_prefix);
720        assert_eq!(config.commit_pattern, default.commit_pattern);
721        assert_eq!(config.breaking_section, default.breaking_section);
722        assert_eq!(config.types.len(), default.types.len());
723        assert!(!config.floating_tags);
724        assert!(!config.sign_tags);
725        assert!(!config.draft);
726    }
727
728    #[test]
729    fn default_template_with_version_files() {
730        let template = default_config_template(&["Cargo.toml".into(), "package.json".into()]);
731        let config: ReleaseConfig = serde_yaml_ng::from_str(&template).unwrap();
732        assert_eq!(config.version_files, vec!["Cargo.toml", "package.json"]);
733    }
734
735    #[test]
736    fn default_template_contains_all_fields() {
737        let template = default_config_template(&[]);
738        for field in [
739            "branches",
740            "tag_prefix",
741            "commit_pattern",
742            "breaking_section",
743            "misc_section",
744            "types",
745            "changelog",
746            "version_files",
747            "version_files_strict",
748            "artifacts",
749            "floating_tags",
750            "build_command",
751            "stage_files",
752            "prerelease",
753            "pre_release_command",
754            "post_release_command",
755            "sign_tags",
756            "draft",
757            "release_name_template",
758            "hooks",
759            "packages",
760        ] {
761            assert!(template.contains(field), "template missing field: {field}");
762        }
763    }
764
765    #[test]
766    fn merge_adds_missing_fields() {
767        let existing = "tag_prefix: rel-\n";
768        let merged = merge_config_yaml(existing).unwrap();
769        let config: ReleaseConfig = serde_yaml_ng::from_str(&merged).unwrap();
770        // User value preserved
771        assert_eq!(config.tag_prefix, "rel-");
772        // Defaults filled in
773        assert_eq!(config.branches, vec!["main", "master"]);
774        assert_eq!(config.breaking_section, "Breaking Changes");
775        assert!(!config.types.is_empty());
776    }
777
778    #[test]
779    fn merge_preserves_user_values() {
780        let existing = "branches:\n  - develop\ntag_prefix: release-\nfloating_tags: true\n";
781        let merged = merge_config_yaml(existing).unwrap();
782        let config: ReleaseConfig = serde_yaml_ng::from_str(&merged).unwrap();
783        assert_eq!(config.branches, vec!["develop"]);
784        assert_eq!(config.tag_prefix, "release-");
785        assert!(config.floating_tags);
786    }
787
788    #[test]
789    fn merge_nested_changelog() {
790        let existing = "changelog:\n  file: CHANGELOG.md\n";
791        let merged = merge_config_yaml(existing).unwrap();
792        let config: ReleaseConfig = serde_yaml_ng::from_str(&merged).unwrap();
793        assert_eq!(config.changelog.file.as_deref(), Some("CHANGELOG.md"));
794        // template field should exist (merged from defaults)
795        assert!(config.changelog.template.is_none());
796    }
797}