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    Pypi {
439        /// Repository name (matches `[tool.twine.repository]` or env).
440        #[serde(default, skip_serializing_if = "Option::is_none")]
441        repository: Option<String>,
442        /// When true, publish every uv workspace member
443        /// (`[tool.uv.workspace].members`). Check aggregates across members.
444        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
445        workspace: bool,
446    },
447    /// Go modules publish by git-tag; sr already cuts the tag, so this is
448    /// effectively a noop documenting the package's presence in the manifest.
449    Go,
450    /// Arbitrary publish command with a user-supplied state check.
451    /// The only place sr shells out for a user-provided command; limited to
452    /// registries that don't have a built-in publisher.
453    Custom {
454        /// Shell command that performs the publish.
455        command: String,
456        /// Shell command that returns exit 0 iff the package is already
457        /// published at the current version. Optional — when absent, the
458        /// publisher always runs.
459        #[serde(default, skip_serializing_if = "Option::is_none")]
460        check: Option<String>,
461        /// Working directory. Defaults to the package path.
462        #[serde(default, skip_serializing_if = "Option::is_none")]
463        cwd: Option<String>,
464    },
465}
466
467// ---------------------------------------------------------------------------
468// Config methods
469// ---------------------------------------------------------------------------
470
471impl Config {
472    /// Find the first config file that exists in the given directory.
473    pub fn find_config(dir: &Path) -> Option<std::path::PathBuf> {
474        for &candidate in CONFIG_CANDIDATES {
475            let path = dir.join(candidate);
476            if path.exists() {
477                return Some(path);
478            }
479        }
480        None
481    }
482
483    /// Load config from a YAML file. Falls back to defaults if the file doesn't exist.
484    pub fn load(path: &Path) -> Result<Self, ReleaseError> {
485        if !path.exists() {
486            return Ok(Self::default());
487        }
488        let contents =
489            std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
490        let config: Self =
491            serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))?;
492        config.validate()?;
493        Ok(config)
494    }
495
496    /// Validate config consistency.
497    fn validate(&self) -> Result<(), ReleaseError> {
498        // Check for duplicate type names across bump levels.
499        let mut seen = std::collections::HashSet::new();
500        for name in self.commit.types.all_type_names() {
501            if !seen.insert(name) {
502                return Err(ReleaseError::Config(format!(
503                    "duplicate commit type: {name}"
504                )));
505            }
506        }
507
508        // Need at least one type with a bump level.
509        if self.commit.types.minor.is_empty() && self.commit.types.patch.is_empty() {
510            return Err(ReleaseError::Config(
511                "commit.types must have at least one minor or patch type".into(),
512            ));
513        }
514
515        // Check for duplicate channel names.
516        let mut channel_names = std::collections::HashSet::new();
517        for ch in &self.channels.content {
518            if !channel_names.insert(&ch.name) {
519                return Err(ReleaseError::Config(format!(
520                    "duplicate channel name: {}",
521                    ch.name
522                )));
523            }
524        }
525
526        Ok(())
527    }
528
529    /// Resolve a named release channel, returning the channel config.
530    pub fn resolve_channel(&self, name: &str) -> Result<&ChannelConfig, ReleaseError> {
531        self.channels
532            .content
533            .iter()
534            .find(|ch| ch.name == name)
535            .ok_or_else(|| {
536                let available: Vec<&str> = self
537                    .channels
538                    .content
539                    .iter()
540                    .map(|c| c.name.as_str())
541                    .collect();
542                ReleaseError::Config(format!(
543                    "channel '{name}' not found. Available: {}",
544                    if available.is_empty() {
545                        "(none)".to_string()
546                    } else {
547                        available.join(", ")
548                    }
549                ))
550            })
551    }
552
553    /// Resolve the default channel.
554    pub fn default_channel(&self) -> Result<&ChannelConfig, ReleaseError> {
555        self.resolve_channel(&self.channels.default)
556    }
557
558    /// Find a package by path.
559    pub fn find_package(&self, path: &str) -> Result<&PackageConfig, ReleaseError> {
560        self.packages
561            .iter()
562            .find(|p| p.path == path)
563            .ok_or_else(|| {
564                let available: Vec<&str> = self.packages.iter().map(|p| p.path.as_str()).collect();
565                ReleaseError::Config(format!(
566                    "package '{path}' not found. Available: {}",
567                    if available.is_empty() {
568                        "(none)".to_string()
569                    } else {
570                        available.join(", ")
571                    }
572                ))
573            })
574    }
575
576    /// Find a package by name (last component of path).
577    pub fn find_package_by_name(&self, name: &str) -> Result<&PackageConfig, ReleaseError> {
578        self.packages
579            .iter()
580            .find(|p| p.path.rsplit('/').next().unwrap_or(&p.path) == name)
581            .ok_or_else(|| {
582                let available: Vec<&str> = self
583                    .packages
584                    .iter()
585                    .map(|p| p.path.rsplit('/').next().unwrap_or(&p.path))
586                    .collect();
587                ReleaseError::Config(format!(
588                    "package '{name}' not found. Available: {}",
589                    if available.is_empty() {
590                        "(none)".to_string()
591                    } else {
592                        available.join(", ")
593                    }
594                ))
595            })
596    }
597
598    /// Resolve effective changelog config for a package.
599    pub fn changelog_for<'a>(&'a self, pkg: &'a PackageConfig) -> &'a ChangelogConfig {
600        pkg.changelog.as_ref().unwrap_or(&self.changelog)
601    }
602
603    /// Resolve effective version files for a package, with auto-detection.
604    pub fn version_files_for(&self, pkg: &PackageConfig) -> Vec<String> {
605        if !pkg.version_files.is_empty() {
606            return pkg.version_files.clone();
607        }
608        let detected = detect_version_files(Path::new(&pkg.path));
609        if pkg.path == "." {
610            detected
611        } else {
612            detected
613                .into_iter()
614                .map(|f| format!("{}/{f}", pkg.path))
615                .collect()
616        }
617    }
618
619    /// Collect all artifacts glob patterns from all packages.
620    pub fn all_artifacts(&self) -> Vec<String> {
621        self.packages
622            .iter()
623            .flat_map(|p| p.artifacts.clone())
624            .collect()
625    }
626}
627
628// ---------------------------------------------------------------------------
629// Template generation
630// ---------------------------------------------------------------------------
631
632pub fn default_config_template(version_files: &[String]) -> String {
633    let vf = if version_files.is_empty() {
634        "    version_files: []\n".to_string()
635    } else {
636        let mut s = "    version_files:\n".to_string();
637        for f in version_files {
638            s.push_str(&format!("      - {f}\n"));
639        }
640        s
641    };
642
643    format!(
644        r#"# sr configuration
645# Full reference: https://github.com/urmzd/sr#configuration
646
647git:
648  tag_prefix: "v"
649  floating_tag: true
650  sign_tags: false
651  v0_protection: true
652  # user:
653  #   name: "sr-releaser[bot]"
654  #   email: "sr-releaser[bot]@users.noreply.github.com"
655  # Commits whose message contains any of these substrings are excluded from
656  # the release plan and changelog. chore(release): is always filtered.
657  skip_patterns:
658    - "[skip release]"
659    - "[skip sr]"
660
661commit:
662  types:
663    minor:
664      - feat
665    patch:
666      - fix
667      - perf
668      - refactor
669    none:
670      - docs
671      - revert
672      - chore
673      - ci
674      - test
675      - build
676      - style
677
678changelog:
679  file: CHANGELOG.md
680  # template: changelog.md.j2
681  groups:
682    - name: breaking
683      content:
684        - breaking
685    - name: features
686      content:
687        - feat
688    - name: bug-fixes
689      content:
690        - fix
691    - name: performance
692      content:
693        - perf
694    - name: misc
695      content:
696        - chore
697        - ci
698        - test
699        - build
700        - style
701
702channels:
703  default: stable
704  branch: main
705  content:
706    - name: stable
707  # - name: rc
708  #   prerelease: rc
709  #   draft: true
710  # - name: canary
711  #   branch: develop
712  #   prerelease: canary
713
714# vcs:
715#   github:
716#     release_name_template: "{{{{ tag_name }}}}"
717
718# Repo-wide lifecycle hooks. Run once per release.
719# hooks:
720#   # Runs before any mutation: tests, lints. May abort the release.
721#   pre_release:
722#     - cargo test
723#   # Runs after tag + GitHub release.
724#   post_release:
725#     - echo "released $SR_VERSION"
726
727packages:
728  - path: .
729{vf}    # version_files_strict: false
730    # stage_files: []
731    # artifacts: []
732    # # Build commands produce this package's declared `artifacts`.
733    # # Runs after version bump, before commit.
734    # build:
735    #   - cargo build --release
736    # # Per-package publish target for `sr publish`. Idempotent.
737    # publish:
738    #   command: cargo publish
739"#
740    )
741}
742
743// ---------------------------------------------------------------------------
744// Tests
745// ---------------------------------------------------------------------------
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    #[test]
752    fn default_values() {
753        let config = Config::default();
754        assert_eq!(config.git.tag_prefix, "v");
755        assert!(config.git.floating_tag);
756        assert!(!config.git.sign_tags);
757        assert_eq!(config.commit.types.minor, vec!["feat"]);
758        assert!(config.commit.types.patch.contains(&"fix".to_string()));
759        assert!(config.commit.types.none.contains(&"chore".to_string()));
760        assert_eq!(config.changelog.file.as_deref(), Some("CHANGELOG.md"));
761        assert!(!config.changelog.groups.is_empty());
762        assert_eq!(config.channels.default, "stable");
763        assert_eq!(config.channels.content.len(), 1);
764        assert_eq!(config.channels.content[0].name, "stable");
765        assert_eq!(config.channels.branch, "main");
766        assert_eq!(config.packages.len(), 1);
767        assert_eq!(config.packages[0].path, ".");
768    }
769
770    #[test]
771    fn load_missing_file() {
772        let dir = tempfile::tempdir().unwrap();
773        let path = dir.path().join("nonexistent.yml");
774        let config = Config::load(&path).unwrap();
775        assert_eq!(config.git.tag_prefix, "v");
776    }
777
778    #[test]
779    fn load_partial_yaml() {
780        let dir = tempfile::tempdir().unwrap();
781        let path = dir.path().join("config.yml");
782        std::fs::write(&path, "git:\n  tag_prefix: rel-\n").unwrap();
783
784        let config = Config::load(&path).unwrap();
785        assert_eq!(config.git.tag_prefix, "rel-");
786        assert_eq!(config.channels.default, "stable");
787    }
788
789    #[test]
790    fn load_yaml_with_packages() {
791        let dir = tempfile::tempdir().unwrap();
792        let path = dir.path().join("config.yml");
793        std::fs::write(
794            &path,
795            "packages:\n  - path: crates/core\n    version_files:\n      - crates/core/Cargo.toml\n",
796        )
797        .unwrap();
798
799        let config = Config::load(&path).unwrap();
800        assert_eq!(config.packages.len(), 1);
801        assert_eq!(config.packages[0].path, "crates/core");
802    }
803
804    #[test]
805    fn commit_types_conversion() {
806        let types = CommitTypesConfig::default();
807        let commit_types = types.into_commit_types();
808        let feat = commit_types.iter().find(|t| t.name == "feat").unwrap();
809        assert_eq!(feat.bump, Some(BumpLevel::Minor));
810        let fix = commit_types.iter().find(|t| t.name == "fix").unwrap();
811        assert_eq!(fix.bump, Some(BumpLevel::Patch));
812        let chore = commit_types.iter().find(|t| t.name == "chore").unwrap();
813        assert_eq!(chore.bump, None);
814    }
815
816    #[test]
817    fn all_type_names() {
818        let types = CommitTypesConfig::default();
819        let names = types.all_type_names();
820        assert!(names.contains(&"feat"));
821        assert!(names.contains(&"fix"));
822        assert!(names.contains(&"chore"));
823    }
824
825    #[test]
826    fn resolve_channel() {
827        let config = Config::default();
828        let channel = config.resolve_channel("stable").unwrap();
829        assert!(channel.prerelease.is_none());
830    }
831
832    #[test]
833    fn resolve_channel_not_found() {
834        let config = Config::default();
835        assert!(config.resolve_channel("missing").is_err());
836    }
837
838    #[test]
839    fn validate_duplicate_types() {
840        let config = Config {
841            commit: CommitConfig {
842                types: CommitTypesConfig {
843                    minor: vec!["feat".into()],
844                    patch: vec!["feat".into()],
845                    none: vec![],
846                },
847            },
848            ..Default::default()
849        };
850        assert!(config.validate().is_err());
851    }
852
853    #[test]
854    fn validate_no_bump_types() {
855        let config = Config {
856            commit: CommitConfig {
857                types: CommitTypesConfig {
858                    minor: vec![],
859                    patch: vec![],
860                    none: vec!["chore".into()],
861                },
862            },
863            ..Default::default()
864        };
865        assert!(config.validate().is_err());
866    }
867
868    #[test]
869    fn validate_duplicate_channels() {
870        let config = Config {
871            channels: ChannelsConfig {
872                default: "stable".into(),
873                branch: "main".into(),
874                content: vec![
875                    ChannelConfig {
876                        name: "stable".into(),
877                        prerelease: None,
878                        draft: false,
879                    },
880                    ChannelConfig {
881                        name: "stable".into(),
882                        prerelease: None,
883                        draft: false,
884                    },
885                ],
886            },
887            ..Default::default()
888        };
889        assert!(config.validate().is_err());
890    }
891
892    #[test]
893    fn default_template_parses() {
894        let template = default_config_template(&[]);
895        let config: Config = serde_yaml_ng::from_str(&template).unwrap();
896        assert_eq!(config.git.tag_prefix, "v");
897        assert!(config.git.floating_tag);
898        assert_eq!(config.channels.default, "stable");
899        assert!(
900            config
901                .git
902                .skip_patterns
903                .iter()
904                .any(|p| p == "[skip release]")
905        );
906    }
907
908    #[test]
909    fn default_skip_patterns_present() {
910        let config = Config::default();
911        assert_eq!(
912            config.git.skip_patterns,
913            vec!["[skip release]".to_string(), "[skip sr]".to_string()]
914        );
915    }
916
917    #[test]
918    fn git_user_defaults_to_none() {
919        let config = Config::default();
920        assert!(config.git.user.name.is_none());
921        assert!(config.git.user.email.is_none());
922    }
923
924    #[test]
925    fn git_user_loads_from_yaml() {
926        let dir = tempfile::tempdir().unwrap();
927        let path = dir.path().join("config.yml");
928        std::fs::write(
929            &path,
930            "git:\n  user:\n    name: \"Bot\"\n    email: \"bot@example.com\"\n",
931        )
932        .unwrap();
933        let config = Config::load(&path).unwrap();
934        assert_eq!(config.git.user.name.as_deref(), Some("Bot"));
935        assert_eq!(config.git.user.email.as_deref(), Some("bot@example.com"));
936    }
937
938    #[test]
939    fn default_template_with_version_files() {
940        let template = default_config_template(&["Cargo.toml".into(), "package.json".into()]);
941        let config: Config = serde_yaml_ng::from_str(&template).unwrap();
942        assert_eq!(
943            config.packages[0].version_files,
944            vec!["Cargo.toml", "package.json"]
945        );
946    }
947
948    #[test]
949    fn find_package_by_name_works() {
950        let config = Config {
951            packages: vec![
952                PackageConfig {
953                    path: "crates/core".into(),
954                    ..Default::default()
955                },
956                PackageConfig {
957                    path: "crates/cli".into(),
958                    ..Default::default()
959                },
960            ],
961            ..Default::default()
962        };
963        let pkg = config.find_package_by_name("core").unwrap();
964        assert_eq!(pkg.path, "crates/core");
965    }
966
967    #[test]
968    fn collect_all_artifacts() {
969        let config = Config {
970            packages: vec![
971                PackageConfig {
972                    path: "crates/core".into(),
973                    artifacts: vec!["core-*".into()],
974                    ..Default::default()
975                },
976                PackageConfig {
977                    path: "crates/cli".into(),
978                    artifacts: vec!["cli-*".into()],
979                    ..Default::default()
980                },
981            ],
982            ..Default::default()
983        };
984        let artifacts = config.all_artifacts();
985        assert_eq!(artifacts, vec!["core-*", "cli-*"]);
986    }
987}