1use std::{
2 collections::{BTreeSet, HashMap},
3 fs,
4 path::{Path, PathBuf},
5 process::Command,
6};
7
8use anyhow::{Context, Result, bail};
9use semver::{BuildMetadata, 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 mut latest_parsed = Version::parse(&latest_version)
164 .with_context(|| format!("invalid crates.io version '{latest_version}'"))?;
165 latest_parsed.build = BuildMetadata::EMPTY;
166 let latest_version = latest_parsed.to_string();
167 let update_needed = latest_parsed > parsed_requirement.version;
168
169 manifest_result.entries.push(CargoDependencyEntry {
170 file: dependency.file.clone(),
171 dependency_name: dependency.dependency_name.clone(),
172 current_requirement: Some(current_requirement.clone()),
173 latest_version: Some(latest_version.clone()),
174 update_needed,
175 managed: true,
176 reason: None,
177 });
178
179 if update_needed && mode != UpdateMode::Status {
180 let new_requirement = rewrite_requirement(¤t_requirement, &latest_version)
181 .with_context(|| {
182 format!(
183 "failed to rewrite requirement '{}' for dependency '{}'",
184 current_requirement, dependency.dependency_name
185 )
186 })?;
187 update_dependency_requirement(
188 &mut document,
189 &dependency.item_path,
190 &new_requirement,
191 )?;
192 manifest_result.changes.push(UpdateChange {
193 kind: UpdateChangeKind::CargoDependency,
194 file: dependency.file.clone(),
195 line_number: None,
196 subject: dependency.dependency_name.clone(),
197 from_version: current_requirement,
198 to_version: new_requirement,
199 });
200 manifest_changed = true;
201 }
202 }
203
204 if manifest_changed {
205 manifest_result.file_update = Some(FileUpdate {
206 file: manifest.to_path_buf(),
207 updated_content: document.to_string(),
208 });
209 }
210
211 Ok(manifest_result)
212 }
213}
214
215pub(crate) fn discover_manifest_files(repo_root: &Path) -> Vec<PathBuf> {
216 let mut files = WalkDir::new(repo_root)
217 .into_iter()
218 .filter_entry(should_scan_entry)
219 .filter_map(std::result::Result::ok)
220 .filter(|entry| entry.file_type().is_file() && entry.file_name() == "Cargo.toml")
221 .map(DirEntry::into_path)
222 .collect::<Vec<_>>();
223
224 files.sort();
225 files
226}
227
228fn should_scan_entry(entry: &DirEntry) -> bool {
229 if entry.depth() == 0 {
230 return true;
231 }
232
233 if !entry.file_type().is_dir() {
234 return true;
235 }
236
237 !matches!(entry.file_name().to_str(), Some("target" | ".git" | ".hg" | ".svn" | "node_modules"))
238}
239
240fn collect_dependencies(file: &Path, document: &DocumentMut) -> Vec<ManifestDependency> {
241 let mut dependencies = Vec::new();
242
243 collect_dependency_table(
244 file,
245 &mut dependencies,
246 document.get("dependencies"),
247 &["dependencies"],
248 );
249 collect_dependency_table(
250 file,
251 &mut dependencies,
252 document.get("dev-dependencies"),
253 &["dev-dependencies"],
254 );
255 collect_dependency_table(
256 file,
257 &mut dependencies,
258 document.get("build-dependencies"),
259 &["build-dependencies"],
260 );
261
262 if let Some(workspace_item) = document.get("workspace")
263 && let Some(workspace_table) = workspace_item.as_table()
264 {
265 collect_dependency_table(
266 file,
267 &mut dependencies,
268 workspace_table.get("dependencies"),
269 &["workspace", "dependencies"],
270 );
271 }
272
273 if let Some(target_item) = document.get("target")
274 && let Some(target_table) = target_item.as_table()
275 {
276 for (target_name, target_config) in target_table {
277 let Some(target_config) = target_config.as_table() else {
278 continue;
279 };
280 collect_dependency_table(
281 file,
282 &mut dependencies,
283 target_config.get("dependencies"),
284 &["target", target_name, "dependencies"],
285 );
286 collect_dependency_table(
287 file,
288 &mut dependencies,
289 target_config.get("dev-dependencies"),
290 &["target", target_name, "dev-dependencies"],
291 );
292 collect_dependency_table(
293 file,
294 &mut dependencies,
295 target_config.get("build-dependencies"),
296 &["target", target_name, "build-dependencies"],
297 );
298 }
299 }
300
301 dependencies
302}
303
304fn collect_dependency_table(
305 file: &Path,
306 dependencies: &mut Vec<ManifestDependency>,
307 item: Option<&Item>,
308 table_path: &[&str],
309) {
310 let Some(item) = item else {
311 return;
312 };
313 let Some(table) = item.as_table() else {
314 return;
315 };
316
317 for (name, dependency_item) in table {
318 let analysis = analyze_dependency_item(dependency_item);
319 let mut item_path =
320 table_path.iter().map(|segment| (*segment).to_owned()).collect::<Vec<_>>();
321 item_path.push(name.to_owned());
322 dependencies.push(ManifestDependency {
323 file: file.to_path_buf(),
324 dependency_name: name.to_owned(),
325 item_path,
326 current_requirement: analysis.current_requirement,
327 managed: analysis.managed,
328 reason: analysis.reason,
329 });
330 }
331}
332
333#[derive(Debug, Clone, Eq, PartialEq)]
334struct DependencyAnalysis {
335 current_requirement: Option<String>,
336 managed: bool,
337 reason: Option<String>,
338}
339
340fn analyze_dependency_item(item: &Item) -> DependencyAnalysis {
341 if let Some(value) = item.as_value()
342 && let Some(requirement) = value.as_str()
343 {
344 return DependencyAnalysis {
345 current_requirement: Some(requirement.to_owned()),
346 managed: true,
347 reason: None,
348 };
349 }
350
351 if let Some(inline_table) = item.as_inline_table() {
352 return analyze_inline_table(inline_table);
353 }
354
355 if let Some(table) = item.as_table() {
356 return analyze_table(table);
357 }
358
359 DependencyAnalysis {
360 current_requirement: None,
361 managed: false,
362 reason: Some(String::from("unsupported dependency declaration")),
363 }
364}
365
366fn analyze_inline_table(table: &InlineTable) -> DependencyAnalysis {
367 if table.contains_key("path") {
368 return unmanaged_reason("path dependency");
369 }
370 if table.contains_key("git") {
371 return unmanaged_reason("git dependency");
372 }
373 if table.contains_key("workspace") {
374 return unmanaged_reason("workspace dependency");
375 }
376 if table.contains_key("registry") || table.contains_key("registry-index") {
377 return unmanaged_reason("alternate registry dependency");
378 }
379
380 let current_requirement = table.get("version").and_then(Value::as_str).map(ToOwned::to_owned);
381 if current_requirement.is_some() {
382 return DependencyAnalysis { current_requirement, managed: true, reason: None };
383 }
384
385 unmanaged_reason("missing version requirement")
386}
387
388fn analyze_table(table: &Table) -> DependencyAnalysis {
389 if table.contains_key("path") {
390 return unmanaged_reason("path dependency");
391 }
392 if table.contains_key("git") {
393 return unmanaged_reason("git dependency");
394 }
395 if table.contains_key("workspace") {
396 return unmanaged_reason("workspace dependency");
397 }
398 if table.contains_key("registry") || table.contains_key("registry-index") {
399 return unmanaged_reason("alternate registry dependency");
400 }
401
402 let current_requirement = table
403 .get("version")
404 .and_then(Item::as_value)
405 .and_then(Value::as_str)
406 .map(ToOwned::to_owned);
407 if current_requirement.is_some() {
408 return DependencyAnalysis { current_requirement, managed: true, reason: None };
409 }
410
411 unmanaged_reason("missing version requirement")
412}
413
414fn unmanaged_reason(reason: &str) -> DependencyAnalysis {
415 DependencyAnalysis {
416 current_requirement: None,
417 managed: false,
418 reason: Some(reason.to_owned()),
419 }
420}
421
422fn build_unmanaged_entry(dependency: &ManifestDependency) -> Option<CargoDependencyEntry> {
423 (!dependency.managed).then(|| CargoDependencyEntry {
424 file: dependency.file.clone(),
425 dependency_name: dependency.dependency_name.clone(),
426 current_requirement: dependency.current_requirement.clone(),
427 latest_version: None,
428 update_needed: false,
429 managed: false,
430 reason: dependency.reason.clone(),
431 })
432}
433
434fn cached_latest_version(
435 latest_version_cache: &mut HashMap<String, String>,
436 crates_io: &CratesIoClient,
437 dependency_name: &str,
438) -> Result<String> {
439 if let Some(version) = latest_version_cache.get(dependency_name) {
440 return Ok(version.clone());
441 }
442
443 let version = crates_io.latest_stable_version(dependency_name)?;
444 latest_version_cache.insert(dependency_name.to_owned(), version.clone());
445 Ok(version)
446}
447
448fn parse_requirement(raw: &str) -> Option<ParsedRequirement> {
449 let trimmed = raw.trim();
450 if trimmed.is_empty()
451 || trimmed.contains(',')
452 || trimmed.contains('*')
453 || trimmed.contains('>')
454 || trimmed.contains('<')
455 || trimmed.contains(' ')
456 {
457 return None;
458 }
459
460 let (operator, version_text) = [
461 ("^", trimmed.strip_prefix('^')),
462 ("~", trimmed.strip_prefix('~')),
463 ("=", trimmed.strip_prefix('=')),
464 ]
465 .into_iter()
466 .find_map(|(operator, version)| version.map(|version| (operator, version)))
467 .unwrap_or(("", trimmed));
468
469 let normalized = normalize_version(version_text.trim())?;
470 let version = Version::parse(&normalized).ok()?;
471 Some(ParsedRequirement { operator: operator.to_owned(), version })
472}
473
474fn normalize_version(raw: &str) -> Option<String> {
475 let trimmed = raw.trim().trim_start_matches('v');
476 if trimmed.is_empty() {
477 return None;
478 }
479
480 let dots = trimmed.matches('.').count();
481 let normalized = match dots {
482 0 => format!("{trimmed}.0.0"),
483 1 => format!("{trimmed}.0"),
484 _ => trimmed.to_owned(),
485 };
486
487 Some(normalized)
488}
489
490fn rewrite_requirement(current_requirement: &str, latest_version: &str) -> Option<String> {
491 let parsed = parse_requirement(current_requirement)?;
492 Some(format!("{}{}", parsed.operator, latest_version))
493}
494
495fn update_dependency_requirement(
496 document: &mut DocumentMut,
497 item_path: &[String],
498 new_requirement: &str,
499) -> Result<()> {
500 let item = get_item_mut(document.as_item_mut(), item_path).ok_or_else(|| {
501 anyhow::anyhow!("failed to find dependency item '{}'", item_path.join("."))
502 })?;
503
504 if let Some(inline_table) = item.as_inline_table_mut() {
505 inline_table.insert("version", Value::from(new_requirement));
506 return Ok(());
507 }
508
509 if let Some(table) = item.as_table_mut() {
510 table["version"] = value(new_requirement);
511 return Ok(());
512 }
513
514 if item.is_value() {
515 *item = value(new_requirement);
516 return Ok(());
517 }
518
519 bail!("unsupported dependency item for '{}'", item_path.join("."))
520}
521
522pub(crate) fn get_item_mut<'a>(item: &'a mut Item, path: &[String]) -> Option<&'a mut Item> {
523 if path.is_empty() {
524 return Some(item);
525 }
526
527 let table_like = item.as_table_like_mut()?;
528 let next = table_like.get_mut(&path[0])?;
529 get_item_mut(next, &path[1..])
530}
531
532fn refresh_lockfiles(repo_root: &Path) -> Result<()> {
533 let manifest_paths = discover_lockfile_manifests(repo_root);
534
535 for manifest_path in manifest_paths {
536 let output = Command::new("cargo")
537 .arg("update")
538 .arg("--workspace")
539 .arg("--manifest-path")
540 .arg(&manifest_path)
541 .current_dir(
542 manifest_path
543 .parent()
544 .ok_or_else(|| anyhow::anyhow!("manifest has no parent directory"))?,
545 )
546 .output()
547 .with_context(|| {
548 format!("failed to refresh Cargo.lock for manifest '{}'", manifest_path.display())
549 })?;
550
551 if !output.status.success() {
552 bail!(
553 "cargo update failed for '{}': {}",
554 manifest_path.display(),
555 String::from_utf8_lossy(&output.stderr).trim()
556 );
557 }
558 }
559
560 Ok(())
561}
562
563fn write_file_updates(file_updates: &[FileUpdate]) -> Result<()> {
564 for file_update in file_updates {
565 fs::write(&file_update.file, &file_update.updated_content).with_context(|| {
566 format!("failed to write updated Cargo manifest '{}'", file_update.file.display())
567 })?;
568 }
569
570 Ok(())
571}
572
573fn discover_lockfile_manifests(repo_root: &Path) -> Vec<PathBuf> {
574 let mut manifests = BTreeSet::new();
575
576 for entry in WalkDir::new(repo_root)
577 .into_iter()
578 .filter_entry(should_scan_entry)
579 .filter_map(std::result::Result::ok)
580 .filter(|entry| entry.file_type().is_file() && entry.file_name() == "Cargo.lock")
581 {
582 let manifest = entry.path().with_file_name("Cargo.toml");
583 if manifest.exists() {
584 manifests.insert(manifest);
585 }
586 }
587
588 manifests.into_iter().collect()
589}
590
591#[cfg(test)]
592#[allow(clippy::significant_drop_tightening)]
593mod tests {
594 use std::fs;
595
596 use mockito::Server;
597 use tempfile::tempdir;
598
599 use super::{CargoUpdateOptions, CargoUpdater};
600 use crate::{CratesIoClient, UpdateMode};
601
602 #[test]
603 fn status_reports_registry_dependencies_and_skips_unmanaged_entries() {
604 let temp_dir = tempdir().expect("tempdir");
605 fs::write(
606 temp_dir.path().join("Cargo.toml"),
607 r#"[package]
608name = "demo"
609version = "0.1.0"
610edition = "2024"
611
612[dependencies]
613anyhow = "1.0.95"
614serde = { version = "^1.0.200", features = ["derive"] }
615local-crate = { path = "../local-crate" }
616git-crate = { git = "https://github.com/example/git-crate" }
617private-crate = { version = "=1.6.8", registry = "example-registry" }
618
619[target.'cfg(unix)'.dependencies]
620regex = "~1.10.0"
621"#,
622 )
623 .expect("write Cargo.toml");
624
625 let mut server = Server::new();
626 let _anyhow = server
627 .mock("GET", "/crates/anyhow")
628 .with_status(200)
629 .with_body(
630 r#"{"crate":{"id":"anyhow","name":"anyhow","max_version":"1.0.100","max_stable_version":"1.0.100","newest_version":"1.0.100"}}"#,
631 )
632 .create();
633 let _serde = server
634 .mock("GET", "/crates/serde")
635 .with_status(200)
636 .with_body(
637 r#"{"crate":{"id":"serde","name":"serde","max_version":"1.0.219","max_stable_version":"1.0.219","newest_version":"1.0.219"}}"#,
638 )
639 .create();
640 let _regex = server
641 .mock("GET", "/crates/regex")
642 .with_status(200)
643 .with_body(
644 r#"{"crate":{"id":"regex","name":"regex","max_version":"1.11.1","max_stable_version":"1.11.1","newest_version":"1.11.1"}}"#,
645 )
646 .create();
647
648 let update_manager =
649 CargoUpdater::new(CratesIoClient::new(server.url()).expect("crates.io client"));
650 let report = update_manager
651 .update(&CargoUpdateOptions {
652 repo_root: temp_dir.path().to_path_buf(),
653 mode: UpdateMode::Status,
654 })
655 .expect("cargo status");
656
657 assert_eq!(report.manifest_files, 1);
658 assert_eq!(report.dependencies_scanned, 6);
659 assert_eq!(report.unmanaged_dependencies, 3);
660 assert_eq!(report.entries.len(), 6);
661
662 let private = report
663 .entries
664 .iter()
665 .find(|entry| entry.dependency_name == "private-crate")
666 .expect("private entry");
667 assert!(!private.managed);
668 assert_eq!(private.reason.as_deref(), Some("alternate registry dependency"));
669
670 let anyhow = report
671 .entries
672 .iter()
673 .find(|entry| entry.dependency_name == "anyhow")
674 .expect("anyhow entry");
675 assert_eq!(anyhow.current_requirement.as_deref(), Some("1.0.95"));
676 assert_eq!(anyhow.latest_version.as_deref(), Some("1.0.100"));
677 assert!(anyhow.managed);
678 assert!(anyhow.update_needed);
679
680 let local = report
681 .entries
682 .iter()
683 .find(|entry| entry.dependency_name == "local-crate")
684 .expect("local entry");
685 assert!(!local.managed);
686 assert_eq!(local.reason.as_deref(), Some("path dependency"));
687 }
688
689 #[test]
690 fn build_metadata_is_stripped_from_latest_versions() {
691 let temp_dir = tempdir().expect("tempdir");
692 fs::write(
693 temp_dir.path().join("Cargo.toml"),
694 r#"[package]
695name = "demo"
696version = "0.1.0"
697edition = "2024"
698
699[dependencies]
700serde_yaml = "0.9.34"
701toml = "1.0"
702"#,
703 )
704 .expect("write Cargo.toml");
705
706 let mut server = Server::new();
707 let _serde_yaml = server
708 .mock("GET", "/crates/serde_yaml")
709 .with_status(200)
710 .with_body(
711 r#"{"crate":{"id":"serde_yaml","name":"serde_yaml","max_version":"0.9.34+deprecated","max_stable_version":"0.9.34+deprecated","newest_version":"0.9.34+deprecated"}}"#,
712 )
713 .create();
714 let _toml = server
715 .mock("GET", "/crates/toml")
716 .with_status(200)
717 .with_body(
718 r#"{"crate":{"id":"toml","name":"toml","max_version":"1.1.4+spec-1.1.0","max_stable_version":"1.1.4+spec-1.1.0","newest_version":"1.1.4+spec-1.1.0"}}"#,
719 )
720 .create();
721
722 let update_manager =
723 CargoUpdater::new(CratesIoClient::new(server.url()).expect("crates.io client"));
724 let report = update_manager
725 .update(&CargoUpdateOptions {
726 repo_root: temp_dir.path().to_path_buf(),
727 mode: UpdateMode::Status,
728 })
729 .expect("cargo status");
730
731 let serde_yaml = report
732 .entries
733 .iter()
734 .find(|entry| entry.dependency_name == "serde_yaml")
735 .expect("serde_yaml entry");
736 assert_eq!(serde_yaml.latest_version.as_deref(), Some("0.9.34"));
737 assert!(!serde_yaml.update_needed);
738
739 let toml = report
740 .entries
741 .iter()
742 .find(|entry| entry.dependency_name == "toml")
743 .expect("toml entry");
744 assert_eq!(toml.latest_version.as_deref(), Some("1.1.4"));
745 assert!(toml.update_needed);
746 }
747
748 #[test]
749 fn apply_rewrites_supported_dependency_versions() {
750 let temp_dir = tempdir().expect("tempdir");
751 let manifest = temp_dir.path().join("Cargo.toml");
752 fs::write(
753 &manifest,
754 r#"[package]
755name = "demo"
756version = "0.1.0"
757edition = "2024"
758
759[dependencies]
760anyhow = "1.0.95"
761serde = { version = "^1.0.200", features = ["derive"] }
762regex = { version = "~1.10.0" }
763reqwest = { version = "=0.12.13", default-features = false }
764"#,
765 )
766 .expect("write Cargo.toml");
767
768 let mut server = Server::new();
769 let _anyhow = server
770 .mock("GET", "/crates/anyhow")
771 .with_status(200)
772 .with_body(
773 r#"{"crate":{"id":"anyhow","name":"anyhow","max_version":"1.0.100","max_stable_version":"1.0.100","newest_version":"1.0.100"}}"#,
774 )
775 .create();
776 let _serde = server
777 .mock("GET", "/crates/serde")
778 .with_status(200)
779 .with_body(
780 r#"{"crate":{"id":"serde","name":"serde","max_version":"1.0.219","max_stable_version":"1.0.219","newest_version":"1.0.219"}}"#,
781 )
782 .create();
783 let _regex = server
784 .mock("GET", "/crates/regex")
785 .with_status(200)
786 .with_body(
787 r#"{"crate":{"id":"regex","name":"regex","max_version":"1.11.1","max_stable_version":"1.11.1","newest_version":"1.11.1"}}"#,
788 )
789 .create();
790 let _reqwest = server
791 .mock("GET", "/crates/reqwest")
792 .with_status(200)
793 .with_body(
794 r#"{"crate":{"id":"reqwest","name":"reqwest","max_version":"0.12.15","max_stable_version":"0.12.15","newest_version":"0.12.15"}}"#,
795 )
796 .create();
797
798 let update_manager =
799 CargoUpdater::new(CratesIoClient::new(server.url()).expect("crates.io client"));
800 update_manager
801 .update(&CargoUpdateOptions {
802 repo_root: temp_dir.path().to_path_buf(),
803 mode: UpdateMode::Apply,
804 })
805 .expect("cargo update");
806
807 let manifest_contents = fs::read_to_string(&manifest).expect("read updated manifest");
808 assert!(manifest_contents.contains(r#"anyhow = "1.0.100""#), "{manifest_contents}");
809 assert!(manifest_contents.contains(r#"version = "^1.0.219""#), "{manifest_contents}");
810 assert!(manifest_contents.contains(r#"version = "~1.11.1""#), "{manifest_contents}");
811 assert!(manifest_contents.contains(r#"version = "=0.12.15""#), "{manifest_contents}");
812 }
813}