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`, которые нужно включить.
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    let core = resolve_dependency_version(project_root, "light-fw-core")?
175        .or(resolve_dependency_version(project_root, "light")?);
176    if let Some(core) = core
177        && !component.core.matches(&core)
178    {
179        return Err(Error::Incompatible {
180            component: name.to_string(),
181            reason: format!(
182                "требуется light-fw-core {}, в проекте {core}",
183                component.core
184            ),
185        });
186    }
187
188    for conflict in &component.conflicts {
189        if installed_names.iter().any(|n| n == conflict) {
190            return Err(Error::Conflict(format!(
191                "{name} несовместим с установленным {conflict}"
192            )));
193        }
194    }
195
196    let feature = match &opts.feature {
197        Some(feature) => feature.clone(),
198        None => choose_feature(&text, name)?,
199    };
200    let creates_feature = !edit::has_feature(&text, &feature)?;
201
202    let dependency = catalog.dependency_entry(&component)?;
203
204    let mut requires = Vec::new();
205    for requirement in &component.requires {
206        let (dep_name, _) = split_requirement(requirement);
207        if is_core_name(dep_name) || installed_names.contains(&dep_name) {
208            continue;
209        }
210        if requires
211            .iter()
212            .any(|r: &RequiredComponent| r.name == dep_name)
213        {
214            continue;
215        }
216        let resolved = catalog.resolve(dep_name)?;
217        requires.push(RequiredComponent {
218            name: dep_name.to_string(),
219            dependency: catalog.dependency_entry(&resolved)?,
220        });
221    }
222
223    let light_features = component.light_features.clone();
224    Ok(AddPlan {
225        component,
226        dependency,
227        crate_features: opts.features.clone(),
228        light_features,
229        feature,
230        creates_feature,
231        already_installed: false,
232        requires,
233    })
234}
235
236/// План для уже установленного компонента: ничего не меняет, но и не ошибка.
237fn already_installed_plan(
238    project_root: &Path,
239    text: &str,
240    name: &str,
241    opts: &AddOptions,
242    catalog: &Catalog,
243) -> Result<AddPlan> {
244    let current = resolve_dependency_version(project_root, name)?;
245    if let (Some(requested), Some(current)) = (&opts.version, &current)
246        && current.to_string() != *requested
247    {
248        return Err(Error::Conflict(format!(
249            "{name} уже установлен (версия {current}), запрошена {requested}"
250        )));
251    }
252    let version = current.unwrap_or_else(|| semver::Version::new(0, 0, 0));
253    let info = catalog.registry().by_name(name);
254    let local = catalog.resolve_local(name)?;
255    let (source, path) = match &local {
256        Some(resolved) => (resolved.source, resolved.path.clone()),
257        None => (SourceKind::CratesIo, None),
258    };
259    let component = ResolvedComponent {
260        name: name.to_string(),
261        version: version.clone(),
262        core: info
263            .map(|i| i.core_requirement.clone())
264            .unwrap_or(semver::VersionReq::STAR),
265        light_features: info.map(|i| i.light_features.clone()).unwrap_or_default(),
266        provides: info.map(|i| i.provides.clone()).unwrap_or_default(),
267        requires: Vec::new(),
268        conflicts: Vec::new(),
269        source,
270        path,
271    };
272    let feature = match &opts.feature {
273        Some(feature) => feature.clone(),
274        None => choose_feature(text, name)?,
275    };
276    Ok(AddPlan {
277        component,
278        dependency: DependencyEntry::Version(version.to_string()),
279        feature,
280        crate_features: opts.features.clone(),
281        light_features: info.map(|i| i.light_features.clone()).unwrap_or_default(),
282        creates_feature: false,
283        already_installed: true,
284        requires: Vec::new(),
285    })
286}
287
288/// Применяет план: мутирует проект и (при необходимости) проверяет сборку.
289pub fn apply_add(
290    project_root: &Path,
291    runner: &dyn CommandRunner,
292    plan: &AddPlan,
293    opts: &AddOptions,
294) -> Result<AddOutcome> {
295    if opts.dry_run || plan.already_installed {
296        return Ok(AddOutcome {
297            component: plan.component.clone(),
298            feature: plan.feature.clone(),
299            added_components: plan.requires.iter().map(|r| r.name.clone()).collect(),
300            verified: false,
301        });
302    }
303
304    let snapshot = backup::create(project_root)?;
305    match do_add(project_root, runner, plan, opts) {
306        Ok(outcome) => Ok(outcome),
307        Err(error) => {
308            backup::restore(&snapshot)?;
309            Err(error)
310        }
311    }
312}
313
314fn do_add(
315    project_root: &Path,
316    runner: &dyn CommandRunner,
317    plan: &AddPlan,
318    opts: &AddOptions,
319) -> Result<AddOutcome> {
320    let cargo_path = project_root.join("Cargo.toml");
321
322    add_dependency(
323        project_root,
324        &plan.component.name,
325        &plan.dependency,
326        &plan.crate_features,
327    )?;
328    let mut added_components = Vec::new();
329    for req in &plan.requires {
330        add_dependency(project_root, &req.name, &req.dependency, &[])?;
331        added_components.push(req.name.clone());
332    }
333
334    let text = read_cargo_toml(&cargo_path)?;
335    let mut doc = edit::parse(&text)?;
336    if edit::has_dependency(&doc, "light") {
337        edit::add_features_to_dependency(&mut doc, "light", &plan.light_features)?;
338    }
339    edit::add_dep_to_feature(&mut doc, &plan.feature, &plan.component.name)?;
340    for req in &plan.requires {
341        edit::add_dep_to_feature(&mut doc, &plan.feature, &req.name)?;
342    }
343    std::fs::write(&cargo_path, doc.to_string())?;
344
345    Lockfile::from_project(project_root)?.write(project_root)?;
346
347    let verified = !opts.no_verify;
348    if verified {
349        cargo_cmd::run_check(runner, project_root, false)?;
350    }
351
352    Ok(AddOutcome {
353        component: plan.component.clone(),
354        feature: plan.feature.clone(),
355        added_components,
356        verified,
357    })
358}
359
360fn add_dependency(
361    project_root: &Path,
362    name: &str,
363    dependency: &DependencyEntry,
364    features: &[String],
365) -> Result<()> {
366    let cargo_path = project_root.join("Cargo.toml");
367    let text = read_cargo_toml(&cargo_path)?;
368    let mut doc = edit::parse(&text)?;
369    match dependency {
370        DependencyEntry::Workspace => edit::set_workspace_optional(&mut doc, name)?,
371        DependencyEntry::Path(path) => {
372            edit::set_path_optional(&mut doc, name, &path.to_string_lossy())?
373        }
374        DependencyEntry::Version(version) => edit::set_version_optional(&mut doc, name, version)?,
375    }
376    edit::add_features_to_dependency(&mut doc, name, features)?;
377    std::fs::write(&cargo_path, doc.to_string())?;
378    Ok(())
379}
380
381fn dependency_line(name: &str, dependency: &DependencyEntry) -> String {
382    match dependency {
383        DependencyEntry::Workspace => format!("{name} = {{ workspace = true, optional = true }}"),
384        DependencyEntry::Path(path) => {
385            format!(
386                "{name} = {{ path = \"{}\", optional = true }}",
387                path.display()
388            )
389        }
390        DependencyEntry::Version(version) => {
391            format!("{name} = {{ version = \"{version}\", optional = true }}")
392        }
393    }
394}
395
396/// Имя фичи по умолчанию: `light-fw-element-form` → `element-form`.
397pub fn default_feature_name(name: &str) -> String {
398    name.strip_prefix("light-fw-").unwrap_or(name).to_string()
399}
400
401/// Выбирает фичу, к которой привязать компонент.
402///
403/// Порядок: фича `<component>` из манифеста → существующая агрегирующая фича
404/// (`dep:light-*`, предпочитая `full`) → новая фича [`default_feature_name`].
405fn choose_feature(text: &str, name: &str) -> Result<String> {
406    let default = default_feature_name(name);
407    if edit::has_feature(text, &default)? {
408        return Ok(default);
409    }
410    let aggregators = edit::features_with_light_component_dep(text)?;
411    if let Some(full) = aggregators.iter().find(|f| f.as_str() == "full") {
412        return Ok(full.clone());
413    }
414    if let Some(first) = aggregators.first() {
415        return Ok(first.clone());
416    }
417    Ok(default)
418}
419
420/// Разбирает `"light-fw-element-form ^0.3"` → `("light-fw-element-form", Some("^0.3"))`.
421fn split_requirement(requirement: &str) -> (&str, Option<&str>) {
422    match requirement.split_once(char::is_whitespace) {
423        Some((name, rest)) => (name, Some(rest.trim())),
424        None => (requirement, None),
425    }
426}
427
428fn is_core_name(name: &str) -> bool {
429    matches!(name, "light" | "light-fw-core")
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use crate::process::CommandOutput;
436    use std::path::PathBuf;
437    use std::sync::Mutex;
438
439    #[derive(Default)]
440    struct MockRunner {
441        calls: Mutex<Vec<String>>,
442        fail_check: bool,
443    }
444
445    impl CommandRunner for MockRunner {
446        fn run(
447            &self,
448            program: &str,
449            args: &[String],
450            _cwd: Option<&Path>,
451        ) -> Result<CommandOutput> {
452            self.calls
453                .lock()
454                .expect("lock")
455                .push(format!("{program} {}", args.join(" ")));
456            if self.fail_check && args.first().map(String::as_str) == Some("check") {
457                return Ok(CommandOutput {
458                    status: 101,
459                    stdout: String::new(),
460                    stderr: "compile error".to_string(),
461                });
462            }
463            Ok(CommandOutput {
464                status: 0,
465                stdout: String::new(),
466                stderr: String::new(),
467            })
468        }
469    }
470
471    fn temp_dir(name: &str) -> PathBuf {
472        let dir = std::env::temp_dir().join(format!("light-fw-cli-add-{name}"));
473        let _ = std::fs::remove_dir_all(&dir);
474        std::fs::create_dir_all(&dir).expect("create temp dir");
475        dir
476    }
477
478    /// Создаёт мини-workspace с компонентом `light-fw-element-form`.
479    fn workspace_fixture(name: &str) -> PathBuf {
480        let root = temp_dir(name);
481        std::fs::write(
482            root.join("Cargo.toml"),
483            "[workspace]\nmembers = [\"app\", \"comp\"]\nresolver = \"2\"\n\n\
484             [workspace.dependencies]\nlight-fw-element-form = { path = \"comp\", version = \"0.1.0\" }\n",
485        )
486        .expect("root manifest");
487        let app = root.join("app");
488        std::fs::create_dir_all(&app).expect("app");
489        std::fs::write(
490            app.join("Cargo.toml"),
491            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
492             [dependencies]\nlight = \"0.1\"\n",
493        )
494        .expect("app manifest");
495        let comp = root.join("comp");
496        std::fs::create_dir_all(comp.join("src")).expect("comp src");
497        std::fs::write(comp.join("src/lib.rs"), "").expect("comp lib");
498        std::fs::write(
499            comp.join("Cargo.toml"),
500            "[package]\nname = \"light-fw-element-form\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
501             [package.metadata.light]\nkind = \"element\"\ndisplay-name = \"Form\"\ncore = \"^0.1\"\n\n\
502             [package.metadata.light.provides]\nelements = [\"checkbox\"]\n",
503        )
504        .expect("comp manifest");
505        app
506    }
507
508    #[test]
509    fn plans_workspace_component() {
510        let app = workspace_fixture("plan");
511        let plan = plan_add(&app, "light-fw-element-form", &AddOptions::default()).expect("plan");
512        assert_eq!(plan.dependency, DependencyEntry::Workspace);
513        assert_eq!(plan.feature, "element-form");
514        assert!(plan.creates_feature);
515        assert!(plan.to_text().contains("light-fw-element-form"));
516    }
517
518    #[test]
519    fn already_installed_is_idempotent() {
520        let app = workspace_fixture("installed");
521        let opts = AddOptions {
522            no_verify: true,
523            ..Default::default()
524        };
525        let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
526        apply_add(&app, &MockRunner::default(), &plan, &opts).expect("apply");
527
528        let again = plan_add(&app, "light-fw-element-form", &opts).expect("idempotent");
529        assert!(again.already_installed);
530        assert_eq!(again.component.version, semver::Version::new(0, 1, 0));
531        assert_eq!(again.component.source, SourceKind::Workspace);
532        // Применение повторного плана ничего не меняет.
533        let before = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
534        apply_add(&app, &MockRunner::default(), &again, &opts).expect("re-apply");
535        assert_eq!(
536            std::fs::read_to_string(app.join("Cargo.toml")).expect("read"),
537            before
538        );
539    }
540
541    #[test]
542    fn already_installed_with_different_version_conflicts() {
543        let app = workspace_fixture("installed-conflict");
544        let opts = AddOptions {
545            no_verify: true,
546            ..Default::default()
547        };
548        let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
549        apply_add(&app, &MockRunner::default(), &plan, &opts).expect("apply");
550
551        let conflicting = AddOptions {
552            version: Some("0.2.0".to_string()),
553            ..opts
554        };
555        let err = plan_add(&app, "light-fw-element-form", &conflicting).expect_err("conflict");
556        assert!(matches!(err, Error::Conflict(_)));
557    }
558
559    #[test]
560    fn dry_run_does_not_touch_disk() {
561        let app = workspace_fixture("dry-run");
562        let original = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
563        let opts = AddOptions {
564            dry_run: true,
565            ..Default::default()
566        };
567        let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
568        apply_add(&app, &MockRunner::default(), &plan, &opts).expect("apply");
569        assert_eq!(
570            std::fs::read_to_string(app.join("Cargo.toml")).expect("read"),
571            original
572        );
573        assert!(!app.join("light.lock").exists());
574    }
575
576    #[test]
577    fn applies_workspace_dependency_and_feature() {
578        let app = workspace_fixture("apply");
579        let opts = AddOptions::default();
580        let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
581        let runner = MockRunner::default();
582        let outcome = apply_add(&app, &runner, &plan, &opts).expect("apply");
583        assert!(outcome.verified);
584
585        let text = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
586        assert!(text.contains("light-fw-element-form = { workspace = true, optional = true }"));
587        assert!(text.contains("[features]"));
588        assert!(text.contains("element-form = [\"dep:light-fw-element-form\"]"));
589        assert!(app.join("light.lock").exists());
590
591        let calls = runner.calls.lock().expect("lock").clone();
592        assert!(calls.iter().any(|c| c == "cargo check --quiet"));
593    }
594
595    struct MockRemote {
596        name: &'static str,
597        version: semver::Version,
598    }
599
600    impl crate::fetch::RemoteRegistry for MockRemote {
601        fn latest_version(&self, name: &str) -> Result<Option<semver::Version>> {
602            if name == self.name {
603                Ok(Some(self.version.clone()))
604            } else {
605                Ok(None)
606            }
607        }
608    }
609
610    #[test]
611    fn adds_external_component_from_crates_io() {
612        let app = workspace_fixture("external");
613        let catalog = Catalog::with_remote(
614            &app,
615            false,
616            Box::new(MockRemote {
617                name: "light-fw-datepicker",
618                version: semver::Version::new(0, 3, 0),
619            }),
620        );
621        let opts = AddOptions {
622            features: vec!["default".to_string()],
623            ..Default::default()
624        };
625        let plan = plan_add_with_catalog(&app, "light-fw-datepicker", &opts, &catalog)
626            .expect("plan external");
627        assert_eq!(
628            plan.dependency,
629            DependencyEntry::Version("0.3.0".to_string())
630        );
631        assert_eq!(plan.feature, "datepicker");
632
633        let outcome = apply_add(&app, &MockRunner::default(), &plan, &opts).expect("apply");
634        assert!(outcome.verified);
635
636        let text = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
637        assert!(
638            text.contains(
639                "light-fw-datepicker = { version = \"0.3.0\", optional = true, features = [\"default\"] }"
640            ),
641            "{text}"
642        );
643        assert!(
644            text.contains("datepicker = [\"dep:light-fw-datepicker\"]"),
645            "{text}"
646        );
647
648        let lock = std::fs::read_to_string(app.join("light.lock")).expect("read lock");
649        assert!(lock.contains("light-fw-datepicker"), "{lock}");
650        assert!(lock.contains("version = \"0.3.0\""), "{lock}");
651    }
652
653    #[test]
654    fn known_component_enables_light_feature() {
655        let app = workspace_fixture("light-feature");
656        let opts = AddOptions {
657            no_verify: true,
658            ..Default::default()
659        };
660        let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
661        assert_eq!(plan.light_features, vec!["elements-form"]);
662        apply_add(&app, &MockRunner::default(), &plan, &opts).expect("apply");
663
664        let text = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
665        assert!(
666            text.contains("light = { version = \"0.1\", features = [\"elements-form\"] }"),
667            "{text}"
668        );
669    }
670
671    #[test]
672    fn failed_check_rolls_back() {
673        let app = workspace_fixture("rollback");
674        let original = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
675        let opts = AddOptions::default();
676        let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
677        let runner = MockRunner {
678            fail_check: true,
679            ..Default::default()
680        };
681        let err = apply_add(&app, &runner, &plan, &opts).expect_err("check failed");
682        assert!(matches!(err, Error::CommandFailed { .. }));
683        assert_eq!(
684            std::fs::read_to_string(app.join("Cargo.toml")).expect("read"),
685            original,
686            "Cargo.toml восстановлен"
687        );
688        assert!(!app.join("light.lock").exists(), "light.lock откачен");
689    }
690
691    #[test]
692    fn default_feature_name_strips_prefix() {
693        assert_eq!(
694            default_feature_name("light-fw-element-form"),
695            "element-form"
696        );
697        assert_eq!(default_feature_name("custom-widget"), "custom-widget");
698    }
699
700    #[test]
701    fn choose_feature_prefers_own_then_aggregator_then_new() {
702        // Собственная фича компонента — приоритетнее агрегатора.
703        let with_own = "[features]\nfull = [\"dep:light-fw-element-form\"]\n\
704                        element-form = [\"dep:light-fw-element-form\"]\n";
705        assert_eq!(
706            choose_feature(with_own, "light-fw-element-form").unwrap(),
707            "element-form"
708        );
709        // Нет собственной — берём существующий агрегирующий (`full`).
710        let with_aggregator =
711            "[features]\ndefault = [\"full\"]\nfull = [\"dep:light-fw-element-form\"]\n";
712        assert_eq!(
713            choose_feature(with_aggregator, "light-fw-behavior-validation").unwrap(),
714            "full"
715        );
716        // Агрегаторов нет — создаём новую фичу по умолчанию.
717        assert_eq!(
718            choose_feature("[features]\nnone = []\n", "light-fw-behavior-validation").unwrap(),
719            "behavior-validation"
720        );
721    }
722}