cargo_autodd/dependency_manager/
updater.rs1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::io::BufReader;
4use std::path::PathBuf;
5use std::process::Command;
6
7use anyhow::{Context, Result};
8use semver::Version;
9use serde::Deserialize;
10use serde_json;
11use toml_edit::{DocumentMut, Item, Table};
12use ureq;
13
14use crate::models::CrateReference;
15use crate::utils::is_essential_dep;
16
17#[derive(Deserialize)]
18struct CratesIoResponse {
19 versions: Vec<CrateVersion>,
20}
21
22#[derive(Deserialize)]
23struct CrateVersion {
24 num: String,
25 yanked: bool,
26}
27
28pub struct DependencyUpdater {
29 project_root: PathBuf,
30 cargo_toml: PathBuf,
31 debug: bool,
32}
33
34impl DependencyUpdater {
35 pub fn new(project_root: PathBuf) -> Self {
36 let cargo_toml = project_root.join("Cargo.toml");
37 Self {
38 project_root,
39 cargo_toml,
40 debug: false,
41 }
42 }
43
44 pub fn with_debug(project_root: PathBuf, debug: bool) -> Self {
45 let cargo_toml = project_root.join("Cargo.toml");
46 Self {
47 project_root,
48 cargo_toml,
49 debug,
50 }
51 }
52
53 pub fn update_cargo_toml(&self, crate_refs: &HashMap<String, CrateReference>) -> Result<()> {
54 let content = fs::read_to_string(&self.cargo_toml)?;
55 let mut doc = content.parse::<DocumentMut>()?;
56
57 let is_workspace = doc.get("workspace").is_some();
59 if is_workspace && doc.get("package").is_none() {
60 if self.debug {
61 println!("This is a workspace root without a package. Skipping dependency update.");
62 }
63 return Ok(());
64 }
65
66 let (regular_deps, dev_deps): (HashMap<_, _>, HashMap<_, _>) = crate_refs
68 .iter()
69 .partition(|(_, crate_ref)| !crate_ref.is_dev_dependency);
70
71 let deps_path = self.get_dependencies_path()?;
73 let dev_deps_path = "dev-dependencies".to_string();
74
75 self.update_dependency_section(&mut doc, ®ular_deps, &deps_path)?;
77
78 if !is_workspace {
80 self.update_dependency_section(&mut doc, &dev_deps, &dev_deps_path)?;
81 }
82
83 fs::write(&self.cargo_toml, doc.to_string())?;
85
86 Ok(())
87 }
88
89 fn update_dependency_section(
90 &self,
91 doc: &mut DocumentMut,
92 deps_map: &HashMap<&String, &CrateReference>,
93 deps_path: &str,
94 ) -> Result<()> {
95 let existing_deps = if let Some(deps) = doc.get(deps_path) {
97 if let Some(table) = deps.as_table() {
98 table
99 .iter()
100 .map(|(k, _)| k.to_string())
101 .collect::<HashSet<_>>()
102 } else {
103 HashSet::new()
104 }
105 } else {
106 HashSet::new()
107 };
108
109 for crate_ref in deps_map.values() {
111 if !existing_deps.contains(&crate_ref.name) {
112 self.add_dependency(doc, crate_ref, deps_path)?;
113 }
114 }
115
116 let used_deps = deps_map
118 .keys()
119 .map(|k| (*k).clone())
120 .collect::<HashSet<_>>();
121 let to_remove = existing_deps
122 .iter()
123 .filter(|dep| !used_deps.contains(*dep) && !is_essential_dep(dep))
124 .cloned()
125 .collect::<Vec<_>>();
126
127 for dep in to_remove {
128 self.remove_dependency(doc, &dep, deps_path)?;
129 }
130
131 Ok(())
132 }
133
134 fn add_dependency(
135 &self,
136 doc: &mut DocumentMut,
137 crate_ref: &CrateReference,
138 deps_path: &str,
139 ) -> Result<()> {
140 if crate_ref.is_path_dependency
142 && let Some(path) = &crate_ref.path
143 {
144 if self.debug {
145 println!(
146 "Adding path dependency: {} with path {}",
147 crate_ref.name, path
148 );
149 }
150
151 let deps = doc
153 .entry(deps_path)
154 .or_insert(toml_edit::table())
155 .as_table_mut()
156 .ok_or_else(|| anyhow::anyhow!("Failed to get dependencies table"))?;
157
158 let mut table = Table::new();
160 table["path"] = toml_edit::value(path.clone());
161
162 if let Some(publish) = crate_ref.publish {
164 table["publish"] = toml_edit::value(publish);
165 }
166
167 deps[&crate_ref.name] = toml_edit::Item::Table(table);
168 return Ok(());
169 }
170
171 let version = match self.get_latest_version(&crate_ref.name) {
173 Ok(v) => v,
174 Err(e) => {
175 if self.debug {
177 println!(
178 "Warning: Failed to get version for {}: {}",
179 crate_ref.name, e
180 );
181 println!("This might be an internal crate not published on crates.io.");
182 println!("Skipping this dependency.");
183 }
184 return Ok(());
185 }
186 };
187
188 if self.debug {
189 println!("Adding dependency: {} = \"{}\"", crate_ref.name, version);
190 }
191
192 let deps = doc
194 .entry(deps_path)
195 .or_insert(toml_edit::table())
196 .as_table_mut()
197 .ok_or_else(|| anyhow::anyhow!("Failed to get dependencies table"))?;
198
199 deps[&crate_ref.name] = toml_edit::value(version);
201
202 Ok(())
203 }
204
205 fn remove_dependency(&self, doc: &mut DocumentMut, name: &str, deps_path: &str) -> Result<()> {
206 if deps_path.contains('.') {
207 let parts: Vec<&str> = deps_path.split('.').collect();
209 if let Some(Item::Table(parent)) = doc.get_mut(parts[0])
210 && let Some(Item::Table(deps)) = parent.get_mut(parts[1])
211 {
212 deps.remove(name);
213 }
214 } else if let Some(Item::Table(deps)) = doc.get_mut(deps_path) {
215 deps.remove(name);
216 }
217 Ok(())
218 }
219
220 pub fn get_latest_version(&self, crate_name: &str) -> Result<String> {
221 if crate_name.contains('-') && crate_name.replace('-', "_") != crate_name {
223 let normalized_name = crate_name.replace('-', "_");
224 if self.debug {
225 println!(
226 "Checking if {} is an internal crate (normalized: {})",
227 crate_name, normalized_name
228 );
229 }
230
231 let workspace_root = self.find_workspace_root()?;
233 let workspace_cargo_toml = workspace_root.join("Cargo.toml");
234
235 if workspace_cargo_toml.exists() {
236 let content = fs::read_to_string(&workspace_cargo_toml)?;
237 if content.contains(&format!("name = \"{}\"", crate_name))
238 || content.contains(&format!("name = \"{}\"", normalized_name))
239 {
240 if self.debug {
241 println!(
242 "{} appears to be an internal crate in the workspace",
243 crate_name
244 );
245 }
246 return Err(anyhow::anyhow!("Internal crate not published on crates.io"));
247 }
248 }
249 }
250
251 let url = format!("https://crates.io/api/v1/crates/{}", crate_name);
253 let response = ureq::get(&url).call();
254
255 match response {
256 Ok(res) => {
257 let reader = BufReader::new(res.into_reader());
258 let crates_io_data: CratesIoResponse = serde_json::from_reader(reader)?;
259
260 let latest_version = crates_io_data
262 .versions
263 .iter()
264 .filter(|v| !v.yanked)
265 .map(|v| Version::parse(&v.num))
266 .filter_map(Result::ok)
267 .max();
268
269 match latest_version {
270 Some(v) => {
271 Ok(format!("{}.{}.{}", v.major, v.minor, v.patch))
273 }
274 None => Err(anyhow::anyhow!(
275 "No valid versions found for {}",
276 crate_name
277 )),
278 }
279 }
280 Err(e) => Err(anyhow::anyhow!("Failed to fetch crate info: {}", e)),
281 }
282 }
283
284 fn find_workspace_root(&self) -> Result<PathBuf> {
286 let mut current_dir = self.project_root.clone();
287
288 loop {
289 let cargo_toml = current_dir.join("Cargo.toml");
290 if cargo_toml.exists() {
291 let content = fs::read_to_string(&cargo_toml)?;
292 if content.contains("[workspace]") {
293 return Ok(current_dir);
294 }
295 }
296
297 if !current_dir.pop() {
298 return Ok(self.project_root.clone());
300 }
301 }
302 }
303
304 pub fn verify_dependencies(&self) -> Result<()> {
305 Command::new("cargo")
306 .current_dir(&self.project_root)
307 .arg("check")
308 .status()
309 .context("Failed to run cargo check")?;
310 Ok(())
311 }
312
313 pub fn get_dependency_version(&self, dep: &Item) -> Option<String> {
314 match dep {
315 Item::Value(v) => Some(v.as_str()?.to_string()),
316 Item::Table(t) => t
317 .get("version")
318 .and_then(|v| v.as_str())
319 .map(|s| s.to_string()),
320 _ => None,
321 }
322 }
323
324 pub fn is_workspace(&self) -> Result<bool> {
326 let content = fs::read_to_string(&self.cargo_toml)?;
327 let doc = content.parse::<DocumentMut>()?;
328 Ok(doc.get("workspace").is_some())
329 }
330
331 pub fn get_dependencies_path(&self) -> Result<String> {
333 if self.is_workspace()? {
334 Ok("workspace.dependencies".to_string())
335 } else {
336 Ok("dependencies".to_string())
337 }
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use std::fs::File;
345 use std::io::Write;
346 use tempfile::TempDir;
347
348 fn create_cargo_toml(dir: &TempDir) -> PathBuf {
349 let path = dir.path().join("Cargo.toml");
350 let content = r#"
351[package]
352name = "test-package"
353version = "0.1.0"
354edition = "2021"
355
356[dependencies]
357serde = "1.0"
358tokio = "1.0"
359"#;
360 let mut file = File::create(&path).unwrap();
361 writeln!(file, "{}", content).unwrap();
362 path
363 }
364
365 fn create_workspace_cargo_toml(dir: &TempDir) -> PathBuf {
366 let path = dir.path().join("Cargo.toml");
367 let content = r#"
368[workspace]
369members = ["crate1", "crate2"]
370
371[package]
372name = "workspace-root"
373version = "0.1.0"
374edition = "2021"
375
376[workspace.dependencies]
377serde = "1.0"
378tokio = "1.0"
379"#;
380 let mut file = File::create(&path).unwrap();
381 writeln!(file, "{}", content).unwrap();
382 path
383 }
384
385 #[test]
386 fn test_update_cargo_toml() -> Result<()> {
387 let temp_dir = TempDir::new()?;
388 create_cargo_toml(&temp_dir);
389
390 let updater = DependencyUpdater::new(temp_dir.path().to_path_buf());
391 let mut crate_refs = HashMap::new();
392
393 let mut new_crate = CrateReference::new("regex".to_string());
395 new_crate.add_feature("unicode".to_string());
396 crate_refs.insert("regex".to_string(), new_crate);
397
398 let serde_crate = CrateReference::new("serde".to_string());
400 crate_refs.insert("serde".to_string(), serde_crate);
401
402 updater.update_cargo_toml(&crate_refs)?;
403
404 let content = fs::read_to_string(updater.cargo_toml)?;
406 assert!(content.contains("regex"));
407 assert!(content.contains("serde"));
408 assert!(!content.contains("unused-dep"));
409
410 Ok(())
411 }
412
413 #[test]
414 fn test_update_workspace_cargo_toml() -> Result<()> {
415 let temp_dir = TempDir::new()?;
416 create_workspace_cargo_toml(&temp_dir);
417
418 let updater = DependencyUpdater::new(temp_dir.path().to_path_buf());
419 let mut crate_refs = HashMap::new();
420
421 let mut new_crate = CrateReference::new("regex".to_string());
423 new_crate.add_feature("unicode".to_string());
424 crate_refs.insert("regex".to_string(), new_crate);
425
426 let serde_crate = CrateReference::new("serde".to_string());
428 crate_refs.insert("serde".to_string(), serde_crate);
429
430 updater.update_cargo_toml(&crate_refs)?;
431
432 let content = fs::read_to_string(updater.cargo_toml)?;
434 assert!(content.contains("regex"));
435 assert!(content.contains("serde"));
436 assert!(content.contains("[workspace.dependencies]"));
437
438 Ok(())
439 }
440
441 #[test]
442 fn test_is_workspace() -> Result<()> {
443 let temp_dir = TempDir::new()?;
444
445 create_cargo_toml(&temp_dir);
447 let updater = DependencyUpdater::new(temp_dir.path().to_path_buf());
448 assert!(!updater.is_workspace()?);
449
450 create_workspace_cargo_toml(&temp_dir);
452 let updater = DependencyUpdater::new(temp_dir.path().to_path_buf());
453 assert!(updater.is_workspace()?);
454
455 Ok(())
456 }
457
458 #[test]
459 fn test_remove_unused_dependency() -> Result<()> {
460 let temp_dir = TempDir::new()?;
461
462 let path = temp_dir.path().join("Cargo.toml");
464 let content = r#"
465[package]
466name = "test-package"
467version = "0.1.0"
468edition = "2021"
469
470[dependencies]
471serde = "1.0"
472tokio = "1.0"
473unused_crate = "0.1"
474another_unused = "0.2"
475"#;
476 let mut file = File::create(&path)?;
477 writeln!(file, "{}", content)?;
478
479 let updater = DependencyUpdater::new(temp_dir.path().to_path_buf());
480 let mut crate_refs = HashMap::new();
481
482 crate_refs.insert(
484 "serde".to_string(),
485 CrateReference::new("serde".to_string()),
486 );
487 crate_refs.insert(
488 "tokio".to_string(),
489 CrateReference::new("tokio".to_string()),
490 );
491
492 updater.update_cargo_toml(&crate_refs)?;
493
494 let result = fs::read_to_string(&path)?;
496 assert!(result.contains("serde"), "serde should remain");
497 assert!(result.contains("tokio"), "tokio should remain");
498 assert!(
499 !result.contains("unused_crate"),
500 "unused_crate should be removed"
501 );
502 assert!(
503 !result.contains("another_unused"),
504 "another_unused should be removed"
505 );
506
507 Ok(())
508 }
509
510 #[test]
511 fn test_preserve_essential_dependencies() -> Result<()> {
512 let temp_dir = TempDir::new()?;
513
514 let path = temp_dir.path().join("Cargo.toml");
516 let content = r#"
517[package]
518name = "test-package"
519version = "0.1.0"
520edition = "2021"
521
522[dependencies]
523serde = "1.0"
524tokio = "1.0"
525anyhow = "1.0"
526thiserror = "1.0"
527unused_crate = "0.1"
528"#;
529 let mut file = File::create(&path)?;
530 writeln!(file, "{}", content)?;
531
532 let updater = DependencyUpdater::new(temp_dir.path().to_path_buf());
533
534 let crate_refs = HashMap::new();
536
537 updater.update_cargo_toml(&crate_refs)?;
538
539 let result = fs::read_to_string(&path)?;
541 assert!(
542 result.contains("serde"),
543 "serde (essential) should be preserved"
544 );
545 assert!(
546 result.contains("tokio"),
547 "tokio (essential) should be preserved"
548 );
549 assert!(
550 result.contains("anyhow"),
551 "anyhow (essential) should be preserved"
552 );
553 assert!(
554 result.contains("thiserror"),
555 "thiserror (essential) should be preserved"
556 );
557 assert!(
558 !result.contains("unused_crate"),
559 "non-essential unused_crate should be removed"
560 );
561
562 Ok(())
563 }
564
565 #[test]
566 fn test_get_dependency_version() -> Result<()> {
567 let temp_dir = TempDir::new()?;
568 create_cargo_toml(&temp_dir);
569
570 let updater = DependencyUpdater::new(temp_dir.path().to_path_buf());
571
572 let simple_version = toml_edit::value("1.0.0");
574 assert_eq!(
575 updater.get_dependency_version(&simple_version),
576 Some("1.0.0".to_string())
577 );
578
579 let mut table = toml_edit::Table::new();
581 table["version"] = toml_edit::value("2.0.0");
582 let table_version = toml_edit::Item::Table(table);
583 assert_eq!(
584 updater.get_dependency_version(&table_version),
585 Some("2.0.0".to_string())
586 );
587
588 Ok(())
589 }
590}