quickstart_lib/
lib.rs

1#![cfg_attr(test, allow(clippy::disallowed_methods))]
2
3//! Library core for cargo-quickstart: project generator logic
4
5use color_eyre::Result;
6use std::{fmt, path::PathBuf};
7
8pub mod template;
9pub mod tools;
10
11/// Project type (binary or library)
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub enum ProjectType {
14    /// A binary application
15    Binary,
16    /// A library crate
17    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/// Configuration for scaffolding a new project
30#[derive(Debug)]
31pub struct ProjectConfig {
32    /// Project name
33    pub name: String,
34    /// Project type (binary or library)
35    pub project_type: ProjectType,
36    /// Rust edition
37    pub edition: String,
38    /// License
39    pub license: String,
40    /// Initialize git repository
41    pub git: bool,
42    /// Target path
43    pub path: PathBuf,
44    /// Accept all defaults without prompting
45    pub yes: bool,
46}
47
48/// Find the nearest `templates/` directory by walking up from the current directory.
49pub 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
66/// Generate a new project based on the provided configuration
67pub fn generate_project(config: ProjectConfig) -> Result<()> {
68    use template::{TemplateEngine, TemplateLoader, TemplateVariables, TemplateVariant};
69
70    // Validate that the parent directory exists
71    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    // Initialize template variables from config
81    let variables = TemplateVariables::from_config(&config);
82
83    // Create the template engine
84    let engine = TemplateEngine::new(variables);
85
86    // Smarter template path resolution: search upwards for templates/
87    let template_path = find_templates_dir()?;
88    let loader = TemplateLoader::new(template_path);
89
90    // Use extended template variant by default
91    let variant = TemplateVariant::Extended;
92
93    // List all templates for this project type
94    let templates = loader.list_templates(config.project_type, variant)?;
95
96    // Create the output directory
97    std::fs::create_dir_all(&config.path)?;
98
99    // Process each template
100    for template_path in templates {
101        // Get relative path for loading template
102        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        // Load template content
107        let template_content = loader.load_template(&rel_path_str)?;
108
109        // Render the template
110        let rendered = engine.render_template(&template_content)?;
111
112        // Determine output path
113        let output_path = loader.get_destination_path(&template_path, &config.path);
114
115        // Create parent directories if needed
116        if let Some(parent) = output_path.parent() {
117            std::fs::create_dir_all(parent)?;
118        }
119
120        // Write rendered content to file
121        std::fs::write(output_path, rendered)?;
122    }
123
124    // TODO: Initialize Git repository if requested
125    if config.git {
126        // git::init_repository(&config.path)?;
127    }
128
129    println!("Successfully generated project: {}", config.name);
130    Ok(())
131}
132
133/// Config type for backward compatibility
134#[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        // Skip under Miri
162        if cfg!(miri) {
163            eprintln!("Skipping file system test under Miri");
164            return;
165        }
166
167        // Use a temp dir with no templates/ parent
168        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        // Instead of relying on directory structure, we'll directly test the error case
217        // by creating a nonexistent path
218        let _nonexistent_path = PathBuf::from("/path/that/definitely/does/not/exist/templates");
219
220        // Create a result that mimics what find_templates_dir would return if no templates dir exists
221        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        // Verify the result is an error
231        assert!(result.is_err(), "Should error if templates/ dir is missing");
232
233        // Verify the error message is the one we expect
234        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        // Skip under Miri
246        if cfg!(miri) {
247            eprintln!("Skipping file system test under Miri");
248            return;
249        }
250
251        // Create a temporary directory for this test
252        let test_dir = tempfile::tempdir().unwrap();
253        let test_path = test_dir.path();
254
255        // Create an output file (not a directory) to cause the write error
256        let output_file = test_path.join("output_file");
257        fs::write(&output_file, "not a dir").unwrap();
258
259        // Create a proper templates directory structure following the expected pattern:
260        // templates/
261        //   ├── base/
262        //   ├── binary/
263        //   │   ├── minimal/
264        //   │   └── extended/
265        //   └── library/
266        //       ├── minimal/
267        //       └── extended/
268
269        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        // Create all the required directories
279        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        // Create template files
286        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        // Save current directory and change to test directory to find templates/
317        let prev = std::env::current_dir().unwrap();
318        std::env::set_current_dir(test_path).unwrap();
319
320        // Create config pointing to the file (not directory) as output path
321        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        // This should fail because the output path is a file, not a directory
332        let result = generate_project(config);
333
334        // Restore previous working directory
335        std::env::set_current_dir(prev).unwrap();
336
337        // Verify that an error occurred
338        assert!(result.is_err(), "Should error when output path is a file");
339
340        // Check that the error is related to the file operation
341        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}