Skip to main content

light_fw_cli_core/
add.rs

1//! Логика команды `light add`: план и применение добавления компонента.
2//!
3//! Разделение на [`plan_add`] (только чтение) и [`apply_add`] (мутации) даёт
4//! `--dry-run` «бесплатно» и делает проверки тестируемыми без записи на диск.
5//! Перед мутациями создаётся резервная копия; при любой ошибке (включая провал
6//! `cargo check`) проект восстанавливается.
7
8use std::path::Path;
9
10use crate::error::{Error, Result};
11use crate::fetch::{Catalog, DependencyEntry, ResolvedComponent, SourceKind};
12use crate::manifest::{
13    collect_dependency_details, has_package_section, read_cargo_toml, resolve_dependency_version,
14};
15use crate::process::CommandRunner;
16use crate::{backup, cargo as cargo_cmd, edit, lockfile::Lockfile};
17
18/// Опции команды `light add`.
19#[derive(Debug, Clone, Default)]
20pub struct AddOptions {
21    /// Явная версия (`--version`).
22    pub version: Option<String>,
23    /// Не задавать вопросов (`--yes`).
24    pub yes: bool,
25    /// Только показать план (`--dry-run`).
26    pub dry_run: bool,
27    /// Без сетевого доступа (`--offline`).
28    pub offline: bool,
29    /// Фича, к которой привязать зависимость (`--feature`).
30    pub feature: Option<String>,
31    /// Не запускать `cargo check` (`--no-verify`).
32    pub no_verify: bool,
33}
34
35/// Компонент, который нужно установить дополнительно (из `requires`).
36#[derive(Debug, Clone)]
37pub struct RequiredComponent {
38    /// Имя крейта.
39    pub name: String,
40    /// Как записать зависимость.
41    pub dependency: DependencyEntry,
42}
43
44/// Подготовленный план добавления компонента.
45#[derive(Debug, Clone)]
46pub struct AddPlan {
47    /// Разрешённый компонент.
48    pub component: ResolvedComponent,
49    /// Как записать зависимость.
50    pub dependency: DependencyEntry,
51    /// Фича, к которой привязывается зависимость.
52    pub feature: String,
53    /// Будет ли фича создана (иначе — уже существует).
54    pub creates_feature: bool,
55    /// Компонент уже установлен (повторный `add` — не ошибка).
56    pub already_installed: bool,
57    /// Дополнительные компоненты из `requires`.
58    pub requires: Vec<RequiredComponent>,
59}
60
61impl AddPlan {
62    /// Человекочитаемое описание плана.
63    pub fn to_text(&self) -> String {
64        let mut out = String::new();
65        out.push_str(&format!("План добавления {}\n", self.component.name));
66        let location = match &self.component.path {
67            Some(p) => format!(" ({})", p.display()),
68            None => String::new(),
69        };
70        out.push_str(&format!(
71            "  источник:    {}{}\n",
72            self.component.source.as_str(),
73            location,
74        ));
75        out.push_str(&format!("  версия:      {}\n", self.component.version));
76        if self.already_installed {
77            out.push_str("  статус:      уже установлен\n");
78            return out;
79        }
80        out.push_str(&format!(
81            "  фича:        {}{}\n",
82            self.feature,
83            if self.creates_feature {
84                " (будет создана)"
85            } else {
86                ""
87            }
88        ));
89        out.push_str(&format!(
90            "  зависимость: {}\n",
91            dependency_line(&self.component.name, &self.dependency)
92        ));
93        for req in &self.requires {
94            out.push_str(&format!(
95                "  также нужно: {} — {}\n",
96                req.name,
97                dependency_line(&req.name, &req.dependency)
98            ));
99        }
100        out
101    }
102}
103
104/// Результат применения плана.
105#[derive(Debug, Clone)]
106pub struct AddOutcome {
107    /// Установленный компонент.
108    pub component: ResolvedComponent,
109    /// Фича, к которой привязана зависимость.
110    pub feature: String,
111    /// Дополнительно установленные компоненты.
112    pub added_components: Vec<String>,
113    /// Запускалась ли проверка сборки.
114    pub verified: bool,
115}
116
117/// Строит план добавления компонента `name` в проект `project_root`.
118pub fn plan_add(project_root: &Path, name: &str, opts: &AddOptions) -> Result<AddPlan> {
119    let cargo_path = project_root.join("Cargo.toml");
120    if !cargo_path.is_file() {
121        return Err(Error::Manifest(format!(
122            "Cargo.toml не найден в {}",
123            project_root.display()
124        )));
125    }
126    let text = read_cargo_toml(&cargo_path)?;
127    if !has_package_section(&text)? {
128        return Err(Error::Manifest(
129            "Cargo.toml не содержит секции [package]: укажите каталог приложения".to_string(),
130        ));
131    }
132
133    let installed = collect_dependency_details(&text, project_root)?;
134    let installed_names: Vec<&str> = installed.iter().map(|d| d.name.as_str()).collect();
135
136    let catalog = Catalog::discover(project_root, opts.offline);
137
138    if installed_names.contains(&name) {
139        return already_installed_plan(project_root, &text, name, opts, &catalog);
140    }
141
142    let mut component = catalog.resolve(name)?;
143    if let Some(version) = &opts.version {
144        component.version = semver::Version::parse(version)?;
145    }
146
147    let core = resolve_dependency_version(project_root, "light-fw-core")?
148        .or(resolve_dependency_version(project_root, "light")?);
149    if let Some(core) = core
150        && !component.core.matches(&core)
151    {
152        return Err(Error::Incompatible {
153            component: name.to_string(),
154            reason: format!(
155                "требуется light-fw-core {}, в проекте {core}",
156                component.core
157            ),
158        });
159    }
160
161    for conflict in &component.conflicts {
162        if installed_names.iter().any(|n| n == conflict) {
163            return Err(Error::Conflict(format!(
164                "{name} несовместим с установленным {conflict}"
165            )));
166        }
167    }
168
169    let feature = match &opts.feature {
170        Some(feature) => feature.clone(),
171        None => choose_feature(&text, name)?,
172    };
173    let creates_feature = !edit::has_feature(&text, &feature)?;
174
175    let dependency = catalog.dependency_entry(&component)?;
176
177    let mut requires = Vec::new();
178    for requirement in &component.requires {
179        let (dep_name, _) = split_requirement(requirement);
180        if is_core_name(dep_name) || installed_names.contains(&dep_name) {
181            continue;
182        }
183        if requires
184            .iter()
185            .any(|r: &RequiredComponent| r.name == dep_name)
186        {
187            continue;
188        }
189        let resolved = catalog.resolve(dep_name)?;
190        requires.push(RequiredComponent {
191            name: dep_name.to_string(),
192            dependency: catalog.dependency_entry(&resolved)?,
193        });
194    }
195
196    Ok(AddPlan {
197        component,
198        dependency,
199        feature,
200        creates_feature,
201        already_installed: false,
202        requires,
203    })
204}
205
206/// План для уже установленного компонента: ничего не меняет, но и не ошибка.
207fn already_installed_plan(
208    project_root: &Path,
209    text: &str,
210    name: &str,
211    opts: &AddOptions,
212    catalog: &Catalog,
213) -> Result<AddPlan> {
214    let current = resolve_dependency_version(project_root, name)?;
215    if let (Some(requested), Some(current)) = (&opts.version, &current)
216        && current.to_string() != *requested
217    {
218        return Err(Error::Conflict(format!(
219            "{name} уже установлен (версия {current}), запрошена {requested}"
220        )));
221    }
222    let version = current.unwrap_or_else(|| semver::Version::new(0, 0, 0));
223    let info = catalog.registry().by_name(name);
224    let local = catalog.resolve_local(name)?;
225    let (source, path) = match &local {
226        Some(resolved) => (resolved.source, resolved.path.clone()),
227        None => (SourceKind::CratesIo, None),
228    };
229    let component = ResolvedComponent {
230        name: name.to_string(),
231        version: version.clone(),
232        core: info
233            .map(|i| i.core_requirement.clone())
234            .unwrap_or(semver::VersionReq::STAR),
235        provides: info.map(|i| i.provides.clone()).unwrap_or_default(),
236        requires: Vec::new(),
237        conflicts: Vec::new(),
238        source,
239        path,
240    };
241    let feature = match &opts.feature {
242        Some(feature) => feature.clone(),
243        None => choose_feature(text, name)?,
244    };
245    Ok(AddPlan {
246        component,
247        dependency: DependencyEntry::Version(version.to_string()),
248        feature,
249        creates_feature: false,
250        already_installed: true,
251        requires: Vec::new(),
252    })
253}
254
255/// Применяет план: мутирует проект и (при необходимости) проверяет сборку.
256pub fn apply_add(
257    project_root: &Path,
258    runner: &dyn CommandRunner,
259    plan: &AddPlan,
260    opts: &AddOptions,
261) -> Result<AddOutcome> {
262    if opts.dry_run || plan.already_installed {
263        return Ok(AddOutcome {
264            component: plan.component.clone(),
265            feature: plan.feature.clone(),
266            added_components: plan.requires.iter().map(|r| r.name.clone()).collect(),
267            verified: false,
268        });
269    }
270
271    let snapshot = backup::create(project_root)?;
272    match do_add(project_root, runner, plan, opts) {
273        Ok(outcome) => Ok(outcome),
274        Err(error) => {
275            backup::restore(&snapshot)?;
276            Err(error)
277        }
278    }
279}
280
281fn do_add(
282    project_root: &Path,
283    runner: &dyn CommandRunner,
284    plan: &AddPlan,
285    opts: &AddOptions,
286) -> Result<AddOutcome> {
287    let cargo_path = project_root.join("Cargo.toml");
288
289    add_dependency(project_root, runner, &plan.component.name, &plan.dependency)?;
290    let mut added_components = Vec::new();
291    for req in &plan.requires {
292        add_dependency(project_root, runner, &req.name, &req.dependency)?;
293        added_components.push(req.name.clone());
294    }
295
296    let text = read_cargo_toml(&cargo_path)?;
297    let mut doc = edit::parse(&text)?;
298    edit::add_dep_to_feature(&mut doc, &plan.feature, &plan.component.name)?;
299    for req in &plan.requires {
300        edit::add_dep_to_feature(&mut doc, &plan.feature, &req.name)?;
301    }
302    std::fs::write(&cargo_path, doc.to_string())?;
303
304    Lockfile::from_project(project_root)?.write(project_root)?;
305
306    let verified = !opts.no_verify;
307    if verified {
308        cargo_cmd::run_check(runner, project_root, false)?;
309    }
310
311    Ok(AddOutcome {
312        component: plan.component.clone(),
313        feature: plan.feature.clone(),
314        added_components,
315        verified,
316    })
317}
318
319fn add_dependency(
320    project_root: &Path,
321    runner: &dyn CommandRunner,
322    name: &str,
323    dependency: &DependencyEntry,
324) -> Result<()> {
325    let cargo_path = project_root.join("Cargo.toml");
326    match dependency {
327        DependencyEntry::Workspace => {
328            let text = read_cargo_toml(&cargo_path)?;
329            let mut doc = edit::parse(&text)?;
330            edit::set_workspace_optional(&mut doc, name)?;
331            std::fs::write(&cargo_path, doc.to_string())?;
332        }
333        DependencyEntry::Path(path) => {
334            cargo_cmd::run_add(runner, project_root, name, None, Some(path), true)?;
335        }
336        DependencyEntry::Version(version) => {
337            cargo_cmd::run_add(runner, project_root, name, Some(version), None, true)?;
338        }
339    }
340    Ok(())
341}
342
343fn dependency_line(name: &str, dependency: &DependencyEntry) -> String {
344    match dependency {
345        DependencyEntry::Workspace => format!("{name} = {{ workspace = true, optional = true }}"),
346        DependencyEntry::Path(path) => {
347            format!(
348                "{name} = {{ path = \"{}\", optional = true }}",
349                path.display()
350            )
351        }
352        DependencyEntry::Version(version) => {
353            format!("{name} = {{ version = \"{version}\", optional = true }}")
354        }
355    }
356}
357
358/// Имя фичи по умолчанию: `light-fw-element-form` → `element-form`.
359pub fn default_feature_name(name: &str) -> String {
360    name.strip_prefix("light-fw-").unwrap_or(name).to_string()
361}
362
363/// Выбирает фичу, к которой привязать компонент.
364///
365/// Порядок: фича `<component>` из манифеста → существующая агрегирующая фича
366/// (`dep:light-*`, предпочитая `full`) → новая фича [`default_feature_name`].
367fn choose_feature(text: &str, name: &str) -> Result<String> {
368    let default = default_feature_name(name);
369    if edit::has_feature(text, &default)? {
370        return Ok(default);
371    }
372    let aggregators = edit::features_with_light_component_dep(text)?;
373    if let Some(full) = aggregators.iter().find(|f| f.as_str() == "full") {
374        return Ok(full.clone());
375    }
376    if let Some(first) = aggregators.first() {
377        return Ok(first.clone());
378    }
379    Ok(default)
380}
381
382/// Разбирает `"light-fw-element-form ^0.3"` → `("light-fw-element-form", Some("^0.3"))`.
383fn split_requirement(requirement: &str) -> (&str, Option<&str>) {
384    match requirement.split_once(char::is_whitespace) {
385        Some((name, rest)) => (name, Some(rest.trim())),
386        None => (requirement, None),
387    }
388}
389
390fn is_core_name(name: &str) -> bool {
391    matches!(name, "light" | "light-fw-core")
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::process::CommandOutput;
398    use std::path::PathBuf;
399    use std::sync::Mutex;
400
401    #[derive(Default)]
402    struct MockRunner {
403        calls: Mutex<Vec<String>>,
404        fail_check: bool,
405    }
406
407    impl CommandRunner for MockRunner {
408        fn run(
409            &self,
410            program: &str,
411            args: &[String],
412            _cwd: Option<&Path>,
413        ) -> Result<CommandOutput> {
414            self.calls
415                .lock()
416                .expect("lock")
417                .push(format!("{program} {}", args.join(" ")));
418            if self.fail_check && args.first().map(String::as_str) == Some("check") {
419                return Ok(CommandOutput {
420                    status: 101,
421                    stdout: String::new(),
422                    stderr: "compile error".to_string(),
423                });
424            }
425            Ok(CommandOutput {
426                status: 0,
427                stdout: String::new(),
428                stderr: String::new(),
429            })
430        }
431    }
432
433    fn temp_dir(name: &str) -> PathBuf {
434        let dir = std::env::temp_dir().join(format!("light-fw-cli-add-{name}"));
435        let _ = std::fs::remove_dir_all(&dir);
436        std::fs::create_dir_all(&dir).expect("create temp dir");
437        dir
438    }
439
440    /// Создаёт мини-workspace с компонентом `light-fw-element-form`.
441    fn workspace_fixture(name: &str) -> PathBuf {
442        let root = temp_dir(name);
443        std::fs::write(
444            root.join("Cargo.toml"),
445            "[workspace]\nmembers = [\"app\", \"comp\"]\nresolver = \"2\"\n\n\
446             [workspace.dependencies]\nlight-fw-element-form = { path = \"comp\", version = \"0.1.0\" }\n",
447        )
448        .expect("root manifest");
449        let app = root.join("app");
450        std::fs::create_dir_all(&app).expect("app");
451        std::fs::write(
452            app.join("Cargo.toml"),
453            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
454             [dependencies]\nlight = \"0.1\"\n",
455        )
456        .expect("app manifest");
457        let comp = root.join("comp");
458        std::fs::create_dir_all(comp.join("src")).expect("comp src");
459        std::fs::write(comp.join("src/lib.rs"), "").expect("comp lib");
460        std::fs::write(
461            comp.join("Cargo.toml"),
462            "[package]\nname = \"light-fw-element-form\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
463             [package.metadata.light]\nkind = \"element\"\ndisplay-name = \"Form\"\ncore = \"^0.1\"\n\n\
464             [package.metadata.light.provides]\nelements = [\"checkbox\"]\n",
465        )
466        .expect("comp manifest");
467        app
468    }
469
470    #[test]
471    fn plans_workspace_component() {
472        let app = workspace_fixture("plan");
473        let plan = plan_add(&app, "light-fw-element-form", &AddOptions::default()).expect("plan");
474        assert_eq!(plan.dependency, DependencyEntry::Workspace);
475        assert_eq!(plan.feature, "element-form");
476        assert!(plan.creates_feature);
477        assert!(plan.to_text().contains("light-fw-element-form"));
478    }
479
480    #[test]
481    fn already_installed_is_idempotent() {
482        let app = workspace_fixture("installed");
483        let opts = AddOptions {
484            no_verify: true,
485            ..Default::default()
486        };
487        let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
488        apply_add(&app, &MockRunner::default(), &plan, &opts).expect("apply");
489
490        let again = plan_add(&app, "light-fw-element-form", &opts).expect("idempotent");
491        assert!(again.already_installed);
492        assert_eq!(again.component.version, semver::Version::new(0, 1, 0));
493        assert_eq!(again.component.source, SourceKind::Workspace);
494        // Применение повторного плана ничего не меняет.
495        let before = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
496        apply_add(&app, &MockRunner::default(), &again, &opts).expect("re-apply");
497        assert_eq!(
498            std::fs::read_to_string(app.join("Cargo.toml")).expect("read"),
499            before
500        );
501    }
502
503    #[test]
504    fn already_installed_with_different_version_conflicts() {
505        let app = workspace_fixture("installed-conflict");
506        let opts = AddOptions {
507            no_verify: true,
508            ..Default::default()
509        };
510        let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
511        apply_add(&app, &MockRunner::default(), &plan, &opts).expect("apply");
512
513        let conflicting = AddOptions {
514            version: Some("0.2.0".to_string()),
515            ..opts
516        };
517        let err = plan_add(&app, "light-fw-element-form", &conflicting).expect_err("conflict");
518        assert!(matches!(err, Error::Conflict(_)));
519    }
520
521    #[test]
522    fn dry_run_does_not_touch_disk() {
523        let app = workspace_fixture("dry-run");
524        let original = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
525        let opts = AddOptions {
526            dry_run: true,
527            ..Default::default()
528        };
529        let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
530        apply_add(&app, &MockRunner::default(), &plan, &opts).expect("apply");
531        assert_eq!(
532            std::fs::read_to_string(app.join("Cargo.toml")).expect("read"),
533            original
534        );
535        assert!(!app.join("light.lock").exists());
536    }
537
538    #[test]
539    fn applies_workspace_dependency_and_feature() {
540        let app = workspace_fixture("apply");
541        let opts = AddOptions::default();
542        let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
543        let runner = MockRunner::default();
544        let outcome = apply_add(&app, &runner, &plan, &opts).expect("apply");
545        assert!(outcome.verified);
546
547        let text = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
548        assert!(text.contains("light-fw-element-form = { workspace = true, optional = true }"));
549        assert!(text.contains("[features]"));
550        assert!(text.contains("element-form = [\"dep:light-fw-element-form\"]"));
551        assert!(app.join("light.lock").exists());
552
553        let calls = runner.calls.lock().expect("lock").clone();
554        assert!(calls.iter().any(|c| c == "cargo check --quiet"));
555    }
556
557    #[test]
558    fn failed_check_rolls_back() {
559        let app = workspace_fixture("rollback");
560        let original = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
561        let opts = AddOptions::default();
562        let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
563        let runner = MockRunner {
564            fail_check: true,
565            ..Default::default()
566        };
567        let err = apply_add(&app, &runner, &plan, &opts).expect_err("check failed");
568        assert!(matches!(err, Error::CommandFailed { .. }));
569        assert_eq!(
570            std::fs::read_to_string(app.join("Cargo.toml")).expect("read"),
571            original,
572            "Cargo.toml восстановлен"
573        );
574        assert!(!app.join("light.lock").exists(), "light.lock откачен");
575    }
576
577    #[test]
578    fn default_feature_name_strips_prefix() {
579        assert_eq!(
580            default_feature_name("light-fw-element-form"),
581            "element-form"
582        );
583        assert_eq!(default_feature_name("custom-widget"), "custom-widget");
584    }
585
586    #[test]
587    fn choose_feature_prefers_own_then_aggregator_then_new() {
588        // Собственная фича компонента — приоритетнее агрегатора.
589        let with_own = "[features]\nfull = [\"dep:light-fw-element-form\"]\n\
590                        element-form = [\"dep:light-fw-element-form\"]\n";
591        assert_eq!(
592            choose_feature(with_own, "light-fw-element-form").unwrap(),
593            "element-form"
594        );
595        // Нет собственной — берём существующий агрегирующий (`full`).
596        let with_aggregator =
597            "[features]\ndefault = [\"full\"]\nfull = [\"dep:light-fw-element-form\"]\n";
598        assert_eq!(
599            choose_feature(with_aggregator, "light-fw-behavior-validation").unwrap(),
600            "full"
601        );
602        // Агрегаторов нет — создаём новую фичу по умолчанию.
603        assert_eq!(
604            choose_feature("[features]\nnone = []\n", "light-fw-behavior-validation").unwrap(),
605            "behavior-validation"
606        );
607    }
608}