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), Logins (per-entry comma-separated list of GitHub usernames for that commit), AllLogins (comma-separated list of all GitHub usernames across the entire release), AuthorUsername (renders `@login` when the login is known, the plain author name otherwise).<br><br>Logins come from the SCM API backends (`use: github`/`gitea`) and — when the release targets GitHub and a token is available — from GitHub-API enrichment of the default `git` backend, so `use: git` changelogs render `@login` mentions too. Release bodies carry bare `@login` (GitHub autolinks them); on-disk `CHANGELOG.md` files get explicit `[@login](https://github.com/login)` links. Without a token (or offline, or with a non-GitHub remote) rendering keeps the plain author name.<br><br>Default depends on backend (the full SHA is used):<br>• `git` backend (default): `"{{ SHA }} {{ Message }}"`<br>• `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. The mention
147 /// renders via `AuthorUsername` (not a literal `@` + raw `Login`) so the
148 /// renderer's mention styling applies — bare `@login` in release bodies,
149 /// `[@login](https://github.com/login)` in on-disk changelogs — while
150 /// `Login` stays the raw branch-condition datum.
151 pub const DEFAULT_FORMAT_SCM: &'static str = "{{ SHA }}: {{ Message }} ({% if Login %}{{ AuthorUsername }}{% else %}{{ AuthorName }} <{{ AuthorEmail }}>{% endif %})";
152
153 /// Default `format` template for the `git` backend.
154 pub const DEFAULT_FORMAT_GIT: &'static str = "{{ SHA }} {{ Message }}";
155
156 /// Resolve the `sort` mode, falling back to [`Self::DEFAULT_SORT`]
157 /// (empty = preserve commit order). Returns an error when the user
158 /// supplied a value outside [`Self::VALID_SORT`] so the invalid mode
159 /// surfaces at the call site.
160 pub fn resolved_sort(&self) -> anyhow::Result<&str> {
161 let value = self.sort.as_deref().unwrap_or(Self::DEFAULT_SORT);
162 if Self::VALID_SORT.contains(&value) {
163 Ok(value)
164 } else {
165 Err(anyhow::anyhow!(
166 "changelog: invalid sort '{}', must be one of: \"\", asc, desc",
167 value
168 ))
169 }
170 }
171
172 /// Resolve the changelog source, falling back to `"git"`.
173 pub fn resolved_use_source(&self) -> &str {
174 self.use_source
175 .as_deref()
176 .unwrap_or(Self::DEFAULT_USE_SOURCE)
177 }
178
179 /// Resolve the title heading, falling back to `"Changelog"`. An empty
180 /// `title:` is preserved (the renderer skips emitting the heading
181 /// when the resolved title is empty), so the schema still allows
182 /// users to suppress the heading with an explicit empty string.
183 pub fn resolved_title(&self) -> &str {
184 self.title.as_deref().unwrap_or(Self::DEFAULT_TITLE)
185 }
186
187 /// Resolve `abbrev`, falling back to [`Self::DEFAULT_ABBREV`] (0 = full SHA).
188 ///
189 /// Values below `-1` are clamped to `-1`. A `git log
190 /// --abbrev=N` would reject `-2`, `-3`, etc.; anodizer renders SHAs in
191 /// Rust so it would not fail, but still clamps for behavioural parity
192 /// — a configuration like `abbrev: -5` produces the same "omit hash"
193 /// output as `abbrev: -1`.
194 pub fn resolved_abbrev(&self) -> i32 {
195 self.abbrev.unwrap_or(Self::DEFAULT_ABBREV).max(-1)
196 }
197
198 /// Resolve the per-entry `format:` template. When the user did not
199 /// set `format:`, returns the backend-specific default keyed off
200 /// `use_source` and `abbrev` (negative abbrev → no-hash template;
201 /// SCM backend → SCM template; git backend → SHA + message).
202 /// Caller should pass the resolved use_source / abbrev values.
203 pub fn resolved_format<'a>(&'a self, use_source: &str, abbrev: i32) -> &'a str {
204 if let Some(f) = self.format.as_deref() {
205 return f;
206 }
207 if abbrev < 0 {
208 return Self::DEFAULT_FORMAT_NO_HASH;
209 }
210 match use_source {
211 "github" | "gitlab" | "gitea" => Self::DEFAULT_FORMAT_SCM,
212 _ => Self::DEFAULT_FORMAT_GIT,
213 }
214 }
215
216 /// Resolve `snapshot`, falling back to `false` (the default:
217 /// skip changelog on `ctx.Snapshot`).
218 pub fn resolved_snapshot(&self) -> bool {
219 self.snapshot.unwrap_or(false)
220 }
221
222 /// Whether per-crate `crates/<name>/CHANGELOG.md` files are requested
223 /// (`files.per_crate: true`). Defaults to `false`. The single place that
224 /// knows `per_crate` lives under `files`.
225 pub fn per_crate(&self) -> bool {
226 self.files
227 .as_ref()
228 .and_then(|f| f.per_crate)
229 .unwrap_or(false)
230 }
231
232 /// The aggregate root `CHANGELOG.md` config block (`files.root`), if set.
233 /// The single place that knows `root` lives under `files`.
234 pub fn root(&self) -> Option<&RootChangelogConfig> {
235 self.files.as_ref().and_then(|f| f.root.as_ref())
236 }
237
238 /// Resolve which changelog files this config produces.
239 ///
240 /// The root changelog is enabled when a `root:` block is present, or when
241 /// `per_crate` is not `true` (its default `false` keeps the back-compat
242 /// root-only behaviour). Per-crate files are produced exactly when
243 /// `per_crate: true`. So a bare `changelog:` block stays root-only, while
244 /// `per_crate: true` without `root:` yields per-crate files only.
245 pub fn resolved_destination(&self) -> ChangelogDestination {
246 let per_crate = self.per_crate();
247 ChangelogDestination {
248 root_enabled: self.root().is_some() || !per_crate,
249 per_crate,
250 }
251 }
252
253 /// Resolve the ordering of release sections in the root `CHANGELOG.md`,
254 /// falling back to [`Chronology::Date`] when `root.chronology` is unset.
255 pub fn resolved_chronology(&self) -> Chronology {
256 self.root()
257 .map(RootChangelogConfig::resolved_chronology)
258 .unwrap_or_default()
259 }
260
261 /// The optional crate filter for the root changelog: `None` means every
262 /// crate contributes a `### <crate>` subsection.
263 pub fn root_crates_filter(&self) -> Option<&[String]> {
264 self.root().and_then(|r| r.crates.as_deref())
265 }
266}
267
268/// Ordering of release sections in the aggregate root `CHANGELOG.md`.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
270#[serde(rename_all = "lowercase")]
271pub enum Chronology {
272 /// Order sections by release date (default).
273 #[default]
274 Date,
275 /// Order sections by semantic tag version.
276 Tag,
277}
278
279/// Configuration for the single aggregate root `CHANGELOG.md`.
280#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
281#[serde(default, deny_unknown_fields)]
282pub struct RootChangelogConfig {
283 /// Ordering of release sections in the root changelog: `date` (default) or
284 /// `tag`.
285 pub chronology: Option<Chronology>,
286 /// Crates that contribute a `### <crate>` subsection to the root changelog.
287 /// When omitted, every crate contributes a subsection.
288 pub crates: Option<Vec<String>>,
289}
290
291impl RootChangelogConfig {
292 /// Resolve the section ordering, falling back to [`Chronology::Date`].
293 pub fn resolved_chronology(&self) -> Chronology {
294 self.chronology.unwrap_or_default()
295 }
296}
297
298/// The resolved changelog destination decision: which files a release writes.
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub struct ChangelogDestination {
301 /// Whether the single aggregate root `CHANGELOG.md` is written.
302 pub root_enabled: bool,
303 /// Whether per-crate `crates/<name>/CHANGELOG.md` files are written.
304 pub per_crate: bool,
305}
306
307/// AI-powered changelog enhancement configuration.
308#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
309#[serde(default, deny_unknown_fields)]
310pub struct ChangelogAiConfig {
311 /// AI provider to use. Valid: "anthropic", "openai", "ollama".
312 /// Empty disables the feature.
313 #[serde(rename = "use")]
314 pub provider: Option<String>,
315 /// Model name (e.g. "claude-sonnet-4-6", "gpt-4o-mini", "llama3.1").
316 /// Defaults to the provider's default model when unset.
317 pub model: Option<String>,
318 /// Prompt template for the AI. Can be a string, or use `from_url`/`from_file`.
319 /// Template variable `{{ ReleaseNotes }}` contains the current changelog.
320 pub prompt: Option<ChangelogAiPrompt>,
321}
322
323/// Prompt source for AI changelog: inline string, URL, or file path.
324#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
325#[serde(untagged)]
326pub enum ChangelogAiPrompt {
327 /// Inline prompt string (supports templates).
328 Inline(String),
329 /// Structured prompt with from_url/from_file sources.
330 Source(ChangelogAiPromptSource),
331}
332
333/// Structured prompt source: load from URL or file.
334#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
335#[serde(default, deny_unknown_fields)]
336pub struct ChangelogAiPromptSource {
337 /// Load prompt from a URL.
338 pub from_url: Option<ContentFromUrl>,
339 /// Load prompt from a local file. Overrides from_url if both set.
340 pub from_file: Option<ContentFromFile>,
341}
342
343/// Resolved prompt source kind after applying priority rules.
344#[derive(Debug, Clone, PartialEq, Eq)]
345pub enum ResolvedPromptSource {
346 /// Load from a local file path.
347 File(String),
348 /// Load from a URL (with optional headers).
349 Url {
350 url: String,
351 headers: Option<std::collections::HashMap<String, String>>,
352 },
353 /// No source configured.
354 None,
355}
356
357impl ChangelogAiPromptSource {
358 /// Resolve the prompt source applying priority: from_file overrides from_url.
359 pub fn resolve(&self) -> ResolvedPromptSource {
360 if let Some(ref file) = self.from_file
361 && let Some(ref path) = file.path
362 {
363 return ResolvedPromptSource::File(path.clone());
364 }
365 if let Some(ref url_cfg) = self.from_url
366 && let Some(ref url) = url_cfg.url
367 {
368 return ResolvedPromptSource::Url {
369 url: url.clone(),
370 headers: url_cfg.headers.clone(),
371 };
372 }
373 ResolvedPromptSource::None
374 }
375}
376
377/// Load content from a URL with optional headers.
378#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
379#[serde(default, deny_unknown_fields)]
380pub struct ContentFromUrl {
381 /// URL to fetch (supports templates).
382 pub url: Option<String>,
383 /// HTTP headers to send with the request.
384 pub headers: Option<std::collections::HashMap<String, String>>,
385}
386
387/// Load content from a local file.
388#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
389#[serde(default, deny_unknown_fields)]
390pub struct ContentFromFile {
391 /// Path to the file (supports templates).
392 pub path: Option<String>,
393}
394
395/// Regex-based commit filters for the changelog stage.
396///
397/// Patterns are NOT compile-validated at config-load — a malformed regex
398/// only surfaces when the changelog stage runs, which on a release pipeline
399/// is well past the point of cheap failure. Test patterns locally
400/// (`anodizer changelog --check` or any external regex tool) before
401/// committing config changes.
402///
403/// Every pattern is matched against the **raw commit subject**, with no
404/// conventional-commit parsing first. Filtering by type therefore has to
405/// spell the scope and the breaking marker out, or the pattern silently
406/// covers less than it looks like it does:
407///
408/// ```yaml
409/// filters:
410/// exclude:
411/// - "^docs(\\(.*\\))?!?:" # docs: · docs(cli): · docs(cli)!:
412/// ```
413///
414/// A bare `^docs:` matches only the unscoped form, so `docs(cli): …` still
415/// reaches the changelog. Dropping the colon instead (`^docs`) over-matches
416/// in the other direction and swallows any subject that merely begins with
417/// those letters.
418#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
419#[serde(default, deny_unknown_fields)]
420pub struct ChangelogFilters {
421 /// Regex patterns: commits matching any of these are excluded from the changelog.
422 pub exclude: Option<Vec<String>>,
423 /// Regex patterns: only commits matching at least one of these are included.
424 pub include: Option<Vec<String>>,
425 /// Exclude anodizer's own version-sync bump commits
426 /// (`chore(release): bump …`, optionally ` [skip ci]`) from the generated
427 /// changelog. They are release machinery, not user-facing changes, so this
428 /// defaults to `true`. Set `false` to keep them. No effect in include mode
429 /// (`include` already drops anything that does not match a pattern).
430 pub exclude_version_sync_commits: Option<bool>,
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
434#[serde(default, deny_unknown_fields)]
435pub struct ChangelogGroup {
436 /// Section heading for this group (e.g., "Features", "Bug Fixes").
437 pub title: String,
438 /// Regex pattern matching commit messages to include in this group.
439 pub regexp: Option<String>,
440 /// Sort order for this group relative to other groups (lower = first).
441 pub order: Option<i32>,
442 /// Nested subgroups within this group. Rendered as sub-sections (e.g. `###`).
443 pub groups: Option<Vec<ChangelogGroup>>,
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449
450 #[test]
451 fn rejects_unknown_top_level_field() {
452 // The pre-`changelog.files` flat keys (`per_crate:` / `root:` at the
453 // changelog top level) must now hard-error rather than be silently
454 // ignored — `deny_unknown_fields` is what turns the moved-key migration
455 // into a loud, actionable failure instead of changed-output-no-error.
456 let yaml = "use: github-native\nper_crate: true\n";
457 assert!(serde_yaml_ng::from_str::<ChangelogConfig>(yaml).is_err());
458 }
459
460 #[test]
461 fn accepts_per_crate_under_files_block() {
462 // The supported form (nested under `files:`) still parses.
463 let yaml = "use: github-native\nfiles:\n per_crate: true\n";
464 let cfg: ChangelogConfig = serde_yaml_ng::from_str(yaml).expect("nested form parses");
465 assert_eq!(cfg.files.and_then(|f| f.per_crate), Some(true));
466 }
467
468 #[test]
469 fn rejects_unknown_field_under_files_block() {
470 let yaml = "files:\n per_crat: true\n";
471 assert!(serde_yaml_ng::from_str::<ChangelogConfig>(yaml).is_err());
472 }
473
474 #[test]
475 fn destination_bare_config_is_root_only() {
476 let cfg = ChangelogConfig::default();
477 let dest = cfg.resolved_destination();
478 assert!(dest.root_enabled, "bare config keeps the root CHANGELOG.md");
479 assert!(!dest.per_crate, "bare config emits no per-crate files");
480 }
481
482 #[test]
483 fn destination_per_crate_true_without_root_is_per_crate_only() {
484 let cfg = ChangelogConfig {
485 files: Some(ChangelogFilesConfig {
486 per_crate: Some(true),
487 ..Default::default()
488 }),
489 ..Default::default()
490 };
491 let dest = cfg.resolved_destination();
492 assert!(
493 !dest.root_enabled,
494 "per_crate: true without root: drops root"
495 );
496 assert!(dest.per_crate);
497 }
498
499 #[test]
500 fn destination_per_crate_true_with_root_is_both() {
501 let cfg = ChangelogConfig {
502 files: Some(ChangelogFilesConfig {
503 per_crate: Some(true),
504 root: Some(RootChangelogConfig::default()),
505 }),
506 ..Default::default()
507 };
508 let dest = cfg.resolved_destination();
509 assert!(dest.root_enabled);
510 assert!(dest.per_crate);
511 }
512
513 #[test]
514 fn destination_per_crate_false_with_root_is_root_only() {
515 let cfg = ChangelogConfig {
516 files: Some(ChangelogFilesConfig {
517 per_crate: Some(false),
518 root: Some(RootChangelogConfig::default()),
519 }),
520 ..Default::default()
521 };
522 let dest = cfg.resolved_destination();
523 assert!(dest.root_enabled);
524 assert!(!dest.per_crate);
525 }
526
527 #[test]
528 fn chronology_defaults_to_date_when_root_unset() {
529 let cfg = ChangelogConfig::default();
530 assert_eq!(cfg.resolved_chronology(), Chronology::Date);
531 }
532
533 #[test]
534 fn chronology_defaults_to_date_when_root_set_without_chronology() {
535 let cfg = ChangelogConfig {
536 files: Some(ChangelogFilesConfig {
537 root: Some(RootChangelogConfig::default()),
538 ..Default::default()
539 }),
540 ..Default::default()
541 };
542 assert_eq!(cfg.resolved_chronology(), Chronology::Date);
543 }
544
545 #[test]
546 fn chronology_override_tag() {
547 let cfg = ChangelogConfig {
548 files: Some(ChangelogFilesConfig {
549 root: Some(RootChangelogConfig {
550 chronology: Some(Chronology::Tag),
551 ..Default::default()
552 }),
553 ..Default::default()
554 }),
555 ..Default::default()
556 };
557 assert_eq!(cfg.resolved_chronology(), Chronology::Tag);
558 }
559
560 #[test]
561 fn resolved_chronology_accessor_on_root_defaults_to_date() {
562 assert_eq!(
563 RootChangelogConfig::default().resolved_chronology(),
564 Chronology::Date
565 );
566 }
567
568 #[test]
569 fn crates_filter_defaults_to_none_meaning_all() {
570 let cfg = ChangelogConfig {
571 files: Some(ChangelogFilesConfig {
572 root: Some(RootChangelogConfig::default()),
573 ..Default::default()
574 }),
575 ..Default::default()
576 };
577 assert_eq!(cfg.root_crates_filter(), None);
578 }
579
580 #[test]
581 fn crates_filter_passes_through_list() {
582 let cfg = ChangelogConfig {
583 files: Some(ChangelogFilesConfig {
584 root: Some(RootChangelogConfig {
585 crates: Some(vec!["a".to_string(), "b".to_string()]),
586 ..Default::default()
587 }),
588 ..Default::default()
589 }),
590 ..Default::default()
591 };
592 assert_eq!(
593 cfg.root_crates_filter(),
594 Some(["a".to_string(), "b".to_string()].as_slice())
595 );
596 }
597
598 #[test]
599 fn deserializes_per_crate_and_root_block() {
600 let yaml = r#"
601files:
602 per_crate: true
603 root:
604 chronology: tag
605 crates: [a, b]
606"#;
607 let cfg: ChangelogConfig = serde_yaml_ng::from_str(yaml).expect("parse changelog block");
608 assert!(cfg.per_crate());
609 let root = cfg.root().expect("root present");
610 assert_eq!(root.chronology, Some(Chronology::Tag));
611 assert_eq!(
612 root.crates.as_deref(),
613 Some(["a".to_string(), "b".to_string()].as_slice())
614 );
615
616 let dest = cfg.resolved_destination();
617 assert!(dest.root_enabled);
618 assert!(dest.per_crate);
619 assert_eq!(cfg.resolved_chronology(), Chronology::Tag);
620 }
621
622 #[test]
623 fn deserializes_bare_block_to_root_only() {
624 let yaml = "sort: asc";
625 let cfg: ChangelogConfig = serde_yaml_ng::from_str(yaml).expect("parse bare block");
626 assert!(!cfg.per_crate());
627 assert!(cfg.root().is_none());
628 let dest = cfg.resolved_destination();
629 assert!(dest.root_enabled);
630 assert!(!dest.per_crate);
631 }
632
633 #[test]
634 fn deserializes_empty_root_block_forces_root_with_default_chronology() {
635 let yaml = "files:\n per_crate: true\n root: {}\n";
636 let cfg: ChangelogConfig = serde_yaml_ng::from_str(yaml).expect("parse");
637 let dest = cfg.resolved_destination();
638 assert!(dest.root_enabled);
639 assert!(dest.per_crate);
640 assert_eq!(cfg.resolved_chronology(), Chronology::Date);
641 }
642
643 #[test]
644 fn empty_crates_list_is_distinct_from_omitted() {
645 let with_empty: ChangelogConfig =
646 serde_yaml_ng::from_str("files:\n root:\n crates: []\n").expect("parse");
647 assert_eq!(with_empty.root_crates_filter(), Some(&[][..]));
648
649 let omitted: ChangelogConfig =
650 serde_yaml_ng::from_str("files:\n root: {}\n").expect("parse");
651 assert_eq!(omitted.root_crates_filter(), None);
652 }
653
654 #[test]
655 fn chronology_serde_rename_is_lowercase() {
656 assert_eq!(
657 serde_yaml_ng::to_string(&Chronology::Date)
658 .expect("ser")
659 .trim(),
660 "date"
661 );
662 assert_eq!(
663 serde_yaml_ng::to_string(&Chronology::Tag)
664 .expect("ser")
665 .trim(),
666 "tag"
667 );
668 }
669}