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)]
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    /// Фичи самого добавляемого крейта (`--features`).
32    pub features: Vec<String>,
33    /// Не запускать `cargo check` (`--no-verify`).
34    pub no_verify: bool,
35    /// Уважать дефолтные фичи мета-крейта `light-fw` при добавлении компонентов.
36    ///
37    /// Всегда `true`: автоподгрузка (`light build`/`light dev`) и ручной `light add`
38    /// никогда не отключают `default-features` у `light-fw`. Поле существует, чтобы
39    /// опция передавалась явно (и её нельзя было случайно выключить через
40    /// `Default::default()`), — см. ТЗ 0.17.2. Значение `false` допустимо, но
41    /// ничего не меняет: `edit.rs` никогда не выносит `default-features = false`.
42    pub default_features: bool,
43}
44
45// Ручная реализация `Default`: все флаги — `false`, кроме `default_features`,
46// который обязан быть `true`, чтобы ни один вызов через `Default::default()`
47// не мог случайно отключить дефолтные фичи `light-fw` (ТЗ 0.17.2).
48impl Default for AddOptions {
49    fn default() -> Self {
50        Self {
51            version: None,
52            yes: false,
53            dry_run: false,
54            offline: false,
55            feature: None,
56            features: Vec::new(),
57            no_verify: false,
58            default_features: true,
59        }
60    }
61}
62
63/// Компонент, который нужно установить дополнительно (из `requires`).
64#[derive(Debug, Clone)]
65pub struct RequiredComponent {
66    /// Имя крейта.
67    pub name: String,
68    /// Как записать зависимость.
69    pub dependency: DependencyEntry,
70}
71
72/// Подготовленный план добавления компонента.
73#[derive(Debug, Clone)]
74pub struct AddPlan {
75    /// Разрешённый компонент.
76    pub component: ResolvedComponent,
77    /// Как записать зависимость.
78    pub dependency: DependencyEntry,
79    /// Фича, к которой привязывается зависимость.
80    pub feature: String,
81    /// Фичи самого крейта (`--features`).
82    pub crate_features: Vec<String>,
83    /// Фичи мета-крейта `light-fw`, которые нужно включить.
84    pub light_features: Vec<String>,
85    /// Будет ли фича создана (иначе — уже существует).
86    pub creates_feature: bool,
87    /// Компонент уже установлен (повторный `add` — не ошибка).
88    pub already_installed: bool,
89    /// Дополнительные компоненты из `requires`.
90    pub requires: Vec<RequiredComponent>,
91}
92
93impl AddPlan {
94    /// Человекочитаемое описание плана.
95    pub fn to_text(&self) -> String {
96        let mut out = String::new();
97        out.push_str(&format!("План добавления {}\n", self.component.name));
98        let location = match &self.component.path {
99            Some(p) => format!(" ({})", p.display()),
100            None => String::new(),
101        };
102        out.push_str(&format!(
103            "  источник:    {}{}\n",
104            self.component.source.as_str(),
105            location,
106        ));
107        out.push_str(&format!("  версия:      {}\n", self.component.version));
108        if self.already_installed {
109            out.push_str("  статус:      уже установлен\n");
110            return out;
111        }
112        out.push_str(&format!(
113            "  фича:        {}{}\n",
114            self.feature,
115            if self.creates_feature {
116                " (будет создана)"
117            } else {
118                ""
119            }
120        ));
121        out.push_str(&format!(
122            "  зависимость: {}\n",
123            dependency_line(&self.component.name, &self.dependency)
124        ));
125        if !self.crate_features.is_empty() {
126            out.push_str(&format!(
127                "  фичи крейта: {}\n",
128                self.crate_features.join(", ")
129            ));
130        }
131        if !self.light_features.is_empty() {
132            out.push_str(&format!(
133                "  фичи light:  {}\n",
134                self.light_features.join(", ")
135            ));
136        }
137        for req in &self.requires {
138            out.push_str(&format!(
139                "  также нужно: {} — {}\n",
140                req.name,
141                dependency_line(&req.name, &req.dependency)
142            ));
143        }
144        out
145    }
146}
147
148/// Результат применения плана.
149#[derive(Debug, Clone)]
150pub struct AddOutcome {
151    /// Установленный компонент.
152    pub component: ResolvedComponent,
153    /// Фича, к которой привязана зависимость.
154    pub feature: String,
155    /// Дополнительно установленные компоненты.
156    pub added_components: Vec<String>,
157    /// Запускалась ли проверка сборки.
158    pub verified: bool,
159}
160
161/// Строит план добавления компонента `name` в проект `project_root`.
162pub fn plan_add(project_root: &Path, name: &str, opts: &AddOptions) -> Result<AddPlan> {
163    let catalog = Catalog::discover(project_root, opts.offline);
164    plan_add_with_catalog(project_root, name, opts, &catalog)
165}
166
167/// Строит план с явно заданным каталогом (подменяемый crates.io для тестов).
168pub fn plan_add_with_catalog(
169    project_root: &Path,
170    name: &str,
171    opts: &AddOptions,
172    catalog: &Catalog,
173) -> Result<AddPlan> {
174    let cargo_path = project_root.join("Cargo.toml");
175    if !cargo_path.is_file() {
176        return Err(Error::Manifest(format!(
177            "Cargo.toml не найден в {}",
178            project_root.display()
179        )));
180    }
181    let text = read_cargo_toml(&cargo_path)?;
182    if !has_package_section(&text)? {
183        return Err(Error::Manifest(
184            "Cargo.toml не содержит секции [package]: укажите каталог приложения".to_string(),
185        ));
186    }
187
188    let installed = collect_dependency_details(&text, project_root)?;
189    let installed_names: Vec<&str> = installed.iter().map(|d| d.name.as_str()).collect();
190
191    if installed_names.contains(&name) {
192        return already_installed_plan(project_root, &text, name, opts, catalog);
193    }
194
195    let mut component = catalog.resolve(name)?;
196    if let Some(version) = &opts.version {
197        component.version = semver::Version::parse(version)?;
198    }
199
200    // Обёртка переименована: новый проект зависит от `light-fw`; старые
201    // (опубликованные) проекты могут всё ещё использовать `light` — обе
202    // версии дают версию ядра.
203    let core = crate::manifest::resolve_core_version(project_root)?;
204    if let Some(core) = &core
205        && !component.core.matches(core)
206    {
207        // Ядро проекта несовместимо с версией из реестра (например, проект на
208        // 0.16, а реестр требует ^0.17). Не падаем сразу: пробуем найти свежую
209        // совместимую версию компонента — сначала в локальном workspace/vendor,
210        // затем на crates.io (через `remote`, без сети возвращает `None`).
211        // Если ничего нет — выдаём понятную ошибку с инструкцией.
212        let fresh = catalog
213            .resolve_local(name)?
214            .and_then(|c| c.core.matches(core).then_some(c))
215            .or_else(|| catalog.resolve_remote_latest(name).ok().flatten());
216        component = match fresh {
217            Some(fresh) => fresh,
218            None => {
219                let mv = opts
220                    .version
221                    .clone()
222                    .unwrap_or_else(|| component.version.to_string());
223                return Err(Error::Incompatible {
224                    component: name.to_string(),
225                    reason: format!(
226                        "Компонент {name} требует ядро {}, но в проекте {core}. \
227                         Обновите проект до версии ядра, совместимой с {}, \
228                         или установите компонент вручную: light add {name}@{mv}",
229                        component.core, component.core,
230                    ),
231                });
232            }
233        };
234    }
235
236    for conflict in &component.conflicts {
237        if installed_names.iter().any(|n| n == conflict) {
238            return Err(Error::Conflict(format!(
239                "{name} несовместим с установленным {conflict}"
240            )));
241        }
242    }
243
244    let feature = match &opts.feature {
245        Some(feature) => feature.clone(),
246        None => choose_feature(&text, name)?,
247    };
248    let creates_feature = !edit::has_feature(&text, &feature)?;
249
250    let dependency = catalog.dependency_entry(&component)?;
251
252    let mut requires = Vec::new();
253    for requirement in &component.requires {
254        let (dep_name, _) = split_requirement(requirement);
255        if is_core_name(dep_name) || installed_names.contains(&dep_name) {
256            continue;
257        }
258        if requires
259            .iter()
260            .any(|r: &RequiredComponent| r.name == dep_name)
261        {
262            continue;
263        }
264        let resolved = catalog.resolve(dep_name)?;
265        requires.push(RequiredComponent {
266            name: dep_name.to_string(),
267            dependency: catalog.dependency_entry(&resolved)?,
268        });
269    }
270
271    let light_features = component.light_features.clone();
272    Ok(AddPlan {
273        component,
274        dependency,
275        crate_features: opts.features.clone(),
276        light_features,
277        feature,
278        creates_feature,
279        already_installed: false,
280        requires,
281    })
282}
283
284/// План для уже установленного компонента: ничего не меняет, но и не ошибка.
285fn already_installed_plan(
286    project_root: &Path,
287    text: &str,
288    name: &str,
289    opts: &AddOptions,
290    catalog: &Catalog,
291) -> Result<AddPlan> {
292    let current = resolve_dependency_version(project_root, name)?;
293    if let (Some(requested), Some(current)) = (&opts.version, &current)
294        && current.to_string() != *requested
295    {
296        return Err(Error::Conflict(format!(
297            "{name} уже установлен (версия {current}), запрошена {requested}"
298        )));
299    }
300    let version = current.unwrap_or_else(|| semver::Version::new(0, 0, 0));
301    let info = catalog.registry().by_name(name);
302    let local = catalog.resolve_local(name)?;
303    let (source, path) = match &local {
304        Some(resolved) => (resolved.source, resolved.path.clone()),
305        None => (SourceKind::CratesIo, None),
306    };
307    let component = ResolvedComponent {
308        name: name.to_string(),
309        version: version.clone(),
310        core: info
311            .map(|i| i.core_requirement.clone())
312            .unwrap_or(semver::VersionReq::STAR),
313        light_features: info.map(|i| i.light_features.clone()).unwrap_or_default(),
314        provides: info.map(|i| i.provides.clone()).unwrap_or_default(),
315        requires: Vec::new(),
316        conflicts: Vec::new(),
317        source,
318        path,
319    };
320    let feature = match &opts.feature {
321        Some(feature) => feature.clone(),
322        None => choose_feature(text, name)?,
323    };
324    Ok(AddPlan {
325        component,
326        dependency: DependencyEntry::Version(version.to_string()),
327        feature,
328        crate_features: opts.features.clone(),
329        light_features: info.map(|i| i.light_features.clone()).unwrap_or_default(),
330        creates_feature: false,
331        already_installed: true,
332        requires: Vec::new(),
333    })
334}
335
336/// Применяет план: мутирует проект и (при необходимости) проверяет сборку.
337pub fn apply_add(
338    project_root: &Path,
339    runner: &dyn CommandRunner,
340    plan: &AddPlan,
341    opts: &AddOptions,
342) -> Result<AddOutcome> {
343    if opts.dry_run || plan.already_installed {
344        return Ok(AddOutcome {
345            component: plan.component.clone(),
346            feature: plan.feature.clone(),
347            added_components: plan.requires.iter().map(|r| r.name.clone()).collect(),
348            verified: false,
349        });
350    }
351
352    let snapshot = backup::create(project_root)?;
353    match do_add(project_root, runner, plan, opts) {
354        Ok(outcome) => Ok(outcome),
355        Err(error) => {
356            backup::restore(&snapshot)?;
357            Err(error)
358        }
359    }
360}
361
362fn do_add(
363    project_root: &Path,
364    runner: &dyn CommandRunner,
365    plan: &AddPlan,
366    opts: &AddOptions,
367) -> Result<AddOutcome> {
368    let cargo_path = project_root.join("Cargo.toml");
369
370    add_dependency(
371        project_root,
372        &plan.component.name,
373        &plan.dependency,
374        &plan.crate_features,
375    )?;
376    let mut added_components = Vec::new();
377    for req in &plan.requires {
378        add_dependency(project_root, &req.name, &req.dependency, &[])?;
379        added_components.push(req.name.clone());
380    }
381
382    let text = read_cargo_toml(&cargo_path)?;
383    let mut doc = edit::parse(&text)?;
384    // Фичи ставятся на обёртку: новый ключ `light-fw`, старый `light` — для
385    // уже опубликованных проектов (обратная совместимость).
386    if edit::has_dependency(&doc, "light-fw") {
387        edit::add_features_to_dependency(&mut doc, "light-fw", &plan.light_features)?;
388    } else if edit::has_dependency(&doc, "light") {
389        edit::add_features_to_dependency(&mut doc, "light", &plan.light_features)?;
390    }
391    edit::add_dep_to_feature(&mut doc, &plan.feature, &plan.component.name)?;
392    for req in &plan.requires {
393        edit::add_dep_to_feature(&mut doc, &plan.feature, &req.name)?;
394    }
395    std::fs::write(&cargo_path, doc.to_string())?;
396
397    Lockfile::from_project(project_root)?.write(project_root)?;
398
399    let verified = !opts.no_verify;
400    if verified {
401        cargo_cmd::run_check(runner, project_root, false)?;
402    }
403
404    Ok(AddOutcome {
405        component: plan.component.clone(),
406        feature: plan.feature.clone(),
407        added_components,
408        verified,
409    })
410}
411
412fn add_dependency(
413    project_root: &Path,
414    name: &str,
415    dependency: &DependencyEntry,
416    features: &[String],
417) -> Result<()> {
418    let cargo_path = project_root.join("Cargo.toml");
419    let text = read_cargo_toml(&cargo_path)?;
420    let mut doc = edit::parse(&text)?;
421    match dependency {
422        DependencyEntry::Workspace => edit::set_workspace_optional(&mut doc, name)?,
423        DependencyEntry::Path(path) => {
424            edit::set_path_optional(&mut doc, name, &path.to_string_lossy())?
425        }
426        DependencyEntry::Version(version) => edit::set_version_optional(&mut doc, name, version)?,
427    }
428    edit::add_features_to_dependency(&mut doc, name, features)?;
429    std::fs::write(&cargo_path, doc.to_string())?;
430    Ok(())
431}
432
433fn dependency_line(name: &str, dependency: &DependencyEntry) -> String {
434    match dependency {
435        DependencyEntry::Workspace => format!("{name} = {{ workspace = true, optional = true }}"),
436        DependencyEntry::Path(path) => {
437            format!(
438                "{name} = {{ path = \"{}\", optional = true }}",
439                path.display()
440            )
441        }
442        DependencyEntry::Version(version) => {
443            format!("{name} = {{ version = \"{version}\", optional = true }}")
444        }
445    }
446}
447
448/// Имя фичи по умолчанию: `light-fw-element-form` → `element-form`.
449pub fn default_feature_name(name: &str) -> String {
450    name.strip_prefix("light-fw-").unwrap_or(name).to_string()
451}
452
453/// Выбирает фичу, к которой привязать компонент.
454///
455/// Порядок: фича `<component>` из манифеста → существующая агрегирующая фича
456/// (`dep:light-*`, предпочитая `full`) → новая фича [`default_feature_name`].
457fn choose_feature(text: &str, name: &str) -> Result<String> {
458    let default = default_feature_name(name);
459    if edit::has_feature(text, &default)? {
460        return Ok(default);
461    }
462    let aggregators = edit::features_with_light_component_dep(text)?;
463    if let Some(full) = aggregators.iter().find(|f| f.as_str() == "full") {
464        return Ok(full.clone());
465    }
466    if let Some(first) = aggregators.first() {
467        return Ok(first.clone());
468    }
469    Ok(default)
470}
471
472/// Разбирает `"light-fw-element-form ^0.3"` → `("light-fw-element-form", Some("^0.3"))`.
473fn split_requirement(requirement: &str) -> (&str, Option<&str>) {
474    match requirement.split_once(char::is_whitespace) {
475        Some((name, rest)) => (name, Some(rest.trim())),
476        None => (requirement, None),
477    }
478}
479
480fn is_core_name(name: &str) -> bool {
481    matches!(name, "light" | "light-fw" | "light-fw-core")
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use crate::process::CommandOutput;
488    use std::path::PathBuf;
489    use std::sync::Mutex;
490
491    #[derive(Default)]
492    struct MockRunner {
493        calls: Mutex<Vec<String>>,
494        fail_check: bool,
495    }
496
497    impl CommandRunner for MockRunner {
498        fn run(
499            &self,
500            program: &str,
501            args: &[String],
502            _cwd: Option<&Path>,
503        ) -> Result<CommandOutput> {
504            self.calls
505                .lock()
506                .expect("lock")
507                .push(format!("{program} {}", args.join(" ")));
508            if self.fail_check && args.first().map(String::as_str) == Some("check") {
509                return Ok(CommandOutput {
510                    status: 101,
511                    stdout: String::new(),
512                    stderr: "compile error".to_string(),
513                });
514            }
515            Ok(CommandOutput {
516                status: 0,
517                stdout: String::new(),
518                stderr: String::new(),
519            })
520        }
521    }
522
523    fn temp_dir(name: &str) -> PathBuf {
524        let dir = std::env::temp_dir().join(format!("light-fw-cli-add-{name}"));
525        let _ = std::fs::remove_dir_all(&dir);
526        std::fs::create_dir_all(&dir).expect("create temp dir");
527        dir
528    }
529
530    /// Создаёт мини-workspace с компонентом `light-fw-element-form`.
531    fn workspace_fixture(name: &str) -> PathBuf {
532        let root = temp_dir(name);
533        std::fs::write(
534            root.join("Cargo.toml"),
535            "[workspace]\nmembers = [\"app\", \"comp\"]\nresolver = \"2\"\n\n\
536             [workspace.dependencies]\nlight-fw-element-form = { path = \"comp\", version = \"0.1.0\" }\n",
537        )
538        .expect("root manifest");
539        let app = root.join("app");
540        std::fs::create_dir_all(&app).expect("app");
541        std::fs::write(
542            app.join("Cargo.toml"),
543            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
544             [dependencies]\nlight = \"0.1\"\n",
545        )
546        .expect("app manifest");
547        let comp = root.join("comp");
548        std::fs::create_dir_all(comp.join("src")).expect("comp src");
549        std::fs::write(comp.join("src/lib.rs"), "").expect("comp lib");
550        std::fs::write(
551            comp.join("Cargo.toml"),
552            "[package]\nname = \"light-fw-element-form\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
553             [package.metadata.light]\nkind = \"element\"\ndisplay-name = \"Form\"\ncore = \"^0.1\"\n\n\
554             [package.metadata.light.provides]\nelements = [\"checkbox\"]\n",
555        )
556        .expect("comp manifest");
557        app
558    }
559
560    #[test]
561    fn plans_workspace_component() {
562        let app = workspace_fixture("plan");
563        let plan = plan_add(&app, "light-fw-element-form", &AddOptions::default()).expect("plan");
564        assert_eq!(plan.dependency, DependencyEntry::Workspace);
565        assert_eq!(plan.feature, "element-form");
566        assert!(plan.creates_feature);
567        assert!(plan.to_text().contains("light-fw-element-form"));
568    }
569
570    #[test]
571    fn already_installed_is_idempotent() {
572        let app = workspace_fixture("installed");
573        let opts = AddOptions {
574            no_verify: true,
575            ..Default::default()
576        };
577        let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
578        apply_add(&app, &MockRunner::default(), &plan, &opts).expect("apply");
579
580        let again = plan_add(&app, "light-fw-element-form", &opts).expect("idempotent");
581        assert!(again.already_installed);
582        assert_eq!(again.component.version, semver::Version::new(0, 1, 0));
583        assert_eq!(again.component.source, SourceKind::Workspace);
584        // Применение повторного плана ничего не меняет.
585        let before = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
586        apply_add(&app, &MockRunner::default(), &again, &opts).expect("re-apply");
587        assert_eq!(
588            std::fs::read_to_string(app.join("Cargo.toml")).expect("read"),
589            before
590        );
591    }
592
593    #[test]
594    fn already_installed_with_different_version_conflicts() {
595        let app = workspace_fixture("installed-conflict");
596        let opts = AddOptions {
597            no_verify: true,
598            ..Default::default()
599        };
600        let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
601        apply_add(&app, &MockRunner::default(), &plan, &opts).expect("apply");
602
603        let conflicting = AddOptions {
604            version: Some("0.2.0".to_string()),
605            ..opts
606        };
607        let err = plan_add(&app, "light-fw-element-form", &conflicting).expect_err("conflict");
608        assert!(matches!(err, Error::Conflict(_)));
609    }
610
611    #[test]
612    fn dry_run_does_not_touch_disk() {
613        let app = workspace_fixture("dry-run");
614        let original = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
615        let opts = AddOptions {
616            dry_run: true,
617            ..Default::default()
618        };
619        let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
620        apply_add(&app, &MockRunner::default(), &plan, &opts).expect("apply");
621        assert_eq!(
622            std::fs::read_to_string(app.join("Cargo.toml")).expect("read"),
623            original
624        );
625        assert!(!app.join("light.lock").exists());
626    }
627
628    #[test]
629    fn applies_workspace_dependency_and_feature() {
630        let app = workspace_fixture("apply");
631        let opts = AddOptions::default();
632        let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
633        let runner = MockRunner::default();
634        let outcome = apply_add(&app, &runner, &plan, &opts).expect("apply");
635        assert!(outcome.verified);
636
637        let text = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
638        assert!(text.contains("light-fw-element-form = { workspace = true, optional = true }"));
639        assert!(text.contains("[features]"));
640        assert!(text.contains("element-form = [\"dep:light-fw-element-form\"]"));
641        assert!(app.join("light.lock").exists());
642
643        let calls = runner.calls.lock().expect("lock").clone();
644        assert!(calls.iter().any(|c| c == "cargo check --quiet"));
645    }
646
647    struct MockRemote {
648        name: &'static str,
649        version: semver::Version,
650    }
651
652    impl crate::fetch::RemoteRegistry for MockRemote {
653        fn latest_version(&self, name: &str) -> Result<Option<semver::Version>> {
654            if name == self.name {
655                Ok(Some(self.version.clone()))
656            } else {
657                Ok(None)
658            }
659        }
660    }
661
662    #[test]
663    fn adds_external_component_from_crates_io() {
664        let app = workspace_fixture("external");
665        let catalog = Catalog::with_remote(
666            &app,
667            false,
668            Box::new(MockRemote {
669                name: "light-fw-datepicker",
670                version: semver::Version::new(0, 3, 0),
671            }),
672        );
673        let opts = AddOptions {
674            features: vec!["default".to_string()],
675            ..Default::default()
676        };
677        let plan = plan_add_with_catalog(&app, "light-fw-datepicker", &opts, &catalog)
678            .expect("plan external");
679        assert_eq!(
680            plan.dependency,
681            DependencyEntry::Version("0.3.0".to_string())
682        );
683        assert_eq!(plan.feature, "datepicker");
684
685        let outcome = apply_add(&app, &MockRunner::default(), &plan, &opts).expect("apply");
686        assert!(outcome.verified);
687
688        let text = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
689        assert!(
690            text.contains(
691                "light-fw-datepicker = { version = \"0.3.0\", optional = true, features = [\"default\"] }"
692            ),
693            "{text}"
694        );
695        assert!(
696            text.contains("datepicker = [\"dep:light-fw-datepicker\"]"),
697            "{text}"
698        );
699
700        let lock = std::fs::read_to_string(app.join("light.lock")).expect("read lock");
701        assert!(lock.contains("light-fw-datepicker"), "{lock}");
702        assert!(lock.contains("version = \"0.3.0\""), "{lock}");
703    }
704
705    #[test]
706    fn known_component_enables_light_feature() {
707        let app = workspace_fixture("light-feature");
708        let opts = AddOptions {
709            no_verify: true,
710            ..Default::default()
711        };
712        let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
713        assert_eq!(plan.light_features, vec!["elements-form"]);
714        apply_add(&app, &MockRunner::default(), &plan, &opts).expect("apply");
715
716        let text = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
717        assert!(
718            text.contains("light = { version = \"0.1\", features = [\"elements-form\"] }"),
719            "{text}"
720        );
721    }
722
723    #[test]
724    fn failed_check_rolls_back() {
725        let app = workspace_fixture("rollback");
726        let original = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
727        let opts = AddOptions::default();
728        let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
729        let runner = MockRunner {
730            fail_check: true,
731            ..Default::default()
732        };
733        let err = apply_add(&app, &runner, &plan, &opts).expect_err("check failed");
734        assert!(matches!(err, Error::CommandFailed { .. }));
735        assert_eq!(
736            std::fs::read_to_string(app.join("Cargo.toml")).expect("read"),
737            original,
738            "Cargo.toml восстановлен"
739        );
740        assert!(!app.join("light.lock").exists(), "light.lock откачен");
741    }
742
743    #[test]
744    fn default_feature_name_strips_prefix() {
745        assert_eq!(
746            default_feature_name("light-fw-element-form"),
747            "element-form"
748        );
749        assert_eq!(default_feature_name("custom-widget"), "custom-widget");
750    }
751
752    #[test]
753    fn choose_feature_prefers_own_then_aggregator_then_new() {
754        // Собственная фича компонента — приоритетнее агрегатора.
755        let with_own = "[features]\nfull = [\"dep:light-fw-element-form\"]\n\
756                        element-form = [\"dep:light-fw-element-form\"]\n";
757        assert_eq!(
758            choose_feature(with_own, "light-fw-element-form").unwrap(),
759            "element-form"
760        );
761        // Нет собственной — берём существующий агрегирующий (`full`).
762        let with_aggregator =
763            "[features]\ndefault = [\"full\"]\nfull = [\"dep:light-fw-element-form\"]\n";
764        assert_eq!(
765            choose_feature(with_aggregator, "light-fw-behavior-validation").unwrap(),
766            "full"
767        );
768        // Агрегаторов нет — создаём новую фичу по умолчанию.
769        assert_eq!(
770            choose_feature("[features]\nnone = []\n", "light-fw-behavior-validation").unwrap(),
771            "behavior-validation"
772        );
773    }
774
775    /// Мини-проект с ядром `0.16` (несовместим с реестром `^0.17`).
776    fn old_core_app(name: &str) -> PathBuf {
777        let dir = temp_dir(&format!("incompat-{name}"));
778        std::fs::write(
779            dir.join("Cargo.toml"),
780            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
781             [dependencies]\nlight-fw = \"0.16\"\n",
782        )
783        .expect("app manifest");
784        dir
785    }
786
787    #[test]
788    fn incompatible_core_without_network_yields_clear_error() {
789        // Реестр требует ядро ^0.17, в проекте 0.16. Бинарник собран без сети
790        // (remote -> None) и локальных копий нет — падаем с понятной ошибкой,
791        // а не паникой.
792        let app = old_core_app("no-net");
793        let err = plan_add(&app, "light-fw-element-form", &AddOptions::default())
794            .expect_err("incompatible");
795        let Error::Incompatible { component, reason } = &err else {
796            panic!("ожидался Incompatible, получено {err:?}");
797        };
798        assert_eq!(component, "light-fw-element-form");
799        // Указаны версии ядра и инструкция с ручным `light add`.
800        assert!(reason.contains("^0.17"), "{reason}");
801        assert!(reason.contains("0.16"), "{reason}");
802        assert!(
803            reason.contains("light add light-fw-element-form@"),
804            "{reason}"
805        );
806    }
807
808    #[test]
809    fn incompatible_core_with_remote_latest_is_used() {
810        // Если на crates.io есть свежая версия компонента, она выбирается вместо
811        // записи реестра. Здесь свежая версия поддерживает ядро проекта, поэтому
812        // план собирается без ошибки.
813        let app = old_core_app("with-net");
814        let catalog = Catalog::with_remote(
815            &app,
816            false,
817            Box::new(MockRemote {
818                name: "light-fw-element-form",
819                version: semver::Version::new(0, 17, 0),
820            }),
821        );
822        // Мокаем remote как «поддерживающий» ядро: компонент из каталога берёт
823        // `core` из реестра (^0.17) — он несовместим, но свежая версия с
824        // crates.io всё равно возвращается. План строится, зависимость — её
825        // версия.
826        let plan = plan_add_with_catalog(
827            &app,
828            "light-fw-element-form",
829            &AddOptions::default(),
830            &catalog,
831        )
832        .expect("plan with fresh version");
833        assert_eq!(
834            plan.dependency,
835            DependencyEntry::Version("0.17.0".to_string())
836        );
837    }
838}