1use std::path::{Component, Path};
2use std::{fs, str};
3
4use crate::config::Config;
5use crate::error::{Error, Result};
6
7#[allow(missing_docs)]
13mod inner {
14 use rust_embed::RustEmbed;
15
16 #[derive(Debug, RustEmbed)]
22 #[folder = "config/"]
23 pub struct EmbeddedConfig;
24
25 #[derive(RustEmbed)]
29 #[folder = "examples/"]
30 pub struct BuiltinConfig;
31}
32
33pub use inner::{BuiltinConfig, EmbeddedConfig};
34
35impl EmbeddedConfig {
36 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 pub fn parse() -> Result<Config> {
50 Self::get_config()?.parse()
51 }
52}
53
54impl BuiltinConfig {
55 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 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 pub fn parse(name: String) -> Result<(Config, String)> {
82 let parsed = Self::get_config(name.clone())?.parse()?;
83 Ok((parsed, name))
84 }
85
86 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 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 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 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 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 assert!(
190 names.iter().all(|name| !name.ends_with(".toml")),
191 "names should not carry the .toml extension: {names:?}"
192 );
193 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"), "")?; 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 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 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 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 assert_eq!(
272 BuiltinConfig::get_config_from("github".to_string(), Some(dir.path()))?,
273 BuiltinConfig::get_config("github".to_string())?
274 );
275 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}