1use std::path::Path;
7
8use crate::error::{Error, Result};
9use crate::fetch::Catalog;
10use crate::manifest::{
11 collect_dependency_details, has_package_section, read_cargo_toml, resolve_dependency_version,
12};
13use crate::process::CommandRunner;
14use crate::registry::ComponentRegistry;
15use crate::{backup, cargo as cargo_cmd, edit, lockfile::Lockfile};
16
17#[derive(Debug, Clone, Default)]
19pub struct RemoveOptions {
20 pub yes: bool,
22 pub dry_run: bool,
24 pub offline: bool,
26}
27
28#[derive(Debug, Clone)]
30pub struct RemovePlan {
31 pub name: String,
33 pub version: Option<String>,
35 pub features: Vec<String>,
37 pub dependents: Vec<String>,
39}
40
41impl RemovePlan {
42 pub fn to_text(&self) -> String {
44 let mut out = String::new();
45 out.push_str(&format!("План удаления {}\n", self.name));
46 out.push_str(&format!(
47 " версия: {}\n",
48 self.version.as_deref().unwrap_or("?")
49 ));
50 if self.features.is_empty() {
51 out.push_str(" фичи: (нет ссылок dep:)\n");
52 } else {
53 out.push_str(&format!(" фичи: {}\n", self.features.join(", ")));
54 }
55 for dep in &self.dependents {
56 out.push_str(&format!(
57 " предупреждение: от {dep} зависит этот компонент\n"
58 ));
59 }
60 out
61 }
62}
63
64pub fn plan_remove(project_root: &Path, name: &str) -> Result<RemovePlan> {
66 let cargo_path = project_root.join("Cargo.toml");
67 if !cargo_path.is_file() {
68 return Err(Error::Manifest(format!(
69 "Cargo.toml не найден в {}",
70 project_root.display()
71 )));
72 }
73 let text = read_cargo_toml(&cargo_path)?;
74 if !has_package_section(&text)? {
75 return Err(Error::Manifest(
76 "Cargo.toml не содержит секции [package]: укажите каталог приложения".to_string(),
77 ));
78 }
79
80 let installed = collect_dependency_details(&text, project_root)?;
81 if !installed.iter().any(|d| d.name == name) {
82 return Err(Error::NotInstalled(name.to_string()));
83 }
84
85 let version = resolve_dependency_version(project_root, name)?.map(|v| v.to_string());
86 let features = edit::features_containing(&text, name)?;
87
88 Ok(RemovePlan {
89 name: name.to_string(),
90 version,
91 features,
92 dependents: Vec::new(),
93 })
94}
95
96pub fn plan_remove_with_dependents(
99 project_root: &Path,
100 name: &str,
101 offline: bool,
102) -> Result<RemovePlan> {
103 let mut plan = plan_remove(project_root, name)?;
104 let text = read_cargo_toml(&project_root.join("Cargo.toml"))?;
105 let installed = collect_dependency_details(&text, project_root)?;
106 let catalog = Catalog::discover(project_root, offline);
107 let registry = ComponentRegistry::builtin();
108
109 for dep in &installed {
110 if dep.name == name || registry.by_name(&dep.name).is_none() {
111 continue;
112 }
113 if let Ok(resolved) = catalog.resolve(&dep.name)
114 && resolved
115 .requires
116 .iter()
117 .any(|r| requirement_name(r) == name)
118 {
119 plan.dependents.push(dep.name.clone());
120 }
121 }
122 plan.dependents.sort();
123 Ok(plan)
124}
125
126pub fn apply_remove(
128 project_root: &Path,
129 runner: &dyn CommandRunner,
130 plan: &RemovePlan,
131 opts: &RemoveOptions,
132) -> Result<()> {
133 if opts.dry_run {
134 return Ok(());
135 }
136 let snapshot = backup::create(project_root)?;
137 match do_remove(project_root, runner, plan) {
138 Ok(()) => Ok(()),
139 Err(error) => {
140 backup::restore(&snapshot)?;
141 Err(error)
142 }
143 }
144}
145
146fn do_remove(project_root: &Path, runner: &dyn CommandRunner, plan: &RemovePlan) -> Result<()> {
147 let cargo_path = project_root.join("Cargo.toml");
148 let text = read_cargo_toml(&cargo_path)?;
149 let dependency = collect_dependency_details(&text, project_root)?
150 .into_iter()
151 .find(|d| d.name == plan.name)
152 .ok_or_else(|| Error::NotInstalled(plan.name.clone()))?;
153
154 if dependency.workspace {
155 let mut doc = edit::parse(&text)?;
156 edit::remove_dependency(&mut doc, &plan.name)?;
157 std::fs::write(&cargo_path, doc.to_string())?;
158 } else {
159 cargo_cmd::run_remove(runner, project_root, &plan.name)?;
160 let text = read_cargo_toml(&cargo_path)?;
161 let mut doc = edit::parse(&text)?;
162 edit::remove_dependency(&mut doc, &plan.name)?;
163 std::fs::write(&cargo_path, doc.to_string())?;
164 }
165
166 Lockfile::from_project(project_root)?.write(project_root)?;
167 cargo_cmd::run_check(runner, project_root, false)?;
168 Ok(())
169}
170
171fn requirement_name(requirement: &str) -> &str {
172 requirement
173 .split_once(char::is_whitespace)
174 .map(|(name, _)| name)
175 .unwrap_or(requirement)
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::process::CommandOutput;
182 use std::path::PathBuf;
183 use std::sync::Mutex;
184
185 #[derive(Default)]
186 struct MockRunner {
187 calls: Mutex<Vec<String>>,
188 }
189
190 impl CommandRunner for MockRunner {
191 fn run(
192 &self,
193 program: &str,
194 args: &[String],
195 _cwd: Option<&Path>,
196 ) -> Result<CommandOutput> {
197 self.calls
198 .lock()
199 .expect("lock")
200 .push(format!("{program} {}", args.join(" ")));
201 Ok(CommandOutput {
202 status: 0,
203 stdout: String::new(),
204 stderr: String::new(),
205 })
206 }
207 }
208
209 fn temp_dir(name: &str) -> PathBuf {
210 let dir = std::env::temp_dir().join(format!("light-fw-cli-remove-{name}"));
211 let _ = std::fs::remove_dir_all(&dir);
212 std::fs::create_dir_all(&dir).expect("create temp dir");
213 dir
214 }
215
216 fn installed_fixture(name: &str) -> PathBuf {
218 let root = temp_dir(name);
219 std::fs::write(
220 root.join("Cargo.toml"),
221 "[workspace]\nmembers = [\"app\", \"comp\"]\nresolver = \"2\"\n\n\
222 [workspace.dependencies]\nlight-fw-element-form = { path = \"comp\", version = \"0.1.0\" }\n",
223 )
224 .expect("root manifest");
225 let app = root.join("app");
226 std::fs::create_dir_all(&app).expect("app");
227 std::fs::write(
228 app.join("Cargo.toml"),
229 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
230 [dependencies]\nlight = \"0.1\"\nlight-fw-element-form = { workspace = true, optional = true }\n\n\
231 [features]\ndefault = [\"full\"]\nfull = [\"dep:light-fw-element-form\"]\nelement-form = [\"dep:light-fw-element-form\"]\n",
232 )
233 .expect("app manifest");
234 let comp = root.join("comp");
235 std::fs::create_dir_all(comp.join("src")).expect("comp src");
236 std::fs::write(comp.join("src/lib.rs"), "").expect("comp lib");
237 std::fs::write(
238 comp.join("Cargo.toml"),
239 "[package]\nname = \"light-fw-element-form\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
240 [package.metadata.light]\nkind = \"element\"\ndisplay-name = \"Form\"\ncore = \"^0.1\"\n\n\
241 [package.metadata.light.provides]\nelements = [\"checkbox\"]\n",
242 )
243 .expect("comp manifest");
244 app
245 }
246
247 #[test]
248 fn plans_removal_with_features() {
249 let app = installed_fixture("plan");
250 let plan = plan_remove(&app, "light-fw-element-form").expect("plan");
251 assert_eq!(plan.version.as_deref(), Some("0.1.0"));
252 assert_eq!(plan.features, vec!["element-form", "full"]);
253 assert!(plan.to_text().contains("light-fw-element-form"));
254 }
255
256 #[test]
257 fn not_installed_is_error() {
258 let app = installed_fixture("missing");
259 let err = plan_remove(&app, "light-widget-datepicker").expect_err("not installed");
260 assert!(matches!(err, Error::NotInstalled(_)));
261 }
262
263 #[test]
264 fn removes_dependency_and_lockfile() {
265 let app = installed_fixture("apply");
266 let plan = plan_remove(&app, "light-fw-element-form").expect("plan");
267 let runner = MockRunner::default();
268 apply_remove(&app, &runner, &plan, &RemoveOptions::default()).expect("apply");
269
270 let text = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
271 assert!(!text.contains("light-fw-element-form"));
272 assert!(text.contains("full = [\"light/x\"]") || text.contains("default = [\"full\"]"));
273 assert!(app.join("light.lock").exists());
274
275 let lock = std::fs::read_to_string(app.join("light.lock")).expect("read lock");
276 assert!(
277 !lock.contains("light-fw-element-form"),
278 "удалённый компонент убран из light.lock: {lock}"
279 );
280 assert!(lock.contains("core = \"0.1.0\""), "ядро в lock: {lock}");
281
282 let calls = runner.calls.lock().expect("lock").clone();
283 assert!(calls.iter().any(|c| c == "cargo check --quiet"));
284 }
285
286 #[test]
287 fn removes_external_component_and_lockfile_entry() {
288 let dir = temp_dir("external");
289 std::fs::write(
290 dir.join("Cargo.toml"),
291 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
292 [dependencies]\nlight = \"0.1\"\n\
293 light-fw-datepicker = { version = \"0.3.0\", optional = true }\n\n\
294 [features]\ndefault = [\"full\"]\nfull = [\"dep:light-fw-datepicker\"]\n",
295 )
296 .expect("app manifest");
297
298 let plan = plan_remove(&dir, "light-fw-datepicker").expect("plan");
299 assert_eq!(plan.features, vec!["full"]);
300 apply_remove(
301 &dir,
302 &MockRunner::default(),
303 &plan,
304 &RemoveOptions::default(),
305 )
306 .expect("apply");
307
308 let text = std::fs::read_to_string(dir.join("Cargo.toml")).expect("read");
309 assert!(!text.contains("light-fw-datepicker"), "{text}");
310 assert!(text.contains("light = \"0.1\""), "{text}");
311 assert!(dir.join("light.lock").exists());
312
313 let lock = std::fs::read_to_string(dir.join("light.lock")).expect("read lock");
314 assert!(!lock.contains("light-fw-datepicker"), "{lock}");
315 }
316
317 #[test]
318 fn dry_run_does_not_touch_disk() {
319 let app = installed_fixture("dry-run");
320 let original = std::fs::read_to_string(app.join("Cargo.toml")).expect("read");
321 let plan = plan_remove(&app, "light-fw-element-form").expect("plan");
322 let opts = RemoveOptions {
323 dry_run: true,
324 ..Default::default()
325 };
326 apply_remove(&app, &MockRunner::default(), &plan, &opts).expect("apply");
327 assert_eq!(
328 std::fs::read_to_string(app.join("Cargo.toml")).expect("read"),
329 original
330 );
331 }
332}