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 fn minor_version() -> String {
22 LIGHT_DEPENDENCY_VERSION
23 .rsplit_once('.')
24 .map(|(prefix, _)| prefix.to_string())
25 .unwrap_or_else(|| LIGHT_DEPENDENCY_VERSION.to_string())
26}
27
28pub const GITIGNORE: &str = "target/\n";
30
31pub const CARGO_CONFIG: &str =
33 "[alias]\ndev = \"run --features light-fw/dev-mode\"\nprod = \"run --release\"\n";
34
35pub const LIGHT_CONFIG: &str = r#"{
38 "dev": {
39 "devtools": {
40 "enabled": true,
41 "port": 9229
42 },
43 "log_level": "debug"
44 },
45 "prod": {
46 "devtools": {
47 "enabled": false
48 },
49 "log_level": "error"
50 }
51}
52"#;
53
54const COUNTER_MAIN: &str = include_str!("../assets/counter/src/main.rs");
55const COUNTER_HTML: &str = include_str!("../assets/counter/templates/counter.html");
56const TODO_MAIN: &str = include_str!("../assets/todo/src/main.rs");
57const TODO_HTML: &str = include_str!("../assets/todo/templates/todo.html");
58const CALCULATOR_MAIN: &str = include_str!("../assets/calculator/src/main.rs");
59const CALCULATOR_HTML: &str = include_str!("../assets/calculator/templates/calculator.html");
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum InitTemplate {
64 Counter,
66 Todo,
68 Calculator,
70}
71
72impl InitTemplate {
73 pub const ALL: [Self; 3] = [Self::Counter, Self::Todo, Self::Calculator];
75
76 pub fn parse(value: &str) -> Result<Self> {
78 match value {
79 "counter" => Ok(Self::Counter),
80 "todo" => Ok(Self::Todo),
81 "calculator" => Ok(Self::Calculator),
82 other => Err(Error::Init(format!(
83 "неизвестный шаблон `{other}` (поддерживаются: counter, todo, calculator)"
84 ))),
85 }
86 }
87
88 pub fn main_rs(self) -> &'static str {
90 match self {
91 Self::Counter => COUNTER_MAIN,
92 Self::Todo => TODO_MAIN,
93 Self::Calculator => CALCULATOR_MAIN,
94 }
95 }
96
97 pub fn template_files(self) -> &'static [(&'static str, &'static str)] {
99 match self {
100 Self::Counter => &[("templates/counter.html", COUNTER_HTML)],
101 Self::Todo => &[("templates/todo.html", TODO_HTML)],
102 Self::Calculator => &[("templates/calculator.html", CALCULATOR_HTML)],
103 }
104 }
105}
106
107impl FromStr for InitTemplate {
108 type Err = Error;
109
110 fn from_str(value: &str) -> Result<Self> {
111 Self::parse(value)
112 }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
117pub enum InitComponent {
118 Form,
120 Validation,
122}
123
124impl InitComponent {
125 pub const ALL: [Self; 2] = [Self::Form, Self::Validation];
127
128 pub fn parse(value: &str) -> Result<Self> {
130 match value {
131 "form" => Ok(Self::Form),
132 "validation" => Ok(Self::Validation),
133 other => Err(Error::Init(format!(
134 "неизвестный компонент `{other}` (поддерживаются: form, validation)"
135 ))),
136 }
137 }
138
139 pub fn crate_name(self) -> &'static str {
141 match self {
142 Self::Form => "light-fw-element-form",
143 Self::Validation => "light-fw-behavior-validation",
144 }
145 }
146
147 pub fn light_feature(self) -> &'static str {
149 match self {
150 Self::Form => "elements-form",
151 Self::Validation => "validation",
152 }
153 }
154}
155
156impl FromStr for InitComponent {
157 type Err = Error;
158
159 fn from_str(value: &str) -> Result<Self> {
160 Self::parse(value)
161 }
162}
163
164#[derive(Debug, Clone)]
166pub struct InitOptions {
167 pub name: String,
169 pub path: Option<PathBuf>,
171 pub minimal: bool,
173 pub components: Vec<InitComponent>,
175 pub template: Option<InitTemplate>,
177}
178
179#[derive(Debug, Clone)]
181pub struct InitPlan {
182 pub directory: PathBuf,
184 pub files: Vec<(PathBuf, String)>,
186 pub write_lockfile: bool,
188}
189
190impl InitPlan {
191 pub fn to_text(&self) -> String {
193 let mut out = format!("Проект в {}\n", self.directory.display());
194 for (path, _) in &self.files {
195 out.push_str(&format!(" + {}\n", path.display()));
196 }
197 if self.write_lockfile {
198 out.push_str(&format!(" + {}\n", LOCKFILE_NAME));
199 }
200 out
201 }
202}
203
204#[derive(Debug, Clone)]
206pub struct InitReport {
207 pub directory: PathBuf,
209 pub files: Vec<PathBuf>,
211 pub lockfile: bool,
213}
214
215impl InitReport {
216 pub fn to_text(&self) -> String {
218 let mut out = format!("✓ Проект создан в {}\n", self.directory.display());
219 for path in &self.files {
220 out.push_str(&format!(" {}\n", path.display()));
221 }
222 if self.lockfile {
223 out.push_str(&format!(" {}\n", LOCKFILE_NAME));
224 }
225 out.push_str(&format!(
226 "\nДалее: cd \"{}\" && cargo run\n",
227 self.directory.display()
228 ));
229 out
230 }
231}
232
233pub fn validate_name(name: &str) -> Result<()> {
237 if name.is_empty() || name.len() > 64 {
238 return Err(Error::Init(
239 "имя проекта должно быть длиной от 1 до 64 символов".to_string(),
240 ));
241 }
242 let mut chars = name.chars();
243 let first = chars.next().expect("непустое имя");
244 if !(first.is_ascii_alphabetic() || first == '_') {
245 return Err(Error::Init(format!(
246 "имя `{name}` должно начинаться с латинской буквы или `_`"
247 )));
248 }
249 if let Some(bad) = name
250 .chars()
251 .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
252 {
253 return Err(Error::Init(format!(
254 "недопустимый символ `{bad}` в имени `{name}` (разрешены латинские буквы, цифры, `-`, `_`)"
255 )));
256 }
257 Ok(())
258}
259
260pub fn project_dir(options: &InitOptions) -> PathBuf {
262 match &options.path {
263 Some(path) => path.clone(),
264 None => PathBuf::from(&options.name),
265 }
266}
267
268fn normalized_components(components: &[InitComponent]) -> Vec<InitComponent> {
270 InitComponent::ALL
271 .into_iter()
272 .filter(|component| components.contains(component))
273 .collect()
274}
275
276const APP_HTML: &str = r#"<window title="Hello, Light!" width="480" height="320">
280 <div width="100%" height="100%" direction="vertical" justify-content="center" align-items="center" padding="16">
281 <h1>Hello, Light!</h1>
282 </div>
283</window>
284"#;
285
286pub fn cargo_toml(name: &str, components: &[InitComponent]) -> String {
289 let components = normalized_components(components);
290 let mut text = format!(
291 "[package]\n\
292 name = \"{name}\"\n\
293 version = \"0.1.0\"\n\
294 edition = \"2021\"\n\
295 \n\
296 [dependencies]\n"
297 );
298
299 if components.is_empty() {
300 text.push_str(&format!("light-fw = \"{}\"\n", minor_version()));
301 } else {
302 let features = components
303 .iter()
304 .map(|component| format!("\"{}\"", component.light_feature()))
305 .collect::<Vec<_>>()
306 .join(", ");
307 let v = minor_version();
308 text.push_str(&format!(
309 "light-fw = {{ version = \"{v}\", features = [{features}] }}\n"
310 ));
311 for component in &components {
312 text.push_str(&format!("{} = \"{v}\"\n", component.crate_name()));
313 }
314 }
315
316 text.push_str("\n[features]\ndev-mode = [\"light-fw/dev-mode\"]\n");
319 text.push_str("\n[workspace]\n");
323 text
324}
325
326const FORM_EXAMPLE: &str = r#"
328// Пример формы (компонент `form`):
329//
330// #[template("templates/form.html")]
331// #[proxy]
332// struct SignUp {
333// email: String,
334// agree: bool,
335// }
336//
337// impl SignUp {
338// fn submit(&self) {
339// if self.agree() {
340// println!("email: {}", self.email());
341// }
342// }
343// }
344//
345// templates/form.html:
346// <window title="Sign up" width="360" height="240">
347// <div direction="vertical" padding="12">
348// <text v-model="email" placeholder="Email" required />
349// <checkbox v-model="agree">Согласен с условиями</checkbox>
350// <button @click="submit" :disabled="!agree">Отправить</button>
351// </div>
352// </window>
353"#;
354
355pub fn main_rs(components: &[InitComponent]) -> String {
358 let mut text = r#"use light_fw::prelude::*;
359
360#[template("templates/app.html")]
361#[proxy]
362struct App {}
363
364fn main() -> light_fw::Result<()> {
365 light_fw::run_with_config("light.config.json", App::new())
366}
367"#
368 .to_string();
369
370 if components.contains(&InitComponent::Form) {
371 text.push_str(FORM_EXAMPLE);
372 }
373 text
374}
375
376pub fn plan(options: &InitOptions) -> Result<InitPlan> {
380 validate_name(&options.name)?;
381 let directory = project_dir(options);
382
383 if directory.is_dir() {
384 let mut entries = std::fs::read_dir(&directory)?;
385 if entries.next().is_some() {
386 return Err(Error::Init(format!(
387 "каталог `{}` не пуст — укажите другое имя или `--path`",
388 directory.display()
389 )));
390 }
391 } else if directory.exists() {
392 return Err(Error::Init(format!(
393 "`{}` существует и не является каталогом",
394 directory.display()
395 )));
396 }
397
398 let components = normalized_components(&options.components);
399 let main = match options.template {
400 Some(template) => template.main_rs().to_string(),
401 None => main_rs(&components),
402 };
403 let mut files = vec![
404 (
405 PathBuf::from("Cargo.toml"),
406 cargo_toml(&options.name, &components),
407 ),
408 (PathBuf::from("src").join("main.rs"), main),
409 (
411 PathBuf::from(".cargo").join("config.toml"),
412 CARGO_CONFIG.to_string(),
413 ),
414 (PathBuf::from("light.config.json"), LIGHT_CONFIG.to_string()),
415 ];
416 if let Some(template) = options.template {
417 for (path, content) in template.template_files() {
418 files.push((PathBuf::from(path), (*content).to_string()));
419 }
420 } else {
421 files.push((
423 PathBuf::from("templates").join("app.html"),
424 APP_HTML.to_string(),
425 ));
426 }
427 if !options.minimal {
428 files.push((PathBuf::from(".gitignore"), GITIGNORE.to_string()));
429 }
430
431 Ok(InitPlan {
432 directory,
433 files,
434 write_lockfile: !options.minimal,
435 })
436}
437
438pub fn require_cargo(runner: &dyn CommandRunner) -> Result<()> {
440 match runner.run("cargo", &["--version".to_string()], None) {
441 Ok(_) => Ok(()),
442 Err(Error::CommandNotFound(_)) => Err(Error::Init(
443 "`cargo` не найден в PATH — установите Rust (https://rustup.rs)".to_string(),
444 )),
445 Err(error) => Err(error),
446 }
447}
448
449pub fn scaffold(plan: &InitPlan) -> Result<()> {
451 for (relative, content) in &plan.files {
452 let path = plan.directory.join(relative);
453 if let Some(parent) = path.parent() {
454 std::fs::create_dir_all(parent)?;
455 }
456 std::fs::write(&path, content)?;
457 }
458 Ok(())
459}
460
461pub fn apply(runner: &dyn CommandRunner, options: &InitOptions) -> Result<InitReport> {
463 require_cargo(runner)?;
464 let plan = plan(options)?;
465 scaffold(&plan)?;
466
467 let lockfile = if plan.write_lockfile {
468 let lock = Lockfile::from_project(&plan.directory)?;
469 lock.write(&plan.directory)?;
470 true
471 } else {
472 false
473 };
474
475 Ok(InitReport {
476 directory: plan.directory,
477 files: plan.files.into_iter().map(|(path, _)| path).collect(),
478 lockfile,
479 })
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use crate::process::CommandOutput;
486 use std::path::Path;
487 use std::sync::atomic::{AtomicU64, Ordering};
488
489 static COUNTER: AtomicU64 = AtomicU64::new(0);
490
491 fn scratch(name: &str) -> PathBuf {
492 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
493 let dir = std::env::temp_dir().join(format!(
494 "light-fw-cli-init-{name}-{}-{unique}",
495 std::process::id()
496 ));
497 let _ = std::fs::remove_dir_all(&dir);
498 std::fs::create_dir_all(&dir).expect("scratch dir");
499 dir
500 }
501
502 struct StubRunner {
503 found: bool,
504 }
505
506 impl CommandRunner for StubRunner {
507 fn run(
508 &self,
509 program: &str,
510 _args: &[String],
511 _cwd: Option<&Path>,
512 ) -> Result<CommandOutput> {
513 if self.found {
514 Ok(CommandOutput {
515 status: 0,
516 stdout: "cargo 1.99.0".to_string(),
517 stderr: String::new(),
518 })
519 } else {
520 Err(Error::CommandNotFound(program.to_string()))
521 }
522 }
523 }
524
525 #[test]
526 fn rejects_invalid_names() {
527 assert!(validate_name("my-app").is_ok());
528 assert!(validate_name("app_2").is_ok());
529 assert!(validate_name("").is_err());
530 assert!(validate_name("2app").is_err());
531 assert!(validate_name("my app").is_err());
532 assert!(validate_name("app!").is_err());
533 }
534
535 #[test]
536 fn plan_default_creates_all_files() {
537 let root = scratch("plan-default");
538 let dir = root.join("cool-app");
539 let options = InitOptions {
540 name: "cool-app".to_string(),
541 path: Some(dir.clone()),
542 minimal: false,
543 components: Vec::new(),
544 template: None,
545 };
546 let plan = plan(&options).expect("plan");
547 let names: Vec<String> = plan
548 .files
549 .iter()
550 .map(|(path, _)| path.to_string_lossy().replace('\\', "/"))
551 .collect();
552 assert!(names.contains(&"Cargo.toml".to_string()));
553 assert!(names.contains(&"src/main.rs".to_string()));
554 assert!(names.contains(&"templates/app.html".to_string()));
555 assert!(names.contains(&".gitignore".to_string()));
556 assert!(names.contains(&".cargo/config.toml".to_string()));
557 assert!(names.contains(&"light.config.json".to_string()));
558 let main = plan
560 .files
561 .iter()
562 .find(|(path, _)| path.to_string_lossy().replace('\\', "/") == "src/main.rs")
563 .map(|(_, c)| c.as_str())
564 .expect("main.rs in plan");
565 assert!(main.contains("#[template"), "{main}");
566 assert!(plan.write_lockfile);
567 }
568
569 #[test]
570 fn plan_minimal_omits_gitignore_and_lockfile() {
571 let root = scratch("plan-minimal");
572 let options = InitOptions {
573 name: "tiny".to_string(),
574 path: Some(root.join("tiny")),
575 minimal: true,
576 components: Vec::new(),
577 template: None,
578 };
579 let plan = plan(&options).expect("plan");
580 let names: Vec<String> = plan
581 .files
582 .iter()
583 .map(|(path, _)| path.to_string_lossy().replace('\\', "/"))
584 .collect();
585 assert!(!names.contains(&".gitignore".to_string()));
586 assert!(!plan.write_lockfile);
587 }
588
589 #[test]
590 fn plan_rejects_non_empty_directory() {
591 let root = scratch("plan-nonempty");
592 let target = root.join("busy");
593 std::fs::create_dir_all(&target).expect("dir");
594 std::fs::write(target.join("file.txt"), "x").expect("file");
595 let options = InitOptions {
596 name: "busy".to_string(),
597 path: Some(target),
598 minimal: false,
599 components: Vec::new(),
600 template: None,
601 };
602 let error = plan(&options).expect_err("должна быть ошибка");
603 assert!(error.to_string().contains("не пуст"), "{error}");
604 }
605
606 #[test]
607 fn cargo_toml_pins_light_version() {
608 let text = cargo_toml("demo", &[]);
609 assert!(text.contains("name = \"demo\""));
610 let mv = minor_version();
611 assert!(text.contains(&format!("light-fw = \"{mv}\"")));
613 assert!(!text.contains(&format!("light = \"{mv}\"")), "{text}");
614 assert!(text.contains("[features]"), "{text}");
616 assert!(
617 text.contains("dev-mode = [\"light-fw/dev-mode\"]"),
618 "{text}"
619 );
620 assert!(text.trim_end().ends_with("[workspace]"), "{text}");
622 }
623
624 #[test]
625 fn cargo_toml_adds_form_dependency_and_feature() {
626 let text = cargo_toml("demo", &[InitComponent::Form]);
627 let mv = minor_version();
628 assert!(
630 text.contains(&format!(
631 "light-fw = {{ version = \"{mv}\", features = [\"elements-form\"] }}"
632 )),
633 "{text}"
634 );
635 assert!(
636 text.contains(&format!("light-fw-element-form = \"{mv}\"")),
637 "{text}"
638 );
639 assert!(!text.contains("light-fw-behavior-validation"), "{text}");
640 }
641
642 #[test]
643 fn cargo_toml_adds_both_dependencies_and_deduplicates() {
644 let text = cargo_toml(
645 "demo",
646 &[
647 InitComponent::Validation,
648 InitComponent::Form,
649 InitComponent::Form,
650 ],
651 );
652 let mv = minor_version();
653 assert!(
655 text.contains(&format!(
656 "light-fw = {{ version = \"{mv}\", features = [\"elements-form\", \"validation\"] }}"
657 )),
658 "{text}"
659 );
660 assert_eq!(text.matches("light-fw-element-form =").count(), 1, "{text}");
661 assert_eq!(
662 text.matches("light-fw-behavior-validation =").count(),
663 1,
664 "{text}"
665 );
666 }
667
668 #[test]
669 fn main_rs_uses_template_macro() {
670 let text = main_rs(&[]);
671 assert!(text.contains("#[template"), "{text}");
672 assert!(text.contains("templates/app.html"), "{text}");
673 assert!(text.contains("#[proxy]"), "{text}");
674 assert!(text.contains("App::new()"), "{text}");
675 assert!(text.contains("run_with_config"), "{text}");
676 assert!(!text.contains("include_str!"));
677 assert!(!text.contains("Пример формы"));
678 }
679
680 #[test]
681 fn main_rs_includes_form_example_only_for_form_component() {
682 let text = main_rs(&[InitComponent::Form]);
683 assert!(text.contains("Пример формы"), "{text}");
684 let validation_only = main_rs(&[InitComponent::Validation]);
685 assert!(
686 !validation_only.contains("Пример формы"),
687 "{validation_only}"
688 );
689 }
690
691 #[test]
692 fn parses_supported_templates() {
693 assert_eq!(
694 InitTemplate::parse("counter").expect("counter"),
695 InitTemplate::Counter
696 );
697 assert_eq!(
698 InitTemplate::parse("todo").expect("todo"),
699 InitTemplate::Todo
700 );
701 assert_eq!(
702 InitTemplate::parse("calculator").expect("calculator"),
703 InitTemplate::Calculator
704 );
705 let error = InitTemplate::parse("unknown").expect_err("unknown template");
706 assert!(error.to_string().contains("counter"), "{error}");
707 }
708
709 #[test]
710 fn template_plan_includes_main_and_html() {
711 let root = scratch("plan-template");
712 let options = InitOptions {
713 name: "todo-app".to_string(),
714 path: Some(root.join("todo-app")),
715 minimal: true,
716 components: Vec::new(),
717 template: Some(InitTemplate::Todo),
718 };
719 let plan = plan(&options).expect("plan");
720 let by_path = |name: &str| {
721 plan.files
722 .iter()
723 .find(|(path, _)| path.to_string_lossy().replace('\\', "/") == name)
724 .map(|(_, content)| content.as_str())
725 };
726 assert!(by_path("src/main.rs").expect("main").contains("TodoApp"));
727 assert!(
728 by_path("templates/todo.html")
729 .expect("todo template")
730 .contains("Light Todo")
731 );
732 assert_eq!(
734 plan.files.len(),
735 5,
736 "Cargo.toml + main.rs + template + cargo config + light config"
737 );
738 assert!(
739 by_path(".cargo/config.toml")
740 .expect("cargo config")
741 .contains("dev = \"run --features light-fw/dev-mode\"")
742 );
743 assert!(
744 by_path("light.config.json")
745 .expect("light config")
746 .contains("\"port\": 9229")
747 );
748 }
749
750 #[test]
751 fn require_cargo_reports_missing_tool() {
752 let error = require_cargo(&StubRunner { found: false }).expect_err("нет cargo");
753 assert!(error.to_string().contains("cargo"), "{error}");
754 assert!(require_cargo(&StubRunner { found: true }).is_ok());
755 }
756
757 #[test]
758 fn apply_creates_project_and_lockfile() {
759 let root = scratch("apply");
760 let dir = root.join("my-app");
761 let options = InitOptions {
762 name: "my-app".to_string(),
763 path: Some(dir.clone()),
764 minimal: false,
765 components: Vec::new(),
766 template: None,
767 };
768 let report = apply(&StubRunner { found: true }, &options).expect("apply");
769 assert!(report.lockfile);
770 assert!(dir.join("Cargo.toml").is_file());
771 assert!(dir.join("src").join("main.rs").is_file());
772 assert!(dir.join(".gitignore").is_file());
773 assert!(dir.join(LOCKFILE_NAME).is_file());
774
775 let lock = Lockfile::read(&dir).expect("read lock");
776 assert_eq!(lock.core, minor_version());
777 }
778
779 #[test]
780 fn apply_minimal_skips_lockfile() {
781 let root = scratch("apply-minimal");
782 let dir = root.join("tiny");
783 let options = InitOptions {
784 name: "tiny".to_string(),
785 path: Some(dir.clone()),
786 minimal: true,
787 components: Vec::new(),
788 template: None,
789 };
790 let report = apply(&StubRunner { found: true }, &options).expect("apply");
791 assert!(!report.lockfile);
792 assert!(dir.join("Cargo.toml").is_file());
793 assert!(!dir.join(".gitignore").exists());
794 assert!(!dir.join(LOCKFILE_NAME).exists());
795 }
796
797 #[test]
798 fn apply_fails_without_cargo_before_writing() {
799 let root = scratch("apply-nocargo");
800 let dir = root.join("nope");
801 let options = InitOptions {
802 name: "nope".to_string(),
803 path: Some(dir.clone()),
804 minimal: false,
805 components: Vec::new(),
806 template: None,
807 };
808 assert!(apply(&StubRunner { found: false }, &options).is_err());
809 assert!(!dir.exists(), "каталог не должен создаваться");
810 }
811
812 #[test]
817 fn cargo_toml_never_disables_default_features() {
818 let bare = cargo_toml("bare-app", &[]);
820 assert!(
821 !bare.contains("default-features"),
822 "bare project must not mention default-features:\n{bare}"
823 );
824
825 let with_components = cargo_toml(
828 "form-app",
829 &[InitComponent::Form, InitComponent::Validation],
830 );
831 assert!(
832 !with_components.contains("default-features"),
833 "project with --with components must not disable default features:\n{with_components}"
834 );
835 assert!(
837 with_components.contains("\"elements-form\""),
838 "missing elements-form feature"
839 );
840 assert!(
841 with_components.contains("\"validation\""),
842 "missing validation feature"
843 );
844 }
845}