Skip to main content

git_cliff_core/
embed.rs

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