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