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, Default)]
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}
36
37#[derive(Debug, Clone)]
39pub struct RequiredComponent {
40 pub name: String,
42 pub dependency: DependencyEntry,
44}
45
46#[derive(Debug, Clone)]
48pub struct AddPlan {
49 pub component: ResolvedComponent,
51 pub dependency: DependencyEntry,
53 pub feature: String,
55 pub crate_features: Vec<String>,
57 pub light_features: Vec<String>,
59 pub creates_feature: bool,
61 pub already_installed: bool,
63 pub requires: Vec<RequiredComponent>,
65}
66
67impl AddPlan {
68 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#[derive(Debug, Clone)]
124pub struct AddOutcome {
125 pub component: ResolvedComponent,
127 pub feature: String,
129 pub added_components: Vec<String>,
131 pub verified: bool,
133}
134
135pub 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
141pub 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 = crate::manifest::resolve_core_version(project_root)?;
178 if let Some(core) = &core
179 && !component.core.matches(core)
180 {
181 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
258fn 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, ¤t)
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
310pub 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 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
422pub fn default_feature_name(name: &str) -> String {
424 name.strip_prefix("light-fw-").unwrap_or(name).to_string()
425}
426
427fn 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
446fn 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 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 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 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 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 assert_eq!(
744 choose_feature("[features]\nnone = []\n", "light-fw-behavior-validation").unwrap(),
745 "behavior-validation"
746 );
747 }
748
749 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 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 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 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 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}