Skip to main content

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}