Skip to main content

git_cliff_core/
embed.rs

1use std::path::{Component, Path};
2use std::{fs, str};
3
4use crate::config::Config;
5use crate::error::{Error, Result};
6
7// Workaround: derive macros may generate structs without docs, triggering `missing_docs` lint
8// errors that can't be suppressed on the item itself. Wrap the type in a module with
9// `#[allow(missing_docs)]` and re-export it.
10//
11// See: https://users.rust-lang.org/t/suppress-missing-doc-error-resulting-from-a-derive-macro/97301
12#[allow(missing_docs)]
13mod inner {
14    use rust_embed::RustEmbed;
15
16    /// Default configuration file embedder/extractor.
17    ///
18    /// Embeds `config/`[`DEFAULT_CONFIG`] into the binary.
19    ///
20    /// [`DEFAULT_CONFIG`]: crate::DEFAULT_CONFIG
21    #[derive(Debug, RustEmbed)]
22    #[folder = "config/"]
23    pub struct EmbeddedConfig;
24
25    /// Built-in configuration file embedder/extractor.
26    ///
27    /// Embeds the files under `/examples/` into the binary.
28    #[derive(RustEmbed)]
29    #[folder = "examples/"]
30    pub struct BuiltinConfig;
31}
32
33pub use inner::{BuiltinConfig, EmbeddedConfig};
34
35impl EmbeddedConfig {
36    /// Extracts the embedded content.
37    pub fn get_config() -> Result<String> {
38        match Self::get(crate::DEFAULT_CONFIG) {
39            Some(v) => Ok(str::from_utf8(&v.data)?.to_string()),
40            None => Err(Error::EmbeddedError(String::from(
41                "Embedded config not found",
42            ))),
43        }
44    }
45
46    /// Parses the extracted content into [`Config`].
47    ///
48    /// [`Config`]: Config
49    pub fn parse() -> Result<Config> {
50        Self::get_config()?.parse()
51    }
52}
53
54impl BuiltinConfig {
55    /// Normalizes a template name to a file name carrying the `.toml`
56    /// extension (appending it when absent).
57    fn with_toml_extension(name: &str) -> String {
58        if Path::new(name)
59            .extension()
60            .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
61        {
62            name.to_string()
63        } else {
64            format!("{name}.toml")
65        }
66    }
67
68    /// Extracts the embedded content.
69    pub fn get_config(name: String) -> Result<String> {
70        let name = Self::with_toml_extension(&name);
71        let contents = match Self::get(&name) {
72            Some(v) => Ok(str::from_utf8(&v.data)?.to_string()),
73            None => Err(Error::EmbeddedError(format!("config {name} not found"))),
74        }?;
75        Ok(contents)
76    }
77
78    /// Parses the extracted content into [`Config`] along with the name.
79    ///
80    /// [`Config`]: Config
81    pub fn parse(name: String) -> Result<(Config, String)> {
82        let parsed = Self::get_config(name.clone())?.parse()?;
83        Ok((parsed, name))
84    }
85
86    /// Validates a user-defined templates directory.
87    pub fn validate_templates_dir(dir: &Path) -> Result<()> {
88        if !dir.is_dir() {
89            return Err(Error::ArgumentError(format!(
90                "templates directory does not exist or is not a directory: {}",
91                dir.display()
92            )));
93        }
94        Ok(())
95    }
96
97    /// Extracts the template content for `name`, preferring a user-provided
98    /// template found in `templates_dir` (if given) over the built-in template
99    /// of the same name. The `.toml` extension is optional in `name`.
100    pub fn get_config_from(name: String, templates_dir: Option<&Path>) -> Result<String> {
101        if let Some(dir) = templates_dir {
102            Self::validate_templates_dir(dir)?;
103            let file_name = Self::with_toml_extension(&name);
104            let mut components = Path::new(&file_name).components();
105            if !matches!(components.next(), Some(Component::Normal(_))) ||
106                components.next().is_some()
107            {
108                return Err(Error::ArgumentError(format!(
109                    "template name must not contain path components: {name}"
110                )));
111            }
112            let path = dir.join(file_name);
113            if path.is_file() {
114                return Ok(fs::read_to_string(path)?);
115            }
116        }
117        Self::get_config(name)
118    }
119
120    /// Lists the names of the built-in templates, without the `.toml`
121    /// extension and in sorted order.
122    pub fn list() -> Vec<String> {
123        let mut names: Vec<String> = Self::iter()
124            .filter_map(|file| {
125                let path = Path::new(file.as_ref());
126                if path
127                    .extension()
128                    .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
129                {
130                    path.file_stem()
131                        .map(|stem| stem.to_string_lossy().to_string())
132                } else {
133                    None
134                }
135            })
136            .collect();
137        names.sort();
138        names
139    }
140
141    /// Lists the names of every available template — built-in templates plus
142    /// the `.toml` templates found in `templates_dir` (if given) — without the
143    /// `.toml` extension, sorted and deduplicated.
144    ///
145    /// Returns an error if `templates_dir` is provided but cannot be read
146    /// (e.g. it does not exist or is not a directory).
147    pub fn list_templates(templates_dir: Option<&Path>) -> Result<Vec<String>> {
148        let mut names = Self::list();
149        if let Some(dir) = templates_dir {
150            Self::validate_templates_dir(dir)?;
151            for entry in fs::read_dir(dir)? {
152                let path = entry?.path();
153                if path
154                    .extension()
155                    .is_some_and(|ext| ext.eq_ignore_ascii_case("toml")) &&
156                    let Some(stem) = path.file_stem()
157                {
158                    names.push(stem.to_string_lossy().to_string());
159                }
160            }
161        }
162        names.sort();
163        names.dedup();
164        Ok(names)
165    }
166}
167
168#[cfg(test)]
169mod test {
170    use std::fs;
171
172    use temp_dir::TempDir;
173
174    use super::*;
175
176    #[test]
177    fn lists_builtin_templates_sorted_without_extension() {
178        let names = BuiltinConfig::list();
179        // a couple of the shipped example templates are expected to be present
180        assert!(
181            names.contains(&"github".to_string()),
182            "expected built-in 'github' in {names:?}"
183        );
184        assert!(
185            names.contains(&"keepachangelog".to_string()),
186            "expected built-in 'keepachangelog' in {names:?}"
187        );
188        // names are reported without the `.toml` extension
189        assert!(
190            names.iter().all(|name| !name.ends_with(".toml")),
191            "names should not carry the .toml extension: {names:?}"
192        );
193        // and are returned in sorted order
194        let mut sorted = names.clone();
195        sorted.sort();
196        assert_eq!(names, sorted, "names should be sorted");
197    }
198
199    #[test]
200    fn list_templates_merges_user_directory() -> Result<()> {
201        let dir = TempDir::new()?;
202        fs::write(dir.path().join("my-custom.toml"), "")?;
203        fs::write(dir.path().join("notes.txt"), "")?; // not a template, ignored
204
205        let names = BuiltinConfig::list_templates(Some(dir.path()))?;
206
207        assert!(
208            names.contains(&"my-custom".to_string()),
209            "user template should be listed: {names:?}"
210        );
211        assert!(
212            names.contains(&"github".to_string()),
213            "built-in templates should still be listed: {names:?}"
214        );
215        assert!(
216            !names.iter().any(|name| name == "notes"),
217            "non-.toml files should be ignored: {names:?}"
218        );
219
220        // sorted and deduplicated
221        let mut expected = names.clone();
222        expected.sort();
223        expected.dedup();
224        assert_eq!(names, expected, "names should be sorted and deduped");
225        Ok(())
226    }
227
228    #[test]
229    fn list_templates_without_user_directory_matches_builtin() -> Result<()> {
230        assert_eq!(BuiltinConfig::list_templates(None)?, BuiltinConfig::list());
231        Ok(())
232    }
233
234    #[test]
235    fn list_templates_errors_clearly_when_directory_missing() {
236        let dir = TempDir::new().expect("temp dir");
237        let missing = dir.path().join("does-not-exist");
238        let err = BuiltinConfig::list_templates(Some(&missing))
239            .expect_err("a missing templates directory should be an error");
240        let message = err.to_string();
241        assert!(
242            message.contains(&missing.display().to_string()),
243            "error should name the offending directory, got: {message}"
244        );
245    }
246
247    #[test]
248    fn get_config_from_prefers_user_template_over_builtin() -> Result<()> {
249        let dir = TempDir::new()?;
250        // shadow the built-in "github" template with custom content
251        fs::write(dir.path().join("github.toml"), "# user override\n")?;
252        let contents = BuiltinConfig::get_config_from("github".to_string(), Some(dir.path()))?;
253        assert_eq!(contents, "# user override\n");
254        Ok(())
255    }
256
257    #[test]
258    fn get_config_from_normalizes_missing_extension() -> Result<()> {
259        let dir = TempDir::new()?;
260        fs::write(dir.path().join("mine.toml"), "# mine\n")?;
261        // name given without the `.toml` extension still resolves the user file
262        let contents = BuiltinConfig::get_config_from("mine".to_string(), Some(dir.path()))?;
263        assert_eq!(contents, "# mine\n");
264        Ok(())
265    }
266
267    #[test]
268    fn get_config_from_falls_back_to_builtin() -> Result<()> {
269        let dir = TempDir::new()?;
270        // user dir has no "github.toml" → falls back to the embedded built-in
271        assert_eq!(
272            BuiltinConfig::get_config_from("github".to_string(), Some(dir.path()))?,
273            BuiltinConfig::get_config("github".to_string())?
274        );
275        // and with no user dir at all it matches the built-in too
276        assert_eq!(
277            BuiltinConfig::get_config_from("github".to_string(), None)?,
278            BuiltinConfig::get_config("github".to_string())?
279        );
280        Ok(())
281    }
282
283    #[test]
284    fn get_config_from_errors_when_directory_missing() {
285        let dir = TempDir::new().expect("temp dir");
286        let missing = dir.path().join("does-not-exist");
287        let err = BuiltinConfig::get_config_from("github".to_string(), Some(&missing))
288            .expect_err("a missing templates directory should be an error");
289        assert!(err.to_string().contains(&missing.display().to_string()));
290    }
291
292    #[test]
293    fn get_config_from_rejects_paths_outside_directory() {
294        let dir = TempDir::new().expect("temp dir");
295        let err = BuiltinConfig::get_config_from("../github".to_string(), Some(dir.path()))
296            .expect_err("a template name with path components should be an error");
297        assert!(err.to_string().contains("must not contain path components"));
298    }
299}