xtask/
bump_versions.rs

1// Copyright (c) Meta Platforms, Inc. and affiliates.
2//
3// This software may be used and distributed according to the terms of the
4// GNU General Public License version 2.
5
6use anyhow::Result;
7use regex::Regex;
8use std::collections::{HashMap, HashSet};
9use std::fs;
10use std::path::PathBuf;
11
12use crate::get_cargo_metadata;
13use crate::get_rust_paths;
14
15pub fn bump_versions_command(packages: Vec<String>, all: bool) -> Result<()> {
16    // Determine target crates
17    let target_crates = if all {
18        get_all_workspace_crates()?
19    } else {
20        packages
21    };
22
23    if target_crates.is_empty() {
24        log::info!("No crates to bump.");
25        return Ok(());
26    }
27
28    log::info!("Analyzing workspace dependencies...");
29
30    // Get cargo metadata
31    let metadata = get_cargo_metadata()?;
32
33    // Build map of workspace crates
34    let workspace_member_ids: HashSet<String> = metadata
35        .workspace_members
36        .iter()
37        .map(|id| id.to_string())
38        .collect();
39
40    let mut workspace_members = HashSet::new();
41    let mut crate_paths = HashMap::new();
42
43    for pkg in &metadata.packages {
44        if workspace_member_ids.contains(&pkg.id.to_string()) {
45            workspace_members.insert(pkg.name.to_string());
46            crate_paths.insert(
47                pkg.name.to_string(),
48                pkg.manifest_path.as_std_path().to_path_buf(),
49            );
50        }
51    }
52
53    // Validate target crates exist
54    for crate_name in &target_crates {
55        if !workspace_members.contains(crate_name) {
56            return Err(anyhow::anyhow!(
57                "Crate '{}' not found in workspace",
58                crate_name
59            ));
60        }
61    }
62
63    // Find all crates that need to be bumped
64    let mut crates_to_bump = HashSet::new();
65    let mut version_updates = HashMap::new();
66
67    // Start with target crates
68    for target in &target_crates {
69        crates_to_bump.insert(target.clone());
70    }
71
72    // Find dependencies of target crates (what the target crates depend on)
73    for target_crate in &target_crates {
74        // Find the target crate's package in metadata
75        for pkg in &metadata.packages {
76            let pkg_name = pkg.name.as_str();
77
78            if pkg_name == target_crate && workspace_members.contains(pkg_name) {
79                // Add all workspace dependencies of this target crate (exclude dev dependencies)
80                for dep in &pkg.dependencies {
81                    let dep_name = dep.name.as_str();
82                    let is_workspace_dep = dep.source.is_none(); // workspace dependency has null source
83                                                                 // Only include regular dependencies and build dependencies
84                                                                 // Exclude dev dependencies
85                    if is_workspace_dep
86                        && workspace_members.contains(dep_name)
87                        && !matches!(dep.kind, cargo_metadata::DependencyKind::Development)
88                    {
89                        crates_to_bump.insert(dep_name.to_string());
90                    }
91                }
92                break;
93            }
94        }
95    }
96
97    let sorted_crates: Vec<String> = crates_to_bump.iter().cloned().collect();
98    log::info!("Bumping versions for: {}", sorted_crates.join(", "));
99
100    // Show dependencies being bumped
101    let target_set: HashSet<String> = target_crates.iter().cloned().collect();
102    let deps: Vec<String> = crates_to_bump.difference(&target_set).cloned().collect();
103    if !deps.is_empty() {
104        log::info!("Found dependencies: {}", deps.join(", "));
105    }
106
107    // Bump all versions
108    for crate_name in &crates_to_bump {
109        if let Some(crate_path) = crate_paths.get(crate_name) {
110            let (old_version, new_version) = bump_crate_version(crate_path)?;
111            version_updates.insert(crate_name.clone(), new_version.clone());
112            log::info!("Bumping {crate_name}: {old_version} → {new_version}");
113        }
114    }
115
116    // Update dependency references in all affected files
117    update_dependent_versions(&version_updates)?;
118
119    log::info!("\nUpdated {} crates successfully.", crates_to_bump.len());
120    Ok(())
121}
122
123pub fn get_all_workspace_crates() -> Result<Vec<String>> {
124    let metadata = get_cargo_metadata()?;
125    let mut crates = Vec::new();
126
127    let workspace_member_ids: HashSet<String> = metadata
128        .workspace_members
129        .iter()
130        .map(|id| id.to_string())
131        .collect();
132
133    for pkg in &metadata.packages {
134        if workspace_member_ids.contains(&pkg.id.to_string()) {
135            crates.push(pkg.name.to_string());
136        }
137    }
138
139    Ok(crates)
140}
141
142pub fn bump_crate_version(crate_path: &PathBuf) -> Result<(String, String)> {
143    let content = fs::read_to_string(crate_path)?;
144    let lines: Vec<&str> = content.lines().collect();
145
146    let version_re = Regex::new(r#"(^\s*version\s*=\s*")([^"]*)(".*$)"#)?;
147
148    for (line_no, line) in lines.iter().enumerate() {
149        if let Some(captures) = version_re.captures(line) {
150            let current_version = captures.get(2).unwrap().as_str();
151            let new_version = increment_patch_version(current_version)?;
152
153            // Update the file
154            let mut new_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
155            new_lines[line_no] = format!(
156                "{}{}{}",
157                captures.get(1).unwrap().as_str(),
158                new_version,
159                captures.get(3).unwrap().as_str()
160            );
161
162            let new_content = new_lines.join("\n") + "\n";
163            fs::write(crate_path, new_content)?;
164
165            return Ok((current_version.to_string(), new_version));
166        }
167    }
168
169    Err(anyhow::anyhow!(
170        "Could not find version in {:?}",
171        crate_path
172    ))
173}
174
175fn increment_patch_version(version: &str) -> Result<String> {
176    let parts: Vec<&str> = version.split('.').collect();
177    if parts.len() >= 3 {
178        let major = parts[0];
179        let minor = parts[1];
180        let patch: u32 = parts[2]
181            .parse()
182            .map_err(|_| anyhow::anyhow!("Invalid patch version: {}", parts[2]))?;
183        let new_patch = patch + 1;
184
185        // Handle any additional parts (like pre-release identifiers)
186        if parts.len() > 3 {
187            let extra: Vec<&str> = parts[3..].to_vec();
188            Ok(format!(
189                "{}.{}.{}.{}",
190                major,
191                minor,
192                new_patch,
193                extra.join(".")
194            ))
195        } else {
196            Ok(format!("{major}.{minor}.{new_patch}"))
197        }
198    } else {
199        Err(anyhow::anyhow!("Invalid version format: {}", version))
200    }
201}
202
203pub fn update_dependent_versions(updates: &HashMap<String, String>) -> Result<()> {
204    let rust_paths = get_rust_paths()?;
205    let section_re = Regex::new(r"^\s*\[([^\[\]]*)\]\s*$")?;
206
207    for path in rust_paths {
208        let content = fs::read_to_string(&path)?;
209        let lines: Vec<&str> = content.lines().collect();
210        let mut new_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
211        let mut modified = false;
212
213        let mut in_dep_section = false;
214        let mut block_depth = 0;
215
216        for (line_no, line) in lines.iter().enumerate() {
217            // Check for dependency sections
218            if let Some(captures) = section_re.captures(line) {
219                if block_depth != 0 {
220                    continue;
221                }
222                let section = captures.get(1).unwrap().as_str().trim();
223                // Include all dependency sections
224                in_dep_section = section == "dependencies"
225                    || section == "build-dependencies"
226                    || section == "dev-dependencies";
227                continue;
228            }
229
230            if !in_dep_section {
231                continue;
232            }
233
234            // Track nesting depth
235            block_depth += line.matches('{').count() as i32 - line.matches('}').count() as i32;
236            block_depth += line.matches('[').count() as i32 - line.matches(']').count() as i32;
237
238            if block_depth == 0 {
239                // Look for workspace dependencies that need version updates
240                for (crate_name, new_version) in updates {
241                    let pattern = format!(
242                        r#"(^\s*{}\s*=.*version\s*=\s*")([^"]*)(".*$)"#,
243                        regex::escape(crate_name)
244                    );
245                    if let Some(captures) = Regex::new(&pattern)?.captures(line) {
246                        new_lines[line_no] = format!(
247                            "{}{}{}",
248                            captures.get(1).unwrap().as_str(),
249                            new_version,
250                            captures.get(3).unwrap().as_str()
251                        );
252                        modified = true;
253                        break;
254                    }
255                }
256            }
257        }
258
259        if modified {
260            let new_content = new_lines.join("\n") + "\n";
261            fs::write(&path, new_content)?;
262        }
263    }
264
265    Ok(())
266}