Skip to main content

camel_cli/commands/
new.rs

1use crate::template::embedded::EmbeddedTemplate;
2use crate::template::{ProfileLayout, TemplateContext, TemplateProvider};
3
4#[derive(clap::Args)]
5pub struct NewArgs {
6    /// Project name and directory to create
7    #[arg(value_parser = validate_project_name)]
8    pub name: String,
9
10    /// Template to use (available: basic)
11    #[arg(long, default_value = "basic")]
12    pub template: String,
13
14    /// Overwrite files if the directory already exists
15    #[arg(long)]
16    pub force: bool,
17
18    /// Profile layout: simple ([default] only) or env ([default], [development], [production])
19    #[arg(long, value_name = "LAYOUT", default_value = "env")]
20    pub profile_layout: ProfileLayout,
21}
22
23fn validate_project_name(name: &str) -> Result<String, String> {
24    let trimmed = name.trim();
25    if trimmed.is_empty() {
26        return Err("project name must not be empty or whitespace".into());
27    }
28
29    let path = std::path::Path::new(trimmed);
30
31    // Reject relative paths containing separators (e.g. "my/project").
32    // Absolute paths are allowed so callers can specify a target directory.
33    if !path.is_absolute() && (trimmed.contains('/') || trimmed.contains('\\')) {
34        return Err(
35            "relative path separators ('/' or '\\') are not allowed; use an absolute path or a plain project name".into(),
36        );
37    }
38
39    // Reject '..' anywhere in path components.
40    if path
41        .components()
42        .any(|c| matches!(c, std::path::Component::ParentDir))
43    {
44        return Err("project name must not contain '..'".into());
45    }
46
47    // Validate the final component (the actual project name).
48    let project_name = path
49        .file_name()
50        .and_then(|s| s.to_str())
51        .ok_or_else(|| "invalid project name: no valid final component".to_string())?;
52
53    if !project_name
54        .chars()
55        .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
56    {
57        return Err(
58            "project name must contain only alphanumeric characters, hyphens, or underscores"
59                .into(),
60        );
61    }
62
63    Ok(trimmed.to_string())
64}
65
66fn resolve_template(name: &str) -> Result<Box<dyn TemplateProvider>, Box<dyn std::error::Error>> {
67    match name {
68        "basic" => Ok(Box::new(EmbeddedTemplate::basic())),
69        other => Err(format!("Unknown template: '{other}'. Available templates: basic").into()),
70    }
71}
72
73pub fn run_new(args: NewArgs) {
74    let NewArgs {
75        name,
76        template,
77        force,
78        profile_layout,
79    } = args;
80
81    let ctx = TemplateContext {
82        project_name: name.clone(),
83        profile_layout,
84    };
85
86    let provider = resolve_template(&template).unwrap_or_else(|e| {
87        eprintln!("Error: {e}");
88        std::process::exit(1);
89    });
90
91    let files = provider.files(&ctx).unwrap_or_else(|e| {
92        eprintln!("Error generating project: {e}");
93        std::process::exit(1);
94    });
95
96    let target = std::path::Path::new(&name);
97    if target.exists() && !force {
98        let is_non_empty = target.read_dir().is_ok_and(|mut d| d.next().is_some());
99        if is_non_empty {
100            eprintln!(
101                "Directory '{}' already exists and is not empty. Use --force to overwrite.",
102                name
103            );
104            std::process::exit(1);
105        }
106    }
107
108    std::fs::create_dir_all(target).unwrap_or_else(|e| {
109        eprintln!("Failed to create directory '{}': {}", name, e);
110        std::process::exit(1);
111    });
112
113    for file in &files {
114        let file_path = target.join(&file.path);
115        if let Some(parent) = file_path.parent() {
116            std::fs::create_dir_all(parent).unwrap_or_else(|e| {
117                eprintln!("Failed to create directory '{}': {}", parent.display(), e);
118                std::process::exit(1);
119            });
120        }
121        std::fs::write(&file_path, &file.content).unwrap_or_else(|e| {
122            eprintln!("Failed to write '{}': {}", file_path.display(), e);
123            std::process::exit(1);
124        });
125    }
126
127    let display_name = std::path::Path::new(&name)
128        .file_name()
129        .and_then(|n| n.to_str())
130        .unwrap_or(&name);
131    println!("Created camel project: {}\n", display_name);
132    println!("Next steps:");
133    println!("  cd {}", name);
134    println!("  camel run");
135    println!("  camel run --watch");
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn resolve_template_basic_returns_ok() {
144        let result = resolve_template("basic");
145        assert!(result.is_ok());
146        assert_eq!(result.unwrap().name(), "basic");
147    }
148
149    #[test]
150    fn resolve_template_unknown_returns_error() {
151        let result = resolve_template("nonexistent");
152        match result {
153            Ok(_) => panic!("expected error, got Ok"),
154            Err(err) => {
155                let msg = err.to_string();
156                assert!(msg.contains("Unknown template"), "got: {msg}");
157                assert!(msg.contains("nonexistent"), "got: {msg}");
158            }
159        }
160    }
161
162    #[test]
163    fn test_empty_name_rejected() {
164        assert!(validate_project_name("").is_err());
165    }
166
167    #[test]
168    fn test_whitespace_name_rejected() {
169        assert!(validate_project_name("   ").is_err());
170    }
171
172    #[test]
173    fn test_valid_name_accepted() {
174        assert!(validate_project_name("my-project").is_ok());
175        assert!(validate_project_name("my_project_123").is_ok());
176    }
177
178    #[test]
179    fn test_name_with_special_chars_rejected() {
180        assert!(validate_project_name("my project").is_err());
181        assert!(validate_project_name("my/project").is_err());
182    }
183
184    #[test]
185    fn test_name_with_backslash_rejected() {
186        let result = validate_project_name("my\\project");
187        assert!(result.is_err());
188        assert!(
189            result.unwrap_err().contains("path separators"),
190            "expected path separator error"
191        );
192    }
193
194    #[test]
195    fn test_dot_and_dotdot_rejected() {
196        assert!(validate_project_name(".").is_err());
197        assert!(validate_project_name("..").is_err());
198    }
199}