Skip to main content

github_actions_maintainer/
cargo.rs

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(&current_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(&current_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}