Skip to main content

anodizer_core/config/
changelog.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::archives::ContentSource;
5use super::{StringOrBool, deserialize_string_or_bool_opt};
6
7// ---------------------------------------------------------------------------
8// ChangelogConfig
9// ---------------------------------------------------------------------------
10
11#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
12#[serde(default, deny_unknown_fields)]
13pub struct ChangelogConfig {
14    /// Sort order for changelog entries: "asc" or "desc" (default: "asc").
15    pub sort: Option<String>,
16    /// Commit message filters to include or exclude from the changelog.
17    pub filters: Option<ChangelogFilters>,
18    /// Groups for organizing changelog entries by commit message prefix.
19    pub groups: Option<Vec<ChangelogGroup>>,
20    /// Text prepended to the changelog. Inline string, `from_file: <path>`,
21    /// or `from_url: <url>` — symmetric with the release block's header/footer
22    /// so users can compose headers from a templated file or remote endpoint
23    /// (the upstream uses a plain string here; anodizer extends to ContentSource
24    /// for consistency with `release.header`).
25    pub header: Option<ContentSource>,
26    /// Text appended to the changelog. Same shape as `header`.
27    pub footer: Option<ContentSource>,
28    /// Skip changelog generation. Accepts bool or template string
29    /// (e.g. `"{{ if IsSnapshot }}true{{ endif }}"` for conditional skip).
30    ///
31    /// Accepts `disable:` as an alias so imported configs (which use
32    /// `changelog.disable:`) parse cleanly without a rename. Anodizer's
33    /// broader convention is `skip:` (mirrors `release.skip_upload`,
34    /// stage-level `skip:` flags), so the canonical key stays `skip:`.
35    #[serde(
36        deserialize_with = "deserialize_string_or_bool_opt",
37        default,
38        alias = "disable"
39    )]
40    pub skip: Option<StringOrBool>,
41    /// Changelog source: `"git"` (default), `"github"`, or `"github-native"`.
42    /// `"github"` fetches commits via the GitHub API, enriching entries with
43    /// author login information (available as the `{{ Logins }}` per-entry
44    /// template variable and the `{{ AllLogins }}` release-wide variable).
45    /// `"github-native"` delegates entirely to GitHub's auto-generated notes.
46    #[serde(rename = "use")]
47    pub use_source: Option<String>,
48    /// Hash abbreviation length. Default: 0 (no truncation, emit the full
49    /// SHA). Set to -1 to omit the hash entirely; positive values truncate
50    /// to N chars. Values below `-1` are clamped to `-1` (a `git log
51    /// --abbrev=N` would otherwise reject `-2`, `-3`, ...).
52    pub abbrev: Option<i32>,
53    /// Template for each changelog commit line. Available variables: SHA (full hash), ShortSHA (abbreviated), Message (commit subject), AuthorName, AuthorEmail, Login (per-commit GitHub username, `github` backend only), Logins (per-entry comma-separated list of GitHub usernames for that commit, `github` backend only), AllLogins (comma-separated list of all GitHub usernames across the entire release, `github` backend only).<br><br>Default depends on backend (the full SHA is used):<br>&bull; `git` backend (default): `"{{ SHA }} {{ Message }}"`<br>&bull; `github`/`gitlab`/`gitea` backend: `"{{ SHA }}: {{ Message }} (@Login or AuthorName <AuthorEmail>)"` — falls back to `AuthorName <AuthorEmail>` when `Login` is empty.<br><br>When `abbrev < 0`, the default reduces to `"{{ Message }}"` (no hash prefix).
54    pub format: Option<String>,
55    /// Optional path filter that NARROWS the per-crate scope by intersection —
56    /// it never replaces it. Each changelog track already scopes to its own
57    /// commits (a per-crate track to its crate directory; the aggregate to the
58    /// union of every crate directory plus the workspace manifests). When set,
59    /// `paths` further restricts that derived scope to commits whose touched
60    /// files match these globs; it can only ever drop commits, never widen to
61    /// another track's directory. A `paths` value that is a superset of the
62    /// derived scope (e.g. `["crates/**", "Cargo.toml", "Cargo.lock"]` over a
63    /// workspace) is therefore a no-op — and so is the recommended default of
64    /// leaving `paths` unset, where scoping is fully derived. The same derived
65    /// scope and intersect drive all three changelog formats
66    /// (`keep-a-changelog`, `json`, and `release-notes`), so they cannot drift.
67    ///
68    /// With `use: git` the intersect is precise (commits are filtered by their
69    /// touched files). With `use: github` only the first path is used for API
70    /// queries; with `use: gitlab` / `gitea` path filtering is unsupported, so a
71    /// narrowing `paths` there is coarse and a warning is emitted. Supports
72    /// template rendering.
73    pub paths: Option<Vec<String>>,
74    /// Title heading for the changelog. Default: "Changelog". Supports templates.
75    pub title: Option<String>,
76    /// Divider string inserted between changelog groups (e.g. `"---"`). Supports templates.
77    pub divider: Option<String>,
78    /// AI-powered changelog enhancement configuration.
79    pub ai: Option<ChangelogAiConfig>,
80    /// When `true`, render the changelog even in snapshot mode. Anodizer
81    /// matches the default (skip changelog on snapshot) and
82    /// lets users opt back in here for local preview / draft generation.
83    /// Wired in `crates/stage-changelog/src/lib.rs::ChangelogStage::run`.
84    pub snapshot: Option<bool>,
85    /// Changelog file-layout controls: which `CHANGELOG.md` files a release
86    /// writes (per-crate vs the aggregate root). Separate from the
87    /// content-generation keys above (`use`, `format`, `groups`, `filters`,
88    /// `paths`, `sort`, ...) so file management and content concerns stay
89    /// orthogonal. See [`ChangelogFilesConfig`].
90    pub files: Option<ChangelogFilesConfig>,
91}
92
93/// Changelog file-layout controls: which `CHANGELOG.md` files a release writes.
94///
95/// Nested under `changelog.files` to keep file-management keys distinct from
96/// the content-generation keys on [`ChangelogConfig`] (`use`, `format`,
97/// `groups`, `filters`, `paths`, `sort`, ...).
98#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
99#[serde(default, deny_unknown_fields)]
100pub struct ChangelogFilesConfig {
101    /// When `true`, write a per-crate `crates/<name>/CHANGELOG.md` for each
102    /// crate instead of (or in addition to) the single root `CHANGELOG.md`.
103    /// Default: `false`. With `per_crate: true` and no `root:` block, only the
104    /// per-crate files are produced; add a `root:` block to keep the aggregate
105    /// root changelog as well.
106    pub per_crate: Option<bool>,
107    /// Controls the single aggregate root `CHANGELOG.md`. Presence of this
108    /// block forces the root changelog on even when `per_crate: true`. When
109    /// omitted, the root changelog is on unless `per_crate: true` turned it off.
110    pub root: Option<RootChangelogConfig>,
111}
112
113impl ChangelogConfig {
114    /// Default `sort` value. Empty string means "preserve commit order"
115    /// (no sort applied). The
116    /// `checkSortDirection`, which accepts "", "asc", "desc".
117    pub const DEFAULT_SORT: &'static str = "";
118
119    /// Valid `sort` values. Anything else is a config error.
120    pub const VALID_SORT: &[&'static str] = &["", "asc", "desc"];
121
122    /// Default changelog source —
123    /// the unset field falls back to git log parsing.
124    pub const DEFAULT_USE_SOURCE: &'static str = "git";
125
126    /// Valid `use:` values. github-native delegates to GitHub's
127    /// auto-generated notes; the others control which API anodize hits
128    /// for commit metadata.
129    pub const VALID_USE_SOURCE: &[&'static str] =
130        &["git", "github", "gitlab", "gitea", "github-native"];
131
132    /// Default changelog title heading
133    /// (always emits a `## Changelog` heading when title is unset).
134    pub const DEFAULT_TITLE: &'static str = "Changelog";
135
136    /// Default `abbrev` (hash truncation length). 0 = full SHA, mirroring
137    /// the abbrev-entry behaviour. The misleading "Default: 7" docstring
138    /// previously on the field has been corrected.
139    pub const DEFAULT_ABBREV: i32 = 0;
140
141    /// Default `format` template when `abbrev` is negative (hash omitted).
142    pub const DEFAULT_FORMAT_NO_HASH: &'static str = "{{ Message }}";
143
144    /// Default `format` template for SCM-backed sources (github/gitlab/gitea).
145    /// Renders SHA, message, and an `@login` mention falling back to
146    /// `AuthorName <AuthorEmail>` when the API returned no login.
147    pub const DEFAULT_FORMAT_SCM: &'static str = "{{ SHA }}: {{ Message }} ({% if Login %}@{{ Login }}{% else %}{{ AuthorName }} <{{ AuthorEmail }}>{% endif %})";
148
149    /// Default `format` template for the `git` backend.
150    pub const DEFAULT_FORMAT_GIT: &'static str = "{{ SHA }} {{ Message }}";
151
152    /// Resolve the `sort` mode, falling back to [`Self::DEFAULT_SORT`]
153    /// (empty = preserve commit order). Returns an error when the user
154    /// supplied a value outside [`Self::VALID_SORT`] so the invalid mode
155    /// surfaces at the call site.
156    pub fn resolved_sort(&self) -> anyhow::Result<&str> {
157        let value = self.sort.as_deref().unwrap_or(Self::DEFAULT_SORT);
158        if Self::VALID_SORT.contains(&value) {
159            Ok(value)
160        } else {
161            Err(anyhow::anyhow!(
162                "changelog: invalid sort '{}', must be one of: \"\", asc, desc",
163                value
164            ))
165        }
166    }
167
168    /// Resolve the changelog source, falling back to `"git"`.
169    pub fn resolved_use_source(&self) -> &str {
170        self.use_source
171            .as_deref()
172            .unwrap_or(Self::DEFAULT_USE_SOURCE)
173    }
174
175    /// Resolve the title heading, falling back to `"Changelog"`. An empty
176    /// `title:` is preserved (the renderer skips emitting the heading
177    /// when the resolved title is empty), so the schema still allows
178    /// users to suppress the heading with an explicit empty string.
179    pub fn resolved_title(&self) -> &str {
180        self.title.as_deref().unwrap_or(Self::DEFAULT_TITLE)
181    }
182
183    /// Resolve `abbrev`, falling back to [`Self::DEFAULT_ABBREV`] (0 = full SHA).
184    ///
185    /// Values below `-1` are clamped to `-1`. A `git log
186    /// --abbrev=N` would reject `-2`, `-3`, etc.; anodizer renders SHAs in
187    /// Rust so it would not fail, but still clamps for behavioural parity
188    /// — a configuration like `abbrev: -5` produces the same "omit hash"
189    /// output as `abbrev: -1`.
190    pub fn resolved_abbrev(&self) -> i32 {
191        self.abbrev.unwrap_or(Self::DEFAULT_ABBREV).max(-1)
192    }
193
194    /// Resolve the per-entry `format:` template. When the user did not
195    /// set `format:`, returns the backend-specific default keyed off
196    /// `use_source` and `abbrev` (negative abbrev → no-hash template;
197    /// SCM backend → SCM template; git backend → SHA + message).
198    /// Caller should pass the resolved use_source / abbrev values.
199    pub fn resolved_format<'a>(&'a self, use_source: &str, abbrev: i32) -> &'a str {
200        if let Some(f) = self.format.as_deref() {
201            return f;
202        }
203        if abbrev < 0 {
204            return Self::DEFAULT_FORMAT_NO_HASH;
205        }
206        match use_source {
207            "github" | "gitlab" | "gitea" => Self::DEFAULT_FORMAT_SCM,
208            _ => Self::DEFAULT_FORMAT_GIT,
209        }
210    }
211
212    /// Resolve `snapshot`, falling back to `false` (the default:
213    /// skip changelog on `ctx.Snapshot`).
214    pub fn resolved_snapshot(&self) -> bool {
215        self.snapshot.unwrap_or(false)
216    }
217
218    /// Whether per-crate `crates/<name>/CHANGELOG.md` files are requested
219    /// (`files.per_crate: true`). Defaults to `false`. The single place that
220    /// knows `per_crate` lives under `files`.
221    pub fn per_crate(&self) -> bool {
222        self.files
223            .as_ref()
224            .and_then(|f| f.per_crate)
225            .unwrap_or(false)
226    }
227
228    /// The aggregate root `CHANGELOG.md` config block (`files.root`), if set.
229    /// The single place that knows `root` lives under `files`.
230    pub fn root(&self) -> Option<&RootChangelogConfig> {
231        self.files.as_ref().and_then(|f| f.root.as_ref())
232    }
233
234    /// Resolve which changelog files this config produces.
235    ///
236    /// The root changelog is enabled when a `root:` block is present, or when
237    /// `per_crate` is not `true` (its default `false` keeps the back-compat
238    /// root-only behaviour). Per-crate files are produced exactly when
239    /// `per_crate: true`. So a bare `changelog:` block stays root-only, while
240    /// `per_crate: true` without `root:` yields per-crate files only.
241    pub fn resolved_destination(&self) -> ChangelogDestination {
242        let per_crate = self.per_crate();
243        ChangelogDestination {
244            root_enabled: self.root().is_some() || !per_crate,
245            per_crate,
246        }
247    }
248
249    /// Resolve the ordering of release sections in the root `CHANGELOG.md`,
250    /// falling back to [`Chronology::Date`] when `root.chronology` is unset.
251    pub fn resolved_chronology(&self) -> Chronology {
252        self.root()
253            .map(RootChangelogConfig::resolved_chronology)
254            .unwrap_or_default()
255    }
256
257    /// The optional crate filter for the root changelog: `None` means every
258    /// crate contributes a `### <crate>` subsection.
259    pub fn root_crates_filter(&self) -> Option<&[String]> {
260        self.root().and_then(|r| r.crates.as_deref())
261    }
262}
263
264/// Ordering of release sections in the aggregate root `CHANGELOG.md`.
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
266#[serde(rename_all = "lowercase")]
267pub enum Chronology {
268    /// Order sections by release date (default).
269    #[default]
270    Date,
271    /// Order sections by semantic tag version.
272    Tag,
273}
274
275/// Configuration for the single aggregate root `CHANGELOG.md`.
276#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
277#[serde(default, deny_unknown_fields)]
278pub struct RootChangelogConfig {
279    /// Ordering of release sections in the root changelog: `date` (default) or
280    /// `tag`.
281    pub chronology: Option<Chronology>,
282    /// Crates that contribute a `### <crate>` subsection to the root changelog.
283    /// When omitted, every crate contributes a subsection.
284    pub crates: Option<Vec<String>>,
285}
286
287impl RootChangelogConfig {
288    /// Resolve the section ordering, falling back to [`Chronology::Date`].
289    pub fn resolved_chronology(&self) -> Chronology {
290        self.chronology.unwrap_or_default()
291    }
292}
293
294/// The resolved changelog destination decision: which files a release writes.
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub struct ChangelogDestination {
297    /// Whether the single aggregate root `CHANGELOG.md` is written.
298    pub root_enabled: bool,
299    /// Whether per-crate `crates/<name>/CHANGELOG.md` files are written.
300    pub per_crate: bool,
301}
302
303/// AI-powered changelog enhancement configuration.
304#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
305#[serde(default, deny_unknown_fields)]
306pub struct ChangelogAiConfig {
307    /// AI provider to use. Valid: "anthropic", "openai", "ollama".
308    /// Empty disables the feature.
309    #[serde(rename = "use")]
310    pub provider: Option<String>,
311    /// Model name (e.g. "claude-sonnet-4-6", "gpt-4o-mini", "llama3.1").
312    /// Defaults to the provider's default model when unset.
313    pub model: Option<String>,
314    /// Prompt template for the AI. Can be a string, or use `from_url`/`from_file`.
315    /// Template variable `{{ ReleaseNotes }}` contains the current changelog.
316    pub prompt: Option<ChangelogAiPrompt>,
317}
318
319/// Prompt source for AI changelog: inline string, URL, or file path.
320#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
321#[serde(untagged)]
322pub enum ChangelogAiPrompt {
323    /// Inline prompt string (supports templates).
324    Inline(String),
325    /// Structured prompt with from_url/from_file sources.
326    Source(ChangelogAiPromptSource),
327}
328
329/// Structured prompt source: load from URL or file.
330#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
331#[serde(default, deny_unknown_fields)]
332pub struct ChangelogAiPromptSource {
333    /// Load prompt from a URL.
334    pub from_url: Option<ContentFromUrl>,
335    /// Load prompt from a local file. Overrides from_url if both set.
336    pub from_file: Option<ContentFromFile>,
337}
338
339/// Resolved prompt source kind after applying priority rules.
340#[derive(Debug, Clone, PartialEq, Eq)]
341pub enum ResolvedPromptSource {
342    /// Load from a local file path.
343    File(String),
344    /// Load from a URL (with optional headers).
345    Url {
346        url: String,
347        headers: Option<std::collections::HashMap<String, String>>,
348    },
349    /// No source configured.
350    None,
351}
352
353impl ChangelogAiPromptSource {
354    /// Resolve the prompt source applying priority: from_file overrides from_url.
355    pub fn resolve(&self) -> ResolvedPromptSource {
356        if let Some(ref file) = self.from_file
357            && let Some(ref path) = file.path
358        {
359            return ResolvedPromptSource::File(path.clone());
360        }
361        if let Some(ref url_cfg) = self.from_url
362            && let Some(ref url) = url_cfg.url
363        {
364            return ResolvedPromptSource::Url {
365                url: url.clone(),
366                headers: url_cfg.headers.clone(),
367            };
368        }
369        ResolvedPromptSource::None
370    }
371}
372
373/// Load content from a URL with optional headers.
374#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
375#[serde(default, deny_unknown_fields)]
376pub struct ContentFromUrl {
377    /// URL to fetch (supports templates).
378    pub url: Option<String>,
379    /// HTTP headers to send with the request.
380    pub headers: Option<std::collections::HashMap<String, String>>,
381}
382
383/// Load content from a local file.
384#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
385#[serde(default, deny_unknown_fields)]
386pub struct ContentFromFile {
387    /// Path to the file (supports templates).
388    pub path: Option<String>,
389}
390
391/// Regex-based commit filters for the changelog stage.
392///
393/// Patterns are NOT compile-validated at config-load — a malformed regex
394/// only surfaces when the changelog stage runs, which on a release pipeline
395/// is well past the point of cheap failure. Test patterns locally
396/// (`anodizer changelog --check` or any external regex tool) before
397/// committing config changes.
398#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
399#[serde(default, deny_unknown_fields)]
400pub struct ChangelogFilters {
401    /// Regex patterns: commits matching any of these are excluded from the changelog.
402    pub exclude: Option<Vec<String>>,
403    /// Regex patterns: only commits matching at least one of these are included.
404    pub include: Option<Vec<String>>,
405    /// Exclude anodizer's own version-sync bump commits
406    /// (`chore(release): bump …`, optionally ` [skip ci]`) from the generated
407    /// changelog. They are release machinery, not user-facing changes, so this
408    /// defaults to `true`. Set `false` to keep them. No effect in include mode
409    /// (`include` already drops anything that does not match a pattern).
410    pub exclude_version_sync_commits: Option<bool>,
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
414#[serde(default, deny_unknown_fields)]
415pub struct ChangelogGroup {
416    /// Section heading for this group (e.g., "Features", "Bug Fixes").
417    pub title: String,
418    /// Regex pattern matching commit messages to include in this group.
419    pub regexp: Option<String>,
420    /// Sort order for this group relative to other groups (lower = first).
421    pub order: Option<i32>,
422    /// Nested subgroups within this group. Rendered as sub-sections (e.g. `###`).
423    pub groups: Option<Vec<ChangelogGroup>>,
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn rejects_unknown_top_level_field() {
432        // The pre-`changelog.files` flat keys (`per_crate:` / `root:` at the
433        // changelog top level) must now hard-error rather than be silently
434        // ignored — `deny_unknown_fields` is what turns the moved-key migration
435        // into a loud, actionable failure instead of changed-output-no-error.
436        let yaml = "use: github-native\nper_crate: true\n";
437        assert!(serde_yaml_ng::from_str::<ChangelogConfig>(yaml).is_err());
438    }
439
440    #[test]
441    fn accepts_per_crate_under_files_block() {
442        // The supported form (nested under `files:`) still parses.
443        let yaml = "use: github-native\nfiles:\n  per_crate: true\n";
444        let cfg: ChangelogConfig = serde_yaml_ng::from_str(yaml).expect("nested form parses");
445        assert_eq!(cfg.files.and_then(|f| f.per_crate), Some(true));
446    }
447
448    #[test]
449    fn rejects_unknown_field_under_files_block() {
450        let yaml = "files:\n  per_crat: true\n";
451        assert!(serde_yaml_ng::from_str::<ChangelogConfig>(yaml).is_err());
452    }
453
454    #[test]
455    fn destination_bare_config_is_root_only() {
456        let cfg = ChangelogConfig::default();
457        let dest = cfg.resolved_destination();
458        assert!(dest.root_enabled, "bare config keeps the root CHANGELOG.md");
459        assert!(!dest.per_crate, "bare config emits no per-crate files");
460    }
461
462    #[test]
463    fn destination_per_crate_true_without_root_is_per_crate_only() {
464        let cfg = ChangelogConfig {
465            files: Some(ChangelogFilesConfig {
466                per_crate: Some(true),
467                ..Default::default()
468            }),
469            ..Default::default()
470        };
471        let dest = cfg.resolved_destination();
472        assert!(
473            !dest.root_enabled,
474            "per_crate: true without root: drops root"
475        );
476        assert!(dest.per_crate);
477    }
478
479    #[test]
480    fn destination_per_crate_true_with_root_is_both() {
481        let cfg = ChangelogConfig {
482            files: Some(ChangelogFilesConfig {
483                per_crate: Some(true),
484                root: Some(RootChangelogConfig::default()),
485            }),
486            ..Default::default()
487        };
488        let dest = cfg.resolved_destination();
489        assert!(dest.root_enabled);
490        assert!(dest.per_crate);
491    }
492
493    #[test]
494    fn destination_per_crate_false_with_root_is_root_only() {
495        let cfg = ChangelogConfig {
496            files: Some(ChangelogFilesConfig {
497                per_crate: Some(false),
498                root: Some(RootChangelogConfig::default()),
499            }),
500            ..Default::default()
501        };
502        let dest = cfg.resolved_destination();
503        assert!(dest.root_enabled);
504        assert!(!dest.per_crate);
505    }
506
507    #[test]
508    fn chronology_defaults_to_date_when_root_unset() {
509        let cfg = ChangelogConfig::default();
510        assert_eq!(cfg.resolved_chronology(), Chronology::Date);
511    }
512
513    #[test]
514    fn chronology_defaults_to_date_when_root_set_without_chronology() {
515        let cfg = ChangelogConfig {
516            files: Some(ChangelogFilesConfig {
517                root: Some(RootChangelogConfig::default()),
518                ..Default::default()
519            }),
520            ..Default::default()
521        };
522        assert_eq!(cfg.resolved_chronology(), Chronology::Date);
523    }
524
525    #[test]
526    fn chronology_override_tag() {
527        let cfg = ChangelogConfig {
528            files: Some(ChangelogFilesConfig {
529                root: Some(RootChangelogConfig {
530                    chronology: Some(Chronology::Tag),
531                    ..Default::default()
532                }),
533                ..Default::default()
534            }),
535            ..Default::default()
536        };
537        assert_eq!(cfg.resolved_chronology(), Chronology::Tag);
538    }
539
540    #[test]
541    fn resolved_chronology_accessor_on_root_defaults_to_date() {
542        assert_eq!(
543            RootChangelogConfig::default().resolved_chronology(),
544            Chronology::Date
545        );
546    }
547
548    #[test]
549    fn crates_filter_defaults_to_none_meaning_all() {
550        let cfg = ChangelogConfig {
551            files: Some(ChangelogFilesConfig {
552                root: Some(RootChangelogConfig::default()),
553                ..Default::default()
554            }),
555            ..Default::default()
556        };
557        assert_eq!(cfg.root_crates_filter(), None);
558    }
559
560    #[test]
561    fn crates_filter_passes_through_list() {
562        let cfg = ChangelogConfig {
563            files: Some(ChangelogFilesConfig {
564                root: Some(RootChangelogConfig {
565                    crates: Some(vec!["a".to_string(), "b".to_string()]),
566                    ..Default::default()
567                }),
568                ..Default::default()
569            }),
570            ..Default::default()
571        };
572        assert_eq!(
573            cfg.root_crates_filter(),
574            Some(["a".to_string(), "b".to_string()].as_slice())
575        );
576    }
577
578    #[test]
579    fn deserializes_per_crate_and_root_block() {
580        let yaml = r#"
581files:
582  per_crate: true
583  root:
584    chronology: tag
585    crates: [a, b]
586"#;
587        let cfg: ChangelogConfig = serde_yaml_ng::from_str(yaml).expect("parse changelog block");
588        assert!(cfg.per_crate());
589        let root = cfg.root().expect("root present");
590        assert_eq!(root.chronology, Some(Chronology::Tag));
591        assert_eq!(
592            root.crates.as_deref(),
593            Some(["a".to_string(), "b".to_string()].as_slice())
594        );
595
596        let dest = cfg.resolved_destination();
597        assert!(dest.root_enabled);
598        assert!(dest.per_crate);
599        assert_eq!(cfg.resolved_chronology(), Chronology::Tag);
600    }
601
602    #[test]
603    fn deserializes_bare_block_to_root_only() {
604        let yaml = "sort: asc";
605        let cfg: ChangelogConfig = serde_yaml_ng::from_str(yaml).expect("parse bare block");
606        assert!(!cfg.per_crate());
607        assert!(cfg.root().is_none());
608        let dest = cfg.resolved_destination();
609        assert!(dest.root_enabled);
610        assert!(!dest.per_crate);
611    }
612
613    #[test]
614    fn deserializes_empty_root_block_forces_root_with_default_chronology() {
615        let yaml = "files:\n  per_crate: true\n  root: {}\n";
616        let cfg: ChangelogConfig = serde_yaml_ng::from_str(yaml).expect("parse");
617        let dest = cfg.resolved_destination();
618        assert!(dest.root_enabled);
619        assert!(dest.per_crate);
620        assert_eq!(cfg.resolved_chronology(), Chronology::Date);
621    }
622
623    #[test]
624    fn empty_crates_list_is_distinct_from_omitted() {
625        let with_empty: ChangelogConfig =
626            serde_yaml_ng::from_str("files:\n  root:\n    crates: []\n").expect("parse");
627        assert_eq!(with_empty.root_crates_filter(), Some(&[][..]));
628
629        let omitted: ChangelogConfig =
630            serde_yaml_ng::from_str("files:\n  root: {}\n").expect("parse");
631        assert_eq!(omitted.root_crates_filter(), None);
632    }
633
634    #[test]
635    fn chronology_serde_rename_is_lowercase() {
636        assert_eq!(
637            serde_yaml_ng::to_string(&Chronology::Date)
638                .expect("ser")
639                .trim(),
640            "date"
641        );
642        assert_eq!(
643            serde_yaml_ng::to_string(&Chronology::Tag)
644                .expect("ser")
645                .trim(),
646            "tag"
647        );
648    }
649}