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#"#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
359
360use light_fw::prelude::*;
361
362#[template("templates/app.html")]
363#[proxy]
364struct App {}
365
366fn main() -> light_fw::Result<()> {
367 light_fw::run_with_config("light.config.json", App::new())
368}
369"#
370 .to_string();
371
372 if components.contains(&InitComponent::Form) {
373 text.push_str(FORM_EXAMPLE);
374 }
375 text
376}
377
378pub fn plan(options: &InitOptions) -> Result<InitPlan> {
382 validate_name(&options.name)?;
383 let directory = project_dir(options);
384
385 if directory.is_dir() {
386 let mut entries = std::fs::read_dir(&directory)?;
387 if entries.next().is_some() {
388 return Err(Error::Init(format!(
389 "каталог `{}` не пуст — укажите другое имя или `--path`",
390 directory.display()
391 )));
392 }
393 } else if directory.exists() {
394 return Err(Error::Init(format!(
395 "`{}` существует и не является каталогом",
396 directory.display()
397 )));
398 }
399
400 let components = normalized_components(&options.components);
401 let main = match options.template {
402 Some(template) => template.main_rs().to_string(),
403 None => main_rs(&components),
404 };
405 let mut files = vec![
406 (
407 PathBuf::from("Cargo.toml"),
408 cargo_toml(&options.name, &components),
409 ),
410 (PathBuf::from("src").join("main.rs"), main),
411 (
413 PathBuf::from(".cargo").join("config.toml"),
414 CARGO_CONFIG.to_string(),
415 ),
416 (PathBuf::from("light.config.json"), LIGHT_CONFIG.to_string()),
417 ];
418 if let Some(template) = options.template {
419 for (path, content) in template.template_files() {
420 files.push((PathBuf::from(path), (*content).to_string()));
421 }
422 } else {
423 files.push((
425 PathBuf::from("templates").join("app.html"),
426 APP_HTML.to_string(),
427 ));
428 }
429 if !options.minimal {
430 files.push((PathBuf::from(".gitignore"), GITIGNORE.to_string()));
431 }
432
433 Ok(InitPlan {
434 directory,
435 files,
436 write_lockfile: !options.minimal,
437 })
438}
439
440pub fn require_cargo(runner: &dyn CommandRunner) -> Result<()> {
442 match runner.run("cargo", &["--version".to_string()], None) {
443 Ok(_) => Ok(()),
444 Err(Error::CommandNotFound(_)) => Err(Error::Init(
445 "`cargo` не найден в PATH — установите Rust (https://rustup.rs)".to_string(),
446 )),
447 Err(error) => Err(error),
448 }
449}
450
451pub fn scaffold(plan: &InitPlan) -> Result<()> {
453 for (relative, content) in &plan.files {
454 let path = plan.directory.join(relative);
455 if let Some(parent) = path.parent() {
456 std::fs::create_dir_all(parent)?;
457 }
458 std::fs::write(&path, content)?;
459 }
460 Ok(())
461}
462
463pub fn apply(runner: &dyn CommandRunner, options: &InitOptions) -> Result<InitReport> {
465 require_cargo(runner)?;
466 let plan = plan(options)?;
467 scaffold(&plan)?;
468
469 let lockfile = if plan.write_lockfile {
470 let lock = Lockfile::from_project(&plan.directory)?;
471 lock.write(&plan.directory)?;
472 true
473 } else {
474 false
475 };
476
477 Ok(InitReport {
478 directory: plan.directory,
479 files: plan.files.into_iter().map(|(path, _)| path).collect(),
480 lockfile,
481 })
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use crate::process::CommandOutput;
488 use std::path::Path;
489 use std::sync::atomic::{AtomicU64, Ordering};
490
491 static COUNTER: AtomicU64 = AtomicU64::new(0);
492
493 fn scratch(name: &str) -> PathBuf {
494 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
495 let dir = std::env::temp_dir().join(format!(
496 "light-fw-cli-init-{name}-{}-{unique}",
497 std::process::id()
498 ));
499 let _ = std::fs::remove_dir_all(&dir);
500 std::fs::create_dir_all(&dir).expect("scratch dir");
501 dir
502 }
503
504 struct StubRunner {
505 found: bool,
506 }
507
508 impl CommandRunner for StubRunner {
509 fn run(
510 &self,
511 program: &str,
512 _args: &[String],
513 _cwd: Option<&Path>,
514 ) -> Result<CommandOutput> {
515 if self.found {
516 Ok(CommandOutput {
517 status: 0,
518 stdout: "cargo 1.99.0".to_string(),
519 stderr: String::new(),
520 })
521 } else {
522 Err(Error::CommandNotFound(program.to_string()))
523 }
524 }
525 }
526
527 #[test]
528 fn rejects_invalid_names() {
529 assert!(validate_name("my-app").is_ok());
530 assert!(validate_name("app_2").is_ok());
531 assert!(validate_name("").is_err());
532 assert!(validate_name("2app").is_err());
533 assert!(validate_name("my app").is_err());
534 assert!(validate_name("app!").is_err());
535 }
536
537 #[test]
538 fn plan_default_creates_all_files() {
539 let root = scratch("plan-default");
540 let dir = root.join("cool-app");
541 let options = InitOptions {
542 name: "cool-app".to_string(),
543 path: Some(dir.clone()),
544 minimal: false,
545 components: Vec::new(),
546 template: None,
547 };
548 let plan = plan(&options).expect("plan");
549 let names: Vec<String> = plan
550 .files
551 .iter()
552 .map(|(path, _)| path.to_string_lossy().replace('\\', "/"))
553 .collect();
554 assert!(names.contains(&"Cargo.toml".to_string()));
555 assert!(names.contains(&"src/main.rs".to_string()));
556 assert!(names.contains(&"templates/app.html".to_string()));
557 assert!(names.contains(&".gitignore".to_string()));
558 assert!(names.contains(&".cargo/config.toml".to_string()));
559 assert!(names.contains(&"light.config.json".to_string()));
560 let main = plan
562 .files
563 .iter()
564 .find(|(path, _)| path.to_string_lossy().replace('\\', "/") == "src/main.rs")
565 .map(|(_, c)| c.as_str())
566 .expect("main.rs in plan");
567 assert!(main.contains("#[template"), "{main}");
568 assert!(plan.write_lockfile);
569 }
570
571 #[test]
572 fn plan_minimal_omits_gitignore_and_lockfile() {
573 let root = scratch("plan-minimal");
574 let options = InitOptions {
575 name: "tiny".to_string(),
576 path: Some(root.join("tiny")),
577 minimal: true,
578 components: Vec::new(),
579 template: None,
580 };
581 let plan = plan(&options).expect("plan");
582 let names: Vec<String> = plan
583 .files
584 .iter()
585 .map(|(path, _)| path.to_string_lossy().replace('\\', "/"))
586 .collect();
587 assert!(!names.contains(&".gitignore".to_string()));
588 assert!(!plan.write_lockfile);
589 }
590
591 #[test]
592 fn plan_rejects_non_empty_directory() {
593 let root = scratch("plan-nonempty");
594 let target = root.join("busy");
595 std::fs::create_dir_all(&target).expect("dir");
596 std::fs::write(target.join("file.txt"), "x").expect("file");
597 let options = InitOptions {
598 name: "busy".to_string(),
599 path: Some(target),
600 minimal: false,
601 components: Vec::new(),
602 template: None,
603 };
604 let error = plan(&options).expect_err("должна быть ошибка");
605 assert!(error.to_string().contains("не пуст"), "{error}");
606 }
607
608 #[test]
609 fn cargo_toml_pins_light_version() {
610 let text = cargo_toml("demo", &[]);
611 assert!(text.contains("name = \"demo\""));
612 let mv = minor_version();
613 assert!(text.contains(&format!("light-fw = \"{mv}\"")));
615 assert!(!text.contains(&format!("light = \"{mv}\"")), "{text}");
616 assert!(text.contains("[features]"), "{text}");
618 assert!(
619 text.contains("dev-mode = [\"light-fw/dev-mode\"]"),
620 "{text}"
621 );
622 assert!(text.trim_end().ends_with("[workspace]"), "{text}");
624 }
625
626 #[test]
627 fn cargo_toml_adds_form_dependency_and_feature() {
628 let text = cargo_toml("demo", &[InitComponent::Form]);
629 let mv = minor_version();
630 assert!(
632 text.contains(&format!(
633 "light-fw = {{ version = \"{mv}\", features = [\"elements-form\"] }}"
634 )),
635 "{text}"
636 );
637 assert!(
638 text.contains(&format!("light-fw-element-form = \"{mv}\"")),
639 "{text}"
640 );
641 assert!(!text.contains("light-fw-behavior-validation"), "{text}");
642 }
643
644 #[test]
645 fn cargo_toml_adds_both_dependencies_and_deduplicates() {
646 let text = cargo_toml(
647 "demo",
648 &[
649 InitComponent::Validation,
650 InitComponent::Form,
651 InitComponent::Form,
652 ],
653 );
654 let mv = minor_version();
655 assert!(
657 text.contains(&format!(
658 "light-fw = {{ version = \"{mv}\", features = [\"elements-form\", \"validation\"] }}"
659 )),
660 "{text}"
661 );
662 assert_eq!(text.matches("light-fw-element-form =").count(), 1, "{text}");
663 assert_eq!(
664 text.matches("light-fw-behavior-validation =").count(),
665 1,
666 "{text}"
667 );
668 }
669
670 #[test]
671 fn main_rs_uses_template_macro() {
672 let text = main_rs(&[]);
673 assert!(text.contains("#[template"), "{text}");
674 assert!(text.contains("templates/app.html"), "{text}");
675 assert!(text.contains("#[proxy]"), "{text}");
676 assert!(text.contains("App::new()"), "{text}");
677 assert!(text.contains("run_with_config"), "{text}");
678 assert!(!text.contains("include_str!"));
679 assert!(!text.contains("Пример формы"));
680 }
681
682 #[test]
683 fn main_rs_includes_form_example_only_for_form_component() {
684 let text = main_rs(&[InitComponent::Form]);
685 assert!(text.contains("Пример формы"), "{text}");
686 let validation_only = main_rs(&[InitComponent::Validation]);
687 assert!(
688 !validation_only.contains("Пример формы"),
689 "{validation_only}"
690 );
691 }
692
693 #[test]
694 fn parses_supported_templates() {
695 assert_eq!(
696 InitTemplate::parse("counter").expect("counter"),
697 InitTemplate::Counter
698 );
699 assert_eq!(
700 InitTemplate::parse("todo").expect("todo"),
701 InitTemplate::Todo
702 );
703 assert_eq!(
704 InitTemplate::parse("calculator").expect("calculator"),
705 InitTemplate::Calculator
706 );
707 let error = InitTemplate::parse("unknown").expect_err("unknown template");
708 assert!(error.to_string().contains("counter"), "{error}");
709 }
710
711 #[test]
712 fn template_plan_includes_main_and_html() {
713 let root = scratch("plan-template");
714 let options = InitOptions {
715 name: "todo-app".to_string(),
716 path: Some(root.join("todo-app")),
717 minimal: true,
718 components: Vec::new(),
719 template: Some(InitTemplate::Todo),
720 };
721 let plan = plan(&options).expect("plan");
722 let by_path = |name: &str| {
723 plan.files
724 .iter()
725 .find(|(path, _)| path.to_string_lossy().replace('\\', "/") == name)
726 .map(|(_, content)| content.as_str())
727 };
728 assert!(by_path("src/main.rs").expect("main").contains("TodoApp"));
729 assert!(
730 by_path("templates/todo.html")
731 .expect("todo template")
732 .contains("Light Todo")
733 );
734 assert_eq!(
736 plan.files.len(),
737 5,
738 "Cargo.toml + main.rs + template + cargo config + light config"
739 );
740 assert!(
741 by_path(".cargo/config.toml")
742 .expect("cargo config")
743 .contains("dev = \"run --features light-fw/dev-mode\"")
744 );
745 assert!(
746 by_path("light.config.json")
747 .expect("light config")
748 .contains("\"port\": 9229")
749 );
750 }
751
752 #[test]
753 fn require_cargo_reports_missing_tool() {
754 let error = require_cargo(&StubRunner { found: false }).expect_err("нет cargo");
755 assert!(error.to_string().contains("cargo"), "{error}");
756 assert!(require_cargo(&StubRunner { found: true }).is_ok());
757 }
758
759 #[test]
760 fn apply_creates_project_and_lockfile() {
761 let root = scratch("apply");
762 let dir = root.join("my-app");
763 let options = InitOptions {
764 name: "my-app".to_string(),
765 path: Some(dir.clone()),
766 minimal: false,
767 components: Vec::new(),
768 template: None,
769 };
770 let report = apply(&StubRunner { found: true }, &options).expect("apply");
771 assert!(report.lockfile);
772 assert!(dir.join("Cargo.toml").is_file());
773 assert!(dir.join("src").join("main.rs").is_file());
774 assert!(dir.join(".gitignore").is_file());
775 assert!(dir.join(LOCKFILE_NAME).is_file());
776
777 let lock = Lockfile::read(&dir).expect("read lock");
778 assert_eq!(lock.core, minor_version());
779 }
780
781 #[test]
782 fn apply_minimal_skips_lockfile() {
783 let root = scratch("apply-minimal");
784 let dir = root.join("tiny");
785 let options = InitOptions {
786 name: "tiny".to_string(),
787 path: Some(dir.clone()),
788 minimal: true,
789 components: Vec::new(),
790 template: None,
791 };
792 let report = apply(&StubRunner { found: true }, &options).expect("apply");
793 assert!(!report.lockfile);
794 assert!(dir.join("Cargo.toml").is_file());
795 assert!(!dir.join(".gitignore").exists());
796 assert!(!dir.join(LOCKFILE_NAME).exists());
797 }
798
799 #[test]
800 fn apply_fails_without_cargo_before_writing() {
801 let root = scratch("apply-nocargo");
802 let dir = root.join("nope");
803 let options = InitOptions {
804 name: "nope".to_string(),
805 path: Some(dir.clone()),
806 minimal: false,
807 components: Vec::new(),
808 template: None,
809 };
810 assert!(apply(&StubRunner { found: false }, &options).is_err());
811 assert!(!dir.exists(), "каталог не должен создаваться");
812 }
813
814 #[test]
819 fn cargo_toml_never_disables_default_features() {
820 let bare = cargo_toml("bare-app", &[]);
822 assert!(
823 !bare.contains("default-features"),
824 "bare project must not mention default-features:\n{bare}"
825 );
826
827 let with_components = cargo_toml(
830 "form-app",
831 &[InitComponent::Form, InitComponent::Validation],
832 );
833 assert!(
834 !with_components.contains("default-features"),
835 "project with --with components must not disable default features:\n{with_components}"
836 );
837 assert!(
839 with_components.contains("\"elements-form\""),
840 "missing elements-form feature"
841 );
842 assert!(
843 with_components.contains("\"validation\""),
844 "missing validation feature"
845 );
846 }
847}