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;
9
10use crate::error::{Error, Result};
11use crate::lockfile::{LOCKFILE_NAME, Lockfile};
12use crate::process::CommandRunner;
13
14/// Версия мета-крейта `light`, которую прописывает `light init`.
15pub const LIGHT_DEPENDENCY_VERSION: &str = env!("CARGO_PKG_VERSION");
16
17/// Содержимое `.gitignore` нового проекта.
18pub const GITIGNORE: &str = "target/\n";
19
20/// Параметры команды `light init`.
21#[derive(Debug, Clone)]
22pub struct InitOptions {
23    /// Имя проекта (оно же имя крейта).
24    pub name: String,
25    /// Каталог проекта; по умолчанию — `./<name>`.
26    pub path: Option<PathBuf>,
27    /// Только `Cargo.toml` и `src/main.rs` (без `.gitignore` и `light.lock`).
28    pub minimal: bool,
29}
30
31/// Что будет создано (результат [`plan`]).
32#[derive(Debug, Clone)]
33pub struct InitPlan {
34    /// Каталог проекта.
35    pub directory: PathBuf,
36    /// Файлы проекта: относительный путь и содержимое.
37    pub files: Vec<(PathBuf, String)>,
38    /// Создавать ли `light.lock`.
39    pub write_lockfile: bool,
40}
41
42impl InitPlan {
43    /// Человекочитаемый план.
44    pub fn to_text(&self) -> String {
45        let mut out = format!("Проект в {}\n", self.directory.display());
46        for (path, _) in &self.files {
47            out.push_str(&format!("  + {}\n", path.display()));
48        }
49        if self.write_lockfile {
50            out.push_str(&format!("  + {}\n", LOCKFILE_NAME));
51        }
52        out
53    }
54}
55
56/// Итог создания проекта.
57#[derive(Debug, Clone)]
58pub struct InitReport {
59    /// Каталог проекта.
60    pub directory: PathBuf,
61    /// Созданные файлы (относительные пути).
62    pub files: Vec<PathBuf>,
63    /// Создан ли `light.lock`.
64    pub lockfile: bool,
65}
66
67impl InitReport {
68    /// Человекочитаемый отчёт с подсказкой о запуске.
69    pub fn to_text(&self) -> String {
70        let mut out = format!("✓ Проект создан в {}\n", self.directory.display());
71        for path in &self.files {
72            out.push_str(&format!("  {}\n", path.display()));
73        }
74        if self.lockfile {
75            out.push_str(&format!("  {}\n", LOCKFILE_NAME));
76        }
77        out.push_str(&format!(
78            "\nДалее: cd \"{}\" && cargo run\n",
79            self.directory.display()
80        ));
81        out
82    }
83}
84
85/// Проверяет, что `name` — допустимое имя крейта.
86///
87/// Разрешены латинские буквы, цифры, `-` и `_`; первый символ — буква или `_`.
88pub fn validate_name(name: &str) -> Result<()> {
89    if name.is_empty() || name.len() > 64 {
90        return Err(Error::Init(
91            "имя проекта должно быть длиной от 1 до 64 символов".to_string(),
92        ));
93    }
94    let mut chars = name.chars();
95    let first = chars.next().expect("непустое имя");
96    if !(first.is_ascii_alphabetic() || first == '_') {
97        return Err(Error::Init(format!(
98            "имя `{name}` должно начинаться с латинской буквы или `_`"
99        )));
100    }
101    if let Some(bad) = name
102        .chars()
103        .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
104    {
105        return Err(Error::Init(format!(
106            "недопустимый символ `{bad}` в имени `{name}` (разрешены латинские буквы, цифры, `-`, `_`)"
107        )));
108    }
109    Ok(())
110}
111
112/// Целевой каталог проекта из параметров.
113pub fn project_dir(options: &InitOptions) -> PathBuf {
114    match &options.path {
115        Some(path) => path.clone(),
116        None => PathBuf::from(&options.name),
117    }
118}
119
120/// Содержимое `Cargo.toml` нового проекта.
121pub fn cargo_toml(name: &str) -> String {
122    format!(
123        "[package]\n\
124         name = \"{name}\"\n\
125         version = \"0.1.0\"\n\
126         edition = \"2021\"\n\
127         \n\
128         [dependencies]\n\
129         light = \"{version}\"\n",
130        version = LIGHT_DEPENDENCY_VERSION,
131    )
132}
133
134/// Содержимое `src/main.rs` — минимальное приложение из одного окна.
135pub fn main_rs() -> &'static str {
136    r#"use light::prelude::*;
137
138struct App;
139
140impl Component for App {
141    fn render(&self) -> VElement {
142        VElement::container(
143            "",
144            NodeKind::Window,
145            vec![VElement::container(
146                "0",
147                NodeKind::Column,
148                vec![VElement::leaf(
149                    "0.0",
150                    NodeKind::Text {
151                        content: "Hello, Light!".to_string(),
152                    },
153                )],
154            )],
155        )
156        .with_window(WindowConfig {
157            title: "Hello, Light!".to_string(),
158            width: 320,
159            height: 200,
160            ..Default::default()
161        })
162    }
163}
164
165fn main() -> light::Result<()> {
166    light::run(App)
167}
168"#
169}
170
171/// Строит план создания проекта, проверяя, что каталог свободен.
172///
173/// Ошибка, если каталог существует и не пуст.
174pub fn plan(options: &InitOptions) -> Result<InitPlan> {
175    validate_name(&options.name)?;
176    let directory = project_dir(options);
177
178    if directory.is_dir() {
179        let mut entries = std::fs::read_dir(&directory)?;
180        if entries.next().is_some() {
181            return Err(Error::Init(format!(
182                "каталог `{}` не пуст — укажите другое имя или `--path`",
183                directory.display()
184            )));
185        }
186    } else if directory.exists() {
187        return Err(Error::Init(format!(
188            "`{}` существует и не является каталогом",
189            directory.display()
190        )));
191    }
192
193    let mut files = vec![
194        (PathBuf::from("Cargo.toml"), cargo_toml(&options.name)),
195        (PathBuf::from("src").join("main.rs"), main_rs().to_string()),
196    ];
197    if !options.minimal {
198        files.push((PathBuf::from(".gitignore"), GITIGNORE.to_string()));
199    }
200
201    Ok(InitPlan {
202        directory,
203        files,
204        write_lockfile: !options.minimal,
205    })
206}
207
208/// Проверяет, что `cargo` доступен в `PATH`.
209pub fn require_cargo(runner: &dyn CommandRunner) -> Result<()> {
210    match runner.run("cargo", &["--version".to_string()], None) {
211        Ok(_) => Ok(()),
212        Err(Error::CommandNotFound(_)) => Err(Error::Init(
213            "`cargo` не найден в PATH — установите Rust (https://rustup.rs)".to_string(),
214        )),
215        Err(error) => Err(error),
216    }
217}
218
219/// Записывает файлы плана на диск, создавая каталоги по мере необходимости.
220pub fn scaffold(plan: &InitPlan) -> Result<()> {
221    for (relative, content) in &plan.files {
222        let path = plan.directory.join(relative);
223        if let Some(parent) = path.parent() {
224            std::fs::create_dir_all(parent)?;
225        }
226        std::fs::write(&path, content)?;
227    }
228    Ok(())
229}
230
231/// Полный цикл: проверка `cargo` → план → запись файлов → `light.lock`.
232pub fn apply(runner: &dyn CommandRunner, options: &InitOptions) -> Result<InitReport> {
233    require_cargo(runner)?;
234    let plan = plan(options)?;
235    scaffold(&plan)?;
236
237    let lockfile = if plan.write_lockfile {
238        let lock = Lockfile::from_project(&plan.directory)?;
239        lock.write(&plan.directory)?;
240        true
241    } else {
242        false
243    };
244
245    Ok(InitReport {
246        directory: plan.directory,
247        files: plan.files.into_iter().map(|(path, _)| path).collect(),
248        lockfile,
249    })
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::process::CommandOutput;
256    use std::path::Path;
257    use std::sync::atomic::{AtomicU64, Ordering};
258
259    static COUNTER: AtomicU64 = AtomicU64::new(0);
260
261    fn scratch(name: &str) -> PathBuf {
262        let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
263        let dir = std::env::temp_dir().join(format!(
264            "light-fw-cli-init-{name}-{}-{unique}",
265            std::process::id()
266        ));
267        let _ = std::fs::remove_dir_all(&dir);
268        std::fs::create_dir_all(&dir).expect("scratch dir");
269        dir
270    }
271
272    struct StubRunner {
273        found: bool,
274    }
275
276    impl CommandRunner for StubRunner {
277        fn run(
278            &self,
279            program: &str,
280            _args: &[String],
281            _cwd: Option<&Path>,
282        ) -> Result<CommandOutput> {
283            if self.found {
284                Ok(CommandOutput {
285                    status: 0,
286                    stdout: "cargo 1.99.0".to_string(),
287                    stderr: String::new(),
288                })
289            } else {
290                Err(Error::CommandNotFound(program.to_string()))
291            }
292        }
293    }
294
295    #[test]
296    fn rejects_invalid_names() {
297        assert!(validate_name("my-app").is_ok());
298        assert!(validate_name("app_2").is_ok());
299        assert!(validate_name("").is_err());
300        assert!(validate_name("2app").is_err());
301        assert!(validate_name("my app").is_err());
302        assert!(validate_name("app!").is_err());
303    }
304
305    #[test]
306    fn plan_default_creates_all_files() {
307        let root = scratch("plan-default");
308        let dir = root.join("cool-app");
309        let options = InitOptions {
310            name: "cool-app".to_string(),
311            path: Some(dir.clone()),
312            minimal: false,
313        };
314        let plan = plan(&options).expect("plan");
315        let names: Vec<String> = plan
316            .files
317            .iter()
318            .map(|(path, _)| path.to_string_lossy().replace('\\', "/"))
319            .collect();
320        assert!(names.contains(&"Cargo.toml".to_string()));
321        assert!(names.contains(&"src/main.rs".to_string()));
322        assert!(names.contains(&".gitignore".to_string()));
323        assert!(plan.write_lockfile);
324    }
325
326    #[test]
327    fn plan_minimal_omits_gitignore_and_lockfile() {
328        let root = scratch("plan-minimal");
329        let options = InitOptions {
330            name: "tiny".to_string(),
331            path: Some(root.join("tiny")),
332            minimal: true,
333        };
334        let plan = plan(&options).expect("plan");
335        let names: Vec<String> = plan
336            .files
337            .iter()
338            .map(|(path, _)| path.to_string_lossy().replace('\\', "/"))
339            .collect();
340        assert!(!names.contains(&".gitignore".to_string()));
341        assert!(!plan.write_lockfile);
342    }
343
344    #[test]
345    fn plan_rejects_non_empty_directory() {
346        let root = scratch("plan-nonempty");
347        let target = root.join("busy");
348        std::fs::create_dir_all(&target).expect("dir");
349        std::fs::write(target.join("file.txt"), "x").expect("file");
350        let options = InitOptions {
351            name: "busy".to_string(),
352            path: Some(target),
353            minimal: false,
354        };
355        let error = plan(&options).expect_err("должна быть ошибка");
356        assert!(error.to_string().contains("не пуст"), "{error}");
357    }
358
359    #[test]
360    fn cargo_toml_pins_light_version() {
361        let text = cargo_toml("demo");
362        assert!(text.contains("name = \"demo\""));
363        assert!(text.contains(&format!("light = \"{LIGHT_DEPENDENCY_VERSION}\"")));
364    }
365
366    #[test]
367    fn main_rs_is_self_contained() {
368        let text = main_rs();
369        assert!(text.contains("Hello, Light!"));
370        assert!(!text.contains("include_str!"));
371        assert!(!text.contains("#[template"));
372    }
373
374    #[test]
375    fn require_cargo_reports_missing_tool() {
376        let error = require_cargo(&StubRunner { found: false }).expect_err("нет cargo");
377        assert!(error.to_string().contains("cargo"), "{error}");
378        assert!(require_cargo(&StubRunner { found: true }).is_ok());
379    }
380
381    #[test]
382    fn apply_creates_project_and_lockfile() {
383        let root = scratch("apply");
384        let dir = root.join("my-app");
385        let options = InitOptions {
386            name: "my-app".to_string(),
387            path: Some(dir.clone()),
388            minimal: false,
389        };
390        let report = apply(&StubRunner { found: true }, &options).expect("apply");
391        assert!(report.lockfile);
392        assert!(dir.join("Cargo.toml").is_file());
393        assert!(dir.join("src").join("main.rs").is_file());
394        assert!(dir.join(".gitignore").is_file());
395        assert!(dir.join(LOCKFILE_NAME).is_file());
396
397        let lock = Lockfile::read(&dir).expect("read lock");
398        assert_eq!(lock.core, LIGHT_DEPENDENCY_VERSION);
399    }
400
401    #[test]
402    fn apply_minimal_skips_lockfile() {
403        let root = scratch("apply-minimal");
404        let dir = root.join("tiny");
405        let options = InitOptions {
406            name: "tiny".to_string(),
407            path: Some(dir.clone()),
408            minimal: true,
409        };
410        let report = apply(&StubRunner { found: true }, &options).expect("apply");
411        assert!(!report.lockfile);
412        assert!(dir.join("Cargo.toml").is_file());
413        assert!(!dir.join(".gitignore").exists());
414        assert!(!dir.join(LOCKFILE_NAME).exists());
415    }
416
417    #[test]
418    fn apply_fails_without_cargo_before_writing() {
419        let root = scratch("apply-nocargo");
420        let dir = root.join("nope");
421        let options = InitOptions {
422            name: "nope".to_string(),
423            path: Some(dir.clone()),
424            minimal: false,
425        };
426        assert!(apply(&StubRunner { found: false }, &options).is_err());
427        assert!(!dir.exists(), "каталог не должен создаваться");
428    }
429}