Skip to main content

light_fw_cli_core/
init.rs

1//! Команда `light init`: создание скелета нового проекта Light.
2//!
3//! Модуль генерирует минимальный проект: `Cargo.toml` с зависимостью `light-fw`,
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-fw`, которую прописывает `light init`.
16pub const LIGHT_DEPENDENCY_VERSION: &str = env!("CARGO_PKG_VERSION");
17
18/// Minor-диапазон версии (`major.minor`), используемый в `Cargo.toml` нового
19/// проекта: пользователь всегда получает latest `major.minor.x`, не привязываясь
20/// к конкретному yanked-патчу.
21pub 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
28/// Содержимое `.gitignore` нового проекта.
29pub const GITIGNORE: &str = "target/\n";
30
31/// Алиасы `cargo dev` / `cargo prod`: отладочный режим (DevTools) и релиз.
32pub const CARGO_CONFIG: &str =
33    "[alias]\ndev = \"run --features light-fw/dev-mode\"\nprod = \"run --release\"\n";
34
35/// Конфигурация `light.config.json`: профили `dev` (DevTools на 9229, debug)
36/// и `prod` (DevTools выключен, error).
37pub 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/// Готовый пример, который `light init --template` кладёт в новый проект.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum InitTemplate {
64    /// Счётчик с реактивным состоянием.
65    Counter,
66    /// Список задач с `v-model`, `v-if` и `v-for`.
67    Todo,
68    /// Калькулятор с обработчиками кнопок.
69    Calculator,
70}
71
72impl InitTemplate {
73    /// Все поддерживаемые шаблоны.
74    pub const ALL: [Self; 3] = [Self::Counter, Self::Todo, Self::Calculator];
75
76    /// Разбирает имя шаблона из `--template`.
77    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    /// Содержимое `src/main.rs` шаблона.
89    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    /// Дополнительные HTML-шаблоны: путь в проекте и содержимое.
98    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/// Дополнительный компонент, подключаемый флагом `--with`.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
117pub enum InitComponent {
118    /// `light-fw-element-form`: `<checkbox>`, `<radio>`, `<select>`/`<option>`.
119    Form,
120    /// `light-fw-behavior-validation`: `required`, `:validator`, `@validate`.
121    Validation,
122}
123
124impl InitComponent {
125    /// Все поддерживаемые компоненты в порядке вывода в `Cargo.toml`.
126    pub const ALL: [Self; 2] = [Self::Form, Self::Validation];
127
128    /// Разбирает имя компонента из `--with`.
129    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    /// Имя крейта-компонента.
140    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    /// Фича мета-крейта `light-fw`, включающая компонент.
148    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/// Параметры команды `light init`.
165#[derive(Debug, Clone)]
166pub struct InitOptions {
167    /// Имя проекта (оно же имя крейта).
168    pub name: String,
169    /// Каталог проекта; по умолчанию — `./<name>`.
170    pub path: Option<PathBuf>,
171    /// Только `Cargo.toml` и `src/main.rs` (без `.gitignore` и `light.lock`).
172    pub minimal: bool,
173    /// Дополнительные компоненты (`--with`).
174    pub components: Vec<InitComponent>,
175    /// Готовый шаблон приложения (`--template`).
176    pub template: Option<InitTemplate>,
177}
178
179/// Что будет создано (результат [`plan`]).
180#[derive(Debug, Clone)]
181pub struct InitPlan {
182    /// Каталог проекта.
183    pub directory: PathBuf,
184    /// Файлы проекта: относительный путь и содержимое.
185    pub files: Vec<(PathBuf, String)>,
186    /// Создавать ли `light.lock`.
187    pub write_lockfile: bool,
188}
189
190impl InitPlan {
191    /// Человекочитаемый план.
192    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/// Итог создания проекта.
205#[derive(Debug, Clone)]
206pub struct InitReport {
207    /// Каталог проекта.
208    pub directory: PathBuf,
209    /// Созданные файлы (относительные пути).
210    pub files: Vec<PathBuf>,
211    /// Создан ли `light.lock`.
212    pub lockfile: bool,
213}
214
215impl InitReport {
216    /// Человекочитаемый отчёт с подсказкой о запуске.
217    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
233/// Проверяет, что `name` — допустимое имя крейта.
234///
235/// Разрешены латинские буквы, цифры, `-` и `_`; первый символ — буква или `_`.
236pub 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
260/// Целевой каталог проекта из параметров.
261pub fn project_dir(options: &InitOptions) -> PathBuf {
262    match &options.path {
263        Some(path) => path.clone(),
264        None => PathBuf::from(&options.name),
265    }
266}
267
268/// Компоненты без дублей в порядке [`InitComponent::ALL`].
269fn normalized_components(components: &[InitComponent]) -> Vec<InitComponent> {
270    InitComponent::ALL
271        .into_iter()
272        .filter(|component| components.contains(component))
273        .collect()
274}
275
276/// Содержимое `Cargo.toml` нового проекта.
277/// Шаблон-заглушка для проекта без `--template`: минимальное окно с
278/// приветствием, центрированным в контентной области.
279const 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
286/// Содержимое `Cargo.toml` для нового проекта: зависимости `light-fw`,
287/// компоненты и фича `dev-mode` (алиас `light-fw/dev-mode`).
288pub 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    // Фича `dev-mode` — алиас для `light-fw/dev-mode` (runtime-templates, hot
317    // reload). Необходима для `light dev` и `.cargo/config.toml`.
318    text.push_str("\n[features]\ndev-mode = [\"light-fw/dev-mode\"]\n");
319    // ТЗ 0.16.1, задача 2: пустой `[workspace]` в самом конце — чтобы проект
320    // не «попадал» в чужой workspace, если создан внутри папки с Cargo.toml
321    // («current package believes it's in a workspace when it's not»).
322    text.push_str("\n[workspace]\n");
323    text
324}
325
326/// Закомментированный пример формы для проектов с компонентом `form`.
327const 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
355/// Содержимое `src/main.rs` — минимальное приложение с HTML-шаблоном
356/// (`templates/app.html`) и пустой прокси-структурой.
357pub 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
378/// Строит план создания проекта, проверяя, что каталог свободен.
379///
380/// Ошибка, если каталог существует и не пуст.
381pub 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        // Алиасы `cargo dev` / `cargo prod` и конфигурация DevTools/логирования.
412        (
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        // Проект без `--template`: `main.rs` ссылается на `templates/app.html`.
424        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
440/// Проверяет, что `cargo` доступен в `PATH`.
441pub 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
451/// Записывает файлы плана на диск, создавая каталоги по мере необходимости.
452pub 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
463/// Полный цикл: проверка `cargo` → план → запись файлов → `light.lock`.
464pub 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        // main.rs использует `#[template]` (HTML-шаблон).
561        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        // Обёртка переименована: новый проект зависит от `light-fw`, а не `light`.
614        assert!(text.contains(&format!("light-fw = \"{mv}\"")));
615        assert!(!text.contains(&format!("light = \"{mv}\"")), "{text}");
616        // Фича `dev-mode` всегда присутствует (алиас `light-fw/dev-mode`).
617        assert!(text.contains("[features]"), "{text}");
618        assert!(
619            text.contains("dev-mode = [\"light-fw/dev-mode\"]"),
620            "{text}"
621        );
622        // ТЗ 0.16.1: в самом конце — пустой `[workspace]`.
623        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        // Мета-крейт — `light-fw` (не `light`).
631        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        // Мета-крейт — `light-fw` (не `light`).
656        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        // Cargo.toml + main.rs + шаблон + `.cargo/config.toml` + `light.config.json`.
735        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    /// Регрессия 0.17.1, задача 1: `light init` **никогда** не записывает
815    /// `default-features = false` в `Cargo.toml` нового проекта. Зависимость
816    /// `light-fw` должна остаться с дефолтными фичами (строка или inline-таблица
817    /// с `version`/`features`, но без `default-features = false`).
818    #[test]
819    fn cargo_toml_never_disables_default_features() {
820        // Без компонентов: `light-fw = "0.17"`.
821        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        // С компонентами: `light-fw = { version, features = [...] }` — тоже без
828        // `default-features = false`.
829        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        // Фичи компонентов при этом на месте.
838        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}