1use 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#[derive(Debug, Clone)]
20pub struct AddOptions {
21 pub version: Option<String>,
23 pub yes: bool,
25 pub dry_run: bool,
27 pub offline: bool,
29 pub feature: Option<String>,
31 pub features: Vec<String>,
33 pub no_verify: bool,
35 pub default_features: bool,
43}
44
45impl 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#[derive(Debug, Clone)]
65pub struct RequiredComponent {
66 pub name: String,
68 pub dependency: DependencyEntry,
70}
71
72#[derive(Debug, Clone)]
74pub struct AddPlan {
75 pub component: ResolvedComponent,
77 pub dependency: DependencyEntry,
79 pub feature: String,
81 pub crate_features: Vec<String>,
83 pub light_features: Vec<String>,
85 pub creates_feature: bool,
87 pub already_installed: bool,
89 pub requires: Vec<RequiredComponent>,
91}
92
93impl AddPlan {
94 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#[derive(Debug, Clone)]
150pub struct AddOutcome {
151 pub component: ResolvedComponent,
153 pub feature: String,
155 pub added_components: Vec<String>,
157 pub verified: bool,
159}
160
161pub 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
167pub 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 let core = crate::manifest::resolve_core_version(project_root)?;
204 if let Some(core) = &core
205 && !component.core.matches(core)
206 {
207 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
284fn 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, ¤t)
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
336pub 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 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
448pub fn default_feature_name(name: &str) -> String {
450 name.strip_prefix("light-fw-").unwrap_or(name).to_string()
451}
452
453fn 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
472fn 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 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 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 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 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 assert_eq!(
770 choose_feature("[features]\nnone = []\n", "light-fw-behavior-validation").unwrap(),
771 "behavior-validation"
772 );
773 }
774
775 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 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 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 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 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}