1use std::{
2 collections::{BTreeSet, HashMap},
3 fs,
4 path::{Path, PathBuf},
5 process::Command,
6};
7
8use anyhow::{Context, Result, bail};
9use semver::Version;
10use toml_edit::{DocumentMut, InlineTable, Item, Table, Value, value};
11use walkdir::{DirEntry, WalkDir};
12
13use crate::{
14 crates_io::CratesIoClient,
15 model::{FileUpdate, UpdateChange, UpdateChangeKind},
16 update::UpdateMode,
17};
18
19#[derive(Debug, Clone, Eq, PartialEq)]
20pub struct CargoUpdateOptions {
21 pub repo_root: PathBuf,
22 pub mode: UpdateMode,
23}
24
25#[derive(Debug, Clone, Eq, PartialEq)]
26pub struct CargoDependencyEntry {
27 pub file: PathBuf,
28 pub dependency_name: String,
29 pub current_requirement: Option<String>,
30 pub latest_version: Option<String>,
31 pub update_needed: bool,
32 pub managed: bool,
33 pub reason: Option<String>,
34}
35
36#[derive(Debug, Clone, Eq, PartialEq, Default)]
37pub struct CargoUpdateReport {
38 pub manifest_files: usize,
39 pub dependencies_scanned: usize,
40 pub unmanaged_dependencies: usize,
41 pub entries: Vec<CargoDependencyEntry>,
42 pub changes: Vec<UpdateChange>,
43 pub file_updates: Vec<FileUpdate>,
44}
45
46#[derive(Debug, Clone)]
47pub struct CargoUpdater {
48 crates_io: CratesIoClient,
49}
50
51#[derive(Debug, Clone, Eq, PartialEq)]
52struct ManifestDependency {
53 file: PathBuf,
54 dependency_name: String,
55 item_path: Vec<String>,
56 current_requirement: Option<String>,
57 managed: bool,
58 reason: Option<String>,
59}
60
61#[derive(Debug, Clone, Eq, PartialEq)]
62struct ParsedRequirement {
63 operator: String,
64 version: Version,
65}
66
67#[derive(Debug, Default)]
68struct ManifestUpdateResult {
69 entries: Vec<CargoDependencyEntry>,
70 changes: Vec<UpdateChange>,
71 file_update: Option<FileUpdate>,
72 unmanaged_dependencies: usize,
73}
74
75impl CargoUpdater {
76 #[must_use]
77 pub const fn new(crates_io: CratesIoClient) -> Self {
78 Self { crates_io }
79 }
80
81 pub fn update(&self, options: &CargoUpdateOptions) -> Result<CargoUpdateReport> {
82 let repo_root = options.repo_root.canonicalize().with_context(|| {
83 format!("failed to resolve repository root '{}'", options.repo_root.display())
84 })?;
85 let manifest_files = discover_manifest_files(&repo_root);
86 let mut latest_version_cache = HashMap::<String, String>::new();
87 let mut entries = Vec::new();
88 let mut dependency_changes = Vec::new();
89 let mut file_updates = Vec::new();
90 let mut unmanaged_dependencies = 0usize;
91
92 for manifest in &manifest_files {
93 let manifest_result =
94 self.process_manifest(manifest, options.mode, &mut latest_version_cache)?;
95 unmanaged_dependencies += manifest_result.unmanaged_dependencies;
96 entries.extend(manifest_result.entries);
97 dependency_changes.extend(manifest_result.changes);
98 if let Some(file_update) = manifest_result.file_update {
99 file_updates.push(file_update);
100 }
101 }
102
103 if options.mode == UpdateMode::Apply && !file_updates.is_empty() {
104 write_file_updates(&file_updates)?;
105 refresh_lockfiles(&repo_root)?;
106 }
107
108 Ok(CargoUpdateReport {
109 manifest_files: manifest_files.len(),
110 dependencies_scanned: entries.len(),
111 unmanaged_dependencies,
112 entries,
113 changes: dependency_changes,
114 file_updates,
115 })
116 }
117
118 fn process_manifest(
119 &self,
120 manifest: &Path,
121 mode: UpdateMode,
122 latest_version_cache: &mut HashMap<String, String>,
123 ) -> Result<ManifestUpdateResult> {
124 let original = fs::read_to_string(manifest)
125 .with_context(|| format!("failed to read Cargo manifest '{}'", manifest.display()))?;
126 let mut document = original
127 .parse::<DocumentMut>()
128 .with_context(|| format!("failed to parse Cargo manifest '{}'", manifest.display()))?;
129 let dependencies = collect_dependencies(manifest, &document);
130 let mut manifest_result = ManifestUpdateResult::default();
131 let mut manifest_changed = false;
132
133 for dependency in dependencies {
134 if let Some(entry) = build_unmanaged_entry(&dependency) {
135 manifest_result.unmanaged_dependencies += 1;
136 manifest_result.entries.push(entry);
137 continue;
138 }
139
140 let current_requirement = dependency
141 .current_requirement
142 .clone()
143 .expect("managed dependencies always have a requirement");
144 let Some(parsed_requirement) = parse_requirement(¤t_requirement) else {
145 manifest_result.unmanaged_dependencies += 1;
146 manifest_result.entries.push(CargoDependencyEntry {
147 file: dependency.file.clone(),
148 dependency_name: dependency.dependency_name.clone(),
149 current_requirement: Some(current_requirement),
150 latest_version: None,
151 update_needed: false,
152 managed: false,
153 reason: Some(String::from("unsupported version requirement")),
154 });
155 continue;
156 };
157
158 let latest_version = cached_latest_version(
159 latest_version_cache,
160 &self.crates_io,
161 &dependency.dependency_name,
162 )?;
163 let latest_parsed = Version::parse(&latest_version)
164 .with_context(|| format!("invalid crates.io version '{latest_version}'"))?;
165 let update_needed = latest_parsed > parsed_requirement.version;
166
167 manifest_result.entries.push(CargoDependencyEntry {
168 file: dependency.file.clone(),
169 dependency_name: dependency.dependency_name.clone(),
170 current_requirement: Some(current_requirement.clone()),
171 latest_version: Some(latest_version.clone()),
172 update_needed,
173 managed: true,
174 reason: None,
175 });
176
177 if update_needed && mode != UpdateMode::Status {
178 let new_requirement = rewrite_requirement(¤t_requirement, &latest_version)
179 .with_context(|| {
180 format!(
181 "failed to rewrite requirement '{}' for dependency '{}'",
182 current_requirement, dependency.dependency_name
183 )
184 })?;
185 update_dependency_requirement(
186 &mut document,
187 &dependency.item_path,
188 &new_requirement,
189 )?;
190 manifest_result.changes.push(UpdateChange {
191 kind: UpdateChangeKind::CargoDependency,
192 file: dependency.file.clone(),
193 line_number: None,
194 subject: dependency.dependency_name.clone(),
195 from_version: current_requirement,
196 to_version: new_requirement,
197 });
198 manifest_changed = true;
199 }
200 }
201
202 if manifest_changed {
203 manifest_result.file_update = Some(FileUpdate {
204 file: manifest.to_path_buf(),
205 updated_content: document.to_string(),
206 });
207 }
208
209 Ok(manifest_result)
210 }
211}
212
213pub(crate) fn discover_manifest_files(repo_root: &Path) -> Vec<PathBuf> {
214 let mut files = WalkDir::new(repo_root)
215 .into_iter()
216 .filter_entry(should_scan_entry)
217 .filter_map(std::result::Result::ok)
218 .filter(|entry| entry.file_type().is_file() && entry.file_name() == "Cargo.toml")
219 .map(DirEntry::into_path)
220 .collect::<Vec<_>>();
221
222 files.sort();
223 files
224}
225
226fn should_scan_entry(entry: &DirEntry) -> bool {
227 if entry.depth() == 0 {
228 return true;
229 }
230
231 if !entry.file_type().is_dir() {
232 return true;
233 }
234
235 !matches!(entry.file_name().to_str(), Some("target" | ".git" | ".hg" | ".svn" | "node_modules"))
236}
237
238fn collect_dependencies(file: &Path, document: &DocumentMut) -> Vec<ManifestDependency> {
239 let mut dependencies = Vec::new();
240
241 collect_dependency_table(
242 file,
243 &mut dependencies,
244 document.get("dependencies"),
245 &["dependencies"],
246 );
247 collect_dependency_table(
248 file,
249 &mut dependencies,
250 document.get("dev-dependencies"),
251 &["dev-dependencies"],
252 );
253 collect_dependency_table(
254 file,
255 &mut dependencies,
256 document.get("build-dependencies"),
257 &["build-dependencies"],
258 );
259
260 if let Some(workspace_item) = document.get("workspace")
261 && let Some(workspace_table) = workspace_item.as_table()
262 {
263 collect_dependency_table(
264 file,
265 &mut dependencies,
266 workspace_table.get("dependencies"),
267 &["workspace", "dependencies"],
268 );
269 }
270
271 if let Some(target_item) = document.get("target")
272 && let Some(target_table) = target_item.as_table()
273 {
274 for (target_name, target_config) in target_table {
275 let Some(target_config) = target_config.as_table() else {
276 continue;
277 };
278 collect_dependency_table(
279 file,
280 &mut dependencies,
281 target_config.get("dependencies"),
282 &["target", target_name, "dependencies"],
283 );
284 collect_dependency_table(
285 file,
286 &mut dependencies,
287 target_config.get("dev-dependencies"),
288 &["target", target_name, "dev-dependencies"],
289 );
290 collect_dependency_table(
291 file,
292 &mut dependencies,
293 target_config.get("build-dependencies"),
294 &["target", target_name, "build-dependencies"],
295 );
296 }
297 }
298
299 dependencies
300}
301
302fn collect_dependency_table(
303 file: &Path,
304 dependencies: &mut Vec<ManifestDependency>,
305 item: Option<&Item>,
306 table_path: &[&str],
307) {
308 let Some(item) = item else {
309 return;
310 };
311 let Some(table) = item.as_table() else {
312 return;
313 };
314
315 for (name, dependency_item) in table {
316 let analysis = analyze_dependency_item(dependency_item);
317 let mut item_path =
318 table_path.iter().map(|segment| (*segment).to_owned()).collect::<Vec<_>>();
319 item_path.push(name.to_owned());
320 dependencies.push(ManifestDependency {
321 file: file.to_path_buf(),
322 dependency_name: name.to_owned(),
323 item_path,
324 current_requirement: analysis.current_requirement,
325 managed: analysis.managed,
326 reason: analysis.reason,
327 });
328 }
329}
330
331#[derive(Debug, Clone, Eq, PartialEq)]
332struct DependencyAnalysis {
333 current_requirement: Option<String>,
334 managed: bool,
335 reason: Option<String>,
336}
337
338fn analyze_dependency_item(item: &Item) -> DependencyAnalysis {
339 if let Some(value) = item.as_value()
340 && let Some(requirement) = value.as_str()
341 {
342 return DependencyAnalysis {
343 current_requirement: Some(requirement.to_owned()),
344 managed: true,
345 reason: None,
346 };
347 }
348
349 if let Some(inline_table) = item.as_inline_table() {
350 return analyze_inline_table(inline_table);
351 }
352
353 if let Some(table) = item.as_table() {
354 return analyze_table(table);
355 }
356
357 DependencyAnalysis {
358 current_requirement: None,
359 managed: false,
360 reason: Some(String::from("unsupported dependency declaration")),
361 }
362}
363
364fn analyze_inline_table(table: &InlineTable) -> DependencyAnalysis {
365 if table.contains_key("path") {
366 return unmanaged_reason("path dependency");
367 }
368 if table.contains_key("git") {
369 return unmanaged_reason("git dependency");
370 }
371 if table.contains_key("workspace") {
372 return unmanaged_reason("workspace dependency");
373 }
374
375 let current_requirement = table.get("version").and_then(Value::as_str).map(ToOwned::to_owned);
376 if current_requirement.is_some() {
377 return DependencyAnalysis { current_requirement, managed: true, reason: None };
378 }
379
380 unmanaged_reason("missing version requirement")
381}
382
383fn analyze_table(table: &Table) -> DependencyAnalysis {
384 if table.contains_key("path") {
385 return unmanaged_reason("path dependency");
386 }
387 if table.contains_key("git") {
388 return unmanaged_reason("git dependency");
389 }
390 if table.contains_key("workspace") {
391 return unmanaged_reason("workspace dependency");
392 }
393
394 let current_requirement = table
395 .get("version")
396 .and_then(Item::as_value)
397 .and_then(Value::as_str)
398 .map(ToOwned::to_owned);
399 if current_requirement.is_some() {
400 return DependencyAnalysis { current_requirement, managed: true, reason: None };
401 }
402
403 unmanaged_reason("missing version requirement")
404}
405
406fn unmanaged_reason(reason: &str) -> DependencyAnalysis {
407 DependencyAnalysis {
408 current_requirement: None,
409 managed: false,
410 reason: Some(reason.to_owned()),
411 }
412}
413
414fn build_unmanaged_entry(dependency: &ManifestDependency) -> Option<CargoDependencyEntry> {
415 (!dependency.managed).then(|| CargoDependencyEntry {
416 file: dependency.file.clone(),
417 dependency_name: dependency.dependency_name.clone(),
418 current_requirement: dependency.current_requirement.clone(),
419 latest_version: None,
420 update_needed: false,
421 managed: false,
422 reason: dependency.reason.clone(),
423 })
424}
425
426fn cached_latest_version(
427 latest_version_cache: &mut HashMap<String, String>,
428 crates_io: &CratesIoClient,
429 dependency_name: &str,
430) -> Result<String> {
431 if let Some(version) = latest_version_cache.get(dependency_name) {
432 return Ok(version.clone());
433 }
434
435 let version = crates_io.latest_stable_version(dependency_name)?;
436 latest_version_cache.insert(dependency_name.to_owned(), version.clone());
437 Ok(version)
438}
439
440fn parse_requirement(raw: &str) -> Option<ParsedRequirement> {
441 let trimmed = raw.trim();
442 if trimmed.is_empty()
443 || trimmed.contains(',')
444 || trimmed.contains('*')
445 || trimmed.contains('>')
446 || trimmed.contains('<')
447 || trimmed.contains(' ')
448 {
449 return None;
450 }
451
452 let (operator, version_text) = [
453 ("^", trimmed.strip_prefix('^')),
454 ("~", trimmed.strip_prefix('~')),
455 ("=", trimmed.strip_prefix('=')),
456 ]
457 .into_iter()
458 .find_map(|(operator, version)| version.map(|version| (operator, version)))
459 .unwrap_or(("", trimmed));
460
461 let normalized = normalize_version(version_text.trim())?;
462 let version = Version::parse(&normalized).ok()?;
463 Some(ParsedRequirement { operator: operator.to_owned(), version })
464}
465
466fn normalize_version(raw: &str) -> Option<String> {
467 let trimmed = raw.trim().trim_start_matches('v');
468 if trimmed.is_empty() {
469 return None;
470 }
471
472 let dots = trimmed.matches('.').count();
473 let normalized = match dots {
474 0 => format!("{trimmed}.0.0"),
475 1 => format!("{trimmed}.0"),
476 _ => trimmed.to_owned(),
477 };
478
479 Some(normalized)
480}
481
482fn rewrite_requirement(current_requirement: &str, latest_version: &str) -> Option<String> {
483 let parsed = parse_requirement(current_requirement)?;
484 Some(format!("{}{}", parsed.operator, latest_version))
485}
486
487fn update_dependency_requirement(
488 document: &mut DocumentMut,
489 item_path: &[String],
490 new_requirement: &str,
491) -> Result<()> {
492 let item = get_item_mut(document.as_item_mut(), item_path).ok_or_else(|| {
493 anyhow::anyhow!("failed to find dependency item '{}'", item_path.join("."))
494 })?;
495
496 if let Some(inline_table) = item.as_inline_table_mut() {
497 inline_table.insert("version", Value::from(new_requirement));
498 return Ok(());
499 }
500
501 if let Some(table) = item.as_table_mut() {
502 table["version"] = value(new_requirement);
503 return Ok(());
504 }
505
506 if item.is_value() {
507 *item = value(new_requirement);
508 return Ok(());
509 }
510
511 bail!("unsupported dependency item for '{}'", item_path.join("."))
512}
513
514pub(crate) fn get_item_mut<'a>(item: &'a mut Item, path: &[String]) -> Option<&'a mut Item> {
515 if path.is_empty() {
516 return Some(item);
517 }
518
519 let table_like = item.as_table_like_mut()?;
520 let next = table_like.get_mut(&path[0])?;
521 get_item_mut(next, &path[1..])
522}
523
524fn refresh_lockfiles(repo_root: &Path) -> Result<()> {
525 let manifest_paths = discover_lockfile_manifests(repo_root);
526
527 for manifest_path in manifest_paths {
528 let output = Command::new("cargo")
529 .arg("update")
530 .arg("--workspace")
531 .arg("--manifest-path")
532 .arg(&manifest_path)
533 .current_dir(
534 manifest_path
535 .parent()
536 .ok_or_else(|| anyhow::anyhow!("manifest has no parent directory"))?,
537 )
538 .output()
539 .with_context(|| {
540 format!("failed to refresh Cargo.lock for manifest '{}'", manifest_path.display())
541 })?;
542
543 if !output.status.success() {
544 bail!(
545 "cargo update failed for '{}': {}",
546 manifest_path.display(),
547 String::from_utf8_lossy(&output.stderr).trim()
548 );
549 }
550 }
551
552 Ok(())
553}
554
555fn write_file_updates(file_updates: &[FileUpdate]) -> Result<()> {
556 for file_update in file_updates {
557 fs::write(&file_update.file, &file_update.updated_content).with_context(|| {
558 format!("failed to write updated Cargo manifest '{}'", file_update.file.display())
559 })?;
560 }
561
562 Ok(())
563}
564
565fn discover_lockfile_manifests(repo_root: &Path) -> Vec<PathBuf> {
566 let mut manifests = BTreeSet::new();
567
568 for entry in WalkDir::new(repo_root)
569 .into_iter()
570 .filter_entry(should_scan_entry)
571 .filter_map(std::result::Result::ok)
572 .filter(|entry| entry.file_type().is_file() && entry.file_name() == "Cargo.lock")
573 {
574 let manifest = entry.path().with_file_name("Cargo.toml");
575 if manifest.exists() {
576 manifests.insert(manifest);
577 }
578 }
579
580 manifests.into_iter().collect()
581}
582
583#[cfg(test)]
584#[allow(clippy::significant_drop_tightening)]
585mod tests {
586 use std::fs;
587
588 use mockito::Server;
589 use tempfile::tempdir;
590
591 use super::{CargoUpdateOptions, CargoUpdater};
592 use crate::{CratesIoClient, UpdateMode};
593
594 #[test]
595 fn status_reports_registry_dependencies_and_skips_unmanaged_entries() {
596 let temp_dir = tempdir().expect("tempdir");
597 fs::write(
598 temp_dir.path().join("Cargo.toml"),
599 r#"[package]
600name = "demo"
601version = "0.1.0"
602edition = "2024"
603
604[dependencies]
605anyhow = "1.0.95"
606serde = { version = "^1.0.200", features = ["derive"] }
607local-crate = { path = "../local-crate" }
608git-crate = { git = "https://github.com/example/git-crate" }
609
610[target.'cfg(unix)'.dependencies]
611regex = "~1.10.0"
612"#,
613 )
614 .expect("write Cargo.toml");
615
616 let mut server = Server::new();
617 let _anyhow = server
618 .mock("GET", "/crates/anyhow")
619 .with_status(200)
620 .with_body(
621 r#"{"crate":{"id":"anyhow","name":"anyhow","max_version":"1.0.100","max_stable_version":"1.0.100","newest_version":"1.0.100"}}"#,
622 )
623 .create();
624 let _serde = server
625 .mock("GET", "/crates/serde")
626 .with_status(200)
627 .with_body(
628 r#"{"crate":{"id":"serde","name":"serde","max_version":"1.0.219","max_stable_version":"1.0.219","newest_version":"1.0.219"}}"#,
629 )
630 .create();
631 let _regex = server
632 .mock("GET", "/crates/regex")
633 .with_status(200)
634 .with_body(
635 r#"{"crate":{"id":"regex","name":"regex","max_version":"1.11.1","max_stable_version":"1.11.1","newest_version":"1.11.1"}}"#,
636 )
637 .create();
638
639 let update_manager =
640 CargoUpdater::new(CratesIoClient::new(server.url()).expect("crates.io client"));
641 let report = update_manager
642 .update(&CargoUpdateOptions {
643 repo_root: temp_dir.path().to_path_buf(),
644 mode: UpdateMode::Status,
645 })
646 .expect("cargo status");
647
648 assert_eq!(report.manifest_files, 1);
649 assert_eq!(report.dependencies_scanned, 5);
650 assert_eq!(report.unmanaged_dependencies, 2);
651 assert_eq!(report.entries.len(), 5);
652
653 let anyhow = report
654 .entries
655 .iter()
656 .find(|entry| entry.dependency_name == "anyhow")
657 .expect("anyhow entry");
658 assert_eq!(anyhow.current_requirement.as_deref(), Some("1.0.95"));
659 assert_eq!(anyhow.latest_version.as_deref(), Some("1.0.100"));
660 assert!(anyhow.managed);
661 assert!(anyhow.update_needed);
662
663 let local = report
664 .entries
665 .iter()
666 .find(|entry| entry.dependency_name == "local-crate")
667 .expect("local entry");
668 assert!(!local.managed);
669 assert_eq!(local.reason.as_deref(), Some("path dependency"));
670 }
671
672 #[test]
673 fn apply_rewrites_supported_dependency_versions() {
674 let temp_dir = tempdir().expect("tempdir");
675 let manifest = temp_dir.path().join("Cargo.toml");
676 fs::write(
677 &manifest,
678 r#"[package]
679name = "demo"
680version = "0.1.0"
681edition = "2024"
682
683[dependencies]
684anyhow = "1.0.95"
685serde = { version = "^1.0.200", features = ["derive"] }
686regex = { version = "~1.10.0" }
687reqwest = { version = "=0.12.13", default-features = false }
688"#,
689 )
690 .expect("write Cargo.toml");
691
692 let mut server = Server::new();
693 let _anyhow = server
694 .mock("GET", "/crates/anyhow")
695 .with_status(200)
696 .with_body(
697 r#"{"crate":{"id":"anyhow","name":"anyhow","max_version":"1.0.100","max_stable_version":"1.0.100","newest_version":"1.0.100"}}"#,
698 )
699 .create();
700 let _serde = server
701 .mock("GET", "/crates/serde")
702 .with_status(200)
703 .with_body(
704 r#"{"crate":{"id":"serde","name":"serde","max_version":"1.0.219","max_stable_version":"1.0.219","newest_version":"1.0.219"}}"#,
705 )
706 .create();
707 let _regex = server
708 .mock("GET", "/crates/regex")
709 .with_status(200)
710 .with_body(
711 r#"{"crate":{"id":"regex","name":"regex","max_version":"1.11.1","max_stable_version":"1.11.1","newest_version":"1.11.1"}}"#,
712 )
713 .create();
714 let _reqwest = server
715 .mock("GET", "/crates/reqwest")
716 .with_status(200)
717 .with_body(
718 r#"{"crate":{"id":"reqwest","name":"reqwest","max_version":"0.12.15","max_stable_version":"0.12.15","newest_version":"0.12.15"}}"#,
719 )
720 .create();
721
722 let update_manager =
723 CargoUpdater::new(CratesIoClient::new(server.url()).expect("crates.io client"));
724 update_manager
725 .update(&CargoUpdateOptions {
726 repo_root: temp_dir.path().to_path_buf(),
727 mode: UpdateMode::Apply,
728 })
729 .expect("cargo update");
730
731 let manifest_contents = fs::read_to_string(&manifest).expect("read updated manifest");
732 assert!(manifest_contents.contains(r#"anyhow = "1.0.100""#), "{manifest_contents}");
733 assert!(manifest_contents.contains(r#"version = "^1.0.219""#), "{manifest_contents}");
734 assert!(manifest_contents.contains(r#"version = "~1.11.1""#), "{manifest_contents}");
735 assert!(manifest_contents.contains(r#"version = "=0.12.15""#), "{manifest_contents}");
736 }
737}