camel_cli/commands/
new.rs1use crate::template::embedded::EmbeddedTemplate;
2use crate::template::{ProfileLayout, TemplateContext, TemplateProvider};
3
4#[derive(clap::Args)]
5pub struct NewArgs {
6 #[arg(value_parser = validate_project_name)]
8 pub name: String,
9
10 #[arg(long, default_value = "basic")]
12 pub template: String,
13
14 #[arg(long)]
16 pub force: bool,
17
18 #[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 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 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 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}