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#[derive(Debug, RustEmbed)]
15#[folder = "config/"]
16pub struct EmbeddedConfig;
17
18impl EmbeddedConfig {
19 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 pub fn parse() -> Result<Config> {
33 Self::get_config()?.parse()
34 }
35}
36
37#[derive(RustEmbed)]
41#[folder = "examples/"]
42pub struct BuiltinConfig;
43
44impl BuiltinConfig {
45 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 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 pub fn parse(name: String) -> Result<(Config, String)> {
72 let parsed = Self::get_config(name.clone())?.parse()?;
73 Ok((parsed, name))
74 }
75
76 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 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 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 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 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 assert!(
181 names.iter().all(|name| !name.ends_with(".toml")),
182 "names should not carry the .toml extension: {names:?}"
183 );
184 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"), "")?; 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 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 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 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 assert_eq!(
263 BuiltinConfig::get_config_from("github".to_string(), Some(dir.path()))?,
264 BuiltinConfig::get_config("github".to_string())?
265 );
266 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}