Skip to main content

light_fw_cli_core/
init.rs

1//! Команда `light init`: создание скелета нового проекта Light.
2//!
3//! Модуль генерирует минимальный проект: `Cargo.toml` с зависимостью `light`,
4//! самодостаточный `src/main.rs` (окно с текстом «Hello, Light!»), `.gitignore`
5//! и `light.lock`. Внешние эффекты (проверка `cargo`) идут через трейт
6//! [`CommandRunner`], поэтому логика тестируется без запуска процессов.
7
8use std::path::PathBuf;
9use std::str::FromStr;
10
11use crate::error::{Error, Result};
12use crate::lockfile::{LOCKFILE_NAME, Lockfile};
13use crate::process::CommandRunner;
14
15/// Версия мета-крейта `light`, которую прописывает `light init`.
16pub const LIGHT_DEPENDENCY_VERSION: &str = env!("CARGO_PKG_VERSION");
17
18/// Содержимое `.gitignore` нового проекта.
19pub const GITIGNORE: &str = "target/\n";
20
21/// Дополнительный компонент, подключаемый флагом `--with`.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23pub enum InitComponent {
24    /// `light-fw-element-form`: `<checkbox>`, `<radio>`, `<select>`/`<option>`.
25    Form,
26    /// `light-fw-behavior-validation`: `required`, `:validator`, `@validate`.
27    Validation,
28}
29
30impl InitComponent {
31    /// Все поддерживаемые компоненты в порядке вывода в `Cargo.toml`.
32    pub const ALL: [Self; 2] = [Self::Form, Self::Validation];
33
34    /// Разбирает имя компонента из `--with`.
35    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    /// Имя крейта-компонента.
46    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    /// Фича мета-крейта `light`, включающая компонент.
54    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/// Параметры команды `light init`.
71#[derive(Debug, Clone)]
72pub struct InitOptions {
73    /// Имя проекта (оно же имя крейта).
74    pub name: String,
75    /// Каталог проекта; по умолчанию — `./<name>`.
76    pub path: Option<PathBuf>,
77    /// Только `Cargo.toml` и `src/main.rs` (без `.gitignore` и `light.lock`).
78    pub minimal: bool,
79    /// Дополнительные компоненты (`--with`).
80    pub components: Vec<InitComponent>,
81}
82
83/// Что будет создано (результат [`plan`]).
84#[derive(Debug, Clone)]
85pub struct InitPlan {
86    /// Каталог проекта.
87    pub directory: PathBuf,
88    /// Файлы проекта: относительный путь и содержимое.
89    pub files: Vec<(PathBuf, String)>,
90    /// Создавать ли `light.lock`.
91    pub write_lockfile: bool,
92}
93
94impl InitPlan {
95    /// Человекочитаемый план.
96    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/// Итог создания проекта.
109#[derive(Debug, Clone)]
110pub struct InitReport {
111    /// Каталог проекта.
112    pub directory: PathBuf,
113    /// Созданные файлы (относительные пути).
114    pub files: Vec<PathBuf>,
115    /// Создан ли `light.lock`.
116    pub lockfile: bool,
117}
118
119impl InitReport {
120    /// Человекочитаемый отчёт с подсказкой о запуске.
121    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
137/// Проверяет, что `name` — допустимое имя крейта.
138///
139/// Разрешены латинские буквы, цифры, `-` и `_`; первый символ — буква или `_`.
140pub 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
164/// Целевой каталог проекта из параметров.
165pub fn project_dir(options: &InitOptions) -> PathBuf {
166    match &options.path {
167        Some(path) => path.clone(),
168        None => PathBuf::from(&options.name),
169    }
170}
171
172/// Компоненты без дублей в порядке [`InitComponent::ALL`].
173fn normalized_components(components: &[InitComponent]) -> Vec<InitComponent> {
174    InitComponent::ALL
175        .into_iter()
176        .filter(|component| components.contains(component))
177        .collect()
178}
179
180/// Содержимое `Cargo.toml` нового проекта.
181pub 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
214/// Закомментированный пример формы для проектов с компонентом `form`.
215const 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
243/// Содержимое `src/main.rs` — минимальное приложение из одного окна.
244pub 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
286/// Строит план создания проекта, проверяя, что каталог свободен.
287///
288/// Ошибка, если каталог существует и не пуст.
289pub 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
327/// Проверяет, что `cargo` доступен в `PATH`.
328pub 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
338/// Записывает файлы плана на диск, создавая каталоги по мере необходимости.
339pub 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
350/// Полный цикл: проверка `cargo` → план → запись файлов → `light.lock`.
351pub 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}