Skip to main content

sr_core/
config.rs

1use std::path::Path;
2
3use serde::{Deserialize, Serialize};
4
5use crate::commit::CommitType;
6use crate::error::ReleaseError;
7use crate::version::BumpLevel;
8use crate::version_files::detect_version_files;
9
10/// Preferred config file name.
11pub const DEFAULT_CONFIG_FILE: &str = "sr.yaml";
12
13/// Config file candidates, checked in priority order.
14pub const CONFIG_CANDIDATES: &[&str] = &["sr.yaml", "sr.yml"];
15
16// ---------------------------------------------------------------------------
17// Top-level config
18// ---------------------------------------------------------------------------
19
20/// Root configuration. Six top-level concerns:
21/// - `git` — tag prefix, floating tags, signing
22/// - `commit` — type→bump classification
23/// - `changelog` — file, template, groups
24/// - `channels` — branch→release mapping
25/// - `vcs` — provider-specific config
26/// - `packages` — version files, artifacts, publish targets
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(default)]
29pub struct Config {
30    pub git: GitConfig,
31    pub commit: CommitConfig,
32    pub changelog: ChangelogConfig,
33    pub channels: ChannelsConfig,
34    pub vcs: VcsConfig,
35    #[serde(default = "default_packages")]
36    pub packages: Vec<PackageConfig>,
37}
38
39impl Default for Config {
40    fn default() -> Self {
41        Self {
42            git: GitConfig::default(),
43            commit: CommitConfig::default(),
44            changelog: ChangelogConfig::default(),
45            channels: ChannelsConfig::default(),
46            vcs: VcsConfig::default(),
47            packages: default_packages(),
48        }
49    }
50}
51
52fn default_packages() -> Vec<PackageConfig> {
53    vec![PackageConfig {
54        path: ".".into(),
55        ..Default::default()
56    }]
57}
58
59// ---------------------------------------------------------------------------
60// Git config
61// ---------------------------------------------------------------------------
62
63/// Git-level settings — tags, signing, identity, commit filtering.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(default)]
66pub struct GitConfig {
67    /// Prefix for git tags (e.g. "v" → "v1.2.0").
68    pub tag_prefix: String,
69    /// Create floating major version tags (e.g. "v3" → latest v3.x.x).
70    pub floating_tag: bool,
71    /// Sign tags with GPG/SSH.
72    pub sign_tags: bool,
73    /// Prevent breaking changes from bumping 0.x.y to 1.0.0.
74    /// When true, major bumps at v0 are downshifted to minor.
75    pub v0_protection: bool,
76    /// Override the git identity used for release commits and tags.
77    /// When unset, sr leaves it to the repo's git config / environment.
78    pub user: GitUserConfig,
79    /// Substrings that, when present in a commit message, exclude that
80    /// commit from release planning and changelog. Matched against the full
81    /// commit message. `chore(release):` is always filtered regardless.
82    pub skip_patterns: Vec<String>,
83}
84
85impl Default for GitConfig {
86    fn default() -> Self {
87        Self {
88            tag_prefix: "v".into(),
89            floating_tag: true,
90            sign_tags: false,
91            v0_protection: true,
92            user: GitUserConfig::default(),
93            skip_patterns: default_skip_patterns(),
94        }
95    }
96}
97
98/// Git author/committer identity for release operations.
99#[derive(Debug, Clone, Default, Serialize, Deserialize)]
100#[serde(default)]
101pub struct GitUserConfig {
102    /// Author/committer name. None = inherit from git config / env.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub name: Option<String>,
105    /// Author/committer email. None = inherit from git config / env.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub email: Option<String>,
108}
109
110/// Default skip tokens. `[skip release]` and `[skip sr]` are recognized out
111/// of the box so users can opt a commit out of the release without touching
112/// config.
113pub fn default_skip_patterns() -> Vec<String> {
114    vec!["[skip release]".into(), "[skip sr]".into()]
115}
116
117// ---------------------------------------------------------------------------
118// Commit config
119// ---------------------------------------------------------------------------
120
121/// How commits are classified by semver bump level.
122#[derive(Debug, Clone, Default, Serialize, Deserialize)]
123#[serde(default)]
124pub struct CommitConfig {
125    /// Commit types grouped by bump level.
126    pub types: CommitTypesConfig,
127}
128
129/// Commit type names grouped by the semver bump level they trigger.
130/// Breaking changes always bump major regardless of configured level.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[serde(default)]
133pub struct CommitTypesConfig {
134    /// Types that trigger a minor version bump.
135    pub minor: Vec<String>,
136    /// Types that trigger a patch version bump.
137    pub patch: Vec<String>,
138    /// Types that do not trigger a release on their own.
139    pub none: Vec<String>,
140}
141
142impl Default for CommitTypesConfig {
143    fn default() -> Self {
144        Self {
145            minor: vec!["feat".into()],
146            patch: vec!["fix".into(), "perf".into(), "refactor".into()],
147            none: vec![
148                "docs".into(),
149                "revert".into(),
150                "chore".into(),
151                "ci".into(),
152                "test".into(),
153                "build".into(),
154                "style".into(),
155            ],
156        }
157    }
158}
159
160impl CommitTypesConfig {
161    /// All type names across all bump levels.
162    pub fn all_type_names(&self) -> Vec<&str> {
163        self.minor
164            .iter()
165            .chain(self.patch.iter())
166            .chain(self.none.iter())
167            .map(|s| s.as_str())
168            .collect()
169    }
170
171    /// Convert to internal `Vec<CommitType>` representation.
172    pub fn into_commit_types(&self) -> Vec<CommitType> {
173        let mut types = Vec::new();
174        for name in &self.minor {
175            types.push(CommitType {
176                name: name.clone(),
177                bump: Some(BumpLevel::Minor),
178            });
179        }
180        for name in &self.patch {
181            types.push(CommitType {
182                name: name.clone(),
183                bump: Some(BumpLevel::Patch),
184            });
185        }
186        for name in &self.none {
187            types.push(CommitType {
188                name: name.clone(),
189                bump: None,
190            });
191        }
192        types
193    }
194}
195
196// ---------------------------------------------------------------------------
197// Changelog config
198// ---------------------------------------------------------------------------
199
200/// Changelog generation — file, template, and commit grouping.
201#[derive(Debug, Clone, Serialize, Deserialize)]
202#[serde(default)]
203pub struct ChangelogConfig {
204    /// Path to the changelog file. None = skip changelog generation.
205    pub file: Option<String>,
206    /// Jinja template — path to file or inline string. None = built-in default.
207    pub template: Option<String>,
208    /// Ordered groups for organizing commits in the changelog.
209    pub groups: Vec<ChangelogGroup>,
210}
211
212impl Default for ChangelogConfig {
213    fn default() -> Self {
214        Self {
215            file: Some("CHANGELOG.md".into()),
216            template: None,
217            groups: default_changelog_groups(),
218        }
219    }
220}
221
222/// A named group of commit types for changelog rendering.
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct ChangelogGroup {
225    /// Machine-readable name (e.g. "breaking", "features").
226    pub name: String,
227    /// Commit types included in this group (e.g. ["feat"]).
228    pub content: Vec<String>,
229}
230
231pub fn default_changelog_groups() -> Vec<ChangelogGroup> {
232    vec![
233        ChangelogGroup {
234            name: "breaking".into(),
235            content: vec!["breaking".into()],
236        },
237        ChangelogGroup {
238            name: "features".into(),
239            content: vec!["feat".into()],
240        },
241        ChangelogGroup {
242            name: "bug-fixes".into(),
243            content: vec!["fix".into()],
244        },
245        ChangelogGroup {
246            name: "performance".into(),
247            content: vec!["perf".into()],
248        },
249        ChangelogGroup {
250            name: "refactoring".into(),
251            content: vec!["refactor".into()],
252        },
253        ChangelogGroup {
254            name: "misc".into(),
255            content: vec![
256                "docs".into(),
257                "revert".into(),
258                "chore".into(),
259                "ci".into(),
260                "test".into(),
261                "build".into(),
262                "style".into(),
263            ],
264        },
265    ]
266}
267
268// ---------------------------------------------------------------------------
269// Channels config
270// ---------------------------------------------------------------------------
271
272/// Release channels for trunk-based promotion.
273/// All channels release from the same branch — channels control the release
274/// strategy (stable vs prerelease vs draft), not the branch.
275#[derive(Debug, Clone, Serialize, Deserialize)]
276#[serde(default)]
277pub struct ChannelsConfig {
278    /// Default channel when no --channel flag given.
279    pub default: String,
280    /// The trunk branch that triggers releases.
281    pub branch: String,
282    /// Channel definitions.
283    pub content: Vec<ChannelConfig>,
284}
285
286impl Default for ChannelsConfig {
287    fn default() -> Self {
288        Self {
289            default: "stable".into(),
290            branch: "main".into(),
291            content: vec![ChannelConfig {
292                name: "stable".into(),
293                prerelease: None,
294                draft: false,
295            }],
296        }
297    }
298}
299
300/// A named release channel.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct ChannelConfig {
303    /// Channel name (e.g. "stable", "rc", "canary").
304    pub name: String,
305    /// Pre-release identifier (e.g. "rc", "canary"). None = stable release.
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub prerelease: Option<String>,
308    /// Create GitHub release as a draft.
309    #[serde(default)]
310    pub draft: bool,
311}
312
313// ---------------------------------------------------------------------------
314// VCS config
315// ---------------------------------------------------------------------------
316
317/// VCS provider-specific configuration.
318#[derive(Debug, Clone, Serialize, Deserialize, Default)]
319#[serde(default)]
320pub struct VcsConfig {
321    pub github: GitHubConfig,
322}
323
324/// GitHub-specific release settings.
325#[derive(Debug, Clone, Serialize, Deserialize, Default)]
326#[serde(default)]
327pub struct GitHubConfig {
328    /// Minijinja template for the GitHub release name.
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub release_name_template: Option<String>,
331}
332
333// ---------------------------------------------------------------------------
334// Package config
335// ---------------------------------------------------------------------------
336
337/// A releasable package — version files, artifacts, build/publish targets.
338///
339/// All packages share one global release tag (`git.tag_prefix` + semver) and
340/// one version line. Each package's `version_files` are bumped to that same
341/// global version on every release; `packages[]` controls *where to write
342/// versions and how to publish*, not *how to version*.
343///
344/// sr does not run user shell commands. Artifact builds happen in CI
345/// between `sr prepare` and `sr release`; sr uploads whatever paths in
346/// `artifacts` point to at tag time.
347#[derive(Debug, Clone, Serialize, Deserialize)]
348#[serde(default)]
349pub struct PackageConfig {
350    /// Directory path relative to repo root. Used for organizing changelog
351    /// sections and as the working directory for typed publishers.
352    pub path: String,
353    /// Manifest files to bump with the global release version. Literal
354    /// paths only — no glob expansion.
355    pub version_files: Vec<String>,
356    /// Fail on unsupported version file formats.
357    pub version_files_strict: bool,
358    /// Additional files to stage in the release commit. Literal paths only.
359    pub stage_files: Vec<String>,
360    /// Artifact files to upload as release assets. Literal paths only —
361    /// every entry must exist on disk before the tag is created.
362    pub artifacts: Vec<String>,
363    /// Changelog config override for this package.
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub changelog: Option<ChangelogConfig>,
366    /// Per-package publish target (invoked during `sr release`).
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub publish: Option<PublishConfig>,
369}
370
371impl Default for PackageConfig {
372    fn default() -> Self {
373        Self {
374            path: ".".into(),
375            version_files: vec![],
376            version_files_strict: false,
377            stage_files: vec![],
378            artifacts: vec![],
379            changelog: None,
380            publish: None,
381        }
382    }
383}
384
385/// Per-package publish configuration. Typed enum — the user picks a
386/// known publisher (`cargo`, `npm`, `docker`, `pypi`, `go`) and sr handles
387/// both the "is it already published?" check (registry API call) and the
388/// actual publish command (shelled out). The `custom` variant is the escape
389/// hatch for arbitrary shell commands + a user-supplied state check.
390///
391/// Deserializes as an internally-tagged enum: `{ type: <publisher>, ... }`.
392#[derive(Debug, Clone, Serialize, Deserialize)]
393#[serde(tag = "type", rename_all = "lowercase")]
394pub enum PublishConfig {
395    /// `cargo publish` to crates.io (or a custom registry).
396    Cargo {
397        /// Feature flags forwarded to `cargo publish --features ...`.
398        #[serde(default, skip_serializing_if = "Vec::is_empty")]
399        features: Vec<String>,
400        /// Cargo registry name (defined in ~/.cargo/config.toml). None =
401        /// default registry (crates.io).
402        #[serde(default, skip_serializing_if = "Option::is_none")]
403        registry: Option<String>,
404        /// When true, publish every `[workspace].members` crate, not just
405        /// the one at `path`. Check aggregates across members (completed
406        /// iff every member is already on the registry).
407        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
408        workspace: bool,
409    },
410    /// `npm publish` to registry.npmjs.org (or a custom registry).
411    /// Auto-detects pnpm / yarn / npm from lockfiles at `path`.
412    Npm {
413        /// Registry URL. None = default (https://registry.npmjs.org/).
414        #[serde(default, skip_serializing_if = "Option::is_none")]
415        registry: Option<String>,
416        /// Access level for scoped packages: "public" or "restricted".
417        #[serde(default, skip_serializing_if = "Option::is_none")]
418        access: Option<String>,
419        /// When true, publish every workspace member (from `package.json`
420        /// `workspaces` or `pnpm-workspace.yaml`). Uses the tool's native
421        /// recursive publish where available.
422        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
423        workspace: bool,
424    },
425    /// Push a container image to a registry using `docker buildx build --push`.
426    Docker {
427        /// Fully-qualified image name (e.g. `ghcr.io/owner/repo`).
428        image: String,
429        /// Target platforms for buildx (e.g. `["linux/amd64", "linux/arm64"]`).
430        /// When empty, buildx picks the default.
431        #[serde(default, skip_serializing_if = "Vec::is_empty")]
432        platforms: Vec<String>,
433        /// Path to Dockerfile relative to the package dir. Default: `Dockerfile`.
434        #[serde(default, skip_serializing_if = "Option::is_none")]
435        dockerfile: Option<String>,
436    },
437    /// Publish to PyPI via `twine upload` or `uv publish` (auto-detected).
438    ///
439    /// Artifacts are resolved per-member from `<package_path>/<dist_dir>` by
440    /// filename prefix (PEP 625 stem + version) — matching `uv build --all`'s
441    /// workspace-root dist layout rather than assuming per-member dist dirs.
442    Pypi {
443        /// Repository name (matches `[tool.twine.repository]` or env).
444        #[serde(default, skip_serializing_if = "Option::is_none")]
445        repository: Option<String>,
446        /// When true, publish every uv workspace member
447        /// (`[tool.uv.workspace].members`). Check aggregates across members.
448        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
449        workspace: bool,
450        /// Directory (relative to the package path) where built wheels/sdists
451        /// live. Defaults to `dist` — matches `uv build --all` output.
452        #[serde(default, skip_serializing_if = "Option::is_none")]
453        dist_dir: Option<String>,
454    },
455    /// Go modules publish by git-tag; sr already cuts the tag, so this is
456    /// effectively a noop documenting the package's presence in the manifest.
457    Go,
458    /// Arbitrary publish command with a user-supplied state check.
459    /// The only place sr shells out for a user-provided command; limited to
460    /// registries that don't have a built-in publisher.
461    Custom {
462        /// Shell command that performs the publish.
463        command: String,
464        /// Shell command that returns exit 0 iff the package is already
465        /// published at the current version. Optional — when absent, the
466        /// publisher always runs.
467        #[serde(default, skip_serializing_if = "Option::is_none")]
468        check: Option<String>,
469        /// Working directory. Defaults to the package path.
470        #[serde(default, skip_serializing_if = "Option::is_none")]
471        cwd: Option<String>,
472    },
473}
474
475// ---------------------------------------------------------------------------
476// Config methods
477// ---------------------------------------------------------------------------
478
479impl Config {
480    /// Find the first config file that exists in the given directory.
481    pub fn find_config(dir: &Path) -> Option<std::path::PathBuf> {
482        for &candidate in CONFIG_CANDIDATES {
483            let path = dir.join(candidate);
484            if path.exists() {
485                return Some(path);
486            }
487        }
488        None
489    }
490
491    /// Load config from a YAML file. Falls back to defaults if the file doesn't exist.
492    pub fn load(path: &Path) -> Result<Self, ReleaseError> {
493        if !path.exists() {
494            return Ok(Self::default());
495        }
496        let contents =
497            std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
498        let config: Self =
499            serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))?;
500        config.validate()?;
501        Ok(config)
502    }
503
504    /// Validate config consistency.
505    fn validate(&self) -> Result<(), ReleaseError> {
506        // Check for duplicate type names across bump levels.
507        let mut seen = std::collections::HashSet::new();
508        for name in self.commit.types.all_type_names() {
509            if !seen.insert(name) {
510                return Err(ReleaseError::Config(format!(
511                    "duplicate commit type: {name}"
512                )));
513            }
514        }
515
516        // Need at least one type with a bump level.
517        if self.commit.types.minor.is_empty() && self.commit.types.patch.is_empty() {
518            return Err(ReleaseError::Config(
519                "commit.types must have at least one minor or patch type".into(),
520            ));
521        }
522
523        // Check for duplicate channel names.
524        let mut channel_names = std::collections::HashSet::new();
525        for ch in &self.channels.content {
526            if !channel_names.insert(&ch.name) {
527                return Err(ReleaseError::Config(format!(
528                    "duplicate channel name: {}",
529                    ch.name
530                )));
531            }
532        }
533
534        Ok(())
535    }
536
537    /// Resolve a named release channel, returning the channel config.
538    pub fn resolve_channel(&self, name: &str) -> Result<&ChannelConfig, ReleaseError> {
539        self.channels
540            .content
541            .iter()
542            .find(|ch| ch.name == name)
543            .ok_or_else(|| {
544                let available: Vec<&str> = self
545                    .channels
546                    .content
547                    .iter()
548                    .map(|c| c.name.as_str())
549                    .collect();
550                ReleaseError::Config(format!(
551                    "channel '{name}' not found. Available: {}",
552                    if available.is_empty() {
553                        "(none)".to_string()
554                    } else {
555                        available.join(", ")
556                    }
557                ))
558            })
559    }
560
561    /// Resolve the default channel.
562    pub fn default_channel(&self) -> Result<&ChannelConfig, ReleaseError> {
563        self.resolve_channel(&self.channels.default)
564    }
565
566    /// Find a package by path.
567    pub fn find_package(&self, path: &str) -> Result<&PackageConfig, ReleaseError> {
568        self.packages
569            .iter()
570            .find(|p| p.path == path)
571            .ok_or_else(|| {
572                let available: Vec<&str> = self.packages.iter().map(|p| p.path.as_str()).collect();
573                ReleaseError::Config(format!(
574                    "package '{path}' not found. Available: {}",
575                    if available.is_empty() {
576                        "(none)".to_string()
577                    } else {
578                        available.join(", ")
579                    }
580                ))
581            })
582    }
583
584    /// Find a package by name (last component of path).
585    pub fn find_package_by_name(&self, name: &str) -> Result<&PackageConfig, ReleaseError> {
586        self.packages
587            .iter()
588            .find(|p| p.path.rsplit('/').next().unwrap_or(&p.path) == name)
589            .ok_or_else(|| {
590                let available: Vec<&str> = self
591                    .packages
592                    .iter()
593                    .map(|p| p.path.rsplit('/').next().unwrap_or(&p.path))
594                    .collect();
595                ReleaseError::Config(format!(
596                    "package '{name}' not found. Available: {}",
597                    if available.is_empty() {
598                        "(none)".to_string()
599                    } else {
600                        available.join(", ")
601                    }
602                ))
603            })
604    }
605
606    /// Resolve effective changelog config for a package.
607    pub fn changelog_for<'a>(&'a self, pkg: &'a PackageConfig) -> &'a ChangelogConfig {
608        pkg.changelog.as_ref().unwrap_or(&self.changelog)
609    }
610
611    /// Resolve effective version files for a package, with auto-detection.
612    pub fn version_files_for(&self, pkg: &PackageConfig) -> Vec<String> {
613        if !pkg.version_files.is_empty() {
614            return pkg.version_files.clone();
615        }
616        let detected = detect_version_files(Path::new(&pkg.path));
617        if pkg.path == "." {
618            detected
619        } else {
620            detected
621                .into_iter()
622                .map(|f| format!("{}/{f}", pkg.path))
623                .collect()
624        }
625    }
626
627    /// Collect all artifacts glob patterns from all packages.
628    pub fn all_artifacts(&self) -> Vec<String> {
629        self.packages
630            .iter()
631            .flat_map(|p| p.artifacts.clone())
632            .collect()
633    }
634}
635
636// ---------------------------------------------------------------------------
637// Template generation
638// ---------------------------------------------------------------------------
639
640pub fn default_config_template(version_files: &[String]) -> String {
641    let vf = if version_files.is_empty() {
642        "    version_files: []\n".to_string()
643    } else {
644        let mut s = "    version_files:\n".to_string();
645        for f in version_files {
646            s.push_str(&format!("      - {f}\n"));
647        }
648        s
649    };
650
651    format!(
652        r#"# sr configuration
653# Full reference: https://github.com/urmzd/sr#configuration
654
655git:
656  tag_prefix: "v"
657  floating_tag: true
658  sign_tags: false
659  v0_protection: true
660  # user:
661  #   name: "sr-releaser[bot]"
662  #   email: "sr-releaser[bot]@users.noreply.github.com"
663  # Commits whose message contains any of these substrings are excluded from
664  # the release plan and changelog. chore(release): is always filtered.
665  skip_patterns:
666    - "[skip release]"
667    - "[skip sr]"
668
669commit:
670  types:
671    minor:
672      - feat
673    patch:
674      - fix
675      - perf
676      - refactor
677    none:
678      - docs
679      - revert
680      - chore
681      - ci
682      - test
683      - build
684      - style
685
686changelog:
687  file: CHANGELOG.md
688  # template: changelog.md.j2
689  groups:
690    - name: breaking
691      content:
692        - breaking
693    - name: features
694      content:
695        - feat
696    - name: bug-fixes
697      content:
698        - fix
699    - name: performance
700      content:
701        - perf
702    - name: misc
703      content:
704        - chore
705        - ci
706        - test
707        - build
708        - style
709
710channels:
711  default: stable
712  branch: main
713  content:
714    - name: stable
715  # - name: rc
716  #   prerelease: rc
717  #   draft: true
718  # - name: canary
719  #   branch: develop
720  #   prerelease: canary
721
722# vcs:
723#   github:
724#     release_name_template: "{{{{ tag_name }}}}"
725
726# Repo-wide lifecycle hooks. Run once per release.
727# hooks:
728#   # Runs before any mutation: tests, lints. May abort the release.
729#   pre_release:
730#     - cargo test
731#   # Runs after tag + GitHub release.
732#   post_release:
733#     - echo "released $SR_VERSION"
734
735packages:
736  - path: .
737{vf}    # version_files_strict: false
738    # stage_files: []
739    # artifacts: []
740    # # Build commands produce this package's declared `artifacts`.
741    # # Runs after version bump, before commit.
742    # build:
743    #   - cargo build --release
744    # # Per-package publish target for `sr publish`. Idempotent.
745    # publish:
746    #   command: cargo publish
747"#
748    )
749}
750
751// ---------------------------------------------------------------------------
752// Tests
753// ---------------------------------------------------------------------------
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758
759    #[test]
760    fn default_values() {
761        let config = Config::default();
762        assert_eq!(config.git.tag_prefix, "v");
763        assert!(config.git.floating_tag);
764        assert!(!config.git.sign_tags);
765        assert_eq!(config.commit.types.minor, vec!["feat"]);
766        assert!(config.commit.types.patch.contains(&"fix".to_string()));
767        assert!(config.commit.types.none.contains(&"chore".to_string()));
768        assert_eq!(config.changelog.file.as_deref(), Some("CHANGELOG.md"));
769        assert!(!config.changelog.groups.is_empty());
770        assert_eq!(config.channels.default, "stable");
771        assert_eq!(config.channels.content.len(), 1);
772        assert_eq!(config.channels.content[0].name, "stable");
773        assert_eq!(config.channels.branch, "main");
774        assert_eq!(config.packages.len(), 1);
775        assert_eq!(config.packages[0].path, ".");
776    }
777
778    #[test]
779    fn load_missing_file() {
780        let dir = tempfile::tempdir().unwrap();
781        let path = dir.path().join("nonexistent.yml");
782        let config = Config::load(&path).unwrap();
783        assert_eq!(config.git.tag_prefix, "v");
784    }
785
786    #[test]
787    fn load_partial_yaml() {
788        let dir = tempfile::tempdir().unwrap();
789        let path = dir.path().join("config.yml");
790        std::fs::write(&path, "git:\n  tag_prefix: rel-\n").unwrap();
791
792        let config = Config::load(&path).unwrap();
793        assert_eq!(config.git.tag_prefix, "rel-");
794        assert_eq!(config.channels.default, "stable");
795    }
796
797    #[test]
798    fn load_yaml_with_packages() {
799        let dir = tempfile::tempdir().unwrap();
800        let path = dir.path().join("config.yml");
801        std::fs::write(
802            &path,
803            "packages:\n  - path: crates/core\n    version_files:\n      - crates/core/Cargo.toml\n",
804        )
805        .unwrap();
806
807        let config = Config::load(&path).unwrap();
808        assert_eq!(config.packages.len(), 1);
809        assert_eq!(config.packages[0].path, "crates/core");
810    }
811
812    #[test]
813    fn commit_types_conversion() {
814        let types = CommitTypesConfig::default();
815        let commit_types = types.into_commit_types();
816        let feat = commit_types.iter().find(|t| t.name == "feat").unwrap();
817        assert_eq!(feat.bump, Some(BumpLevel::Minor));
818        let fix = commit_types.iter().find(|t| t.name == "fix").unwrap();
819        assert_eq!(fix.bump, Some(BumpLevel::Patch));
820        let chore = commit_types.iter().find(|t| t.name == "chore").unwrap();
821        assert_eq!(chore.bump, None);
822    }
823
824    #[test]
825    fn all_type_names() {
826        let types = CommitTypesConfig::default();
827        let names = types.all_type_names();
828        assert!(names.contains(&"feat"));
829        assert!(names.contains(&"fix"));
830        assert!(names.contains(&"chore"));
831    }
832
833    #[test]
834    fn resolve_channel() {
835        let config = Config::default();
836        let channel = config.resolve_channel("stable").unwrap();
837        assert!(channel.prerelease.is_none());
838    }
839
840    #[test]
841    fn resolve_channel_not_found() {
842        let config = Config::default();
843        assert!(config.resolve_channel("missing").is_err());
844    }
845
846    #[test]
847    fn validate_duplicate_types() {
848        let config = Config {
849            commit: CommitConfig {
850                types: CommitTypesConfig {
851                    minor: vec!["feat".into()],
852                    patch: vec!["feat".into()],
853                    none: vec![],
854                },
855            },
856            ..Default::default()
857        };
858        assert!(config.validate().is_err());
859    }
860
861    #[test]
862    fn validate_no_bump_types() {
863        let config = Config {
864            commit: CommitConfig {
865                types: CommitTypesConfig {
866                    minor: vec![],
867                    patch: vec![],
868                    none: vec!["chore".into()],
869                },
870            },
871            ..Default::default()
872        };
873        assert!(config.validate().is_err());
874    }
875
876    #[test]
877    fn validate_duplicate_channels() {
878        let config = Config {
879            channels: ChannelsConfig {
880                default: "stable".into(),
881                branch: "main".into(),
882                content: vec![
883                    ChannelConfig {
884                        name: "stable".into(),
885                        prerelease: None,
886                        draft: false,
887                    },
888                    ChannelConfig {
889                        name: "stable".into(),
890                        prerelease: None,
891                        draft: false,
892                    },
893                ],
894            },
895            ..Default::default()
896        };
897        assert!(config.validate().is_err());
898    }
899
900    #[test]
901    fn default_template_parses() {
902        let template = default_config_template(&[]);
903        let config: Config = serde_yaml_ng::from_str(&template).unwrap();
904        assert_eq!(config.git.tag_prefix, "v");
905        assert!(config.git.floating_tag);
906        assert_eq!(config.channels.default, "stable");
907        assert!(
908            config
909                .git
910                .skip_patterns
911                .iter()
912                .any(|p| p == "[skip release]")
913        );
914    }
915
916    #[test]
917    fn default_skip_patterns_present() {
918        let config = Config::default();
919        assert_eq!(
920            config.git.skip_patterns,
921            vec!["[skip release]".to_string(), "[skip sr]".to_string()]
922        );
923    }
924
925    #[test]
926    fn git_user_defaults_to_none() {
927        let config = Config::default();
928        assert!(config.git.user.name.is_none());
929        assert!(config.git.user.email.is_none());
930    }
931
932    #[test]
933    fn git_user_loads_from_yaml() {
934        let dir = tempfile::tempdir().unwrap();
935        let path = dir.path().join("config.yml");
936        std::fs::write(
937            &path,
938            "git:\n  user:\n    name: \"Bot\"\n    email: \"bot@example.com\"\n",
939        )
940        .unwrap();
941        let config = Config::load(&path).unwrap();
942        assert_eq!(config.git.user.name.as_deref(), Some("Bot"));
943        assert_eq!(config.git.user.email.as_deref(), Some("bot@example.com"));
944    }
945
946    #[test]
947    fn default_template_with_version_files() {
948        let template = default_config_template(&["Cargo.toml".into(), "package.json".into()]);
949        let config: Config = serde_yaml_ng::from_str(&template).unwrap();
950        assert_eq!(
951            config.packages[0].version_files,
952            vec!["Cargo.toml", "package.json"]
953        );
954    }
955
956    #[test]
957    fn find_package_by_name_works() {
958        let config = Config {
959            packages: vec![
960                PackageConfig {
961                    path: "crates/core".into(),
962                    ..Default::default()
963                },
964                PackageConfig {
965                    path: "crates/cli".into(),
966                    ..Default::default()
967                },
968            ],
969            ..Default::default()
970        };
971        let pkg = config.find_package_by_name("core").unwrap();
972        assert_eq!(pkg.path, "crates/core");
973    }
974
975    #[test]
976    fn collect_all_artifacts() {
977        let config = Config {
978            packages: vec![
979                PackageConfig {
980                    path: "crates/core".into(),
981                    artifacts: vec!["core-*".into()],
982                    ..Default::default()
983                },
984                PackageConfig {
985                    path: "crates/cli".into(),
986                    artifacts: vec!["cli-*".into()],
987                    ..Default::default()
988                },
989            ],
990            ..Default::default()
991        };
992        let artifacts = config.all_artifacts();
993        assert_eq!(artifacts, vec!["core-*", "cli-*"]);
994    }
995}