1use std::path::PathBuf;
9use std::str::FromStr;
10
11use crate::error::{Error, Result};
12use crate::lockfile::{LOCKFILE_NAME, Lockfile};
13use crate::process::CommandRunner;
14
15pub const LIGHT_DEPENDENCY_VERSION: &str = env!("CARGO_PKG_VERSION");
17
18pub const GITIGNORE: &str = "target/\n";
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23pub enum InitComponent {
24 Form,
26 Validation,
28}
29
30impl InitComponent {
31 pub const ALL: [Self; 2] = [Self::Form, Self::Validation];
33
34 pub fn parse(value: &str) -> Result<Self> {
36 match value {
37 "form" => Ok(Self::Form),
38 "validation" => Ok(Self::Validation),
39 other => Err(Error::Init(format!(
40 "неизвестный компонент `{other}` (поддерживаются: form, validation)"
41 ))),
42 }
43 }
44
45 pub fn crate_name(self) -> &'static str {
47 match self {
48 Self::Form => "light-fw-element-form",
49 Self::Validation => "light-fw-behavior-validation",
50 }
51 }
52
53 pub fn light_feature(self) -> &'static str {
55 match self {
56 Self::Form => "elements-form",
57 Self::Validation => "validation",
58 }
59 }
60}
61
62impl FromStr for InitComponent {
63 type Err = Error;
64
65 fn from_str(value: &str) -> Result<Self> {
66 Self::parse(value)
67 }
68}
69
70#[derive(Debug, Clone)]
72pub struct InitOptions {
73 pub name: String,
75 pub path: Option<PathBuf>,
77 pub minimal: bool,
79 pub components: Vec<InitComponent>,
81}
82
83#[derive(Debug, Clone)]
85pub struct InitPlan {
86 pub directory: PathBuf,
88 pub files: Vec<(PathBuf, String)>,
90 pub write_lockfile: bool,
92}
93
94impl InitPlan {
95 pub fn to_text(&self) -> String {
97 let mut out = format!("Проект в {}\n", self.directory.display());
98 for (path, _) in &self.files {
99 out.push_str(&format!(" + {}\n", path.display()));
100 }
101 if self.write_lockfile {
102 out.push_str(&format!(" + {}\n", LOCKFILE_NAME));
103 }
104 out
105 }
106}
107
108#[derive(Debug, Clone)]
110pub struct InitReport {
111 pub directory: PathBuf,
113 pub files: Vec<PathBuf>,
115 pub lockfile: bool,
117}
118
119impl InitReport {
120 pub fn to_text(&self) -> String {
122 let mut out = format!("✓ Проект создан в {}\n", self.directory.display());
123 for path in &self.files {
124 out.push_str(&format!(" {}\n", path.display()));
125 }
126 if self.lockfile {
127 out.push_str(&format!(" {}\n", LOCKFILE_NAME));
128 }
129 out.push_str(&format!(
130 "\nДалее: cd \"{}\" && cargo run\n",
131 self.directory.display()
132 ));
133 out
134 }
135}
136
137pub fn validate_name(name: &str) -> Result<()> {
141 if name.is_empty() || name.len() > 64 {
142 return Err(Error::Init(
143 "имя проекта должно быть длиной от 1 до 64 символов".to_string(),
144 ));
145 }
146 let mut chars = name.chars();
147 let first = chars.next().expect("непустое имя");
148 if !(first.is_ascii_alphabetic() || first == '_') {
149 return Err(Error::Init(format!(
150 "имя `{name}` должно начинаться с латинской буквы или `_`"
151 )));
152 }
153 if let Some(bad) = name
154 .chars()
155 .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
156 {
157 return Err(Error::Init(format!(
158 "недопустимый символ `{bad}` в имени `{name}` (разрешены латинские буквы, цифры, `-`, `_`)"
159 )));
160 }
161 Ok(())
162}
163
164pub fn project_dir(options: &InitOptions) -> PathBuf {
166 match &options.path {
167 Some(path) => path.clone(),
168 None => PathBuf::from(&options.name),
169 }
170}
171
172fn normalized_components(components: &[InitComponent]) -> Vec<InitComponent> {
174 InitComponent::ALL
175 .into_iter()
176 .filter(|component| components.contains(component))
177 .collect()
178}
179
180pub fn cargo_toml(name: &str, components: &[InitComponent]) -> String {
182 let components = normalized_components(components);
183 let mut text = format!(
184 "[package]\n\
185 name = \"{name}\"\n\
186 version = \"0.1.0\"\n\
187 edition = \"2021\"\n\
188 \n\
189 [dependencies]\n"
190 );
191
192 if components.is_empty() {
193 text.push_str(&format!("light = \"{LIGHT_DEPENDENCY_VERSION}\"\n"));
194 return text;
195 }
196
197 let features = components
198 .iter()
199 .map(|component| format!("\"{}\"", component.light_feature()))
200 .collect::<Vec<_>>()
201 .join(", ");
202 text.push_str(&format!(
203 "light = {{ version = \"{LIGHT_DEPENDENCY_VERSION}\", features = [{features}] }}\n"
204 ));
205 for component in &components {
206 text.push_str(&format!(
207 "{} = \"{LIGHT_DEPENDENCY_VERSION}\"\n",
208 component.crate_name()
209 ));
210 }
211 text
212}
213
214const FORM_EXAMPLE: &str = r#"
216// Пример формы (компонент `form`):
217//
218// #[template("templates/form.html")]
219// #[proxy]
220// struct SignUp {
221// email: String,
222// agree: bool,
223// }
224//
225// impl SignUp {
226// fn submit(&self) {
227// if self.agree() {
228// println!("email: {}", self.email());
229// }
230// }
231// }
232//
233// templates/form.html:
234// <window title="Sign up" width="360" height="240">
235// <div direction="vertical" padding="12">
236// <text v-model="email" placeholder="Email" required />
237// <checkbox v-model="agree">Согласен с условиями</checkbox>
238// <button @click="submit" :disabled="!agree">Отправить</button>
239// </div>
240// </window>
241"#;
242
243pub fn main_rs(components: &[InitComponent]) -> String {
245 let mut text = r#"use light::prelude::*;
246
247struct App;
248
249impl Component for App {
250 fn render(&self) -> VElement {
251 VElement::container(
252 "",
253 NodeKind::Window,
254 vec![VElement::container(
255 "0",
256 NodeKind::Column,
257 vec![VElement::leaf(
258 "0.0",
259 NodeKind::Text {
260 content: "Hello, Light!".to_string(),
261 },
262 )],
263 )],
264 )
265 .with_window(WindowConfig {
266 title: "Hello, Light!".to_string(),
267 width: 320,
268 height: 200,
269 ..Default::default()
270 })
271 }
272}
273
274fn main() -> light::Result<()> {
275 light::run(App)
276}
277"#
278 .to_string();
279
280 if components.contains(&InitComponent::Form) {
281 text.push_str(FORM_EXAMPLE);
282 }
283 text
284}
285
286pub fn plan(options: &InitOptions) -> Result<InitPlan> {
290 validate_name(&options.name)?;
291 let directory = project_dir(options);
292
293 if directory.is_dir() {
294 let mut entries = std::fs::read_dir(&directory)?;
295 if entries.next().is_some() {
296 return Err(Error::Init(format!(
297 "каталог `{}` не пуст — укажите другое имя или `--path`",
298 directory.display()
299 )));
300 }
301 } else if directory.exists() {
302 return Err(Error::Init(format!(
303 "`{}` существует и не является каталогом",
304 directory.display()
305 )));
306 }
307
308 let components = normalized_components(&options.components);
309 let mut files = vec![
310 (
311 PathBuf::from("Cargo.toml"),
312 cargo_toml(&options.name, &components),
313 ),
314 (PathBuf::from("src").join("main.rs"), main_rs(&components)),
315 ];
316 if !options.minimal {
317 files.push((PathBuf::from(".gitignore"), GITIGNORE.to_string()));
318 }
319
320 Ok(InitPlan {
321 directory,
322 files,
323 write_lockfile: !options.minimal,
324 })
325}
326
327pub fn require_cargo(runner: &dyn CommandRunner) -> Result<()> {
329 match runner.run("cargo", &["--version".to_string()], None) {
330 Ok(_) => Ok(()),
331 Err(Error::CommandNotFound(_)) => Err(Error::Init(
332 "`cargo` не найден в PATH — установите Rust (https://rustup.rs)".to_string(),
333 )),
334 Err(error) => Err(error),
335 }
336}
337
338pub fn scaffold(plan: &InitPlan) -> Result<()> {
340 for (relative, content) in &plan.files {
341 let path = plan.directory.join(relative);
342 if let Some(parent) = path.parent() {
343 std::fs::create_dir_all(parent)?;
344 }
345 std::fs::write(&path, content)?;
346 }
347 Ok(())
348}
349
350pub fn apply(runner: &dyn CommandRunner, options: &InitOptions) -> Result<InitReport> {
352 require_cargo(runner)?;
353 let plan = plan(options)?;
354 scaffold(&plan)?;
355
356 let lockfile = if plan.write_lockfile {
357 let lock = Lockfile::from_project(&plan.directory)?;
358 lock.write(&plan.directory)?;
359 true
360 } else {
361 false
362 };
363
364 Ok(InitReport {
365 directory: plan.directory,
366 files: plan.files.into_iter().map(|(path, _)| path).collect(),
367 lockfile,
368 })
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use crate::process::CommandOutput;
375 use std::path::Path;
376 use std::sync::atomic::{AtomicU64, Ordering};
377
378 static COUNTER: AtomicU64 = AtomicU64::new(0);
379
380 fn scratch(name: &str) -> PathBuf {
381 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
382 let dir = std::env::temp_dir().join(format!(
383 "light-fw-cli-init-{name}-{}-{unique}",
384 std::process::id()
385 ));
386 let _ = std::fs::remove_dir_all(&dir);
387 std::fs::create_dir_all(&dir).expect("scratch dir");
388 dir
389 }
390
391 struct StubRunner {
392 found: bool,
393 }
394
395 impl CommandRunner for StubRunner {
396 fn run(
397 &self,
398 program: &str,
399 _args: &[String],
400 _cwd: Option<&Path>,
401 ) -> Result<CommandOutput> {
402 if self.found {
403 Ok(CommandOutput {
404 status: 0,
405 stdout: "cargo 1.99.0".to_string(),
406 stderr: String::new(),
407 })
408 } else {
409 Err(Error::CommandNotFound(program.to_string()))
410 }
411 }
412 }
413
414 #[test]
415 fn rejects_invalid_names() {
416 assert!(validate_name("my-app").is_ok());
417 assert!(validate_name("app_2").is_ok());
418 assert!(validate_name("").is_err());
419 assert!(validate_name("2app").is_err());
420 assert!(validate_name("my app").is_err());
421 assert!(validate_name("app!").is_err());
422 }
423
424 #[test]
425 fn plan_default_creates_all_files() {
426 let root = scratch("plan-default");
427 let dir = root.join("cool-app");
428 let options = InitOptions {
429 name: "cool-app".to_string(),
430 path: Some(dir.clone()),
431 minimal: false,
432 components: Vec::new(),
433 };
434 let plan = plan(&options).expect("plan");
435 let names: Vec<String> = plan
436 .files
437 .iter()
438 .map(|(path, _)| path.to_string_lossy().replace('\\', "/"))
439 .collect();
440 assert!(names.contains(&"Cargo.toml".to_string()));
441 assert!(names.contains(&"src/main.rs".to_string()));
442 assert!(names.contains(&".gitignore".to_string()));
443 assert!(plan.write_lockfile);
444 }
445
446 #[test]
447 fn plan_minimal_omits_gitignore_and_lockfile() {
448 let root = scratch("plan-minimal");
449 let options = InitOptions {
450 name: "tiny".to_string(),
451 path: Some(root.join("tiny")),
452 minimal: true,
453 components: Vec::new(),
454 };
455 let plan = plan(&options).expect("plan");
456 let names: Vec<String> = plan
457 .files
458 .iter()
459 .map(|(path, _)| path.to_string_lossy().replace('\\', "/"))
460 .collect();
461 assert!(!names.contains(&".gitignore".to_string()));
462 assert!(!plan.write_lockfile);
463 }
464
465 #[test]
466 fn plan_rejects_non_empty_directory() {
467 let root = scratch("plan-nonempty");
468 let target = root.join("busy");
469 std::fs::create_dir_all(&target).expect("dir");
470 std::fs::write(target.join("file.txt"), "x").expect("file");
471 let options = InitOptions {
472 name: "busy".to_string(),
473 path: Some(target),
474 minimal: false,
475 components: Vec::new(),
476 };
477 let error = plan(&options).expect_err("должна быть ошибка");
478 assert!(error.to_string().contains("не пуст"), "{error}");
479 }
480
481 #[test]
482 fn cargo_toml_pins_light_version() {
483 let text = cargo_toml("demo", &[]);
484 assert!(text.contains("name = \"demo\""));
485 assert!(text.contains(&format!("light = \"{LIGHT_DEPENDENCY_VERSION}\"")));
486 assert!(!text.contains("features ="));
487 }
488
489 #[test]
490 fn cargo_toml_adds_form_dependency_and_feature() {
491 let text = cargo_toml("demo", &[InitComponent::Form]);
492 assert!(
493 text.contains(&format!(
494 "light = {{ version = \"{LIGHT_DEPENDENCY_VERSION}\", features = [\"elements-form\"] }}"
495 )),
496 "{text}"
497 );
498 assert!(
499 text.contains(&format!(
500 "light-fw-element-form = \"{LIGHT_DEPENDENCY_VERSION}\""
501 )),
502 "{text}"
503 );
504 assert!(!text.contains("light-fw-behavior-validation"), "{text}");
505 }
506
507 #[test]
508 fn cargo_toml_adds_both_dependencies_and_deduplicates() {
509 let text = cargo_toml(
510 "demo",
511 &[
512 InitComponent::Validation,
513 InitComponent::Form,
514 InitComponent::Form,
515 ],
516 );
517 assert!(
518 text.contains(&format!(
519 "light = {{ version = \"{LIGHT_DEPENDENCY_VERSION}\", features = [\"elements-form\", \"validation\"] }}"
520 )),
521 "{text}"
522 );
523 assert_eq!(text.matches("light-fw-element-form =").count(), 1, "{text}");
524 assert_eq!(
525 text.matches("light-fw-behavior-validation =").count(),
526 1,
527 "{text}"
528 );
529 }
530
531 #[test]
532 fn main_rs_is_self_contained() {
533 let text = main_rs(&[]);
534 assert!(text.contains("Hello, Light!"));
535 assert!(!text.contains("include_str!"));
536 assert!(!text.contains("#[template"));
537 assert!(!text.contains("Пример формы"));
538 }
539
540 #[test]
541 fn main_rs_includes_form_example_only_for_form_component() {
542 let text = main_rs(&[InitComponent::Form]);
543 assert!(text.contains("Пример формы"), "{text}");
544 let validation_only = main_rs(&[InitComponent::Validation]);
545 assert!(
546 !validation_only.contains("Пример формы"),
547 "{validation_only}"
548 );
549 }
550
551 #[test]
552 fn require_cargo_reports_missing_tool() {
553 let error = require_cargo(&StubRunner { found: false }).expect_err("нет cargo");
554 assert!(error.to_string().contains("cargo"), "{error}");
555 assert!(require_cargo(&StubRunner { found: true }).is_ok());
556 }
557
558 #[test]
559 fn apply_creates_project_and_lockfile() {
560 let root = scratch("apply");
561 let dir = root.join("my-app");
562 let options = InitOptions {
563 name: "my-app".to_string(),
564 path: Some(dir.clone()),
565 minimal: false,
566 components: Vec::new(),
567 };
568 let report = apply(&StubRunner { found: true }, &options).expect("apply");
569 assert!(report.lockfile);
570 assert!(dir.join("Cargo.toml").is_file());
571 assert!(dir.join("src").join("main.rs").is_file());
572 assert!(dir.join(".gitignore").is_file());
573 assert!(dir.join(LOCKFILE_NAME).is_file());
574
575 let lock = Lockfile::read(&dir).expect("read lock");
576 assert_eq!(lock.core, LIGHT_DEPENDENCY_VERSION);
577 }
578
579 #[test]
580 fn apply_minimal_skips_lockfile() {
581 let root = scratch("apply-minimal");
582 let dir = root.join("tiny");
583 let options = InitOptions {
584 name: "tiny".to_string(),
585 path: Some(dir.clone()),
586 minimal: true,
587 components: Vec::new(),
588 };
589 let report = apply(&StubRunner { found: true }, &options).expect("apply");
590 assert!(!report.lockfile);
591 assert!(dir.join("Cargo.toml").is_file());
592 assert!(!dir.join(".gitignore").exists());
593 assert!(!dir.join(LOCKFILE_NAME).exists());
594 }
595
596 #[test]
597 fn apply_fails_without_cargo_before_writing() {
598 let root = scratch("apply-nocargo");
599 let dir = root.join("nope");
600 let options = InitOptions {
601 name: "nope".to_string(),
602 path: Some(dir.clone()),
603 minimal: false,
604 components: Vec::new(),
605 };
606 assert!(apply(&StubRunner { found: false }, &options).is_err());
607 assert!(!dir.exists(), "каталог не должен создаваться");
608 }
609}