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 = 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
236fn 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, ¤t)
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
288pub 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
396pub fn default_feature_name(name: &str) -> String {
398 name.strip_prefix("light-fw-").unwrap_or(name).to_string()
399}
400
401fn 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
420fn 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 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 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 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 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 assert_eq!(
718 choose_feature("[features]\nnone = []\n", "light-fw-behavior-validation").unwrap(),
719 "behavior-validation"
720 );
721 }
722}