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#"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
376/// Строит план создания проекта, проверяя, что каталог свободен.
377///
378/// Ошибка, если каталог существует и не пуст.
379pub 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        // Алиасы `cargo dev` / `cargo prod` и конфигурация DevTools/логирования.
410        (
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        // Проект без `--template`: `main.rs` ссылается на `templates/app.html`.
422        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
438/// Проверяет, что `cargo` доступен в `PATH`.
439pub 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
449/// Записывает файлы плана на диск, создавая каталоги по мере необходимости.
450pub 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
461/// Полный цикл: проверка `cargo` → план → запись файлов → `light.lock`.
462pub 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        // main.rs использует `#[template]` (HTML-шаблон).
559        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        // Обёртка переименована: новый проект зависит от `light-fw`, а не `light`.
612        assert!(text.contains(&format!("light-fw = \"{mv}\"")));
613        assert!(!text.contains(&format!("light = \"{mv}\"")), "{text}");
614        // Фича `dev-mode` всегда присутствует (алиас `light-fw/dev-mode`).
615        assert!(text.contains("[features]"), "{text}");
616        assert!(
617            text.contains("dev-mode = [\"light-fw/dev-mode\"]"),
618            "{text}"
619        );
620        // ТЗ 0.16.1: в самом конце — пустой `[workspace]`.
621        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        // Мета-крейт — `light-fw` (не `light`).
629        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        // Мета-крейт — `light-fw` (не `light`).
654        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        // Cargo.toml + main.rs + шаблон + `.cargo/config.toml` + `light.config.json`.
733        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    /// Регрессия 0.17.1, задача 1: `light init` **никогда** не записывает
813    /// `default-features = false` в `Cargo.toml` нового проекта. Зависимость
814    /// `light-fw` должна остаться с дефолтными фичами (строка или inline-таблица
815    /// с `version`/`features`, но без `default-features = false`).
816    #[test]
817    fn cargo_toml_never_disables_default_features() {
818        // Без компонентов: `light-fw = "0.17"`.
819        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        // С компонентами: `light-fw = { version, features = [...] }` — тоже без
826        // `default-features = false`.
827        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        // Фичи компонентов при этом на месте.
836        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}