dampen_cli/commands/
new.rs

1//! Create a new Dampen project
2//!
3//! This module provides the `dampen new` command which scaffolds a new
4//! Dampen UI project with a simple Hello World example using the
5//! auto-loading pattern.
6//!
7//! # Example
8//!
9//! ```bash
10//! dampen new my-app
11//! cd my-app
12//! cargo run
13//! ```
14
15#![allow(clippy::print_stdout)]
16
17use std::fs;
18use std::path::{Path, PathBuf};
19
20/// Arguments for the new command
21///
22/// # Fields
23///
24/// * `name` - The name of the project to create. Must be a valid Rust package name.
25#[derive(Debug, clap::Args)]
26pub struct NewArgs {
27    /// Name of the project to create
28    pub name: String,
29}
30
31/// Execute the new command
32///
33/// Creates a new Dampen project directory with:
34/// - `Cargo.toml` with Dampen dependencies
35/// - `src/main.rs` with a complete Hello World application using auto-loading
36/// - `src/ui/mod.rs` - UI module
37/// - `src/ui/window.rs` - UI model and handlers with `#[dampen_ui]` macro
38/// - `src/ui/window.dampen` - Declarative UI definition (XML)
39/// - `tests/integration.rs` - Integration tests
40/// - `README.md` with comprehensive getting started instructions
41///
42/// # Arguments
43///
44/// * `args` - Command arguments containing the project name
45///
46/// # Returns
47///
48/// * `Ok(())` - If project was created successfully
49/// * `Err(String)` - If creation failed with error message
50///
51/// # Errors
52///
53/// This function will return an error if:
54/// - The project name is invalid
55/// - A directory with the same name already exists
56/// - File system operations fail (e.g., permission denied)
57pub fn execute(args: &NewArgs) -> Result<(), String> {
58    let project_name = &args.name;
59
60    // Validate project name
61    validate_project_name(project_name)?;
62
63    // Get the project path
64    let project_path = PathBuf::from(project_name);
65
66    // Check if directory already exists
67    if project_path.exists() {
68        return Err(format!("Directory '{}' already exists", project_name));
69    }
70
71    // Create project structure
72    match create_project(project_name, &project_path) {
73        Ok(()) => {
74            println!("Created new Dampen project: {}", project_name);
75            println!();
76            println!("Next steps:");
77            println!("  cd {}", project_name);
78            println!("  dampen run");
79            Ok(())
80        }
81        Err(e) => {
82            // Cleanup on error
83            cleanup_on_error(&project_path);
84            Err(e)
85        }
86    }
87}
88
89/// Validate the project name
90///
91/// A valid project name must:
92/// - Not be empty
93/// - Start with a letter or underscore
94/// - Contain only alphanumeric characters, hyphens, and underscores
95/// - Not be a reserved name
96fn validate_project_name(name: &str) -> Result<(), String> {
97    // Check if empty
98    if name.is_empty() {
99        return Err("Project name cannot be empty".to_string());
100    }
101
102    // Check first character
103    if let Some(first) = name.chars().next() {
104        if !first.is_alphabetic() && first != '_' {
105            return Err("Project name must start with a letter or underscore".to_string());
106        }
107    }
108
109    // Check all characters
110    if !name
111        .chars()
112        .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
113    {
114        return Err(
115            "Project name can only contain letters, numbers, hyphens, and underscores".to_string(),
116        );
117    }
118
119    // Check reserved names
120    const RESERVED: &[&str] = &["test", "doc", "build", "target", "src"];
121    if RESERVED.contains(&name) {
122        return Err(format!("'{}' is a reserved name", name));
123    }
124
125    Ok(())
126}
127
128/// Create the complete project structure
129fn create_project(project_name: &str, project_path: &Path) -> Result<(), String> {
130    // Create directories
131    create_project_structure(project_path)?;
132
133    // Generate files
134    generate_cargo_toml(project_path, project_name)?;
135    generate_build_rs(project_path, project_name)?;
136    generate_main_rs(project_path, project_name)?;
137    generate_ui_mod_rs(project_path, project_name)?;
138    generate_ui_window_rs(project_path, project_name)?;
139    generate_window_dampen(project_path, project_name)?;
140    generate_theme_dampen(project_path, project_name)?;
141    generate_integration_test(project_path, project_name)?;
142    generate_readme(project_path, project_name)?;
143
144    Ok(())
145}
146
147/// Create the directory structure
148fn create_project_structure(project_path: &Path) -> Result<(), String> {
149    // Create main project directory
150    fs::create_dir(project_path).map_err(|e| {
151        format!(
152            "Failed to create directory '{}': {}",
153            project_path.display(),
154            e
155        )
156    })?;
157
158    // Create src/ directory
159    let src_dir = project_path.join("src");
160    fs::create_dir(&src_dir)
161        .map_err(|e| format!("Failed to create directory '{}': {}", src_dir.display(), e))?;
162
163    // Create src/ui/ directory
164    let ui_dir = src_dir.join("ui");
165    fs::create_dir(&ui_dir)
166        .map_err(|e| format!("Failed to create directory '{}': {}", ui_dir.display(), e))?;
167
168    // Create src/ui/theme/ directory
169    let theme_dir = ui_dir.join("theme");
170    fs::create_dir(&theme_dir).map_err(|e| {
171        format!(
172            "Failed to create directory '{}': {}",
173            theme_dir.display(),
174            e
175        )
176    })?;
177
178    // Create tests/ directory
179    let tests_dir = project_path.join("tests");
180    fs::create_dir(&tests_dir).map_err(|e| {
181        format!(
182            "Failed to create directory '{}': {}",
183            tests_dir.display(),
184            e
185        )
186    })?;
187
188    Ok(())
189}
190
191/// Generate Cargo.toml from template
192fn generate_cargo_toml(project_path: &Path, project_name: &str) -> Result<(), String> {
193    let template = include_str!("../../templates/new/Cargo.toml.template");
194
195    // Get versions from build.rs environment variables
196    let dampen_version = env!("CARGO_PKG_VERSION");
197    let iced_version = env!("ICED_VERSION");
198    let serde_version = env!("SERDE_VERSION");
199    let serde_json_version = env!("SERDE_JSON_VERSION");
200
201    let content = template
202        .replace("{{PROJECT_NAME}}", project_name)
203        .replace("{{DAMPEN_VERSION}}", dampen_version)
204        .replace("{{ICED_VERSION}}", iced_version)
205        .replace("{{SERDE_VERSION}}", serde_version)
206        .replace("{{SERDE_JSON_VERSION}}", serde_json_version);
207
208    let file_path = project_path.join("Cargo.toml");
209    fs::write(&file_path, content)
210        .map_err(|e| format!("Failed to write '{}': {}", file_path.display(), e))?;
211
212    Ok(())
213}
214
215/// Generate build.rs from template
216fn generate_build_rs(project_path: &Path, _project_name: &str) -> Result<(), String> {
217    let template = include_str!("../../templates/build.rs.template");
218    // No replacements needed for build.rs - it's generic
219
220    let file_path = project_path.join("build.rs");
221    fs::write(&file_path, template)
222        .map_err(|e| format!("Failed to write '{}': {}", file_path.display(), e))?;
223
224    Ok(())
225}
226
227/// Generate src/main.rs from template
228fn generate_main_rs(project_path: &Path, project_name: &str) -> Result<(), String> {
229    let template = include_str!("../../templates/new/src/main.rs.template");
230    let content = template.replace("{{PROJECT_NAME}}", project_name);
231
232    let file_path = project_path.join("src/main.rs");
233    fs::write(&file_path, content)
234        .map_err(|e| format!("Failed to write '{}': {}", file_path.display(), e))?;
235
236    Ok(())
237}
238
239/// Generate src/ui/mod.rs from template
240fn generate_ui_mod_rs(project_path: &Path, project_name: &str) -> Result<(), String> {
241    let template = include_str!("../../templates/new/src/ui/mod.rs.template");
242    let content = template.replace("{{PROJECT_NAME}}", project_name);
243
244    let file_path = project_path.join("src/ui/mod.rs");
245    fs::write(&file_path, content)
246        .map_err(|e| format!("Failed to write '{}': {}", file_path.display(), e))?;
247
248    Ok(())
249}
250
251/// Generate src/ui/window.rs from template
252fn generate_ui_window_rs(project_path: &Path, project_name: &str) -> Result<(), String> {
253    let template = include_str!("../../templates/new/src/ui/window.rs.template");
254    let content = template.replace("{{PROJECT_NAME}}", project_name);
255
256    let file_path = project_path.join("src/ui/window.rs");
257    fs::write(&file_path, content)
258        .map_err(|e| format!("Failed to write '{}': {}", file_path.display(), e))?;
259
260    Ok(())
261}
262
263/// Generate src/ui/window.dampen from template
264fn generate_window_dampen(project_path: &Path, project_name: &str) -> Result<(), String> {
265    let template = include_str!("../../templates/new/src/ui/window.dampen.template");
266    let content = template.replace("{{PROJECT_NAME}}", project_name);
267
268    let file_path = project_path.join("src/ui/window.dampen");
269    fs::write(&file_path, content)
270        .map_err(|e| format!("Failed to write '{}': {}", file_path.display(), e))?;
271
272    Ok(())
273}
274
275/// Generate src/ui/theme/theme.dampen template
276fn generate_theme_dampen(project_path: &Path, project_name: &str) -> Result<(), String> {
277    let template = include_str!("../../templates/new/src/ui/theme/theme.dampen.template");
278    let content = template.replace("{{PROJECT_NAME}}", project_name);
279
280    let file_path = project_path.join("src/ui/theme/theme.dampen");
281    fs::write(&file_path, content)
282        .map_err(|e| format!("Failed to write '{}': {}", file_path.display(), e))?;
283
284    Ok(())
285}
286
287/// Generate tests/integration.rs from template
288fn generate_integration_test(project_path: &Path, project_name: &str) -> Result<(), String> {
289    let template = include_str!("../../templates/new/tests/integration.rs.template");
290
291    // Sanitize project name for use in Rust identifiers (replace hyphens with underscores)
292    let sanitized_name = project_name.replace('-', "_");
293
294    let content = template.replace("{{PROJECT_NAME}}", &sanitized_name);
295
296    let file_path = project_path.join("tests/integration.rs");
297    fs::write(&file_path, content)
298        .map_err(|e| format!("Failed to write '{}': {}", file_path.display(), e))?;
299
300    Ok(())
301}
302
303/// Generate README.md from template
304fn generate_readme(project_path: &Path, project_name: &str) -> Result<(), String> {
305    let template = include_str!("../../templates/new/README.md.template");
306    let content = template.replace("{{PROJECT_NAME}}", project_name);
307
308    let file_path = project_path.join("README.md");
309    fs::write(&file_path, content)
310        .map_err(|e| format!("Failed to write '{}': {}", file_path.display(), e))?;
311
312    Ok(())
313}
314
315/// Cleanup project directory on error
316fn cleanup_on_error(project_path: &Path) {
317    if project_path.exists() {
318        let _ = fs::remove_dir_all(project_path);
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_validate_project_name_valid() {
328        assert!(validate_project_name("my-app").is_ok());
329        assert!(validate_project_name("my_app").is_ok());
330        assert!(validate_project_name("myapp").is_ok());
331        assert!(validate_project_name("MyApp").is_ok());
332        assert!(validate_project_name("my-app-123").is_ok());
333        assert!(validate_project_name("_private").is_ok());
334    }
335
336    #[test]
337    fn test_validate_project_name_invalid() {
338        assert!(validate_project_name("").is_err());
339        assert!(validate_project_name("123").is_err());
340        assert!(validate_project_name("-invalid").is_err());
341        assert!(validate_project_name("my app").is_err());
342        assert!(validate_project_name("my/app").is_err());
343        assert!(validate_project_name("test").is_err());
344        assert!(validate_project_name("build").is_err());
345    }
346}