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 no_verify: bool,
33}
34
35#[derive(Debug, Clone)]
37pub struct RequiredComponent {
38 pub name: String,
40 pub dependency: DependencyEntry,
42}
43
44#[derive(Debug, Clone)]
46pub struct AddPlan {
47 pub component: ResolvedComponent,
49 pub dependency: DependencyEntry,
51 pub feature: String,
53 pub creates_feature: bool,
55 pub already_installed: bool,
57 pub requires: Vec<RequiredComponent>,
59}
60
61impl AddPlan {
62 pub fn to_text(&self) -> String {
64 let mut out = String::new();
65 out.push_str(&format!("План добавления {}\n", self.component.name));
66 let location = match &self.component.path {
67 Some(p) => format!(" ({})", p.display()),
68 None => String::new(),
69 };
70 out.push_str(&format!(
71 " источник: {}{}\n",
72 self.component.source.as_str(),
73 location,
74 ));
75 out.push_str(&format!(" версия: {}\n", self.component.version));
76 if self.already_installed {
77 out.push_str(" статус: уже установлен\n");
78 return out;
79 }
80 out.push_str(&format!(
81 " фича: {}{}\n",
82 self.feature,
83 if self.creates_feature {
84 " (будет создана)"
85 } else {
86 ""
87 }
88 ));
89 out.push_str(&format!(
90 " зависимость: {}\n",
91 dependency_line(&self.component.name, &self.dependency)
92 ));
93 for req in &self.requires {
94 out.push_str(&format!(
95 " также нужно: {} — {}\n",
96 req.name,
97 dependency_line(&req.name, &req.dependency)
98 ));
99 }
100 out
101 }
102}
103
104#[derive(Debug, Clone)]
106pub struct AddOutcome {
107 pub component: ResolvedComponent,
109 pub feature: String,
111 pub added_components: Vec<String>,
113 pub verified: bool,
115}
116
117pub fn plan_add(project_root: &Path, name: &str, opts: &AddOptions) -> Result<AddPlan> {
119 let cargo_path = project_root.join("Cargo.toml");
120 if !cargo_path.is_file() {
121 return Err(Error::Manifest(format!(
122 "Cargo.toml не найден в {}",
123 project_root.display()
124 )));
125 }
126 let text = read_cargo_toml(&cargo_path)?;
127 if !has_package_section(&text)? {
128 return Err(Error::Manifest(
129 "Cargo.toml не содержит секции [package]: укажите каталог приложения".to_string(),
130 ));
131 }
132
133 let installed = collect_dependency_details(&text, project_root)?;
134 let installed_names: Vec<&str> = installed.iter().map(|d| d.name.as_str()).collect();
135
136 let catalog = Catalog::discover(project_root, opts.offline);
137
138 if installed_names.contains(&name) {
139 return already_installed_plan(project_root, &text, name, opts, &catalog);
140 }
141
142 let mut component = catalog.resolve(name)?;
143 if let Some(version) = &opts.version {
144 component.version = semver::Version::parse(version)?;
145 }
146
147 let core = resolve_dependency_version(project_root, "light-fw-core")?
148 .or(resolve_dependency_version(project_root, "light")?);
149 if let Some(core) = core
150 && !component.core.matches(&core)
151 {
152 return Err(Error::Incompatible {
153 component: name.to_string(),
154 reason: format!(
155 "требуется light-fw-core {}, в проекте {core}",
156 component.core
157 ),
158 });
159 }
160
161 for conflict in &component.conflicts {
162 if installed_names.iter().any(|n| n == conflict) {
163 return Err(Error::Conflict(format!(
164 "{name} несовместим с установленным {conflict}"
165 )));
166 }
167 }
168
169 let feature = match &opts.feature {
170 Some(feature) => feature.clone(),
171 None => choose_feature(&text, name)?,
172 };
173 let creates_feature = !edit::has_feature(&text, &feature)?;
174
175 let dependency = catalog.dependency_entry(&component)?;
176
177 let mut requires = Vec::new();
178 for requirement in &component.requires {
179 let (dep_name, _) = split_requirement(requirement);
180 if is_core_name(dep_name) || installed_names.contains(&dep_name) {
181 continue;
182 }
183 if requires
184 .iter()
185 .any(|r: &RequiredComponent| r.name == dep_name)
186 {
187 continue;
188 }
189 let resolved = catalog.resolve(dep_name)?;
190 requires.push(RequiredComponent {
191 name: dep_name.to_string(),
192 dependency: catalog.dependency_entry(&resolved)?,
193 });
194 }
195
196 Ok(AddPlan {
197 component,
198 dependency,
199 feature,
200 creates_feature,
201 already_installed: false,
202 requires,
203 })
204}
205
206fn already_installed_plan(
208 project_root: &Path,
209 text: &str,
210 name: &str,
211 opts: &AddOptions,
212 catalog: &Catalog,
213) -> Result<AddPlan> {
214 let current = resolve_dependency_version(project_root, name)?;
215 if let (Some(requested), Some(current)) = (&opts.version, ¤t)
216 && current.to_string() != *requested
217 {
218 return Err(Error::Conflict(format!(
219 "{name} уже установлен (версия {current}), запрошена {requested}"
220 )));
221 }
222 let version = current.unwrap_or_else(|| semver::Version::new(0, 0, 0));
223 let info = catalog.registry().by_name(name);
224 let local = catalog.resolve_local(name)?;
225 let (source, path) = match &local {
226 Some(resolved) => (resolved.source, resolved.path.clone()),
227 None => (SourceKind::CratesIo, None),
228 };
229 let component = ResolvedComponent {
230 name: name.to_string(),
231 version: version.clone(),
232 core: info
233 .map(|i| i.core_requirement.clone())
234 .unwrap_or(semver::VersionReq::STAR),
235 provides: info.map(|i| i.provides.clone()).unwrap_or_default(),
236 requires: Vec::new(),
237 conflicts: Vec::new(),
238 source,
239 path,
240 };
241 let feature = match &opts.feature {
242 Some(feature) => feature.clone(),
243 None => choose_feature(text, name)?,
244 };
245 Ok(AddPlan {
246 component,
247 dependency: DependencyEntry::Version(version.to_string()),
248 feature,
249 creates_feature: false,
250 already_installed: true,
251 requires: Vec::new(),
252 })
253}
254
255pub fn apply_add(
257 project_root: &Path,
258 runner: &dyn CommandRunner,
259 plan: &AddPlan,
260 opts: &AddOptions,
261) -> Result<AddOutcome> {
262 if opts.dry_run || plan.already_installed {
263 return Ok(AddOutcome {
264 component: plan.component.clone(),
265 feature: plan.feature.clone(),
266 added_components: plan.requires.iter().map(|r| r.name.clone()).collect(),
267 verified: false,
268 });
269 }
270
271 let snapshot = backup::create(project_root)?;
272 match do_add(project_root, runner, plan, opts) {
273 Ok(outcome) => Ok(outcome),
274 Err(error) => {
275 backup::restore(&snapshot)?;
276 Err(error)
277 }
278 }
279}
280
281fn do_add(
282 project_root: &Path,
283 runner: &dyn CommandRunner,
284 plan: &AddPlan,
285 opts: &AddOptions,
286) -> Result<AddOutcome> {
287 let cargo_path = project_root.join("Cargo.toml");
288
289 add_dependency(project_root, runner, &plan.component.name, &plan.dependency)?;
290 let mut added_components = Vec::new();
291 for req in &plan.requires {
292 add_dependency(project_root, runner, &req.name, &req.dependency)?;
293 added_components.push(req.name.clone());
294 }
295
296 let text = read_cargo_toml(&cargo_path)?;
297 let mut doc = edit::parse(&text)?;
298 edit::add_dep_to_feature(&mut doc, &plan.feature, &plan.component.name)?;
299 for req in &plan.requires {
300 edit::add_dep_to_feature(&mut doc, &plan.feature, &req.name)?;
301 }
302 std::fs::write(&cargo_path, doc.to_string())?;
303
304 Lockfile::from_project(project_root)?.write(project_root)?;
305
306 let verified = !opts.no_verify;
307 if verified {
308 cargo_cmd::run_check(runner, project_root, false)?;
309 }
310
311 Ok(AddOutcome {
312 component: plan.component.clone(),
313 feature: plan.feature.clone(),
314 added_components,
315 verified,
316 })
317}
318
319fn add_dependency(
320 project_root: &Path,
321 runner: &dyn CommandRunner,
322 name: &str,
323 dependency: &DependencyEntry,
324) -> Result<()> {
325 let cargo_path = project_root.join("Cargo.toml");
326 match dependency {
327 DependencyEntry::Workspace => {
328 let text = read_cargo_toml(&cargo_path)?;
329 let mut doc = edit::parse(&text)?;
330 edit::set_workspace_optional(&mut doc, name)?;
331 std::fs::write(&cargo_path, doc.to_string())?;
332 }
333 DependencyEntry::Path(path) => {
334 cargo_cmd::run_add(runner, project_root, name, None, Some(path), true)?;
335 }
336 DependencyEntry::Version(version) => {
337 cargo_cmd::run_add(runner, project_root, name, Some(version), None, true)?;
338 }
339 }
340 Ok(())
341}
342
343fn dependency_line(name: &str, dependency: &DependencyEntry) -> String {
344 match dependency {
345 DependencyEntry::Workspace => format!("{name} = {{ workspace = true, optional = true }}"),
346 DependencyEntry::Path(path) => {
347 format!(
348 "{name} = {{ path = \"{}\", optional = true }}",
349 path.display()
350 )
351 }
352 DependencyEntry::Version(version) => {
353 format!("{name} = {{ version = \"{version}\", optional = true }}")
354 }
355 }
356}
357
358pub fn default_feature_name(name: &str) -> String {
360 name.strip_prefix("light-fw-").unwrap_or(name).to_string()
361}
362
363fn choose_feature(text: &str, name: &str) -> Result<String> {
368 let default = default_feature_name(name);
369 if edit::has_feature(text, &default)? {
370 return Ok(default);
371 }
372 let aggregators = edit::features_with_light_component_dep(text)?;
373 if let Some(full) = aggregators.iter().find(|f| f.as_str() == "full") {
374 return Ok(full.clone());
375 }
376 if let Some(first) = aggregators.first() {
377 return Ok(first.clone());
378 }
379 Ok(default)
380}
381
382fn split_requirement(requirement: &str) -> (&str, Option<&str>) {
384 match requirement.split_once(char::is_whitespace) {
385 Some((name, rest)) => (name, Some(rest.trim())),
386 None => (requirement, None),
387 }
388}
389
390fn is_core_name(name: &str) -> bool {
391 matches!(name, "light" | "light-fw-core")
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use crate::process::CommandOutput;
398 use std::path::PathBuf;
399 use std::sync::Mutex;
400
401 #[derive(Default)]
402 struct MockRunner {
403 calls: Mutex<Vec<String>>,
404 fail_check: bool,
405 }
406
407 impl CommandRunner for MockRunner {
408 fn run(
409 &self,
410 program: &str,
411 args: &[String],
412 _cwd: Option<&Path>,
413 ) -> Result<CommandOutput> {
414 self.calls
415 .lock()
416 .expect("lock")
417 .push(format!("{program} {}", args.join(" ")));
418 if self.fail_check && args.first().map(String::as_str) == Some("check") {
419 return Ok(CommandOutput {
420 status: 101,
421 stdout: String::new(),
422 stderr: "compile error".to_string(),
423 });
424 }
425 Ok(CommandOutput {
426 status: 0,
427 stdout: String::new(),
428 stderr: String::new(),
429 })
430 }
431 }
432
433 fn temp_dir(name: &str) -> PathBuf {
434 let dir = std::env::temp_dir().join(format!("light-fw-cli-add-{name}"));
435 let _ = std::fs::remove_dir_all(&dir);
436 std::fs::create_dir_all(&dir).expect("create temp dir");
437 dir
438 }
439
440 fn workspace_fixture(name: &str) -> PathBuf {
442 let root = temp_dir(name);
443 std::fs::write(
444 root.join("Cargo.toml"),
445 "[workspace]\nmembers = [\"app\", \"comp\"]\nresolver = \"2\"\n\n\
446 [workspace.dependencies]\nlight-fw-element-form = { path = \"comp\", version = \"0.1.0\" }\n",
447 )
448 .expect("root manifest");
449 let app = root.join("app");
450 std::fs::create_dir_all(&app).expect("app");
451 std::fs::write(
452 app.join("Cargo.toml"),
453 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
454 [dependencies]\nlight = \"0.1\"\n",
455 )
456 .expect("app manifest");
457 let comp = root.join("comp");
458 std::fs::create_dir_all(comp.join("src")).expect("comp src");
459 std::fs::write(comp.join("src/lib.rs"), "").expect("comp lib");
460 std::fs::write(
461 comp.join("Cargo.toml"),
462 "[package]\nname = \"light-fw-element-form\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
463 [package.metadata.light]\nkind = \"element\"\ndisplay-name = \"Form\"\ncore = \"^0.1\"\n\n\
464 [package.metadata.light.provides]\nelements = [\"checkbox\"]\n",
465 )
466 .expect("comp manifest");
467 app
468 }
469
470 #[test]
471 fn plans_workspace_component() {
472 let app = workspace_fixture("plan");
473 let plan = plan_add(&app, "light-fw-element-form", &AddOptions::default()).expect("plan");
474 assert_eq!(plan.dependency, DependencyEntry::Workspace);
475 assert_eq!(plan.feature, "element-form");
476 assert!(plan.creates_feature);
477 assert!(plan.to_text().contains("light-fw-element-form"));
478 }
479
480 #[test]
481 fn already_installed_is_idempotent() {
482 let app = workspace_fixture("installed");
483 let opts = AddOptions {
484 no_verify: true,
485 ..Default::default()
486 };
487 let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
488 apply_add(&app, &MockRunner::default(), &plan, &opts).expect("apply");
489
490 let again = plan_add(&app, "light-fw-element-form", &opts).expect("idempotent");
491 assert!(again.already_installed);
492 assert_eq!(again.component.version, semver::Version::new(0, 1, 0));
493 assert_eq!(again.component.source, SourceKind::Workspace);
494 let before = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
496 apply_add(&app, &MockRunner::default(), &again, &opts).expect("re-apply");
497 assert_eq!(
498 std::fs::read_to_string(app.join("Cargo.toml")).expect("read"),
499 before
500 );
501 }
502
503 #[test]
504 fn already_installed_with_different_version_conflicts() {
505 let app = workspace_fixture("installed-conflict");
506 let opts = AddOptions {
507 no_verify: true,
508 ..Default::default()
509 };
510 let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
511 apply_add(&app, &MockRunner::default(), &plan, &opts).expect("apply");
512
513 let conflicting = AddOptions {
514 version: Some("0.2.0".to_string()),
515 ..opts
516 };
517 let err = plan_add(&app, "light-fw-element-form", &conflicting).expect_err("conflict");
518 assert!(matches!(err, Error::Conflict(_)));
519 }
520
521 #[test]
522 fn dry_run_does_not_touch_disk() {
523 let app = workspace_fixture("dry-run");
524 let original = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
525 let opts = AddOptions {
526 dry_run: true,
527 ..Default::default()
528 };
529 let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
530 apply_add(&app, &MockRunner::default(), &plan, &opts).expect("apply");
531 assert_eq!(
532 std::fs::read_to_string(app.join("Cargo.toml")).expect("read"),
533 original
534 );
535 assert!(!app.join("light.lock").exists());
536 }
537
538 #[test]
539 fn applies_workspace_dependency_and_feature() {
540 let app = workspace_fixture("apply");
541 let opts = AddOptions::default();
542 let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
543 let runner = MockRunner::default();
544 let outcome = apply_add(&app, &runner, &plan, &opts).expect("apply");
545 assert!(outcome.verified);
546
547 let text = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
548 assert!(text.contains("light-fw-element-form = { workspace = true, optional = true }"));
549 assert!(text.contains("[features]"));
550 assert!(text.contains("element-form = [\"dep:light-fw-element-form\"]"));
551 assert!(app.join("light.lock").exists());
552
553 let calls = runner.calls.lock().expect("lock").clone();
554 assert!(calls.iter().any(|c| c == "cargo check --quiet"));
555 }
556
557 #[test]
558 fn failed_check_rolls_back() {
559 let app = workspace_fixture("rollback");
560 let original = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
561 let opts = AddOptions::default();
562 let plan = plan_add(&app, "light-fw-element-form", &opts).expect("plan");
563 let runner = MockRunner {
564 fail_check: true,
565 ..Default::default()
566 };
567 let err = apply_add(&app, &runner, &plan, &opts).expect_err("check failed");
568 assert!(matches!(err, Error::CommandFailed { .. }));
569 assert_eq!(
570 std::fs::read_to_string(app.join("Cargo.toml")).expect("read"),
571 original,
572 "Cargo.toml восстановлен"
573 );
574 assert!(!app.join("light.lock").exists(), "light.lock откачен");
575 }
576
577 #[test]
578 fn default_feature_name_strips_prefix() {
579 assert_eq!(
580 default_feature_name("light-fw-element-form"),
581 "element-form"
582 );
583 assert_eq!(default_feature_name("custom-widget"), "custom-widget");
584 }
585
586 #[test]
587 fn choose_feature_prefers_own_then_aggregator_then_new() {
588 let with_own = "[features]\nfull = [\"dep:light-fw-element-form\"]\n\
590 element-form = [\"dep:light-fw-element-form\"]\n";
591 assert_eq!(
592 choose_feature(with_own, "light-fw-element-form").unwrap(),
593 "element-form"
594 );
595 let with_aggregator =
597 "[features]\ndefault = [\"full\"]\nfull = [\"dep:light-fw-element-form\"]\n";
598 assert_eq!(
599 choose_feature(with_aggregator, "light-fw-behavior-validation").unwrap(),
600 "full"
601 );
602 assert_eq!(
604 choose_feature("[features]\nnone = []\n", "light-fw-behavior-validation").unwrap(),
605 "behavior-validation"
606 );
607 }
608}