1use std::path::PathBuf;
9
10use crate::error::{Error, Result};
11use crate::lockfile::{LOCKFILE_NAME, Lockfile};
12use crate::process::CommandRunner;
13
14pub const LIGHT_DEPENDENCY_VERSION: &str = env!("CARGO_PKG_VERSION");
16
17pub const GITIGNORE: &str = "target/\n";
19
20#[derive(Debug, Clone)]
22pub struct InitOptions {
23 pub name: String,
25 pub path: Option<PathBuf>,
27 pub minimal: bool,
29}
30
31#[derive(Debug, Clone)]
33pub struct InitPlan {
34 pub directory: PathBuf,
36 pub files: Vec<(PathBuf, String)>,
38 pub write_lockfile: bool,
40}
41
42impl InitPlan {
43 pub fn to_text(&self) -> String {
45 let mut out = format!("Проект в {}\n", self.directory.display());
46 for (path, _) in &self.files {
47 out.push_str(&format!(" + {}\n", path.display()));
48 }
49 if self.write_lockfile {
50 out.push_str(&format!(" + {}\n", LOCKFILE_NAME));
51 }
52 out
53 }
54}
55
56#[derive(Debug, Clone)]
58pub struct InitReport {
59 pub directory: PathBuf,
61 pub files: Vec<PathBuf>,
63 pub lockfile: bool,
65}
66
67impl InitReport {
68 pub fn to_text(&self) -> String {
70 let mut out = format!("✓ Проект создан в {}\n", self.directory.display());
71 for path in &self.files {
72 out.push_str(&format!(" {}\n", path.display()));
73 }
74 if self.lockfile {
75 out.push_str(&format!(" {}\n", LOCKFILE_NAME));
76 }
77 out.push_str(&format!(
78 "\nДалее: cd \"{}\" && cargo run\n",
79 self.directory.display()
80 ));
81 out
82 }
83}
84
85pub fn validate_name(name: &str) -> Result<()> {
89 if name.is_empty() || name.len() > 64 {
90 return Err(Error::Init(
91 "имя проекта должно быть длиной от 1 до 64 символов".to_string(),
92 ));
93 }
94 let mut chars = name.chars();
95 let first = chars.next().expect("непустое имя");
96 if !(first.is_ascii_alphabetic() || first == '_') {
97 return Err(Error::Init(format!(
98 "имя `{name}` должно начинаться с латинской буквы или `_`"
99 )));
100 }
101 if let Some(bad) = name
102 .chars()
103 .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
104 {
105 return Err(Error::Init(format!(
106 "недопустимый символ `{bad}` в имени `{name}` (разрешены латинские буквы, цифры, `-`, `_`)"
107 )));
108 }
109 Ok(())
110}
111
112pub fn project_dir(options: &InitOptions) -> PathBuf {
114 match &options.path {
115 Some(path) => path.clone(),
116 None => PathBuf::from(&options.name),
117 }
118}
119
120pub fn cargo_toml(name: &str) -> String {
122 format!(
123 "[package]\n\
124 name = \"{name}\"\n\
125 version = \"0.1.0\"\n\
126 edition = \"2021\"\n\
127 \n\
128 [dependencies]\n\
129 light = \"{version}\"\n",
130 version = LIGHT_DEPENDENCY_VERSION,
131 )
132}
133
134pub fn main_rs() -> &'static str {
136 r#"use light::prelude::*;
137
138struct App;
139
140impl Component for App {
141 fn render(&self) -> VElement {
142 VElement::container(
143 "",
144 NodeKind::Window,
145 vec![VElement::container(
146 "0",
147 NodeKind::Column,
148 vec![VElement::leaf(
149 "0.0",
150 NodeKind::Text {
151 content: "Hello, Light!".to_string(),
152 },
153 )],
154 )],
155 )
156 .with_window(WindowConfig {
157 title: "Hello, Light!".to_string(),
158 width: 320,
159 height: 200,
160 ..Default::default()
161 })
162 }
163}
164
165fn main() -> light::Result<()> {
166 light::run(App)
167}
168"#
169}
170
171pub fn plan(options: &InitOptions) -> Result<InitPlan> {
175 validate_name(&options.name)?;
176 let directory = project_dir(options);
177
178 if directory.is_dir() {
179 let mut entries = std::fs::read_dir(&directory)?;
180 if entries.next().is_some() {
181 return Err(Error::Init(format!(
182 "каталог `{}` не пуст — укажите другое имя или `--path`",
183 directory.display()
184 )));
185 }
186 } else if directory.exists() {
187 return Err(Error::Init(format!(
188 "`{}` существует и не является каталогом",
189 directory.display()
190 )));
191 }
192
193 let mut files = vec![
194 (PathBuf::from("Cargo.toml"), cargo_toml(&options.name)),
195 (PathBuf::from("src").join("main.rs"), main_rs().to_string()),
196 ];
197 if !options.minimal {
198 files.push((PathBuf::from(".gitignore"), GITIGNORE.to_string()));
199 }
200
201 Ok(InitPlan {
202 directory,
203 files,
204 write_lockfile: !options.minimal,
205 })
206}
207
208pub fn require_cargo(runner: &dyn CommandRunner) -> Result<()> {
210 match runner.run("cargo", &["--version".to_string()], None) {
211 Ok(_) => Ok(()),
212 Err(Error::CommandNotFound(_)) => Err(Error::Init(
213 "`cargo` не найден в PATH — установите Rust (https://rustup.rs)".to_string(),
214 )),
215 Err(error) => Err(error),
216 }
217}
218
219pub fn scaffold(plan: &InitPlan) -> Result<()> {
221 for (relative, content) in &plan.files {
222 let path = plan.directory.join(relative);
223 if let Some(parent) = path.parent() {
224 std::fs::create_dir_all(parent)?;
225 }
226 std::fs::write(&path, content)?;
227 }
228 Ok(())
229}
230
231pub fn apply(runner: &dyn CommandRunner, options: &InitOptions) -> Result<InitReport> {
233 require_cargo(runner)?;
234 let plan = plan(options)?;
235 scaffold(&plan)?;
236
237 let lockfile = if plan.write_lockfile {
238 let lock = Lockfile::from_project(&plan.directory)?;
239 lock.write(&plan.directory)?;
240 true
241 } else {
242 false
243 };
244
245 Ok(InitReport {
246 directory: plan.directory,
247 files: plan.files.into_iter().map(|(path, _)| path).collect(),
248 lockfile,
249 })
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use crate::process::CommandOutput;
256 use std::path::Path;
257 use std::sync::atomic::{AtomicU64, Ordering};
258
259 static COUNTER: AtomicU64 = AtomicU64::new(0);
260
261 fn scratch(name: &str) -> PathBuf {
262 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
263 let dir = std::env::temp_dir().join(format!(
264 "light-fw-cli-init-{name}-{}-{unique}",
265 std::process::id()
266 ));
267 let _ = std::fs::remove_dir_all(&dir);
268 std::fs::create_dir_all(&dir).expect("scratch dir");
269 dir
270 }
271
272 struct StubRunner {
273 found: bool,
274 }
275
276 impl CommandRunner for StubRunner {
277 fn run(
278 &self,
279 program: &str,
280 _args: &[String],
281 _cwd: Option<&Path>,
282 ) -> Result<CommandOutput> {
283 if self.found {
284 Ok(CommandOutput {
285 status: 0,
286 stdout: "cargo 1.99.0".to_string(),
287 stderr: String::new(),
288 })
289 } else {
290 Err(Error::CommandNotFound(program.to_string()))
291 }
292 }
293 }
294
295 #[test]
296 fn rejects_invalid_names() {
297 assert!(validate_name("my-app").is_ok());
298 assert!(validate_name("app_2").is_ok());
299 assert!(validate_name("").is_err());
300 assert!(validate_name("2app").is_err());
301 assert!(validate_name("my app").is_err());
302 assert!(validate_name("app!").is_err());
303 }
304
305 #[test]
306 fn plan_default_creates_all_files() {
307 let root = scratch("plan-default");
308 let dir = root.join("cool-app");
309 let options = InitOptions {
310 name: "cool-app".to_string(),
311 path: Some(dir.clone()),
312 minimal: false,
313 };
314 let plan = plan(&options).expect("plan");
315 let names: Vec<String> = plan
316 .files
317 .iter()
318 .map(|(path, _)| path.to_string_lossy().replace('\\', "/"))
319 .collect();
320 assert!(names.contains(&"Cargo.toml".to_string()));
321 assert!(names.contains(&"src/main.rs".to_string()));
322 assert!(names.contains(&".gitignore".to_string()));
323 assert!(plan.write_lockfile);
324 }
325
326 #[test]
327 fn plan_minimal_omits_gitignore_and_lockfile() {
328 let root = scratch("plan-minimal");
329 let options = InitOptions {
330 name: "tiny".to_string(),
331 path: Some(root.join("tiny")),
332 minimal: true,
333 };
334 let plan = plan(&options).expect("plan");
335 let names: Vec<String> = plan
336 .files
337 .iter()
338 .map(|(path, _)| path.to_string_lossy().replace('\\', "/"))
339 .collect();
340 assert!(!names.contains(&".gitignore".to_string()));
341 assert!(!plan.write_lockfile);
342 }
343
344 #[test]
345 fn plan_rejects_non_empty_directory() {
346 let root = scratch("plan-nonempty");
347 let target = root.join("busy");
348 std::fs::create_dir_all(&target).expect("dir");
349 std::fs::write(target.join("file.txt"), "x").expect("file");
350 let options = InitOptions {
351 name: "busy".to_string(),
352 path: Some(target),
353 minimal: false,
354 };
355 let error = plan(&options).expect_err("должна быть ошибка");
356 assert!(error.to_string().contains("не пуст"), "{error}");
357 }
358
359 #[test]
360 fn cargo_toml_pins_light_version() {
361 let text = cargo_toml("demo");
362 assert!(text.contains("name = \"demo\""));
363 assert!(text.contains(&format!("light = \"{LIGHT_DEPENDENCY_VERSION}\"")));
364 }
365
366 #[test]
367 fn main_rs_is_self_contained() {
368 let text = main_rs();
369 assert!(text.contains("Hello, Light!"));
370 assert!(!text.contains("include_str!"));
371 assert!(!text.contains("#[template"));
372 }
373
374 #[test]
375 fn require_cargo_reports_missing_tool() {
376 let error = require_cargo(&StubRunner { found: false }).expect_err("нет cargo");
377 assert!(error.to_string().contains("cargo"), "{error}");
378 assert!(require_cargo(&StubRunner { found: true }).is_ok());
379 }
380
381 #[test]
382 fn apply_creates_project_and_lockfile() {
383 let root = scratch("apply");
384 let dir = root.join("my-app");
385 let options = InitOptions {
386 name: "my-app".to_string(),
387 path: Some(dir.clone()),
388 minimal: false,
389 };
390 let report = apply(&StubRunner { found: true }, &options).expect("apply");
391 assert!(report.lockfile);
392 assert!(dir.join("Cargo.toml").is_file());
393 assert!(dir.join("src").join("main.rs").is_file());
394 assert!(dir.join(".gitignore").is_file());
395 assert!(dir.join(LOCKFILE_NAME).is_file());
396
397 let lock = Lockfile::read(&dir).expect("read lock");
398 assert_eq!(lock.core, LIGHT_DEPENDENCY_VERSION);
399 }
400
401 #[test]
402 fn apply_minimal_skips_lockfile() {
403 let root = scratch("apply-minimal");
404 let dir = root.join("tiny");
405 let options = InitOptions {
406 name: "tiny".to_string(),
407 path: Some(dir.clone()),
408 minimal: true,
409 };
410 let report = apply(&StubRunner { found: true }, &options).expect("apply");
411 assert!(!report.lockfile);
412 assert!(dir.join("Cargo.toml").is_file());
413 assert!(!dir.join(".gitignore").exists());
414 assert!(!dir.join(LOCKFILE_NAME).exists());
415 }
416
417 #[test]
418 fn apply_fails_without_cargo_before_writing() {
419 let root = scratch("apply-nocargo");
420 let dir = root.join("nope");
421 let options = InitOptions {
422 name: "nope".to_string(),
423 path: Some(dir.clone()),
424 minimal: false,
425 };
426 assert!(apply(&StubRunner { found: false }, &options).is_err());
427 assert!(!dir.exists(), "каталог не должен создаваться");
428 }
429}