changelog/
input.rs

1// Copyright 2017-2018 by Aldrin J D'Souza.
2// Licensed under the MIT License <https://opensource.org/licenses/MIT>
3
4use std::fs::File;
5use super::Result;
6use std::io::prelude::*;
7use serde_yaml::from_str;
8use std::env::current_dir;
9
10/// The YAML configuration file name (`.changelog.yml`).
11///
12/// The library looks for a file with this name in the current directory and all its ancestors (up to root). If
13/// one is found, it is used to initialize configuration, if not the default configuration is used.
14pub const CONFIG_FILE: &str = ".changelog.yml";
15
16/// The embedded configuration used when none is provided by the user.
17const CONFIG_DEFAULT: &str = include_str!("assets/changelog.yml");
18
19/// The Handlebars template file name (`.changelog.hbs`).
20///
21/// The library looks for a file with this name (`.changelog.hbs`) in the current directory and all its ancestors
22/// (up to root). If one is found, it is used to render the changelog, if not the default template is used.
23pub const TEMPLATE_FILE: &str = ".changelog.hbs";
24
25/// The embedded template that is used when none is provided by the user.
26const TEMPLATE_DEFAULT: &str = include_str!("assets/changelog.hbs");
27
28/// The tool configuration.
29///
30/// The configuration defines the repository conventions and output preferences.
31#[serde(default)]
32#[derive(Debug, Default, Deserialize)]
33pub struct Configuration {
34    /// The project conventions
35    pub conventions: Conventions,
36
37    /// The output preferences
38    pub output: OutputPreferences,
39}
40
41/// The change categorization conventions used by a repository/project.
42#[serde(default)]
43#[derive(Debug, Default, Deserialize, Eq, PartialEq)]
44pub struct Conventions {
45    /// The scope keywords
46    pub scopes: Vec<Keyword>,
47
48    /// The category keywords
49    pub categories: Vec<Keyword>,
50}
51
52/// A keyword used to categorize commit message lines.
53#[serde(default)]
54#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
55pub struct Keyword {
56    /// The identifying tag used in commit messages.
57    pub tag: String,
58
59    /// The presentation title that shows up in the final change log.
60    pub title: String,
61}
62
63/// The output preferences
64#[serde(default)]
65#[derive(Debug, Default, Deserialize, Eq, PartialEq)]
66pub struct OutputPreferences {
67    /// Output as JSON
68    pub json: bool,
69
70    /// Output Handlebar template
71    pub template: Option<String>,
72
73    /// The remote url
74    pub remote: Option<String>,
75
76    /// Output line post-processors
77    pub post_processors: Vec<PostProcessor>,
78}
79
80/// A post-processor definition.
81#[serde(default)]
82#[derive(Debug, Default, Deserialize, Eq, PartialEq)]
83pub struct PostProcessor {
84    /// The lookup pattern
85    pub lookup: String,
86
87    /// The replace pattern
88    pub replace: String,
89}
90
91impl Configuration {
92    /// Default constructor
93    pub fn new() -> Self {
94        Self::from_file(None).unwrap_or_else(|_| Self::default())
95    }
96
97    /// Construct from the given YAML file
98    pub fn from_file(file: Option<&str>) -> Result<Self> {
99        file.map(str::to_owned)
100            .or_else(|| find_file(CONFIG_FILE))
101            .map_or_else(|| Ok(String::from(CONFIG_DEFAULT)), |f| read_file(&f))
102            .and_then(|yml| Self::from_yaml(&yml))
103    }
104
105    /// Construct from the given YAML string
106    pub fn from_yaml(yml: &str) -> Result<Self> {
107        from_str(yml).map_err(|e| format_err!("Configuration contains invalid YAML: {}", e))
108    }
109}
110
111impl Conventions {
112    /// Get the title for the given scope
113    pub fn scope_title(&self, scope: Option<String>) -> Option<&str> {
114        self.title(&self.scopes, scope)
115    }
116
117    /// Get the title for the given category
118    pub fn category_title(&self, category: Option<String>) -> Option<&str> {
119        self.title(&self.categories, category)
120    }
121
122    /// Get the titles for all the categories defined
123    pub fn category_titles(&self) -> Vec<&str> {
124        Self::titles(&self.categories)
125    }
126
127    /// Get the titles for all the scopes defined
128    pub fn scope_titles(&self) -> Vec<&str> {
129        Self::titles(&self.scopes)
130    }
131
132    /// Given the available keywords, get the title for the given tag
133    fn title<'a>(&'a self, keywords: &'a [Keyword], tag: Option<String>) -> Option<&'a str> {
134        // The least we have is a "blank" one.
135        if keywords.is_empty() && tag.is_none() {
136            return Some("");
137        }
138
139        // Look in the list for one that matches the given tag
140        let given = tag.unwrap_or_default();
141        for kw in keywords {
142            if kw.tag == given {
143                return Some(&kw.title);
144            }
145        }
146
147        None
148    }
149
150    /// Given the available keywords, get a iterable list of the titles
151    fn titles(keywords: &[Keyword]) -> Vec<&str> {
152        if keywords.is_empty() {
153            vec![""]
154        } else {
155            keywords.iter().map(|k| k.title.as_ref()).collect()
156        }
157    }
158}
159
160impl Keyword {
161    /// Construct a keyword from the tag and title
162    pub fn new<T: AsRef<str>>(tag: T, title: T) -> Self {
163        Keyword {
164            tag: tag.as_ref().to_owned(),
165            title: title.as_ref().to_owned(),
166        }
167    }
168}
169
170impl OutputPreferences {
171    /// Default constructor
172    pub fn new() -> Self {
173        Self::default()
174    }
175
176    /// Get the template definition
177    pub fn get_template(&self) -> Result<String> {
178        self.template
179            .clone()
180            .or_else(|| find_file(TEMPLATE_FILE))
181            .map_or_else(|| Ok(String::from(TEMPLATE_DEFAULT)), |f| read_file(&f))
182    }
183}
184
185/// Read the given file to a String (with logging)
186fn read_file(name: &str) -> Result<String> {
187    // Return the data
188    info!("Reading file '{}'", name);
189    let mut contents = String::new();
190
191    File::open(name)
192        .map_err(|e| format_err!("Cannot open file '{}' (Reason: {})", name, e))?
193        .read_to_string(&mut contents)
194        .map_err(|e| format_err!("Cannot read file '{}' (Reason: {})", name, e))?;
195
196    Ok(contents)
197}
198
199/// Identify the closest configuration file that should be used for this run
200fn find_file(file: &str) -> Option<String> {
201    // Start at the current directory
202    let mut cwd = current_dir().expect("Current directory is invalid");
203
204    // While we have hope
205    while cwd.exists() {
206        // Set the filename we're looking for
207        cwd.push(file);
208
209        // If we find it
210        if cwd.is_file() {
211            // return it
212            return Some(cwd.to_string_lossy().to_string());
213        }
214
215        // If not, remove the filename
216        cwd.pop();
217
218        // If we have room to go up
219        if cwd.parent().is_some() {
220            // Go up the path
221            cwd.pop();
222        } else {
223            // Get out
224            break;
225        }
226    }
227
228    // No file found
229    None
230}
231
232#[cfg(test)]
233mod tests {
234    use super::Configuration;
235
236    #[test]
237    fn configuration_from_yaml() {
238        let project = include_str!("../.changelog.yml");
239        let no_category = r#"
240        conventions:
241          scopes: [{keyword:"a", title: "A"}]
242        "#;
243        let no_scope = r#"
244        conventions:
245          categories: [{keyword:"a", title: "A"}]
246        "#;
247        assert!(Configuration::from_yaml("").is_err());
248        assert!(Configuration::from_yaml(project).is_ok());
249        assert!(Configuration::from_yaml(no_scope).is_ok());
250        assert!(Configuration::from_yaml(no_category).is_ok());
251    }
252
253    #[test]
254    fn find_file() {
255        use super::find_file;
256        assert!(find_file("unknown").is_none());
257        assert!(find_file("Cargo.toml").is_some());
258    }
259}