logloom/model.rs
1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5/// An owned, serializable changelog independent of its source format.
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7#[non_exhaustive]
8pub struct Changelog {
9 /// Source format, retained so validation uses the correct conventions.
10 /// Defaults to Keep a Changelog when reading older serialized models.
11 #[serde(default)]
12 pub format: crate::Format,
13 /// Title and introductory Markdown.
14 pub metadata: Metadata,
15 /// All releases, including pending releases, in original document order.
16 pub releases: Vec<Release>,
17 /// Document-wide reference definitions in source order.
18 /// Independently decoded bodies keep their definitions in [`Release::links`].
19 pub links: Vec<LinkDefinition>,
20 /// Exact input, including whitespace, duplicate definitions, and line endings.
21 /// Defaults to empty when deserializing CLI exports, which omit this field.
22 /// Spans still refer to the original input when the source copy is absent.
23 #[serde(default)]
24 pub source: String,
25}
26
27/// Document-level information; arbitrary metadata stays in the introduction.
28#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
29#[non_exhaustive]
30pub struct Metadata {
31 /// Plain text of the optional level-one title.
32 pub title: Option<String>,
33 /// Introductory source text before the first release, excluding a Markdown title.
34 pub description: String,
35}
36
37/// A release identifier is opaque: neither `SemVer` nor chronological sorting is imposed.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[non_exhaustive]
40pub struct Release {
41 /// Opaque identifier; `None` represents pending or unversioned content.
42 pub version: Option<String>,
43 /// Optional calendar date. Use validation to check calendar correctness.
44 /// Timestamp-based formats retain the original timestamp in attributes.
45 pub date: Option<String>,
46 /// Source location of a date stored outside the release heading, if known.
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub date_span: Option<Span>,
49 /// Whether the source explicitly marks this release as withdrawn.
50 pub yanked: bool,
51 /// Original heading text, or decoded release title from a structured export.
52 pub heading: String,
53 /// Resolved link on the release heading, if present.
54 pub link: Option<String>,
55 /// Ordered groups, including unnamed groups for uncategorized content.
56 pub sections: Vec<Section>,
57 /// Source location of the release heading.
58 pub span: Span,
59 /// Format-specific attributes such as package, bump, author, or timestamp.
60 /// Empty for ordinary Keep a Changelog releases.
61 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
62 pub attributes: BTreeMap<String, String>,
63 /// References local to an independently decoded body, overriding document links.
64 /// Empty for formats whose releases share one Markdown document.
65 #[serde(default, skip_serializing_if = "Vec::is_empty")]
66 pub links: Vec<LinkDefinition>,
67}
68
69/// Semantic release state, independent of whether an identifier is present.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72#[non_exhaustive]
73pub enum ReleaseStatus {
74 /// Pending changes or a news fragment.
75 Pending,
76 /// An explicitly marked draft, possibly with a version identifier.
77 Draft,
78 /// A versioned, published release; includes prereleases and withdrawn releases.
79 Published,
80 /// A published date/author record without an invented version identifier.
81 DatedRecord,
82 /// Notes whose publication state is unknown.
83 Unversioned,
84}
85
86impl Changelog {
87 /// Create an empty document of the given format, without source text.
88 ///
89 /// ```
90 /// use logloom::{Changelog, Format, Release};
91 /// let mut changelog = Changelog::new(Format::GenericMarkdown);
92 /// changelog.releases.push(Release::new(Some("preview".into())));
93 /// assert_eq!(changelog.releases.len(), 1);
94 /// ```
95 #[must_use]
96 pub fn new(format: crate::Format) -> Self {
97 Self {
98 format,
99 metadata: Metadata::default(),
100 releases: Vec::new(),
101 links: Vec::new(),
102 source: String::new(),
103 }
104 }
105}
106
107impl Release {
108 /// Create a release with no date, content, attributes, or source location.
109 /// A missing identifier starts as pending; attributes can refine its state.
110 #[must_use]
111 pub fn new(version: Option<String>) -> Self {
112 Self {
113 version,
114 date: None,
115 date_span: None,
116 yanked: false,
117 heading: String::new(),
118 link: None,
119 sections: Vec::new(),
120 span: Span::default(),
121 attributes: BTreeMap::new(),
122 links: Vec::new(),
123 }
124 }
125
126 /// Classify this release using its current identifier and format attributes.
127 ///
128 /// ```
129 /// use logloom::{Release, ReleaseStatus};
130 /// let mut release = Release::new(Some("v1".into()));
131 /// assert_eq!(release.status(), ReleaseStatus::Published);
132 /// release.attributes.insert("draft".into(), "true".into());
133 /// assert_eq!(release.status(), ReleaseStatus::Draft);
134 /// ```
135 #[must_use]
136 pub fn status(&self) -> ReleaseStatus {
137 if self
138 .attributes
139 .get("draft")
140 .is_some_and(|value| value == "true")
141 {
142 ReleaseStatus::Draft
143 } else if self
144 .attributes
145 .get("record-kind")
146 .is_some_and(|value| value == "dated-change")
147 {
148 ReleaseStatus::DatedRecord
149 } else if self.version.is_some() {
150 ReleaseStatus::Published
151 } else if self
152 .attributes
153 .get("version-status")
154 .is_some_and(|value| value == "unknown")
155 {
156 ReleaseStatus::Unversioned
157 } else {
158 ReleaseStatus::Pending
159 }
160 }
161
162 /// Whether published selectors should include this release or date/author record.
163 #[must_use]
164 pub fn is_published(&self) -> bool {
165 matches!(
166 self.status(),
167 ReleaseStatus::Published | ReleaseStatus::DatedRecord
168 )
169 }
170
171 /// Whether this release is pending, including explicitly marked drafts.
172 #[must_use]
173 pub fn is_pending(&self) -> bool {
174 matches!(self.status(), ReleaseStatus::Pending | ReleaseStatus::Draft)
175 }
176
177 /// Borrow the format's package name, when present.
178 #[must_use]
179 pub fn package(&self) -> Option<&str> {
180 self.attributes.get("package").map(String::as_str)
181 }
182
183 /// Whether the input explicitly marks this as a prerelease.
184 #[must_use]
185 pub fn is_prerelease(&self) -> bool {
186 self.attributes
187 .get("prerelease")
188 .is_some_and(|value| value == "true")
189 }
190}
191
192/// A group of ordered entries.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[non_exhaustive]
195pub struct Section {
196 /// Optional component name shared by this category's entries.
197 /// Absent for release-wide categories and older serialized models.
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub component: Option<String>,
200 /// `None` for uncategorized content at release or component level.
201 pub category: Option<Category>,
202 /// Exact heading Markdown; absent for an unnamed group.
203 pub heading: Option<String>,
204 /// Entries in their original order, retaining nested content.
205 pub entries: Vec<Entry>,
206 /// Source location of the heading, or first entry for an unnamed group.
207 pub span: Span,
208}
209
210impl Section {
211 /// Create an empty group without a component, heading, or source location.
212 #[must_use]
213 pub fn new(category: Option<Category>) -> Self {
214 Self {
215 component: None,
216 category,
217 heading: None,
218 entries: Vec::new(),
219 span: Span::default(),
220 }
221 }
222}
223
224/// A normalized category with a lossless fallback for other names.
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(tag = "kind", content = "name", rename_all = "snake_case")]
227#[non_exhaustive]
228pub enum Category {
229 /// New functionality.
230 Added,
231 /// Changes to existing functionality.
232 Changed,
233 /// Functionality scheduled for removal.
234 Deprecated,
235 /// Removed functionality.
236 Removed,
237 /// Corrections to existing behavior.
238 Fixed,
239 /// Security-related changes.
240 Security,
241 /// A category outside the standard vocabulary, preserving its plain text.
242 Custom(String),
243}
244
245impl Category {
246 /// Return the normalized display name, preserving custom category names.
247 ///
248 /// ```
249 /// use logloom::Category;
250 /// assert_eq!(Category::Fixed.as_str(), "Fixed");
251 /// assert_eq!(Category::Custom("Performance".into()).as_str(), "Performance");
252 /// ```
253 #[must_use]
254 pub fn as_str(&self) -> &str {
255 match self {
256 Self::Added => "Added",
257 Self::Changed => "Changed",
258 Self::Deprecated => "Deprecated",
259 Self::Removed => "Removed",
260 Self::Fixed => "Fixed",
261 Self::Security => "Security",
262 Self::Custom(name) => name,
263 }
264 }
265
266 pub(crate) fn from_name(name: &str) -> Self {
267 match name.to_ascii_lowercase().as_str() {
268 "added" => Self::Added,
269 "changed" => Self::Changed,
270 "deprecated" => Self::Deprecated,
271 "removed" => Self::Removed,
272 "fixed" => Self::Fixed,
273 "security" => Self::Security,
274 _ => Self::Custom(name.to_owned()),
275 }
276 }
277}
278
279/// A top-level list item, Markdown block, or raw text block within a section.
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
281#[non_exhaustive]
282pub struct Entry {
283 /// Original content, trimmed only at block boundaries; may be Markdown or text.
284 /// List markers, nested lists, code, and reference labels are retained.
285 pub markdown: String,
286 /// Whether this entry originated as a list item, Markdown block, or raw text.
287 pub kind: EntryKind,
288 /// Original source location.
289 pub span: Span,
290}
291
292impl Entry {
293 /// Create an entry without a source location; content is kept as supplied.
294 #[must_use]
295 pub fn new(markdown: String, kind: EntryKind) -> Self {
296 Self {
297 markdown,
298 kind,
299 span: Span::default(),
300 }
301 }
302}
303
304/// Structural origin of an entry.
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
306#[serde(rename_all = "snake_case")]
307#[non_exhaustive]
308pub enum EntryKind {
309 /// One complete item from a top-level ordered or unordered list.
310 ListItem,
311 /// Another Markdown block (paragraph, code, quote, table, HTML, or subheading).
312 Markdown,
313 /// Plain text or reStructuredText retained without Markdown interpretation.
314 Text,
315}
316
317/// One effective `CommonMark` link definition; duplicate definitions remain in `source`.
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319#[non_exhaustive]
320pub struct LinkDefinition {
321 /// Reference label as reported by the Markdown parser.
322 pub label: String,
323 /// Resolved destination.
324 pub destination: String,
325 /// Optional reference title.
326 pub title: Option<String>,
327 /// Source location of the definition.
328 pub span: Span,
329}
330
331impl LinkDefinition {
332 /// Create a reference without a title or source location.
333 #[must_use]
334 pub fn new(label: String, destination: String) -> Self {
335 Self {
336 label,
337 destination,
338 title: None,
339 span: Span::default(),
340 }
341 }
342}
343
344/// Half-open UTF-8 byte offsets in the original source, plus a one-based line.
345/// The default is an empty range on line one for newly constructed content.
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
347#[non_exhaustive]
348pub struct Span {
349 /// Inclusive starting byte offset.
350 pub start: usize,
351 /// Exclusive ending byte offset.
352 pub end: usize,
353 /// One-based line containing the starting offset.
354 pub line: usize,
355}
356
357impl Span {
358 /// Construct original UTF-8 byte offsets and a one-based line.
359 /// Callers must ensure offsets are valid for their source; this performs no I/O.
360 #[must_use]
361 pub const fn new(start: usize, end: usize, line: usize) -> Self {
362 Self { start, end, line }
363 }
364}
365
366impl Default for Span {
367 fn default() -> Self {
368 Self::new(0, 0, 1)
369 }
370}