use std::path::Path;
use crate::error::{Error, Result};
use crate::fetch::Catalog;
use crate::manifest::{
collect_dependency_details, has_package_section, read_cargo_toml, resolve_dependency_version,
};
use crate::process::CommandRunner;
use crate::registry::ComponentRegistry;
use crate::{backup, cargo as cargo_cmd, edit, lockfile::Lockfile};
#[derive(Debug, Clone, Default)]
pub struct RemoveOptions {
pub yes: bool,
pub dry_run: bool,
pub offline: bool,
}
#[derive(Debug, Clone)]
pub struct RemovePlan {
pub name: String,
pub version: Option<String>,
pub features: Vec<String>,
pub dependents: Vec<String>,
}
impl RemovePlan {
pub fn to_text(&self) -> String {
let mut out = String::new();
out.push_str(&format!("План удаления {}\n", self.name));
out.push_str(&format!(
" версия: {}\n",
self.version.as_deref().unwrap_or("?")
));
if self.features.is_empty() {
out.push_str(" фичи: (нет ссылок dep:)\n");
} else {
out.push_str(&format!(" фичи: {}\n", self.features.join(", ")));
}
for dep in &self.dependents {
out.push_str(&format!(
" предупреждение: от {dep} зависит этот компонент\n"
));
}
out
}
}
pub fn plan_remove(project_root: &Path, name: &str) -> Result<RemovePlan> {
let cargo_path = project_root.join("Cargo.toml");
if !cargo_path.is_file() {
return Err(Error::Manifest(format!(
"Cargo.toml не найден в {}",
project_root.display()
)));
}
let text = read_cargo_toml(&cargo_path)?;
if !has_package_section(&text)? {
return Err(Error::Manifest(
"Cargo.toml не содержит секции [package]: укажите каталог приложения".to_string(),
));
}
let installed = collect_dependency_details(&text, project_root)?;
if !installed.iter().any(|d| d.name == name) {
return Err(Error::NotInstalled(name.to_string()));
}
let version = resolve_dependency_version(project_root, name)?.map(|v| v.to_string());
let features = edit::features_containing(&text, name)?;
Ok(RemovePlan {
name: name.to_string(),
version,
features,
dependents: Vec::new(),
})
}
pub fn plan_remove_with_dependents(
project_root: &Path,
name: &str,
offline: bool,
) -> Result<RemovePlan> {
let mut plan = plan_remove(project_root, name)?;
let text = read_cargo_toml(&project_root.join("Cargo.toml"))?;
let installed = collect_dependency_details(&text, project_root)?;
let catalog = Catalog::discover(project_root, offline);
let registry = ComponentRegistry::builtin();
for dep in &installed {
if dep.name == name || registry.by_name(&dep.name).is_none() {
continue;
}
if let Ok(resolved) = catalog.resolve(&dep.name)
&& resolved
.requires
.iter()
.any(|r| requirement_name(r) == name)
{
plan.dependents.push(dep.name.clone());
}
}
plan.dependents.sort();
Ok(plan)
}
pub fn apply_remove(
project_root: &Path,
runner: &dyn CommandRunner,
plan: &RemovePlan,
opts: &RemoveOptions,
) -> Result<()> {
if opts.dry_run {
return Ok(());
}
let snapshot = backup::create(project_root)?;
match do_remove(project_root, runner, plan) {
Ok(()) => Ok(()),
Err(error) => {
backup::restore(&snapshot)?;
Err(error)
}
}
}
fn do_remove(project_root: &Path, runner: &dyn CommandRunner, plan: &RemovePlan) -> Result<()> {
let cargo_path = project_root.join("Cargo.toml");
let text = read_cargo_toml(&cargo_path)?;
let dependency = collect_dependency_details(&text, project_root)?
.into_iter()
.find(|d| d.name == plan.name)
.ok_or_else(|| Error::NotInstalled(plan.name.clone()))?;
if dependency.workspace {
let mut doc = edit::parse(&text)?;
edit::remove_dependency(&mut doc, &plan.name)?;
std::fs::write(&cargo_path, doc.to_string())?;
} else {
cargo_cmd::run_remove(runner, project_root, &plan.name)?;
let text = read_cargo_toml(&cargo_path)?;
let mut doc = edit::parse(&text)?;
edit::remove_dependency(&mut doc, &plan.name)?;
std::fs::write(&cargo_path, doc.to_string())?;
}
Lockfile::from_project(project_root)?.write(project_root)?;
cargo_cmd::run_check(runner, project_root, false)?;
Ok(())
}
fn requirement_name(requirement: &str) -> &str {
requirement
.split_once(char::is_whitespace)
.map(|(name, _)| name)
.unwrap_or(requirement)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::process::CommandOutput;
use std::path::PathBuf;
use std::sync::Mutex;
#[derive(Default)]
struct MockRunner {
calls: Mutex<Vec<String>>,
}
impl CommandRunner for MockRunner {
fn run(
&self,
program: &str,
args: &[String],
_cwd: Option<&Path>,
) -> Result<CommandOutput> {
self.calls
.lock()
.expect("lock")
.push(format!("{program} {}", args.join(" ")));
Ok(CommandOutput {
status: 0,
stdout: String::new(),
stderr: String::new(),
})
}
}
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("light-fw-cli-remove-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp dir");
dir
}
fn installed_fixture(name: &str) -> PathBuf {
let root = temp_dir(name);
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"app\", \"comp\"]\nresolver = \"2\"\n\n\
[workspace.dependencies]\nlight-fw-element-form = { path = \"comp\", version = \"0.1.0\" }\n",
)
.expect("root manifest");
let app = root.join("app");
std::fs::create_dir_all(&app).expect("app");
std::fs::write(
app.join("Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
[dependencies]\nlight = \"0.1\"\nlight-fw-element-form = { workspace = true, optional = true }\n\n\
[features]\ndefault = [\"full\"]\nfull = [\"dep:light-fw-element-form\"]\nelement-form = [\"dep:light-fw-element-form\"]\n",
)
.expect("app manifest");
let comp = root.join("comp");
std::fs::create_dir_all(comp.join("src")).expect("comp src");
std::fs::write(comp.join("src/lib.rs"), "").expect("comp lib");
std::fs::write(
comp.join("Cargo.toml"),
"[package]\nname = \"light-fw-element-form\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
[package.metadata.light]\nkind = \"element\"\ndisplay-name = \"Form\"\ncore = \"^0.1\"\n\n\
[package.metadata.light.provides]\nelements = [\"checkbox\"]\n",
)
.expect("comp manifest");
app
}
#[test]
fn plans_removal_with_features() {
let app = installed_fixture("plan");
let plan = plan_remove(&app, "light-fw-element-form").expect("plan");
assert_eq!(plan.version.as_deref(), Some("0.1.0"));
assert_eq!(plan.features, vec!["element-form", "full"]);
assert!(plan.to_text().contains("light-fw-element-form"));
}
#[test]
fn not_installed_is_error() {
let app = installed_fixture("missing");
let err = plan_remove(&app, "light-widget-datepicker").expect_err("not installed");
assert!(matches!(err, Error::NotInstalled(_)));
}
#[test]
fn removes_dependency_and_lockfile() {
let app = installed_fixture("apply");
let plan = plan_remove(&app, "light-fw-element-form").expect("plan");
let runner = MockRunner::default();
apply_remove(&app, &runner, &plan, &RemoveOptions::default()).expect("apply");
let text = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
assert!(!text.contains("light-fw-element-form"));
assert!(text.contains("full = [\"light/x\"]") || text.contains("default = [\"full\"]"));
assert!(app.join("light.lock").exists());
let lock = std::fs::read_to_string(app.join("light.lock")).expect("read lock");
assert!(
!lock.contains("light-fw-element-form"),
"удалённый компонент убран из light.lock: {lock}"
);
assert!(lock.contains("core = \"0.1.0\""), "ядро в lock: {lock}");
let calls = runner.calls.lock().expect("lock").clone();
assert!(calls.iter().any(|c| c == "cargo check --quiet"));
}
#[test]
fn removes_external_component_and_lockfile_entry() {
let dir = temp_dir("external");
std::fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
[dependencies]\nlight = \"0.1\"\n\
light-fw-datepicker = { version = \"0.3.0\", optional = true }\n\n\
[features]\ndefault = [\"full\"]\nfull = [\"dep:light-fw-datepicker\"]\n",
)
.expect("app manifest");
let plan = plan_remove(&dir, "light-fw-datepicker").expect("plan");
assert_eq!(plan.features, vec!["full"]);
apply_remove(
&dir,
&MockRunner::default(),
&plan,
&RemoveOptions::default(),
)
.expect("apply");
let text = std::fs::read_to_string(dir.join("Cargo.toml")).expect("read");
assert!(!text.contains("light-fw-datepicker"), "{text}");
assert!(text.contains("light = \"0.1\""), "{text}");
assert!(dir.join("light.lock").exists());
let lock = std::fs::read_to_string(dir.join("light.lock")).expect("read lock");
assert!(!lock.contains("light-fw-datepicker"), "{lock}");
}
#[test]
fn dry_run_does_not_touch_disk() {
let app = installed_fixture("dry-run");
let original = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
let plan = plan_remove(&app, "light-fw-element-form").expect("plan");
let opts = RemoveOptions {
dry_run: true,
..Default::default()
};
apply_remove(&app, &MockRunner::default(), &plan, &opts).expect("apply");
assert_eq!(
std::fs::read_to_string(app.join("Cargo.toml")).expect("read"),
original
);
}
}