Skip to main content

light_fw_cli_core/
remove.rs

1//! Логика команды `light remove`: план и применение удаления компонента.
2//!
3//! Аналогично [`crate::add`], разделено на [`plan_remove`] и [`apply_remove`].
4//! Перед мутацией создаётся резервная копия; при ошибке проект откатывается.
5
6use 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/// Опции команды `light remove`.
18#[derive(Debug, Clone, Default)]
19pub struct RemoveOptions {
20    /// Не задавать вопросов (`--yes`).
21    pub yes: bool,
22    /// Только показать план (`--dry-run`).
23    pub dry_run: bool,
24    /// Без сетевого доступа (`--offline`).
25    pub offline: bool,
26}
27
28/// План удаления компонента.
29#[derive(Debug, Clone)]
30pub struct RemovePlan {
31    /// Имя крейта.
32    pub name: String,
33    /// Установленная версия (если определена).
34    pub version: Option<String>,
35    /// Фичи, из которых будет убран `dep:`-токен.
36    pub features: Vec<String>,
37    /// Установленные компоненты, зависящие от удаляемого.
38    pub dependents: Vec<String>,
39}
40
41impl RemovePlan {
42    /// Человекочитаемое описание плана.
43    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
64/// Строит план удаления компонента `name`.
65pub 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
96/// Строит план удаления, определяя зависимые компоненты (нужен сетевой доступ
97/// для удалённых компонентов, если не задан `--offline`).
98pub 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
126/// Применяет план удаления: мутирует проект и проверяет сборку.
127pub 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    /// Мини-workspace с уже установленным компонентом и фичей.
217    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}