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