Skip to main content

light_fw_cli_core/
update.rs

1//! Логика команды `light update`: обновление компонентов до актуальных версий.
2//!
3//! Версия локальных (workspace/vendor) компонентов определяется самим крейтом,
4//! поэтому они помечаются как не требующие обновления. Для компонентов с
5//! crates.io источником актуальной считается версия из встроенного реестра.
6//! Сетевые запросы не выполняются — команда работает офлайн.
7
8use std::path::Path;
9
10use crate::error::{Error, Result};
11use crate::manifest::{
12    DepDetail, collect_dependency_details, read_cargo_toml, resolve_dependency_version,
13};
14use crate::process::CommandRunner;
15use crate::registry::ComponentRegistry;
16use crate::{backup, cargo as cargo_cmd, edit, lockfile::Lockfile};
17
18/// Опции команды `light update`.
19#[derive(Debug, Clone, Default)]
20pub struct UpdateOptions {
21    /// Обновить только этот компонент.
22    pub component: Option<String>,
23    /// Не задавать вопросов.
24    pub yes: bool,
25    /// Только показать план.
26    pub dry_run: bool,
27    /// Не запускать `cargo check`.
28    pub no_verify: bool,
29}
30
31/// Один компонент в плане обновления.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct UpdateEntry {
34    /// Имя крейта.
35    pub name: String,
36    /// Текущая версия.
37    pub from: Option<String>,
38    /// Актуальная версия.
39    pub to: String,
40    /// Есть ли что обновлять.
41    pub changed: bool,
42    /// Пояснение (для локальных компонентов).
43    pub note: Option<String>,
44}
45
46/// План обновления.
47#[derive(Debug, Clone)]
48pub struct UpdatePlan {
49    /// Компоненты.
50    pub entries: Vec<UpdateEntry>,
51}
52
53impl UpdatePlan {
54    /// Сколько компонентов изменится.
55    pub fn changed_count(&self) -> usize {
56        self.entries.iter().filter(|e| e.changed).count()
57    }
58
59    /// Человекочитаемое описание.
60    pub fn to_text(&self) -> String {
61        let mut out = String::new();
62        if self.entries.is_empty() {
63            out.push_str("Нет установленных компонентов.\n");
64            return out;
65        }
66        out.push_str("Обновление компонентов:\n");
67        for entry in &self.entries {
68            let from = entry.from.as_deref().unwrap_or("?");
69            if entry.changed {
70                out.push_str(&format!("  ↑ {} {} → {}\n", entry.name, from, entry.to));
71            } else if let Some(note) = &entry.note {
72                out.push_str(&format!("  · {} {from} ({note})\n", entry.name));
73            } else {
74                out.push_str(&format!("  = {} {from} (актуален)\n", entry.name));
75            }
76        }
77        out
78    }
79}
80
81/// Строит план обновления.
82pub fn plan_update(project_root: &Path, opts: &UpdateOptions) -> Result<UpdatePlan> {
83    let cargo_path = project_root.join("Cargo.toml");
84    if !cargo_path.is_file() {
85        return Err(Error::Manifest(format!(
86            "Cargo.toml не найден в {}",
87            project_root.display()
88        )));
89    }
90    let text = read_cargo_toml(&cargo_path)?;
91    let installed = collect_dependency_details(&text, project_root)?;
92    let registry = ComponentRegistry::builtin();
93
94    let mut entries = Vec::new();
95    for dep in &installed {
96        if registry.by_name(&dep.name).is_none() {
97            continue;
98        }
99        if let Some(filter) = &opts.component
100            && &dep.name != filter
101        {
102            continue;
103        }
104        entries.push(plan_entry(project_root, dep, &registry)?);
105    }
106
107    if let Some(filter) = &opts.component
108        && entries.is_empty()
109    {
110        return Err(Error::NotInstalled(filter.clone()));
111    }
112
113    entries.sort_by(|a, b| a.name.cmp(&b.name));
114    Ok(UpdatePlan { entries })
115}
116
117fn plan_entry(
118    project_root: &Path,
119    dep: &DepDetail,
120    registry: &ComponentRegistry,
121) -> Result<UpdateEntry> {
122    let from = resolve_dependency_version(project_root, &dep.name)?;
123    let local = dep.workspace || dep.path.is_some();
124    let to = if local {
125        from.clone().or_else(|| {
126            registry
127                .by_name(&dep.name)
128                .map(|i| i.latest_version.clone())
129        })
130    } else {
131        registry
132            .by_name(&dep.name)
133            .map(|i| i.latest_version.clone())
134    };
135    let to = to.ok_or_else(|| {
136        Error::Manifest(format!(
137            "не удалось определить актуальную версию {}",
138            dep.name
139        ))
140    })?;
141
142    let changed = !local && from.as_ref().is_none_or(|f| to > *f);
143    Ok(UpdateEntry {
144        name: dep.name.clone(),
145        from: from.map(|v| v.to_string()),
146        to: to.to_string(),
147        changed,
148        note: local.then(|| "версия определяется локальным крейтом".to_string()),
149    })
150}
151
152/// Применяет план обновления.
153pub fn apply_update(
154    project_root: &Path,
155    runner: &dyn CommandRunner,
156    plan: &UpdatePlan,
157    opts: &UpdateOptions,
158) -> Result<()> {
159    if opts.dry_run || plan.changed_count() == 0 {
160        return Ok(());
161    }
162    let snapshot = backup::create(project_root)?;
163    match do_update(project_root, runner, plan, opts) {
164        Ok(()) => Ok(()),
165        Err(error) => {
166            backup::restore(&snapshot)?;
167            Err(error)
168        }
169    }
170}
171
172fn do_update(
173    project_root: &Path,
174    runner: &dyn CommandRunner,
175    plan: &UpdatePlan,
176    opts: &UpdateOptions,
177) -> Result<()> {
178    let cargo_path = project_root.join("Cargo.toml");
179    let text = read_cargo_toml(&cargo_path)?;
180    let mut doc = edit::parse(&text)?;
181    for entry in plan.entries.iter().filter(|e| e.changed) {
182        edit::set_dependency_version(&mut doc, &entry.name, &entry.to)?;
183    }
184    std::fs::write(&cargo_path, doc.to_string())?;
185
186    Lockfile::from_project(project_root)?.write(project_root)?;
187    if !opts.no_verify {
188        cargo_cmd::run_check(runner, project_root, false)?;
189    }
190    Ok(())
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::process::CommandOutput;
197    use std::path::PathBuf;
198
199    struct MockRunner;
200
201    impl CommandRunner for MockRunner {
202        fn run(
203            &self,
204            _program: &str,
205            _args: &[String],
206            _cwd: Option<&Path>,
207        ) -> Result<CommandOutput> {
208            Ok(CommandOutput {
209                status: 0,
210                stdout: String::new(),
211                stderr: String::new(),
212            })
213        }
214    }
215
216    fn temp_dir(name: &str) -> PathBuf {
217        let dir = std::env::temp_dir().join(format!("light-fw-cli-update-{name}"));
218        let _ = std::fs::remove_dir_all(&dir);
219        std::fs::create_dir_all(&dir).expect("create temp dir");
220        dir
221    }
222
223    const OLD_MANIFEST: &str = "[package]\nname = \"p\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
224         [workspace]\n\n[dependencies]\nlight = \"0.1\"\nlight-fw-element-form = \"0.0.1\"\n";
225
226    #[test]
227    fn plans_outdated_component() {
228        let dir = temp_dir("plan");
229        std::fs::write(dir.join("Cargo.toml"), OLD_MANIFEST).expect("write");
230        // light не компонент? — он в реестре как ядро? нет: registry знает только компоненты.
231        let plan = plan_update(&dir, &UpdateOptions::default()).expect("plan");
232        assert_eq!(plan.changed_count(), 1);
233        let entry = &plan.entries[0];
234        assert_eq!(entry.name, "light-fw-element-form");
235        assert_eq!(entry.from.as_deref(), Some("0.0.1"));
236        assert_eq!(entry.to, "0.4.0");
237        assert!(plan.to_text().contains("→ 0.4.0"));
238    }
239
240    #[test]
241    fn applies_version_update() {
242        let dir = temp_dir("apply");
243        std::fs::write(dir.join("Cargo.toml"), OLD_MANIFEST).expect("write");
244        let plan = plan_update(&dir, &UpdateOptions::default()).expect("plan");
245        apply_update(&dir, &MockRunner, &plan, &UpdateOptions::default()).expect("apply");
246        let text = std::fs::read_to_string(dir.join("Cargo.toml")).expect("read");
247        assert!(text.contains("light-fw-element-form = \"0.4.0\""), "{text}");
248        assert!(dir.join("light.lock").exists());
249    }
250
251    #[test]
252    fn up_to_date_is_noop() {
253        let dir = temp_dir("uptodate");
254        std::fs::write(
255            dir.join("Cargo.toml"),
256            "[package]\nname = \"p\"\nversion = \"0.1.0\"\n\n[workspace]\n\n[dependencies]\nlight-fw-element-form = \"0.4.0\"\n",
257        )
258        .expect("write");
259        let plan = plan_update(&dir, &UpdateOptions::default()).expect("plan");
260        assert_eq!(plan.changed_count(), 0);
261        apply_update(&dir, &MockRunner, &plan, &UpdateOptions::default()).expect("apply");
262        assert!(!dir.join("light.lock").exists(), "ничего не менялось");
263    }
264
265    #[test]
266    fn filter_unknown_component_is_error() {
267        let dir = temp_dir("filter");
268        std::fs::write(dir.join("Cargo.toml"), OLD_MANIFEST).expect("write");
269        let opts = UpdateOptions {
270            component: Some("light-widget-datepicker".to_string()),
271            ..Default::default()
272        };
273        let err = plan_update(&dir, &opts).expect_err("not installed");
274        assert!(matches!(err, Error::NotInstalled(_)));
275    }
276}