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)]
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    /// (GoReleaser 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    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
31    pub skip: Option<StringOrBool>,
32    /// Changelog source: `"git"` (default), `"github"`, or `"github-native"`.
33    /// `"github"` fetches commits via the GitHub API, enriching entries with
34    /// author login information (available as the `{{ Logins }}` per-entry
35    /// template variable and the `{{ AllLogins }}` release-wide variable).
36    /// `"github-native"` delegates entirely to GitHub's auto-generated notes.
37    #[serde(rename = "use")]
38    pub use_source: Option<String>,
39    /// Hash abbreviation length. Default: 0 (no truncation, emit the full
40    /// SHA). Set to -1 to omit the hash entirely; positive values truncate
41    /// to N chars. Values below `-1` are clamped to `-1` for parity with
42    /// GoReleaser (whose `git log --abbrev=N` panics for `-2`, `-3`, ...).
43    /// Mirrors GoReleaser `internal/pipe/changelog/changelog.go`'s
44    /// `abbrevEntry`.
45    pub abbrev: Option<i32>,
46    /// 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 (mirrors GoReleaser `internal/pipe/changelog/changelog.go`'s `formatEntry`, which uses the full SHA):<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).
47    pub format: Option<String>,
48    /// File paths to filter commits by. Only commits touching files under these
49    /// paths are included. Works with `use: git` for precise per-commit filtering.
50    /// With `use: github`, only the first path is used for API queries; multi-path
51    /// filtering is coarse. Supports template rendering.
52    pub paths: Option<Vec<String>>,
53    /// Title heading for the changelog. Default: "Changelog". Supports templates.
54    pub title: Option<String>,
55    /// Divider string inserted between changelog groups (e.g. `"---"`). Supports templates.
56    pub divider: Option<String>,
57    /// AI-powered changelog enhancement configuration.
58    pub ai: Option<ChangelogAiConfig>,
59    /// When `true`, render the changelog even in snapshot mode. Anodizer
60    /// matches GoReleaser's default (skip changelog on `ctx.Snapshot`) and
61    /// lets users opt back in here for local preview / draft generation.
62    /// Wired in `crates/stage-changelog/src/lib.rs::ChangelogStage::run`.
63    pub snapshot: Option<bool>,
64}
65
66impl ChangelogConfig {
67    /// Default `sort` value. Empty string means "preserve commit order"
68    /// (no sort applied). Mirrors GoReleaser `changelog.go`'s
69    /// `checkSortDirection`, which accepts "", "asc", "desc".
70    pub const DEFAULT_SORT: &'static str = "";
71
72    /// Valid `sort` values. Anything else is a config error.
73    pub const VALID_SORT: &[&'static str] = &["", "asc", "desc"];
74
75    /// Default changelog source. Mirrors GoReleaser `changelog.go` —
76    /// the unset field falls back to git log parsing.
77    pub const DEFAULT_USE_SOURCE: &'static str = "git";
78
79    /// Valid `use:` values. github-native delegates to GitHub's
80    /// auto-generated notes; the others control which API anodize hits
81    /// for commit metadata.
82    pub const VALID_USE_SOURCE: &[&'static str] =
83        &["git", "github", "gitlab", "gitea", "github-native"];
84
85    /// Default changelog title heading. Mirrors GoReleaser `changelog.go`
86    /// (always emits a `## Changelog` heading when title is unset).
87    pub const DEFAULT_TITLE: &'static str = "Changelog";
88
89    /// Default `abbrev` (hash truncation length). 0 = full SHA, mirroring
90    /// GoReleaser `abbrevEntry`. The misleading "Default: 7" docstring
91    /// previously on the field has been corrected.
92    pub const DEFAULT_ABBREV: i32 = 0;
93
94    /// Default `format` template when `abbrev` is negative (hash omitted).
95    pub const DEFAULT_FORMAT_NO_HASH: &'static str = "{{ Message }}";
96
97    /// Default `format` template for SCM-backed sources (github/gitlab/gitea).
98    /// Renders SHA, message, and an `@login` mention falling back to
99    /// `AuthorName <AuthorEmail>` when the API returned no login.
100    pub const DEFAULT_FORMAT_SCM: &'static str = "{{ SHA }}: {{ Message }} ({% if Login %}@{{ Login }}{% else %}{{ AuthorName }} <{{ AuthorEmail }}>{% endif %})";
101
102    /// Default `format` template for the `git` backend. Mirrors GoReleaser.
103    pub const DEFAULT_FORMAT_GIT: &'static str = "{{ SHA }} {{ Message }}";
104
105    /// Resolve the `sort` mode, falling back to [`Self::DEFAULT_SORT`]
106    /// (empty = preserve commit order). Returns an error when the user
107    /// supplied a value outside [`Self::VALID_SORT`] so the invalid mode
108    /// surfaces at the call site.
109    pub fn resolved_sort(&self) -> anyhow::Result<&str> {
110        let value = self.sort.as_deref().unwrap_or(Self::DEFAULT_SORT);
111        if Self::VALID_SORT.contains(&value) {
112            Ok(value)
113        } else {
114            Err(anyhow::anyhow!(
115                "changelog: invalid sort '{}', must be one of: \"\", asc, desc",
116                value
117            ))
118        }
119    }
120
121    /// Resolve the changelog source, falling back to `"git"`.
122    pub fn resolved_use_source(&self) -> &str {
123        self.use_source
124            .as_deref()
125            .unwrap_or(Self::DEFAULT_USE_SOURCE)
126    }
127
128    /// Resolve the title heading, falling back to `"Changelog"`. An empty
129    /// `title:` is preserved (the renderer skips emitting the heading
130    /// when the resolved title is empty), so the schema still allows
131    /// users to suppress the heading with an explicit empty string.
132    pub fn resolved_title(&self) -> &str {
133        self.title.as_deref().unwrap_or(Self::DEFAULT_TITLE)
134    }
135
136    /// Resolve `abbrev`, falling back to [`Self::DEFAULT_ABBREV`] (0 = full SHA).
137    ///
138    /// Mirrors GoReleaser `internal/pipe/changelog/changelog.go` (commit
139    /// 88daaf3): values below `-1` are clamped to `-1`. Upstream's `git log
140    /// --abbrev=N` panics for `-2`, `-3`, etc.; anodizer renders SHAs in
141    /// Rust so it would not panic, but we still clamp for behavioural parity
142    /// — a configuration like `abbrev: -5` produces the same "omit hash"
143    /// output as `abbrev: -1`.
144    pub fn resolved_abbrev(&self) -> i32 {
145        self.abbrev.unwrap_or(Self::DEFAULT_ABBREV).max(-1)
146    }
147
148    /// Resolve the per-entry `format:` template. When the user did not
149    /// set `format:`, returns the backend-specific default keyed off
150    /// `use_source` and `abbrev` (negative abbrev → no-hash template;
151    /// SCM backend → SCM template; git backend → SHA + message).
152    /// Caller should pass the resolved use_source / abbrev values.
153    pub fn resolved_format<'a>(&'a self, use_source: &str, abbrev: i32) -> &'a str {
154        if let Some(f) = self.format.as_deref() {
155            return f;
156        }
157        if abbrev < 0 {
158            return Self::DEFAULT_FORMAT_NO_HASH;
159        }
160        match use_source {
161            "github" | "gitlab" | "gitea" => Self::DEFAULT_FORMAT_SCM,
162            _ => Self::DEFAULT_FORMAT_GIT,
163        }
164    }
165
166    /// Resolve `snapshot`, falling back to `false` (matches GoReleaser:
167    /// skip changelog on `ctx.Snapshot`).
168    pub fn resolved_snapshot(&self) -> bool {
169        self.snapshot.unwrap_or(false)
170    }
171}
172
173/// AI-powered changelog enhancement configuration.
174#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
175#[serde(default)]
176pub struct ChangelogAiConfig {
177    /// AI provider to use. Valid: "anthropic", "openai", "ollama".
178    /// Empty disables the feature.
179    #[serde(rename = "use")]
180    pub provider: Option<String>,
181    /// Model name (e.g. "gpt-4", "claude-sonnet-4-20250514"). Defaults to provider's default.
182    pub model: Option<String>,
183    /// Prompt template for the AI. Can be a string, or use `from_url`/`from_file`.
184    /// Template variable `.ReleaseNotes` contains the current changelog.
185    pub prompt: Option<ChangelogAiPrompt>,
186}
187
188/// Prompt source for AI changelog: inline string, URL, or file path.
189#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
190#[serde(untagged)]
191pub enum ChangelogAiPrompt {
192    /// Inline prompt string (supports templates).
193    Inline(String),
194    /// Structured prompt with from_url/from_file sources.
195    Source(ChangelogAiPromptSource),
196}
197
198/// Structured prompt source: load from URL or file.
199#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
200#[serde(default)]
201pub struct ChangelogAiPromptSource {
202    /// Load prompt from a URL.
203    pub from_url: Option<ContentFromUrl>,
204    /// Load prompt from a local file. Overrides from_url if both set.
205    pub from_file: Option<ContentFromFile>,
206}
207
208/// Resolved prompt source kind after applying priority rules.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum ResolvedPromptSource {
211    /// Load from a local file path.
212    File(String),
213    /// Load from a URL (with optional headers).
214    Url {
215        url: String,
216        headers: Option<std::collections::HashMap<String, String>>,
217    },
218    /// No source configured.
219    None,
220}
221
222impl ChangelogAiPromptSource {
223    /// Resolve the prompt source applying priority: from_file overrides from_url.
224    pub fn resolve(&self) -> ResolvedPromptSource {
225        if let Some(ref file) = self.from_file
226            && let Some(ref path) = file.path
227        {
228            return ResolvedPromptSource::File(path.clone());
229        }
230        if let Some(ref url_cfg) = self.from_url
231            && let Some(ref url) = url_cfg.url
232        {
233            return ResolvedPromptSource::Url {
234                url: url.clone(),
235                headers: url_cfg.headers.clone(),
236            };
237        }
238        ResolvedPromptSource::None
239    }
240}
241
242/// Load content from a URL with optional headers.
243#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
244#[serde(default)]
245pub struct ContentFromUrl {
246    /// URL to fetch (supports templates).
247    pub url: Option<String>,
248    /// HTTP headers to send with the request.
249    pub headers: Option<std::collections::HashMap<String, String>>,
250}
251
252/// Load content from a local file.
253#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
254#[serde(default)]
255pub struct ContentFromFile {
256    /// Path to the file (supports templates).
257    pub path: Option<String>,
258}
259
260/// Regex-based commit filters for the changelog stage.
261///
262/// Patterns are NOT compile-validated at config-load — a malformed regex
263/// only surfaces when the changelog stage runs, which on a release pipeline
264/// is well past the point of cheap failure. Test patterns locally
265/// (`anodizer changelog --check` or any external regex tool) before
266/// committing config changes.
267#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
268#[serde(default)]
269pub struct ChangelogFilters {
270    /// Regex patterns: commits matching any of these are excluded from the changelog.
271    pub exclude: Option<Vec<String>>,
272    /// Regex patterns: only commits matching at least one of these are included.
273    pub include: Option<Vec<String>>,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
277#[serde(default)]
278pub struct ChangelogGroup {
279    /// Section heading for this group (e.g., "Features", "Bug Fixes").
280    pub title: String,
281    /// Regex pattern matching commit messages to include in this group.
282    pub regexp: Option<String>,
283    /// Sort order for this group relative to other groups (lower = first).
284    pub order: Option<i32>,
285    /// Nested subgroups within this group. Rendered as sub-sections (e.g. `###`).
286    pub groups: Option<Vec<ChangelogGroup>>,
287}