1use crate::templates::{render, Vars, FILES};
8use std::fs;
9use std::io;
10use std::path::{Path, PathBuf};
11
12pub struct NewArgs {
14 pub name: String,
15 pub path: Option<PathBuf>,
16 pub force: bool,
17}
18
19#[derive(Debug)]
22pub enum NewError {
23 InvalidName(String),
24 AlreadyExists(PathBuf),
25 Io { path: PathBuf, source: io::Error },
26}
27
28impl std::fmt::Display for NewError {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 match self {
31 Self::InvalidName(name) => write!(
32 f,
33 "`{name}` is not a valid project name. Use lowercase letters, digits, hyphens, and underscores only (and start with a letter)."
34 ),
35 Self::AlreadyExists(p) => write!(
36 f,
37 "destination `{}` already exists. Re-run with --force to use it anyway (existing files inside are NOT removed).",
38 p.display()
39 ),
40 Self::Io { path, source } => write!(f, "I/O error at `{}`: {source}", path.display()),
41 }
42 }
43}
44
45impl std::error::Error for NewError {}
46
47pub fn validate_name(name: &str) -> Result<(), NewError> {
51 let bad = |reason: &str| -> NewError { NewError::InvalidName(format!("{name} ({reason})")) };
52
53 if name.is_empty() {
54 return Err(bad("empty"));
55 }
56 let mut chars = name.chars();
57 let first = chars.next().unwrap();
58 if !first.is_ascii_lowercase() {
59 return Err(bad("must start with a lowercase letter"));
60 }
61 for c in chars {
62 let ok = c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_';
63 if !ok {
64 return Err(bad("illegal character"));
65 }
66 }
67 Ok(())
68}
69
70pub fn run(args: &NewArgs) -> Result<PathBuf, NewError> {
73 validate_name(&args.name)?;
74
75 let dest = args
76 .path
77 .clone()
78 .unwrap_or_else(|| PathBuf::from(&args.name));
79
80 if dest.exists() && !args.force {
81 return Err(NewError::AlreadyExists(dest));
82 }
83 if !dest.exists() {
84 fs::create_dir_all(&dest).map_err(|e| NewError::Io {
85 path: dest.clone(),
86 source: e,
87 })?;
88 }
89
90 let vars = Vars {
91 project_name: &args.name,
92 project_name_snake: &args.name.replace('-', "_"),
93 };
94
95 for (rel, contents) in FILES {
96 write_one(&dest, rel, contents, &vars)?;
97 }
98
99 Ok(dest)
100}
101
102fn write_one(dest: &Path, rel: &str, template: &str, vars: &Vars<'_>) -> Result<(), NewError> {
103 let target = dest.join(rel);
104 if let Some(parent) = target.parent() {
105 fs::create_dir_all(parent).map_err(|e| NewError::Io {
106 path: parent.to_path_buf(),
107 source: e,
108 })?;
109 }
110 let rendered = render(template, vars);
111 fs::write(&target, rendered).map_err(|e| NewError::Io {
112 path: target.clone(),
113 source: e,
114 })
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn validate_name_accepts_typical_names() {
123 assert!(validate_name("my-app").is_ok());
124 assert!(validate_name("my_app").is_ok());
125 assert!(validate_name("api1").is_ok());
126 assert!(validate_name("a").is_ok());
127 }
128
129 #[test]
130 fn validate_name_rejects_bad_names() {
131 assert!(validate_name("").is_err());
132 assert!(validate_name("1leading-digit").is_err());
133 assert!(validate_name("UPPER").is_err());
134 assert!(validate_name("has space").is_err());
135 assert!(validate_name("has.dot").is_err());
136 }
137
138 #[test]
139 fn run_scaffolds_into_tempdir() {
140 let tmp = tempfile::tempdir().unwrap();
141 let target = tmp.path().join("my-app");
142 let args = NewArgs {
143 name: "my-app".into(),
144 path: Some(target.clone()),
145 force: false,
146 };
147 let written = run(&args).unwrap();
148 assert_eq!(written, target);
149
150 let cargo = std::fs::read_to_string(target.join("Cargo.toml")).unwrap();
152 assert!(cargo.contains(r#"name = "my-app""#));
153 let envex = std::fs::read_to_string(target.join(".env.example")).unwrap();
154 assert!(
155 envex.contains("my_app=debug"),
156 "expected snake-cased target in .env.example, got: {envex}"
157 );
158 assert!(target.join("src/modules/hello/handlers.rs").is_file());
160 }
161
162 #[test]
163 fn run_refuses_to_overwrite_existing_dir() {
164 let tmp = tempfile::tempdir().unwrap();
165 let target = tmp.path().join("existing");
166 std::fs::create_dir(&target).unwrap();
167
168 let args = NewArgs {
169 name: "existing".into(),
170 path: Some(target.clone()),
171 force: false,
172 };
173 let err = run(&args).unwrap_err();
174 assert!(matches!(err, NewError::AlreadyExists(_)), "got {err:?}");
175 }
176
177 #[test]
178 fn run_with_force_writes_into_existing_dir() {
179 let tmp = tempfile::tempdir().unwrap();
180 let target = tmp.path().join("existing");
181 std::fs::create_dir(&target).unwrap();
182 std::fs::write(target.join("untouched.txt"), "stay put").unwrap();
183
184 let args = NewArgs {
185 name: "existing".into(),
186 path: Some(target.clone()),
187 force: true,
188 };
189 run(&args).unwrap();
190
191 assert!(target.join("untouched.txt").is_file());
194 assert!(target.join("Cargo.toml").is_file());
195 }
196}