Skip to main content

changeset_project/
config.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use changeset_changelog::{ChangelogConfig, ChangelogLocation, ComparisonLinksSetting};
5use changeset_core::ZeroVersionBehavior;
6use changeset_git::DEFAULT_BASE_BRANCH;
7use globset::{Glob, GlobSet, GlobSetBuilder};
8
9use crate::error::ProjectError;
10use crate::manifest::{ChangesetMetadata, TagFormatValue, read_manifest};
11use crate::project::{CargoProject, ProjectKind};
12
13const DEFAULT_DEPENDENCY_BUMP_CHANGELOG_TEMPLATE: &str =
14    "Updated dependency `{dependency}` to v{version}";
15const DEFAULT_NONE_BUMP_PROMOTE_MESSAGE_TEMPLATE: &str = "Internal architectural changes";
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum TagFormat {
19    #[default]
20    VersionOnly,
21    CratePrefixed,
22}
23
24#[derive(Debug, Clone)]
25#[allow(clippy::struct_excessive_bools)]
26pub struct GitConfig {
27    commit: bool,
28    tags: bool,
29    keep_changesets: bool,
30    tag_format: TagFormat,
31    commit_title_template: String,
32    changes_in_body: bool,
33}
34
35impl GitConfig {
36    #[must_use]
37    pub fn commit(&self) -> bool {
38        self.commit
39    }
40
41    #[must_use]
42    pub fn tags(&self) -> bool {
43        self.tags
44    }
45
46    #[must_use]
47    pub fn keep_changesets(&self) -> bool {
48        self.keep_changesets
49    }
50
51    #[must_use]
52    pub fn tag_format(&self) -> TagFormat {
53        self.tag_format
54    }
55
56    #[must_use]
57    pub fn commit_title_template(&self) -> &str {
58        &self.commit_title_template
59    }
60
61    #[must_use]
62    pub fn changes_in_body(&self) -> bool {
63        self.changes_in_body
64    }
65
66    #[cfg(any(test, feature = "testing"))]
67    #[must_use]
68    pub fn with_changes_in_body(mut self, changes_in_body: bool) -> Self {
69        self.changes_in_body = changes_in_body;
70        self
71    }
72}
73
74impl Default for GitConfig {
75    fn default() -> Self {
76        Self {
77            commit: true,
78            tags: true,
79            keep_changesets: false,
80            tag_format: TagFormat::default(),
81            commit_title_template: String::from("{new-version}"),
82            changes_in_body: true,
83        }
84    }
85}
86
87#[derive(Debug, Clone)]
88pub struct RootChangesetConfig {
89    ignored_files: GlobSet,
90    changeset_dir: PathBuf,
91    changelog_config: ChangelogConfig,
92    git_config: GitConfig,
93    zero_version_behavior: ZeroVersionBehavior,
94    dependency_bump_changelog_template: String,
95    base_branch: String,
96    none_bump_behavior: changeset_core::NoneBumpBehavior,
97    none_bump_promote_message_template: String,
98}
99
100impl RootChangesetConfig {
101    #[must_use]
102    pub fn ignored_files(&self) -> &GlobSet {
103        &self.ignored_files
104    }
105
106    #[must_use]
107    pub fn is_ignored(&self, path: &Path) -> bool {
108        self.ignored_files.is_match(path)
109    }
110
111    #[must_use]
112    pub fn changeset_dir(&self) -> &Path {
113        &self.changeset_dir
114    }
115
116    #[must_use]
117    pub fn changelog_config(&self) -> &ChangelogConfig {
118        &self.changelog_config
119    }
120
121    #[must_use]
122    pub fn git_config(&self) -> &GitConfig {
123        &self.git_config
124    }
125
126    #[must_use]
127    pub fn zero_version_behavior(&self) -> ZeroVersionBehavior {
128        self.zero_version_behavior
129    }
130
131    #[must_use]
132    pub fn dependency_bump_changelog_template(&self) -> &str {
133        &self.dependency_bump_changelog_template
134    }
135
136    #[must_use]
137    pub fn base_branch(&self) -> &str {
138        &self.base_branch
139    }
140
141    #[must_use]
142    pub fn none_bump_behavior(&self) -> changeset_core::NoneBumpBehavior {
143        self.none_bump_behavior
144    }
145
146    #[must_use]
147    pub fn none_bump_promote_message_template(&self) -> &str {
148        &self.none_bump_promote_message_template
149    }
150
151    #[cfg(any(test, feature = "testing"))]
152    #[must_use]
153    pub fn with_git_config(mut self, git_config: GitConfig) -> Self {
154        self.git_config = git_config;
155        self
156    }
157
158    #[cfg(any(test, feature = "testing"))]
159    #[must_use]
160    pub fn with_none_bump_behavior(mut self, behavior: changeset_core::NoneBumpBehavior) -> Self {
161        self.none_bump_behavior = behavior;
162        self
163    }
164}
165
166impl Default for RootChangesetConfig {
167    fn default() -> Self {
168        Self {
169            ignored_files: GlobSet::empty(),
170            changeset_dir: PathBuf::from(crate::DEFAULT_CHANGESET_DIR),
171            changelog_config: ChangelogConfig::default(),
172            git_config: GitConfig::default(),
173            zero_version_behavior: ZeroVersionBehavior::default(),
174            dependency_bump_changelog_template: String::from(
175                DEFAULT_DEPENDENCY_BUMP_CHANGELOG_TEMPLATE,
176            ),
177            base_branch: String::from(DEFAULT_BASE_BRANCH),
178            none_bump_behavior: changeset_core::NoneBumpBehavior::default(),
179            none_bump_promote_message_template: String::from(
180                DEFAULT_NONE_BUMP_PROMOTE_MESSAGE_TEMPLATE,
181            ),
182        }
183    }
184}
185
186#[derive(Debug, Default)]
187pub struct PackageChangesetConfig {
188    ignored_files: GlobSet,
189}
190
191impl PackageChangesetConfig {
192    #[must_use]
193    pub fn ignored_files(&self) -> &GlobSet {
194        &self.ignored_files
195    }
196
197    #[must_use]
198    pub fn is_ignored(&self, path: &Path) -> bool {
199        self.ignored_files.is_match(path)
200    }
201}
202
203enum CargoRootConfigType {
204    Workspace,
205    Package,
206}
207
208/// Parses the root changeset configuration based on project kind.
209///
210/// For single-package projects, reads from `[package.metadata.changeset]`.
211/// For workspaces, reads from `[workspace.metadata.changeset]`.
212///
213/// # Errors
214///
215/// Returns `ProjectError` if the manifest cannot be read or parsed, or if glob patterns are invalid.
216pub fn parse_root_config(project: &CargoProject) -> Result<RootChangesetConfig, ProjectError> {
217    match project.kind() {
218        ProjectKind::SinglePackage => {
219            parse_cargo_root_config(project.root(), CargoRootConfigType::Package)
220        }
221        ProjectKind::VirtualWorkspace | ProjectKind::WorkspaceWithRoot => {
222            parse_cargo_root_config(project.root(), CargoRootConfigType::Workspace)
223        }
224    }
225}
226
227/// # Errors
228///
229/// Returns `ProjectError` if the manifest cannot be read or parsed, or if glob patterns are invalid.
230pub fn parse_package_config(package_path: &Path) -> Result<PackageChangesetConfig, ProjectError> {
231    let manifest_path = package_path.join("Cargo.toml");
232    let manifest = read_manifest(&manifest_path)?;
233
234    let patterns = manifest
235        .package
236        .and_then(|pkg| pkg.metadata)
237        .and_then(|meta| meta.changeset)
238        .map(|cs| cs.ignored_files)
239        .unwrap_or_default();
240
241    let ignored_files = build_glob_set(&patterns)?;
242
243    Ok(PackageChangesetConfig { ignored_files })
244}
245
246/// # Errors
247///
248/// Returns an error if any manifest cannot be read or parsed, or if glob patterns are invalid.
249pub fn load_changeset_configs(
250    project: &CargoProject,
251) -> Result<(RootChangesetConfig, HashMap<String, PackageChangesetConfig>), ProjectError> {
252    let root_config = parse_root_config(project)?;
253
254    let mut package_configs = HashMap::new();
255    for package in project.packages() {
256        let config = parse_package_config(package.path())?;
257        package_configs.insert(package.name().clone(), config);
258    }
259
260    Ok((root_config, package_configs))
261}
262
263fn build_glob_set(patterns: &[String]) -> Result<GlobSet, ProjectError> {
264    let mut builder = GlobSetBuilder::new();
265    for pattern in patterns {
266        let glob = Glob::new(pattern).map_err(|source| ProjectError::GlobPattern {
267            pattern: pattern.clone(),
268            source,
269        })?;
270        builder.add(glob);
271    }
272    builder.build().map_err(|source| ProjectError::GlobPattern {
273        pattern: patterns.join(", "),
274        source,
275    })
276}
277
278fn build_changelog_config(
279    changelog: Option<ChangelogLocation>,
280    comparison_links: Option<ComparisonLinksSetting>,
281    comparison_links_template: Option<String>,
282) -> ChangelogConfig {
283    ChangelogConfig::new(
284        changelog.unwrap_or_default(),
285        comparison_links.unwrap_or_default(),
286        comparison_links_template,
287    )
288}
289
290fn build_git_config(metadata: Option<&ChangesetMetadata>) -> GitConfig {
291    let defaults = GitConfig::default();
292    match metadata {
293        None => defaults,
294        Some(cs) => GitConfig {
295            commit: cs.commit.unwrap_or(defaults.commit),
296            tags: cs.tags.unwrap_or(defaults.tags),
297            keep_changesets: cs.keep_changesets.unwrap_or(defaults.keep_changesets),
298            tag_format: cs.tag_format.map_or(defaults.tag_format, |tf| match tf {
299                TagFormatValue::VersionOnly => TagFormat::VersionOnly,
300                TagFormatValue::CratePrefixed => TagFormat::CratePrefixed,
301            }),
302            commit_title_template: cs
303                .commit_title_template
304                .clone()
305                .unwrap_or(defaults.commit_title_template),
306            changes_in_body: cs.changes_in_body.unwrap_or(defaults.changes_in_body),
307        },
308    }
309}
310
311fn parse_cargo_root_config(
312    project_root: &Path,
313    config_type: CargoRootConfigType,
314) -> Result<RootChangesetConfig, ProjectError> {
315    let manifest_path = project_root.join("Cargo.toml");
316    let manifest = read_manifest(&manifest_path)?;
317
318    let changeset_metadata = match config_type {
319        CargoRootConfigType::Workspace => manifest
320            .workspace
321            .and_then(|ws| ws.metadata)
322            .and_then(|meta| meta.changeset),
323        CargoRootConfigType::Package => manifest
324            .package
325            .and_then(|pkg| pkg.metadata)
326            .and_then(|meta| meta.changeset),
327    };
328
329    let patterns = changeset_metadata
330        .as_ref()
331        .map(|cs| cs.ignored_files.clone())
332        .unwrap_or_default();
333
334    let changeset_dir = changeset_metadata
335        .as_ref()
336        .and_then(|cs| cs.changeset_dir.clone())
337        .unwrap_or_else(|| crate::DEFAULT_CHANGESET_DIR.to_string());
338
339    let ignored_files = build_glob_set(&patterns)?;
340
341    let changelog_config = build_changelog_config(
342        changeset_metadata.as_ref().and_then(|cs| cs.changelog),
343        changeset_metadata
344            .as_ref()
345            .and_then(|cs| cs.comparison_links),
346        changeset_metadata
347            .as_ref()
348            .and_then(|cs| cs.comparison_links_template.clone()),
349    );
350
351    let git_config = build_git_config(changeset_metadata.as_ref());
352
353    let zero_version_behavior = changeset_metadata
354        .as_ref()
355        .and_then(|cs| cs.zero_version_behavior)
356        .unwrap_or_default();
357
358    let dependency_bump_changelog_template = changeset_metadata
359        .as_ref()
360        .and_then(|cs| cs.dependency_bump_changelog_template.clone())
361        .unwrap_or_else(|| String::from(DEFAULT_DEPENDENCY_BUMP_CHANGELOG_TEMPLATE));
362
363    let base_branch = changeset_metadata
364        .as_ref()
365        .and_then(|cs| cs.base_branch.clone())
366        .unwrap_or_else(|| String::from(DEFAULT_BASE_BRANCH));
367
368    let none_bump_behavior = changeset_metadata
369        .as_ref()
370        .and_then(|cs| cs.none_bump_behavior)
371        .unwrap_or_default();
372
373    let none_bump_promote_message_template = changeset_metadata
374        .as_ref()
375        .and_then(|cs| cs.none_bump_promote_message_template.clone())
376        .unwrap_or_else(|| String::from(DEFAULT_NONE_BUMP_PROMOTE_MESSAGE_TEMPLATE));
377
378    Ok(RootChangesetConfig {
379        ignored_files,
380        changeset_dir: PathBuf::from(changeset_dir),
381        changelog_config,
382        git_config,
383        zero_version_behavior,
384        dependency_bump_changelog_template,
385        base_branch,
386        none_bump_behavior,
387        none_bump_promote_message_template,
388    })
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use std::fs;
395    use tempfile::TempDir;
396
397    fn setup_with_config(toml_content: &str) -> anyhow::Result<TempDir> {
398        let dir = TempDir::new()?;
399        fs::write(dir.path().join("Cargo.toml"), toml_content)?;
400        Ok(dir)
401    }
402
403    #[test]
404    fn parse_workspace_root_config_with_ignored_files() -> anyhow::Result<()> {
405        let toml = r#"
406[workspace]
407members = ["crates/*"]
408
409[workspace.metadata.changeset]
410ignored-files = ["*.md", "docs/**"]
411"#;
412        let dir = setup_with_config(toml)?;
413
414        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
415
416        assert!(config.is_ignored(Path::new("README.md")));
417        assert!(config.is_ignored(Path::new("docs/guide.md")));
418        assert!(!config.is_ignored(Path::new("src/lib.rs")));
419
420        Ok(())
421    }
422
423    #[test]
424    fn parse_workspace_root_config_without_metadata() -> anyhow::Result<()> {
425        let toml = r#"
426[workspace]
427members = ["crates/*"]
428"#;
429        let dir = setup_with_config(toml)?;
430
431        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
432
433        assert!(!config.is_ignored(Path::new("README.md")));
434        assert!(!config.is_ignored(Path::new("src/lib.rs")));
435
436        Ok(())
437    }
438
439    #[test]
440    fn parse_workspace_root_config_with_custom_changeset_dir() -> anyhow::Result<()> {
441        let toml = r#"
442[workspace]
443members = ["crates/*"]
444
445[workspace.metadata.changeset]
446changeset-dir = "changes"
447"#;
448        let dir = setup_with_config(toml)?;
449
450        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
451
452        assert_eq!(config.changeset_dir(), Path::new("changes"));
453
454        Ok(())
455    }
456
457    #[test]
458    fn parse_workspace_root_config_default_changeset_dir() -> anyhow::Result<()> {
459        let toml = r#"
460[workspace]
461members = ["crates/*"]
462"#;
463        let dir = setup_with_config(toml)?;
464
465        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
466
467        assert_eq!(config.changeset_dir(), Path::new(".changeset"));
468
469        Ok(())
470    }
471
472    #[test]
473    fn parse_package_config_with_ignored_files() -> anyhow::Result<()> {
474        let toml = r#"
475[package]
476name = "my-crate"
477version = "0.1.0"
478
479[package.metadata.changeset]
480ignored-files = ["benches/**", "examples/**"]
481"#;
482        let dir = setup_with_config(toml)?;
483
484        let config = parse_package_config(dir.path())?;
485
486        assert!(config.is_ignored(Path::new("benches/bench.rs")));
487        assert!(config.is_ignored(Path::new("examples/demo.rs")));
488        assert!(!config.is_ignored(Path::new("src/lib.rs")));
489
490        Ok(())
491    }
492
493    #[test]
494    fn parse_package_config_without_metadata() -> anyhow::Result<()> {
495        let toml = r#"
496[package]
497name = "my-crate"
498version = "0.1.0"
499"#;
500        let dir = setup_with_config(toml)?;
501
502        let config = parse_package_config(dir.path())?;
503
504        assert!(!config.is_ignored(Path::new("benches/bench.rs")));
505
506        Ok(())
507    }
508
509    #[test]
510    fn parse_single_package_root_config() -> anyhow::Result<()> {
511        let toml = r#"
512[package]
513name = "my-crate"
514version = "0.1.0"
515
516[package.metadata.changeset]
517ignored-files = ["*.md"]
518changeset-dir = "my-changesets"
519"#;
520        let dir = setup_with_config(toml)?;
521
522        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Package)?;
523
524        assert!(config.is_ignored(Path::new("README.md")));
525        assert_eq!(config.changeset_dir(), Path::new("my-changesets"));
526
527        Ok(())
528    }
529
530    #[test]
531    fn invalid_glob_pattern_returns_error() -> anyhow::Result<()> {
532        let toml = r#"
533[workspace]
534members = ["crates/*"]
535
536[workspace.metadata.changeset]
537ignored-files = ["[invalid"]
538"#;
539        let dir = setup_with_config(toml)?;
540
541        let result = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace);
542
543        assert!(result.is_err());
544        let err = result.expect_err("should fail on invalid glob");
545        assert!(matches!(err, ProjectError::GlobPattern { .. }));
546
547        Ok(())
548    }
549
550    #[test]
551    fn empty_ignored_files_list() -> anyhow::Result<()> {
552        let toml = r#"
553[workspace]
554members = ["crates/*"]
555
556[workspace.metadata.changeset]
557ignored-files = []
558"#;
559        let dir = setup_with_config(toml)?;
560
561        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
562
563        assert!(!config.is_ignored(Path::new("anything.txt")));
564
565        Ok(())
566    }
567
568    #[test]
569    fn parse_workspace_changelog_config() -> anyhow::Result<()> {
570        let toml = r#"
571[workspace]
572members = ["crates/*"]
573
574[workspace.metadata.changeset]
575changelog = "per-package"
576comparison-links = "enabled"
577comparison-links-template = "https://example.com/{repository}/compare/{base}...{target}"
578"#;
579        let dir = setup_with_config(toml)?;
580
581        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
582        let changelog_config = config.changelog_config();
583
584        assert_eq!(changelog_config.changelog(), ChangelogLocation::PerPackage);
585        assert_eq!(
586            changelog_config.comparison_links(),
587            ComparisonLinksSetting::Enabled
588        );
589        assert_eq!(
590            changelog_config.comparison_links_template(),
591            Some("https://example.com/{repository}/compare/{base}...{target}")
592        );
593
594        Ok(())
595    }
596
597    #[test]
598    fn parse_changelog_config_defaults() -> anyhow::Result<()> {
599        let toml = r#"
600[workspace]
601members = ["crates/*"]
602"#;
603        let dir = setup_with_config(toml)?;
604
605        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
606        let changelog_config = config.changelog_config();
607
608        assert_eq!(changelog_config.changelog(), ChangelogLocation::Root);
609        assert_eq!(
610            changelog_config.comparison_links(),
611            ComparisonLinksSetting::Auto
612        );
613        assert!(changelog_config.comparison_links_template().is_none());
614
615        Ok(())
616    }
617
618    #[test]
619    fn parse_single_package_changelog_config() -> anyhow::Result<()> {
620        let toml = r#"
621[package]
622name = "my-crate"
623version = "0.1.0"
624
625[package.metadata.changeset]
626changelog = "root"
627comparison-links = "disabled"
628"#;
629        let dir = setup_with_config(toml)?;
630
631        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Package)?;
632        let changelog_config = config.changelog_config();
633
634        assert_eq!(changelog_config.changelog(), ChangelogLocation::Root);
635        assert_eq!(
636            changelog_config.comparison_links(),
637            ComparisonLinksSetting::Disabled
638        );
639
640        Ok(())
641    }
642
643    #[test]
644    fn parse_git_config_defaults() -> anyhow::Result<()> {
645        let toml = r#"
646[workspace]
647members = ["crates/*"]
648"#;
649        let dir = setup_with_config(toml)?;
650
651        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
652        let git_config = config.git_config();
653
654        assert!(git_config.commit());
655        assert!(git_config.tags());
656        assert!(!git_config.keep_changesets());
657        assert_eq!(git_config.tag_format(), TagFormat::VersionOnly);
658        assert_eq!(git_config.commit_title_template(), "{new-version}");
659        assert!(git_config.changes_in_body());
660
661        Ok(())
662    }
663
664    #[test]
665    fn parse_git_config_all_options() -> anyhow::Result<()> {
666        let toml = r#"
667[workspace]
668members = ["crates/*"]
669
670[workspace.metadata.changeset]
671commit = false
672tags = false
673keep-changesets = true
674tag-format = "crate-prefixed"
675commit-title-template = "chore(release): {new-version}"
676changes-in-body = false
677"#;
678        let dir = setup_with_config(toml)?;
679
680        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
681        let git_config = config.git_config();
682
683        assert!(!git_config.commit());
684        assert!(!git_config.tags());
685        assert!(git_config.keep_changesets());
686        assert_eq!(git_config.tag_format(), TagFormat::CratePrefixed);
687        assert_eq!(
688            git_config.commit_title_template(),
689            "chore(release): {new-version}"
690        );
691        assert!(!git_config.changes_in_body());
692
693        Ok(())
694    }
695
696    #[test]
697    fn parse_git_config_version_only_format() -> anyhow::Result<()> {
698        let toml = r#"
699[workspace]
700members = ["crates/*"]
701
702[workspace.metadata.changeset]
703tag-format = "version-only"
704"#;
705        let dir = setup_with_config(toml)?;
706
707        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
708        let git_config = config.git_config();
709
710        assert_eq!(git_config.tag_format(), TagFormat::VersionOnly);
711
712        Ok(())
713    }
714
715    #[test]
716    fn parse_single_package_git_config() -> anyhow::Result<()> {
717        let toml = r#"
718[package]
719name = "my-crate"
720version = "0.1.0"
721
722[package.metadata.changeset]
723commit = false
724tags = true
725keep-changesets = true
726"#;
727        let dir = setup_with_config(toml)?;
728
729        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Package)?;
730        let git_config = config.git_config();
731
732        assert!(!git_config.commit());
733        assert!(git_config.tags());
734        assert!(git_config.keep_changesets());
735
736        Ok(())
737    }
738
739    #[test]
740    fn parse_zero_version_behavior_default() -> anyhow::Result<()> {
741        let toml = r#"
742[workspace]
743members = ["crates/*"]
744"#;
745        let dir = setup_with_config(toml)?;
746
747        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
748
749        assert_eq!(
750            config.zero_version_behavior(),
751            ZeroVersionBehavior::EffectiveMinor
752        );
753
754        Ok(())
755    }
756
757    #[test]
758    fn parse_zero_version_behavior_effective_minor() -> anyhow::Result<()> {
759        let toml = r#"
760[workspace]
761members = ["crates/*"]
762
763[workspace.metadata.changeset]
764zero-version-behavior = "effective-minor"
765"#;
766        let dir = setup_with_config(toml)?;
767
768        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
769
770        assert_eq!(
771            config.zero_version_behavior(),
772            ZeroVersionBehavior::EffectiveMinor
773        );
774
775        Ok(())
776    }
777
778    #[test]
779    fn parse_zero_version_behavior_auto_promote() -> anyhow::Result<()> {
780        let toml = r#"
781[workspace]
782members = ["crates/*"]
783
784[workspace.metadata.changeset]
785zero-version-behavior = "auto-promote-on-major"
786"#;
787        let dir = setup_with_config(toml)?;
788
789        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
790
791        assert_eq!(
792            config.zero_version_behavior(),
793            ZeroVersionBehavior::AutoPromoteOnMajor
794        );
795
796        Ok(())
797    }
798
799    #[test]
800    fn parse_single_package_zero_version_behavior() -> anyhow::Result<()> {
801        let toml = r#"
802[package]
803name = "my-crate"
804version = "0.1.0"
805
806[package.metadata.changeset]
807zero-version-behavior = "auto-promote-on-major"
808"#;
809        let dir = setup_with_config(toml)?;
810
811        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Package)?;
812
813        assert_eq!(
814            config.zero_version_behavior(),
815            ZeroVersionBehavior::AutoPromoteOnMajor
816        );
817
818        Ok(())
819    }
820
821    #[test]
822    fn parse_dependency_bump_changelog_template_from_workspace_metadata() -> anyhow::Result<()> {
823        let toml = r#"
824[workspace]
825members = ["crates/*"]
826
827[workspace.metadata.changeset]
828dependency-bump-changelog-template = "Upgraded `{dependency}` to {version}"
829"#;
830        let dir = setup_with_config(toml)?;
831
832        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
833
834        assert_eq!(
835            config.dependency_bump_changelog_template(),
836            "Upgraded `{dependency}` to {version}"
837        );
838
839        Ok(())
840    }
841
842    #[test]
843    fn parse_dependency_bump_changelog_template_from_package_metadata() -> anyhow::Result<()> {
844        let toml = r#"
845[package]
846name = "my-crate"
847version = "0.1.0"
848
849[package.metadata.changeset]
850dependency-bump-changelog-template = "Bumped `{dependency}` to {version}"
851"#;
852        let dir = setup_with_config(toml)?;
853
854        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Package)?;
855
856        assert_eq!(
857            config.dependency_bump_changelog_template(),
858            "Bumped `{dependency}` to {version}"
859        );
860
861        Ok(())
862    }
863
864    #[test]
865    fn dependency_bump_changelog_template_defaults_when_not_specified() -> anyhow::Result<()> {
866        let toml = r#"
867[workspace]
868members = ["crates/*"]
869"#;
870        let dir = setup_with_config(toml)?;
871
872        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
873
874        assert_eq!(
875            config.dependency_bump_changelog_template(),
876            "Updated dependency `{dependency}` to v{version}"
877        );
878
879        Ok(())
880    }
881
882    #[test]
883    fn parse_workspace_root_config_base_branch_defaults_to_main() -> anyhow::Result<()> {
884        let toml = r#"
885[workspace]
886members = ["crates/*"]
887"#;
888        let dir = setup_with_config(toml)?;
889
890        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
891
892        assert_eq!(config.base_branch(), "main");
893
894        Ok(())
895    }
896
897    #[test]
898    fn parse_workspace_root_config_with_base_branch() -> anyhow::Result<()> {
899        let toml = r#"
900[workspace]
901members = ["crates/*"]
902
903[workspace.metadata.changeset]
904base-branch = "develop"
905"#;
906        let dir = setup_with_config(toml)?;
907
908        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
909
910        assert_eq!(config.base_branch(), "develop");
911
912        Ok(())
913    }
914
915    #[test]
916    fn parse_single_package_root_config_with_base_branch() -> anyhow::Result<()> {
917        let toml = r#"
918[package]
919name = "my-crate"
920version = "0.1.0"
921
922[package.metadata.changeset]
923base-branch = "master"
924"#;
925        let dir = setup_with_config(toml)?;
926
927        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Package)?;
928
929        assert_eq!(config.base_branch(), "master");
930
931        Ok(())
932    }
933
934    #[test]
935    fn parse_none_bump_behavior_default() -> anyhow::Result<()> {
936        let toml = r#"
937[workspace]
938members = ["crates/*"]
939"#;
940        let dir = setup_with_config(toml)?;
941
942        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
943
944        assert_eq!(
945            config.none_bump_behavior(),
946            changeset_core::NoneBumpBehavior::PromoteToPatch
947        );
948
949        Ok(())
950    }
951
952    #[test]
953    fn parse_none_bump_behavior_allow() -> anyhow::Result<()> {
954        let toml = r#"
955[workspace]
956members = ["crates/*"]
957
958[workspace.metadata.changeset]
959none-bump-behavior = "allow"
960"#;
961        let dir = setup_with_config(toml)?;
962
963        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
964
965        assert_eq!(
966            config.none_bump_behavior(),
967            changeset_core::NoneBumpBehavior::Allow
968        );
969
970        Ok(())
971    }
972
973    #[test]
974    fn parse_none_bump_behavior_disallow() -> anyhow::Result<()> {
975        let toml = r#"
976[workspace]
977members = ["crates/*"]
978
979[workspace.metadata.changeset]
980none-bump-behavior = "disallow"
981"#;
982        let dir = setup_with_config(toml)?;
983
984        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
985
986        assert_eq!(
987            config.none_bump_behavior(),
988            changeset_core::NoneBumpBehavior::Disallow
989        );
990
991        Ok(())
992    }
993
994    #[test]
995    fn parse_none_bump_promote_message_template_custom() -> anyhow::Result<()> {
996        let toml = r#"
997[workspace]
998members = ["crates/*"]
999
1000[workspace.metadata.changeset]
1001none-bump-promote-message-template = "chore: internal refactor"
1002"#;
1003        let dir = setup_with_config(toml)?;
1004
1005        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
1006
1007        assert_eq!(
1008            config.none_bump_promote_message_template(),
1009            "chore: internal refactor"
1010        );
1011
1012        Ok(())
1013    }
1014
1015    #[test]
1016    fn parse_none_bump_promote_message_template_default() -> anyhow::Result<()> {
1017        let toml = r#"
1018[workspace]
1019members = ["crates/*"]
1020"#;
1021        let dir = setup_with_config(toml)?;
1022
1023        let config = parse_cargo_root_config(dir.path(), CargoRootConfigType::Workspace)?;
1024
1025        assert_eq!(
1026            config.none_bump_promote_message_template(),
1027            "Internal architectural changes"
1028        );
1029
1030        Ok(())
1031    }
1032}