anodizer-core 0.21.0

Core configuration, context, and template engine for the anodizer release tool
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use super::archives::ContentSource;
use super::{StringOrBool, deserialize_string_or_bool_opt};

// ---------------------------------------------------------------------------
// ChangelogConfig
// ---------------------------------------------------------------------------

#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct ChangelogConfig {
    /// Sort order for changelog entries: "asc" or "desc" (default: "asc").
    pub sort: Option<String>,
    /// Commit message filters to include or exclude from the changelog.
    pub filters: Option<ChangelogFilters>,
    /// Groups for organizing changelog entries by commit message prefix.
    pub groups: Option<Vec<ChangelogGroup>>,
    /// Text prepended to the changelog. Inline string, `from_file: <path>`,
    /// or `from_url: <url>` — symmetric with the release block's header/footer
    /// so users can compose headers from a templated file or remote endpoint
    /// (the upstream uses a plain string here; anodizer extends to ContentSource
    /// for consistency with `release.header`).
    pub header: Option<ContentSource>,
    /// Text appended to the changelog. Same shape as `header`.
    pub footer: Option<ContentSource>,
    /// Skip changelog generation. Accepts bool or template string
    /// (e.g. `"{{ if IsSnapshot }}true{{ endif }}"` for conditional skip).
    ///
    /// Accepts `disable:` as an alias so imported configs (which use
    /// `changelog.disable:`) parse cleanly without a rename. Anodizer's
    /// broader convention is `skip:` (mirrors `release.skip_upload`,
    /// stage-level `skip:` flags), so the canonical key stays `skip:`.
    #[serde(
        deserialize_with = "deserialize_string_or_bool_opt",
        default,
        alias = "disable"
    )]
    pub skip: Option<StringOrBool>,
    /// Changelog source: `"git"` (default), `"github"`, or `"github-native"`.
    /// `"github"` fetches commits via the GitHub API, enriching entries with
    /// author login information (available as the `{{ Logins }}` per-entry
    /// template variable and the `{{ AllLogins }}` release-wide variable).
    /// `"github-native"` delegates entirely to GitHub's auto-generated notes.
    #[serde(rename = "use")]
    pub use_source: Option<String>,
    /// Hash abbreviation length. Default: 0 (no truncation, emit the full
    /// SHA). Set to -1 to omit the hash entirely; positive values truncate
    /// to N chars. Values below `-1` are clamped to `-1` (a `git log
    /// --abbrev=N` would otherwise reject `-2`, `-3`, ...).
    pub abbrev: Option<i32>,
    /// 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>&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).
    pub format: Option<String>,
    /// Optional path filter that NARROWS the per-crate scope by intersection —
    /// it never replaces it. Each changelog track already scopes to its own
    /// commits (a per-crate track to its crate directory; the aggregate to the
    /// union of every crate directory plus the workspace manifests). When set,
    /// `paths` further restricts that derived scope to commits whose touched
    /// files match these globs; it can only ever drop commits, never widen to
    /// another track's directory. A `paths` value that is a superset of the
    /// derived scope (e.g. `["crates/**", "Cargo.toml", "Cargo.lock"]` over a
    /// workspace) is therefore a no-op — and so is the recommended default of
    /// leaving `paths` unset, where scoping is fully derived. The same derived
    /// scope and intersect drive all three changelog formats
    /// (`keep-a-changelog`, `json`, and `release-notes`), so they cannot drift.
    ///
    /// With `use: git` the intersect is precise (commits are filtered by their
    /// touched files). With `use: github` only the first path is used for API
    /// queries; with `use: gitlab` / `gitea` path filtering is unsupported, so a
    /// narrowing `paths` there is coarse and a warning is emitted. Supports
    /// template rendering.
    pub paths: Option<Vec<String>>,
    /// Title heading for the changelog. Default: "Changelog". Supports templates.
    pub title: Option<String>,
    /// Divider string inserted between changelog groups (e.g. `"---"`). Supports templates.
    pub divider: Option<String>,
    /// AI-powered changelog enhancement configuration.
    pub ai: Option<ChangelogAiConfig>,
    /// When `true`, render the changelog even in snapshot mode. Anodizer
    /// matches the default (skip changelog on snapshot) and
    /// lets users opt back in here for local preview / draft generation.
    /// Wired in `crates/stage-changelog/src/lib.rs::ChangelogStage::run`.
    pub snapshot: Option<bool>,
    /// Changelog file-layout controls: which `CHANGELOG.md` files a release
    /// writes (per-crate vs the aggregate root). Separate from the
    /// content-generation keys above (`use`, `format`, `groups`, `filters`,
    /// `paths`, `sort`, ...) so file management and content concerns stay
    /// orthogonal. See [`ChangelogFilesConfig`].
    pub files: Option<ChangelogFilesConfig>,
}

/// Changelog file-layout controls: which `CHANGELOG.md` files a release writes.
///
/// Nested under `changelog.files` to keep file-management keys distinct from
/// the content-generation keys on [`ChangelogConfig`] (`use`, `format`,
/// `groups`, `filters`, `paths`, `sort`, ...).
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct ChangelogFilesConfig {
    /// When `true`, write a per-crate `crates/<name>/CHANGELOG.md` for each
    /// crate instead of (or in addition to) the single root `CHANGELOG.md`.
    /// Default: `false`. With `per_crate: true` and no `root:` block, only the
    /// per-crate files are produced; add a `root:` block to keep the aggregate
    /// root changelog as well.
    pub per_crate: Option<bool>,
    /// Controls the single aggregate root `CHANGELOG.md`. Presence of this
    /// block forces the root changelog on even when `per_crate: true`. When
    /// omitted, the root changelog is on unless `per_crate: true` turned it off.
    pub root: Option<RootChangelogConfig>,
}

impl ChangelogConfig {
    /// Default `sort` value. Empty string means "preserve commit order"
    /// (no sort applied). The
    /// `checkSortDirection`, which accepts "", "asc", "desc".
    pub const DEFAULT_SORT: &'static str = "";

    /// Valid `sort` values. Anything else is a config error.
    pub const VALID_SORT: &[&'static str] = &["", "asc", "desc"];

    /// Default changelog source —
    /// the unset field falls back to git log parsing.
    pub const DEFAULT_USE_SOURCE: &'static str = "git";

    /// Valid `use:` values. github-native delegates to GitHub's
    /// auto-generated notes; the others control which API anodize hits
    /// for commit metadata.
    pub const VALID_USE_SOURCE: &[&'static str] =
        &["git", "github", "gitlab", "gitea", "github-native"];

    /// Default changelog title heading
    /// (always emits a `## Changelog` heading when title is unset).
    pub const DEFAULT_TITLE: &'static str = "Changelog";

    /// Default `abbrev` (hash truncation length). 0 = full SHA, mirroring
    /// the abbrev-entry behaviour. The misleading "Default: 7" docstring
    /// previously on the field has been corrected.
    pub const DEFAULT_ABBREV: i32 = 0;

    /// Default `format` template when `abbrev` is negative (hash omitted).
    pub const DEFAULT_FORMAT_NO_HASH: &'static str = "{{ Message }}";

    /// Default `format` template for SCM-backed sources (github/gitlab/gitea).
    /// Renders SHA, message, and an `@login` mention falling back to
    /// `AuthorName <AuthorEmail>` when the API returned no login. The mention
    /// renders via `AuthorUsername` (not a literal `@` + raw `Login`) so the
    /// renderer's mention styling applies — bare `@login` in release bodies,
    /// `[@login](https://github.com/login)` in on-disk changelogs — while
    /// `Login` stays the raw branch-condition datum.
    pub const DEFAULT_FORMAT_SCM: &'static str = "{{ SHA }}: {{ Message }} ({% if Login %}{{ AuthorUsername }}{% else %}{{ AuthorName }} <{{ AuthorEmail }}>{% endif %})";

    /// Default `format` template for the `git` backend.
    pub const DEFAULT_FORMAT_GIT: &'static str = "{{ SHA }} {{ Message }}";

    /// Resolve the `sort` mode, falling back to [`Self::DEFAULT_SORT`]
    /// (empty = preserve commit order). Returns an error when the user
    /// supplied a value outside [`Self::VALID_SORT`] so the invalid mode
    /// surfaces at the call site.
    pub fn resolved_sort(&self) -> anyhow::Result<&str> {
        let value = self.sort.as_deref().unwrap_or(Self::DEFAULT_SORT);
        if Self::VALID_SORT.contains(&value) {
            Ok(value)
        } else {
            Err(anyhow::anyhow!(
                "changelog: invalid sort '{}', must be one of: \"\", asc, desc",
                value
            ))
        }
    }

    /// Resolve the changelog source, falling back to `"git"`.
    pub fn resolved_use_source(&self) -> &str {
        self.use_source
            .as_deref()
            .unwrap_or(Self::DEFAULT_USE_SOURCE)
    }

    /// Resolve the title heading, falling back to `"Changelog"`. An empty
    /// `title:` is preserved (the renderer skips emitting the heading
    /// when the resolved title is empty), so the schema still allows
    /// users to suppress the heading with an explicit empty string.
    pub fn resolved_title(&self) -> &str {
        self.title.as_deref().unwrap_or(Self::DEFAULT_TITLE)
    }

    /// Resolve `abbrev`, falling back to [`Self::DEFAULT_ABBREV`] (0 = full SHA).
    ///
    /// Values below `-1` are clamped to `-1`. A `git log
    /// --abbrev=N` would reject `-2`, `-3`, etc.; anodizer renders SHAs in
    /// Rust so it would not fail, but still clamps for behavioural parity
    /// — a configuration like `abbrev: -5` produces the same "omit hash"
    /// output as `abbrev: -1`.
    pub fn resolved_abbrev(&self) -> i32 {
        self.abbrev.unwrap_or(Self::DEFAULT_ABBREV).max(-1)
    }

    /// Resolve the per-entry `format:` template. When the user did not
    /// set `format:`, returns the backend-specific default keyed off
    /// `use_source` and `abbrev` (negative abbrev → no-hash template;
    /// SCM backend → SCM template; git backend → SHA + message).
    /// Caller should pass the resolved use_source / abbrev values.
    pub fn resolved_format<'a>(&'a self, use_source: &str, abbrev: i32) -> &'a str {
        if let Some(f) = self.format.as_deref() {
            return f;
        }
        if abbrev < 0 {
            return Self::DEFAULT_FORMAT_NO_HASH;
        }
        match use_source {
            "github" | "gitlab" | "gitea" => Self::DEFAULT_FORMAT_SCM,
            _ => Self::DEFAULT_FORMAT_GIT,
        }
    }

    /// Resolve `snapshot`, falling back to `false` (the default:
    /// skip changelog on `ctx.Snapshot`).
    pub fn resolved_snapshot(&self) -> bool {
        self.snapshot.unwrap_or(false)
    }

    /// Whether per-crate `crates/<name>/CHANGELOG.md` files are requested
    /// (`files.per_crate: true`). Defaults to `false`. The single place that
    /// knows `per_crate` lives under `files`.
    pub fn per_crate(&self) -> bool {
        self.files
            .as_ref()
            .and_then(|f| f.per_crate)
            .unwrap_or(false)
    }

    /// The aggregate root `CHANGELOG.md` config block (`files.root`), if set.
    /// The single place that knows `root` lives under `files`.
    pub fn root(&self) -> Option<&RootChangelogConfig> {
        self.files.as_ref().and_then(|f| f.root.as_ref())
    }

    /// Resolve which changelog files this config produces.
    ///
    /// The root changelog is enabled when a `root:` block is present, or when
    /// `per_crate` is not `true` (its default `false` keeps the back-compat
    /// root-only behaviour). Per-crate files are produced exactly when
    /// `per_crate: true`. So a bare `changelog:` block stays root-only, while
    /// `per_crate: true` without `root:` yields per-crate files only.
    pub fn resolved_destination(&self) -> ChangelogDestination {
        let per_crate = self.per_crate();
        ChangelogDestination {
            root_enabled: self.root().is_some() || !per_crate,
            per_crate,
        }
    }

    /// Resolve the ordering of release sections in the root `CHANGELOG.md`,
    /// falling back to [`Chronology::Date`] when `root.chronology` is unset.
    pub fn resolved_chronology(&self) -> Chronology {
        self.root()
            .map(RootChangelogConfig::resolved_chronology)
            .unwrap_or_default()
    }

    /// The optional crate filter for the root changelog: `None` means every
    /// crate contributes a `### <crate>` subsection.
    pub fn root_crates_filter(&self) -> Option<&[String]> {
        self.root().and_then(|r| r.crates.as_deref())
    }
}

/// Ordering of release sections in the aggregate root `CHANGELOG.md`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Chronology {
    /// Order sections by release date (default).
    #[default]
    Date,
    /// Order sections by semantic tag version.
    Tag,
}

/// Configuration for the single aggregate root `CHANGELOG.md`.
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct RootChangelogConfig {
    /// Ordering of release sections in the root changelog: `date` (default) or
    /// `tag`.
    pub chronology: Option<Chronology>,
    /// Crates that contribute a `### <crate>` subsection to the root changelog.
    /// When omitted, every crate contributes a subsection.
    pub crates: Option<Vec<String>>,
}

impl RootChangelogConfig {
    /// Resolve the section ordering, falling back to [`Chronology::Date`].
    pub fn resolved_chronology(&self) -> Chronology {
        self.chronology.unwrap_or_default()
    }
}

/// The resolved changelog destination decision: which files a release writes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChangelogDestination {
    /// Whether the single aggregate root `CHANGELOG.md` is written.
    pub root_enabled: bool,
    /// Whether per-crate `crates/<name>/CHANGELOG.md` files are written.
    pub per_crate: bool,
}

/// AI-powered changelog enhancement configuration.
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct ChangelogAiConfig {
    /// AI provider to use. Valid: "anthropic", "openai", "ollama".
    /// Empty disables the feature.
    #[serde(rename = "use")]
    pub provider: Option<String>,
    /// Model name (e.g. "claude-sonnet-4-6", "gpt-4o-mini", "llama3.1").
    /// Defaults to the provider's default model when unset.
    pub model: Option<String>,
    /// Prompt template for the AI. Can be a string, or use `from_url`/`from_file`.
    /// Template variable `{{ ReleaseNotes }}` contains the current changelog.
    pub prompt: Option<ChangelogAiPrompt>,
}

/// Prompt source for AI changelog: inline string, URL, or file path.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum ChangelogAiPrompt {
    /// Inline prompt string (supports templates).
    Inline(String),
    /// Structured prompt with from_url/from_file sources.
    Source(ChangelogAiPromptSource),
}

/// Structured prompt source: load from URL or file.
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct ChangelogAiPromptSource {
    /// Load prompt from a URL.
    pub from_url: Option<ContentFromUrl>,
    /// Load prompt from a local file. Overrides from_url if both set.
    pub from_file: Option<ContentFromFile>,
}

/// Resolved prompt source kind after applying priority rules.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedPromptSource {
    /// Load from a local file path.
    File(String),
    /// Load from a URL (with optional headers).
    Url {
        url: String,
        headers: Option<std::collections::HashMap<String, String>>,
    },
    /// No source configured.
    None,
}

impl ChangelogAiPromptSource {
    /// Resolve the prompt source applying priority: from_file overrides from_url.
    pub fn resolve(&self) -> ResolvedPromptSource {
        if let Some(ref file) = self.from_file
            && let Some(ref path) = file.path
        {
            return ResolvedPromptSource::File(path.clone());
        }
        if let Some(ref url_cfg) = self.from_url
            && let Some(ref url) = url_cfg.url
        {
            return ResolvedPromptSource::Url {
                url: url.clone(),
                headers: url_cfg.headers.clone(),
            };
        }
        ResolvedPromptSource::None
    }
}

/// Load content from a URL with optional headers.
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct ContentFromUrl {
    /// URL to fetch (supports templates).
    pub url: Option<String>,
    /// HTTP headers to send with the request.
    pub headers: Option<std::collections::HashMap<String, String>>,
}

/// Load content from a local file.
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct ContentFromFile {
    /// Path to the file (supports templates).
    pub path: Option<String>,
}

/// Regex-based commit filters for the changelog stage.
///
/// Patterns are NOT compile-validated at config-load — a malformed regex
/// only surfaces when the changelog stage runs, which on a release pipeline
/// is well past the point of cheap failure. Test patterns locally
/// (`anodizer changelog --check` or any external regex tool) before
/// committing config changes.
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct ChangelogFilters {
    /// Regex patterns: commits matching any of these are excluded from the changelog.
    pub exclude: Option<Vec<String>>,
    /// Regex patterns: only commits matching at least one of these are included.
    pub include: Option<Vec<String>>,
    /// Exclude anodizer's own version-sync bump commits
    /// (`chore(release): bump …`, optionally ` [skip ci]`) from the generated
    /// changelog. They are release machinery, not user-facing changes, so this
    /// defaults to `true`. Set `false` to keep them. No effect in include mode
    /// (`include` already drops anything that does not match a pattern).
    pub exclude_version_sync_commits: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct ChangelogGroup {
    /// Section heading for this group (e.g., "Features", "Bug Fixes").
    pub title: String,
    /// Regex pattern matching commit messages to include in this group.
    pub regexp: Option<String>,
    /// Sort order for this group relative to other groups (lower = first).
    pub order: Option<i32>,
    /// Nested subgroups within this group. Rendered as sub-sections (e.g. `###`).
    pub groups: Option<Vec<ChangelogGroup>>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rejects_unknown_top_level_field() {
        // The pre-`changelog.files` flat keys (`per_crate:` / `root:` at the
        // changelog top level) must now hard-error rather than be silently
        // ignored — `deny_unknown_fields` is what turns the moved-key migration
        // into a loud, actionable failure instead of changed-output-no-error.
        let yaml = "use: github-native\nper_crate: true\n";
        assert!(serde_yaml_ng::from_str::<ChangelogConfig>(yaml).is_err());
    }

    #[test]
    fn accepts_per_crate_under_files_block() {
        // The supported form (nested under `files:`) still parses.
        let yaml = "use: github-native\nfiles:\n  per_crate: true\n";
        let cfg: ChangelogConfig = serde_yaml_ng::from_str(yaml).expect("nested form parses");
        assert_eq!(cfg.files.and_then(|f| f.per_crate), Some(true));
    }

    #[test]
    fn rejects_unknown_field_under_files_block() {
        let yaml = "files:\n  per_crat: true\n";
        assert!(serde_yaml_ng::from_str::<ChangelogConfig>(yaml).is_err());
    }

    #[test]
    fn destination_bare_config_is_root_only() {
        let cfg = ChangelogConfig::default();
        let dest = cfg.resolved_destination();
        assert!(dest.root_enabled, "bare config keeps the root CHANGELOG.md");
        assert!(!dest.per_crate, "bare config emits no per-crate files");
    }

    #[test]
    fn destination_per_crate_true_without_root_is_per_crate_only() {
        let cfg = ChangelogConfig {
            files: Some(ChangelogFilesConfig {
                per_crate: Some(true),
                ..Default::default()
            }),
            ..Default::default()
        };
        let dest = cfg.resolved_destination();
        assert!(
            !dest.root_enabled,
            "per_crate: true without root: drops root"
        );
        assert!(dest.per_crate);
    }

    #[test]
    fn destination_per_crate_true_with_root_is_both() {
        let cfg = ChangelogConfig {
            files: Some(ChangelogFilesConfig {
                per_crate: Some(true),
                root: Some(RootChangelogConfig::default()),
            }),
            ..Default::default()
        };
        let dest = cfg.resolved_destination();
        assert!(dest.root_enabled);
        assert!(dest.per_crate);
    }

    #[test]
    fn destination_per_crate_false_with_root_is_root_only() {
        let cfg = ChangelogConfig {
            files: Some(ChangelogFilesConfig {
                per_crate: Some(false),
                root: Some(RootChangelogConfig::default()),
            }),
            ..Default::default()
        };
        let dest = cfg.resolved_destination();
        assert!(dest.root_enabled);
        assert!(!dest.per_crate);
    }

    #[test]
    fn chronology_defaults_to_date_when_root_unset() {
        let cfg = ChangelogConfig::default();
        assert_eq!(cfg.resolved_chronology(), Chronology::Date);
    }

    #[test]
    fn chronology_defaults_to_date_when_root_set_without_chronology() {
        let cfg = ChangelogConfig {
            files: Some(ChangelogFilesConfig {
                root: Some(RootChangelogConfig::default()),
                ..Default::default()
            }),
            ..Default::default()
        };
        assert_eq!(cfg.resolved_chronology(), Chronology::Date);
    }

    #[test]
    fn chronology_override_tag() {
        let cfg = ChangelogConfig {
            files: Some(ChangelogFilesConfig {
                root: Some(RootChangelogConfig {
                    chronology: Some(Chronology::Tag),
                    ..Default::default()
                }),
                ..Default::default()
            }),
            ..Default::default()
        };
        assert_eq!(cfg.resolved_chronology(), Chronology::Tag);
    }

    #[test]
    fn resolved_chronology_accessor_on_root_defaults_to_date() {
        assert_eq!(
            RootChangelogConfig::default().resolved_chronology(),
            Chronology::Date
        );
    }

    #[test]
    fn crates_filter_defaults_to_none_meaning_all() {
        let cfg = ChangelogConfig {
            files: Some(ChangelogFilesConfig {
                root: Some(RootChangelogConfig::default()),
                ..Default::default()
            }),
            ..Default::default()
        };
        assert_eq!(cfg.root_crates_filter(), None);
    }

    #[test]
    fn crates_filter_passes_through_list() {
        let cfg = ChangelogConfig {
            files: Some(ChangelogFilesConfig {
                root: Some(RootChangelogConfig {
                    crates: Some(vec!["a".to_string(), "b".to_string()]),
                    ..Default::default()
                }),
                ..Default::default()
            }),
            ..Default::default()
        };
        assert_eq!(
            cfg.root_crates_filter(),
            Some(["a".to_string(), "b".to_string()].as_slice())
        );
    }

    #[test]
    fn deserializes_per_crate_and_root_block() {
        let yaml = r#"
files:
  per_crate: true
  root:
    chronology: tag
    crates: [a, b]
"#;
        let cfg: ChangelogConfig = serde_yaml_ng::from_str(yaml).expect("parse changelog block");
        assert!(cfg.per_crate());
        let root = cfg.root().expect("root present");
        assert_eq!(root.chronology, Some(Chronology::Tag));
        assert_eq!(
            root.crates.as_deref(),
            Some(["a".to_string(), "b".to_string()].as_slice())
        );

        let dest = cfg.resolved_destination();
        assert!(dest.root_enabled);
        assert!(dest.per_crate);
        assert_eq!(cfg.resolved_chronology(), Chronology::Tag);
    }

    #[test]
    fn deserializes_bare_block_to_root_only() {
        let yaml = "sort: asc";
        let cfg: ChangelogConfig = serde_yaml_ng::from_str(yaml).expect("parse bare block");
        assert!(!cfg.per_crate());
        assert!(cfg.root().is_none());
        let dest = cfg.resolved_destination();
        assert!(dest.root_enabled);
        assert!(!dest.per_crate);
    }

    #[test]
    fn deserializes_empty_root_block_forces_root_with_default_chronology() {
        let yaml = "files:\n  per_crate: true\n  root: {}\n";
        let cfg: ChangelogConfig = serde_yaml_ng::from_str(yaml).expect("parse");
        let dest = cfg.resolved_destination();
        assert!(dest.root_enabled);
        assert!(dest.per_crate);
        assert_eq!(cfg.resolved_chronology(), Chronology::Date);
    }

    #[test]
    fn empty_crates_list_is_distinct_from_omitted() {
        let with_empty: ChangelogConfig =
            serde_yaml_ng::from_str("files:\n  root:\n    crates: []\n").expect("parse");
        assert_eq!(with_empty.root_crates_filter(), Some(&[][..]));

        let omitted: ChangelogConfig =
            serde_yaml_ng::from_str("files:\n  root: {}\n").expect("parse");
        assert_eq!(omitted.root_crates_filter(), None);
    }

    #[test]
    fn chronology_serde_rename_is_lowercase() {
        assert_eq!(
            serde_yaml_ng::to_string(&Chronology::Date)
                .expect("ser")
                .trim(),
            "date"
        );
        assert_eq!(
            serde_yaml_ng::to_string(&Chronology::Tag)
                .expect("ser")
                .trim(),
            "tag"
        );
    }
}