1use 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#[derive(Debug, Clone, Default)]
20pub struct UpdateOptions {
21 pub component: Option<String>,
23 pub yes: bool,
25 pub dry_run: bool,
27 pub no_verify: bool,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct UpdateEntry {
34 pub name: String,
36 pub from: Option<String>,
38 pub to: String,
40 pub changed: bool,
42 pub note: Option<String>,
44}
45
46#[derive(Debug, Clone)]
48pub struct UpdatePlan {
49 pub entries: Vec<UpdateEntry>,
51}
52
53impl UpdatePlan {
54 pub fn changed_count(&self) -> usize {
56 self.entries.iter().filter(|e| e.changed).count()
57 }
58
59 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
81pub 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, ®istry)?);
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
152pub 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 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}