1#![cfg_attr(test, allow(clippy::disallowed_methods))]
2
3use color_eyre::Result;
6use std::{fmt, path::PathBuf};
7
8pub mod template;
9pub mod tools;
10
11#[derive(Debug, Clone, Copy, PartialEq)]
13pub enum ProjectType {
14 Binary,
16 Library,
18}
19
20impl fmt::Display for ProjectType {
21 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22 match self {
23 ProjectType::Binary => write!(f, "Binary application"),
24 ProjectType::Library => write!(f, "Library crate"),
25 }
26 }
27}
28
29#[derive(Debug)]
31pub struct ProjectConfig {
32 pub name: String,
34 pub project_type: ProjectType,
36 pub edition: String,
38 pub license: String,
40 pub git: bool,
42 pub path: PathBuf,
44 pub yes: bool,
46}
47
48pub fn find_templates_dir() -> Result<PathBuf, std::io::Error> {
50 let mut dir = std::env::current_dir()?;
51 loop {
52 let candidate = dir.join("templates");
53 if candidate.is_dir() {
54 return Ok(candidate);
55 }
56 if !dir.pop() {
57 break;
58 }
59 }
60 Err(std::io::Error::new(
61 std::io::ErrorKind::NotFound,
62 "Could not find a 'templates/' directory in this or any parent directory.",
63 ))
64}
65
66pub fn generate_project(config: ProjectConfig) -> Result<()> {
68 use template::{TemplateEngine, TemplateLoader, TemplateVariables, TemplateVariant};
69
70 if let Some(parent) = config.path.parent() {
72 if !parent.exists() {
73 return Err(color_eyre::eyre::eyre!(
74 "Parent directory '{}' does not exist. Please create it first.",
75 parent.display()
76 ));
77 }
78 }
79
80 let variables = TemplateVariables::from_config(&config);
82
83 let engine = TemplateEngine::new(variables);
85
86 let template_path = find_templates_dir()?;
88 let loader = TemplateLoader::new(template_path);
89
90 let variant = TemplateVariant::Extended;
92
93 let templates = loader.list_templates(config.project_type, variant)?;
95
96 std::fs::create_dir_all(&config.path)?;
98
99 for template_path in templates {
101 let rel_path = pathdiff::diff_paths(&template_path, loader.base_path())
103 .unwrap_or_else(|| template_path.clone());
104 let rel_path_str = rel_path.to_string_lossy();
105
106 let template_content = loader.load_template(&rel_path_str)?;
108
109 let rendered = engine.render_template(&template_content)?;
111
112 let output_path = loader.get_destination_path(&template_path, &config.path);
114
115 if let Some(parent) = output_path.parent() {
117 std::fs::create_dir_all(parent)?;
118 }
119
120 std::fs::write(output_path, rendered)?;
122 }
123
124 if config.git {
126 }
128
129 println!("Successfully generated project: {}", config.name);
130 Ok(())
131}
132
133#[derive(Debug)]
135pub struct Config {
136 pub name: String,
137 pub bin: bool,
138 pub lib: bool,
139 pub edition: String,
140 pub license: String,
141 pub git: bool,
142 pub path: PathBuf,
143 pub yes: bool,
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use pretty_assertions::assert_eq;
150 use std::fs;
151 use tempfile::tempdir;
152
153 #[test]
154 fn test_project_type_display() {
155 assert_eq!(ProjectType::Binary.to_string(), "Binary application");
156 assert_eq!(ProjectType::Library.to_string(), "Library crate");
157 }
158
159 #[test]
160 fn test_find_templates_dir_error() {
161 if cfg!(miri) {
163 eprintln!("Skipping file system test under Miri");
164 return;
165 }
166
167 let dir = tempdir().unwrap();
169 let prev = std::env::current_dir().unwrap();
170 std::env::set_current_dir(dir.path()).unwrap();
171 let result = find_templates_dir();
172 std::env::set_current_dir(prev).unwrap();
173 assert!(result.is_err());
174 }
175
176 #[test]
177 fn test_config_struct_instantiation() {
178 let config = Config {
179 name: "foo".to_string(),
180 bin: true,
181 lib: false,
182 edition: "2021".to_string(),
183 license: "MIT".to_string(),
184 git: true,
185 path: PathBuf::from("/tmp/foo"),
186 yes: false,
187 };
188 assert_eq!(config.name, "foo");
189 assert!(config.bin);
190 }
191
192 #[test]
193 fn test_project_config_edge_cases() {
194 let config = ProjectConfig {
195 name: "".to_string(),
196 project_type: ProjectType::Library,
197 edition: "2015".to_string(),
198 license: "GPL-3.0".to_string(),
199 git: false,
200 path: PathBuf::from("/tmp/empty"),
201 yes: true,
202 };
203 assert_eq!(config.name, "");
204 match config.project_type {
205 ProjectType::Library => {}
206 _ => panic!("Expected Library variant"),
207 }
208 assert_eq!(config.edition, "2015");
209 assert_eq!(config.license, "GPL-3.0");
210 assert!(!config.git);
211 assert!(config.yes);
212 }
213
214 #[test]
215 fn test_generate_project_template_error() {
216 let _nonexistent_path = PathBuf::from("/path/that/definitely/does/not/exist/templates");
219
220 let template_error = std::io::Error::new(
222 std::io::ErrorKind::NotFound,
223 "Could not find a 'templates/' directory in this or any parent directory.",
224 );
225 let result: Result<(), template::TemplateError> = Err(template::TemplateError::LoadError {
226 path: "templates".to_string(),
227 source: template_error,
228 });
229
230 assert!(result.is_err(), "Should error if templates/ dir is missing");
232
233 if let Err(e) = result {
235 assert!(
236 e.to_string()
237 .contains("Could not find a 'templates/' directory"),
238 "Error should mention missing templates directory, got: {e}"
239 );
240 }
241 }
242
243 #[test]
244 fn test_generate_project_write_error() {
245 if cfg!(miri) {
247 eprintln!("Skipping file system test under Miri");
248 return;
249 }
250
251 let test_dir = tempfile::tempdir().unwrap();
253 let test_path = test_dir.path();
254
255 let output_file = test_path.join("output_file");
257 fs::write(&output_file, "not a dir").unwrap();
258
259 let templates_dir = test_path.join("templates");
270 let base_dir = templates_dir.join("base");
271 let binary_dir = templates_dir.join("binary");
272 let binary_extended_dir = binary_dir.join("extended");
273 let binary_minimal_dir = binary_dir.join("minimal");
274 let library_dir = templates_dir.join("library");
275 let library_extended_dir = library_dir.join("extended");
276 let library_minimal_dir = library_dir.join("minimal");
277
278 fs::create_dir_all(&base_dir).unwrap();
280 fs::create_dir_all(&binary_extended_dir).unwrap();
281 fs::create_dir_all(&binary_minimal_dir).unwrap();
282 fs::create_dir_all(&library_extended_dir).unwrap();
283 fs::create_dir_all(&library_minimal_dir).unwrap();
284
285 fs::write(
287 base_dir.join("README.md.hbs"),
288 "# {{name}}\n\nThis is a test project.",
289 )
290 .unwrap();
291
292 fs::write(
293 binary_extended_dir.join("main.rs.hbs"),
294 "fn main() {\n println!(\"Hello from {{name}}!\");\n}",
295 )
296 .unwrap();
297
298 fs::write(
299 binary_minimal_dir.join("main.rs.hbs"),
300 "fn main() {\n println!(\"Minimal {{name}}!\");\n}",
301 )
302 .unwrap();
303
304 fs::write(
305 library_extended_dir.join("lib.rs.hbs"),
306 "pub fn hello() {\n println!(\"Hello from {{name}} library!\");\n}",
307 )
308 .unwrap();
309
310 fs::write(
311 library_minimal_dir.join("lib.rs.hbs"),
312 "pub fn hello() {}\n",
313 )
314 .unwrap();
315
316 let prev = std::env::current_dir().unwrap();
318 std::env::set_current_dir(test_path).unwrap();
319
320 let config = ProjectConfig {
322 name: "test-project".to_string(),
323 project_type: ProjectType::Binary,
324 edition: "2021".to_string(),
325 license: "MIT".to_string(),
326 git: false,
327 path: output_file,
328 yes: true,
329 };
330
331 let result = generate_project(config);
333
334 std::env::set_current_dir(prev).unwrap();
336
337 assert!(result.is_err(), "Should error when output path is a file");
339
340 if let Err(e) = result {
342 assert!(
343 e.to_string().contains("Not a directory")
344 || e.to_string().contains("Is a file")
345 || e.to_string().contains("already exists")
346 || e.to_string().contains("Permission denied")
347 || e.to_string().contains("File exists"),
348 "Error should be about output path being a file, got: {e}"
349 );
350 }
351 }
352}