changelog_md/
lib.rs

1#![warn(missing_docs)]
2
3//! A serializable format for updating CHANGELOG files
4//! and generating CHANGELOG.md
5
6use anyhow::anyhow;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use serde_with::{KeyValueMap, serde_as};
10
11/// A user-friendly format for writing Changelogs in a
12/// verifiable and more git-friendly format
13#[serde_as]
14#[derive(Debug, Deserialize, Serialize, JsonSchema)]
15#[serde(deny_unknown_fields)]
16pub struct Changelog {
17    /// Your changelog's heading
18    pub title: String,
19    /// A description of your project.
20    /// It's recommended to note whether you follow semantic versioning
21    pub description: String,
22    /// Your source repository link
23    pub repository: String,
24    /// Currently unreleased changes
25    pub unreleased: Changes,
26    /// Releases
27    #[serde_as(as = "KeyValueMap<_>")]
28    pub versions: Vec<Version>,
29}
30
31/// A released version
32#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
33#[serde(deny_unknown_fields)]
34pub struct Version {
35    /// The version name
36    #[serde(rename = "$key$")]
37    pub version: String,
38    /// Git tag associated with this version
39    pub tag: String,
40    /// Date the version was released as an ISO Date String
41    #[schemars(regex(pattern = r"^\d{4}-[01]\d-[0-3]\d$"))]
42    pub date: String,
43    /// Optional Markdown description of this version
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub description: Option<String>,
46    /// If a version was yanked, the reason why
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub yanked: Option<String>,
49    /// Changes within this version
50    #[serde(flatten)]
51    pub changes: Changes,
52}
53
54/// Any changes made in this version
55#[derive(Debug, Default, Deserialize, Serialize, JsonSchema, PartialEq)]
56#[serde(deny_unknown_fields)]
57pub struct Changes {
58    /// New additions made in this version
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub added: Vec<String>,
61    /// Changes to existing features
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub changed: Vec<String>,
64    /// Deprecations
65    #[serde(default, skip_serializing_if = "Vec::is_empty")]
66    pub deprecated: Vec<String>,
67    /// Changes the removed a feature
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub removed: Vec<String>,
70    /// Fixes to existing features
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub fixed: Vec<String>,
73    /// Security changes
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub security: Vec<String>,
76}
77
78impl Changes {
79    /// Add a new feature
80    pub fn push_added(&mut self, change: String) {
81        self.added.push(change)
82    }
83    /// Add a change
84    pub fn push_changed(&mut self, change: String) {
85        self.changed.push(change)
86    }
87    /// Add a deprecation
88    pub fn push_deprecated(&mut self, change: String) {
89        self.deprecated.push(change)
90    }
91    /// Add a fix
92    pub fn push_fixed(&mut self, change: String) {
93        self.fixed.push(change)
94    }
95    /// Add a removal change
96    pub fn push_removed(&mut self, change: String) {
97        self.removed.push(change)
98    }
99    /// Add a security change
100    pub fn push_security(&mut self, change: String) {
101        self.security.push(change)
102    }
103
104    /// Check if this Changes is empty
105    pub fn is_empty(&self) -> bool {
106        self.added.is_empty()
107            && self.changed.is_empty()
108            && self.deprecated.is_empty()
109            && self.fixed.is_empty()
110            && self.removed.is_empty()
111            && self.security.is_empty()
112    }
113
114    // Helper to write a block of changes
115    fn write_changes_if_exist(
116        &self,
117        f: &mut std::fmt::Formatter<'_>,
118        title: &str,
119        changes: &Vec<String>,
120    ) -> std::fmt::Result {
121        if !changes.is_empty() {
122            writeln!(f)?;
123            writeln!(f, "### {}", title)?;
124            writeln!(f)?;
125            for change in changes {
126                writeln!(f, "- {}", change)?;
127            }
128        }
129        Ok(())
130    }
131}
132
133impl std::fmt::Display for Changelog {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        writeln!(f, "# {}", self.title)?;
136        writeln!(f)?;
137        writeln!(f, "{}", self.description)?;
138        if !self.description.ends_with("\n") {
139            writeln!(f)?;
140        }
141        if !self.unreleased.is_empty() {
142            writeln!(f, "## [Unreleased]")?;
143            writeln!(f, "{}", self.unreleased)?;
144        }
145
146        for version in &self.versions {
147            write!(f, "{}", version)?;
148        }
149
150        writeln!(f)?;
151        writeln!(f, "# Revisions")?;
152        writeln!(f)?;
153        match &self.versions[..] {
154            // We haven't released a version, just link all commits
155            [] => writeln!(f, "- [unreleased] <{}/commits/>", self.repository)?,
156
157            versions @ [.., last] => {
158                writeln!(
159                    f,
160                    "- [unreleased] <{}/compare/{}...HEAD>",
161                    self.repository, versions[0].tag
162                )?;
163                for idx in 0..(versions.len() - 1) {
164                    writeln!(
165                        f,
166                        "- [{}] <{}/compare/{}..{}>",
167                        versions[idx].version,
168                        self.repository,
169                        versions[idx + 1].tag,
170                        versions[idx].tag,
171                    )?;
172                }
173                // The initial version is a commit url
174                writeln!(
175                    f,
176                    "- [{}] <{}/commits/{}>",
177                    last.version, self.repository, last.tag
178                )?;
179            }
180        };
181
182        Ok(())
183    }
184}
185
186impl std::fmt::Display for Version {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        write!(f, "## {} - {}", self.version, self.date)?;
189        if let Some(reason) = &self.yanked {
190            write!(f, " [YANKED] {}", reason)?;
191        }
192        writeln!(f)?;
193        writeln!(f)?;
194        if let Some(desc) = &self.description {
195            writeln!(f, "{}", desc.trim())?;
196        }
197        if !self.changes.is_empty() {
198            writeln!(f, "{}", self.changes)?;
199        }
200
201        Ok(())
202    }
203}
204
205impl std::fmt::Display for Changes {
206    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        self.write_changes_if_exist(f, "Added", &self.added)?;
208        self.write_changes_if_exist(f, "Changed", &self.changed)?;
209        self.write_changes_if_exist(f, "Deprecated", &self.deprecated)?;
210        self.write_changes_if_exist(f, "Removed", &self.removed)?;
211        self.write_changes_if_exist(f, "Fixed", &self.fixed)?;
212        self.write_changes_if_exist(f, "Security", &self.security)?;
213
214        Ok(())
215    }
216}
217
218impl Changelog {
219    /// Read a Changelog source file from a filesystem path
220    ///
221    /// Encoding is assumed based on extension, this may change in the future
222    pub fn from_path(path: impl Into<std::path::PathBuf>) -> anyhow::Result<Changelog> {
223        let path = path.into();
224
225        if !path.exists() {
226            return Err(anyhow!("no such file {}", path.display()));
227        }
228
229        match path.extension().map(|e| e.to_ascii_lowercase()) {
230            Some(e) if e == "yml" || e == "yaml" => {
231                let s = &std::fs::read_to_string(path)?;
232                Self::from_yaml(s)
233            }
234            Some(e) if e == "toml" => {
235                let s = &std::fs::read_to_string(path)?;
236                Self::from_toml(s)
237            }
238            Some(e) if e == "json" => {
239                let s = &std::fs::read_to_string(path)?;
240                Self::from_json(s)
241            }
242            Some(e) => Err(anyhow!("Invalid file extension {}", e.to_string_lossy())),
243            None => Err(anyhow!(
244                "Unable to read {} without an extension",
245                path.display()
246            )),
247        }
248    }
249
250    /// Parse a Changelog from a YAML string
251    pub fn from_yaml(s: &str) -> anyhow::Result<Changelog> {
252        let de = serde_yml::Deserializer::from_str(s);
253        Ok(serde_path_to_error::deserialize(de)?)
254    }
255
256    /// Parse a Changelog from a JSON string
257    pub fn from_json(s: &str) -> anyhow::Result<Changelog> {
258        let mut de = serde_json::Deserializer::from_str(s);
259        Ok(serde_path_to_error::deserialize(&mut de)?)
260    }
261
262    /// Parse a Changelog from a TOML string
263    pub fn from_toml(s: &str) -> anyhow::Result<Changelog> {
264        let de = toml::Deserializer::new(s);
265        Ok(serde_path_to_error::deserialize(de)?)
266    }
267
268    /// Serialize this Changelog into a YAML string
269    pub fn to_yaml(&self) -> anyhow::Result<String> {
270        Ok(serde_yml::to_string(&self)?)
271    }
272
273    /// Serialize this Changelog into a TOML string
274    pub fn to_toml(&self) -> anyhow::Result<String> {
275        Ok(toml::to_string_pretty(&self)?)
276    }
277
278    /// Serialize this Changelog into a JSON string
279    pub fn to_json(&self) -> anyhow::Result<String> {
280        Ok(serde_json::to_string_pretty(&self)? + "\n")
281    }
282}
283
284impl Default for Changelog {
285    fn default() -> Self {
286        Self {
287            title: "Changelog".into(),
288            description: r#"All notable changes to this project will be documented in this file.
289
290The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
291and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
292"#
293            .into(),
294            repository: "https://github.com/me/my-swanky-project".into(),
295            unreleased: Changes {
296                added: vec![
297                    "Starting using [changelog-md](https://github.com/kageurufu/changelog-md)"
298                        .to_string(),
299                ],
300                ..Default::default()
301            },
302            versions: vec![],
303        }
304    }
305}