1#![allow(clippy::disallowed_methods, reason = "tooling is exempt")]
2use std::io::{self, Write};
3use std::process::{Command, Output, Stdio};
4
5use anyhow::{Context as _, Result, bail};
6use clap::Parser;
7
8#[derive(Parser)]
9pub struct PublishGpuiArgs {
10 /// Optional pre-release identifier to append to the version (e.g., alpha, test.1). Always bumps the minor version.
11 #[arg(long)]
12 pre_release: Option<String>,
13
14 /// Perform a dry-run and wait for user confirmation before each publish
15 #[arg(long)]
16 dry_run: bool,
17}
18
19pub fn run_publish_gpui(args: PublishGpuiArgs) -> Result<()> {
20 println!(
21 "Starting GPUI publish process{}...",
22 if args.dry_run { " (with dry-run)" } else { "" }
23 );
24
25 let start_time = std::time::Instant::now();
26 check_workspace_root()?;
27 ensure_cargo_set_version()?;
28 check_git_clean()?;
29
30 let current_version = read_gpui_version()?;
31 let new_version = bump_version(¤t_version, args.pre_release.as_deref())?;
32 println!(
33 "Updating GPUI version: {} -> {}",
34 current_version, new_version
35 );
36 publish_dependencies(&new_version, args.dry_run)?;
37 publish_gpui(&new_version, args.dry_run)?;
38 println!("GPUI published in {}s", start_time.elapsed().as_secs_f32());
39 Ok(())
40}
41
42fn read_gpui_version() -> Result<String> {
43 let gpui_cargo_toml_path = "crates/gpui/Cargo.toml";
44 let contents = std::fs::read_to_string(gpui_cargo_toml_path)
45 .context("Failed to read crates/gpui/Cargo.toml")?;
46
47 let cargo_toml: toml::Value =
48 toml::from_str(&contents).context("Failed to parse crates/gpui/Cargo.toml")?;
49
50 let version = cargo_toml
51 .get("package")
52 .and_then(|p| p.get("version"))
53 .and_then(|v| v.as_str())
54 .context("Failed to find version in crates/gpui/Cargo.toml")?;
55
56 Ok(version.to_string())
57}
58
59fn bump_version(current_version: &str, pre_release: Option<&str>) -> Result<String> {
60 // Strip any existing metadata and pre-release
61 let without_metadata = current_version.split('+').next().unwrap();
62 let base_version = without_metadata.split('-').next().unwrap();
63
64 // Parse major.minor.patch
65 let parts: Vec<&str> = base_version.split('.').collect();
66 if parts.len() != 3 {
67 bail!("Invalid version format: {}", current_version);
68 }
69
70 let major: u32 = parts[0].parse().context("Failed to parse major version")?;
71 let minor: u32 = parts[1].parse().context("Failed to parse minor version")?;
72
73 // Always bump minor version
74 let new_version = format!("{}.{}.0", major, minor + 1);
75
76 // Add pre-release if specified
77 if let Some(pre) = pre_release {
78 Ok(format!("{}-{}", new_version, pre))
79 } else {
80 Ok(new_version)
81 }
82}
83
84fn publish_dependencies(new_version: &str, dry_run: bool) -> Result<()> {
85 let gpui_dependencies = vec![
86 ("zed-collections", "collections"),
87 ("zed-perf", "perf"),
88 ("zed-util-macros", "util_macros"),
89 ("zed-util", "util"),
90 ("gpui-macros", "gpui_macros"),
91 ("zed-http-client", "http_client"),
92 ("zed-derive-refineable", "derive_refineable"),
93 ("zed-refineable", "refineable"),
94 ("zed-semantic-version", "semantic_version"),
95 ("zed-sum-tree", "sum_tree"),
96 ("zed-media", "media"),
97 ];
98
99 for (crate_name, package_name) in gpui_dependencies {
100 println!(
101 "Publishing dependency: {} (package: {})",
102 crate_name, package_name
103 );
104
105 update_crate_version(crate_name, new_version)?;
106 update_workspace_dependency_version(package_name, new_version)?;
107 publish_crate(crate_name, dry_run)?;
108
109 // println!("Waiting 60s for the rate limit...");
110 // thread::sleep(Duration::from_secs(60));
111 }
112
113 Ok(())
114}
115
116fn publish_gpui(new_version: &str, dry_run: bool) -> Result<()> {
117 update_crate_version("gpui", new_version)?;
118
119 publish_crate("gpui", dry_run)?;
120
121 Ok(())
122}
123
124fn update_crate_version(package_name: &str, new_version: &str) -> Result<()> {
125 let output = run_command(
126 Command::new("cargo")
127 .arg("set-version")
128 .arg("--package")
129 .arg(package_name)
130 .arg(new_version),
131 )?;
132
133 if !output.status.success() {
134 bail!("Failed to set version for package {}", package_name);
135 }
136
137 Ok(())
138}
139
140fn publish_crate(crate_name: &str, dry_run: bool) -> Result<()> {
141 let publish_crate_impl = |crate_name, dry_run| {
142 let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
143
144 let mut command = Command::new(&cargo);
145 command
146 .arg("publish")
147 .arg("--allow-dirty")
148 .args(["-p", crate_name]);
149
150 if dry_run {
151 command.arg("--dry-run");
152 }
153
154 run_command(&mut command)?;
155
156 anyhow::Ok(())
157 };
158
159 if dry_run {
160 publish_crate_impl(crate_name, true)?;
161
162 print!("Press Enter to publish for real (or ctrl-c to abort)...");
163 io::stdout().flush()?;
164
165 let mut input = String::new();
166 io::stdin().read_line(&mut input)?;
167 }
168
169 publish_crate_impl(crate_name, false)?;
170
171 Ok(())
172}
173
174fn update_workspace_dependency_version(package_name: &str, new_version: &str) -> Result<()> {
175 let workspace_cargo_toml_path = "Cargo.toml";
176 let contents = std::fs::read_to_string(workspace_cargo_toml_path)
177 .context("Failed to read workspace Cargo.toml")?;
178
179 let updated = update_dependency_version_in_toml(&contents, package_name, new_version)?;
180
181 std::fs::write(workspace_cargo_toml_path, updated)
182 .context("Failed to write workspace Cargo.toml")?;
183
184 Ok(())
185}
186
187fn update_dependency_version_in_toml(
188 toml_contents: &str,
189 package_name: &str,
190 new_version: &str,
191) -> Result<String> {
192 let mut doc = toml_contents
193 .parse::<toml_edit::DocumentMut>()
194 .context("Failed to parse TOML")?;
195
196 // Navigate to workspace.dependencies.<package_name>
197 let dependency = doc
198 .get_mut("workspace")
199 .and_then(|w| w.get_mut("dependencies"))
200 .and_then(|d| d.get_mut(package_name))
201 .context(format!(
202 "Failed to find {} in workspace dependencies",
203 package_name
204 ))?;
205
206 // Update the version field if it exists
207 if let Some(dep_table) = dependency.as_table_like_mut() {
208 if dep_table.contains_key("version") {
209 dep_table.insert("version", toml_edit::value(new_version));
210 } else {
211 bail!(
212 "No version field found for {} in workspace dependencies",
213 package_name
214 );
215 }
216 } else {
217 bail!("{} is not a table in workspace dependencies", package_name);
218 }
219
220 Ok(doc.to_string())
221}
222
223fn check_workspace_root() -> Result<()> {
224 let cwd = std::env::current_dir().context("Failed to get current directory")?;
225
226 // Check if Cargo.toml exists in the current directory
227 let cargo_toml_path = cwd.join("Cargo.toml");
228 if !cargo_toml_path.exists() {
229 bail!(
230 "Cargo.toml not found in current directory. Please run this command from the workspace root."
231 );
232 }
233
234 // Check if it's a workspace by looking for [workspace] section
235 let contents =
236 std::fs::read_to_string(&cargo_toml_path).context("Failed to read Cargo.toml")?;
237
238 if !contents.contains("[workspace]") {
239 bail!(
240 "Current directory does not appear to be a workspace root. Please run this command from the workspace root."
241 );
242 }
243
244 Ok(())
245}
246
247fn ensure_cargo_set_version() -> Result<()> {
248 let output = run_command(
249 Command::new("which")
250 .arg("cargo-set-version")
251 .stdout(Stdio::piped()),
252 )
253 .context("Failed to check for cargo-set-version")?;
254
255 if !output.status.success() {
256 println!("cargo-set-version not found. Installing cargo-edit...");
257
258 let install_output = run_command(Command::new("cargo").arg("install").arg("cargo-edit"))?;
259
260 if !install_output.status.success() {
261 bail!("Failed to install cargo-edit");
262 }
263 }
264
265 Ok(())
266}
267
268fn check_git_clean() -> Result<()> {
269 let output = run_command(
270 Command::new("git")
271 .args(["status", "--porcelain"])
272 .stdout(Stdio::piped())
273 .stderr(Stdio::piped()),
274 )?;
275
276 if !output.status.success() {
277 bail!("git status command failed");
278 }
279
280 let stdout = String::from_utf8_lossy(&output.stdout);
281 if !stdout.trim().is_empty() {
282 bail!(
283 "Working directory is not clean. Please commit or stash your changes before publishing."
284 );
285 }
286
287 Ok(())
288}
289
290fn run_command(command: &mut Command) -> Result<Output> {
291 let command_str = {
292 let program = command.get_program().to_string_lossy();
293 let args = command
294 .get_args()
295 .map(|arg| arg.to_string_lossy())
296 .collect::<Vec<_>>()
297 .join(" ");
298
299 if args.is_empty() {
300 program.to_string()
301 } else {
302 format!("{} {}", program, args)
303 }
304 };
305 eprintln!("+ {}", command_str);
306
307 let output = command
308 .spawn()
309 .context("failed to spawn child process")?
310 .wait_with_output()
311 .context("failed to wait for child process")?;
312
313 Ok(output)
314}
315
316#[cfg(test)]
317mod tests {
318 use indoc::indoc;
319
320 use super::*;
321
322 #[test]
323 fn test_update_dependency_version_in_toml() {
324 let input = indoc! {r#"
325 [workspace]
326 resolver = "2"
327
328 [workspace.dependencies]
329 # here's a comment
330 collections = { path = "crates/collections", package = "zed-collections", version = "0.1.0" }
331
332 util = { path = "crates/util", package = "zed-util", version = "0.1.0" }
333 "#};
334
335 let result = update_dependency_version_in_toml(input, "collections", "0.2.0").unwrap();
336
337 let output = indoc! {r#"
338 [workspace]
339 resolver = "2"
340
341 [workspace.dependencies]
342 # here's a comment
343 collections = { path = "crates/collections", package = "zed-collections", version = "0.2.0" }
344
345 util = { path = "crates/util", package = "zed-util", version = "0.1.0" }
346 "#};
347
348 assert_eq!(result, output);
349 }
350
351 #[test]
352 fn test_bump_version() {
353 // Test bumping minor version (default behavior)
354 assert_eq!(bump_version("0.1.0", None).unwrap(), "0.2.0");
355 assert_eq!(bump_version("0.1.5", None).unwrap(), "0.2.0");
356 assert_eq!(bump_version("1.42.7", None).unwrap(), "1.43.0");
357
358 // Test stripping pre-release and bumping minor
359 assert_eq!(bump_version("0.1.0-alpha.1", None).unwrap(), "0.2.0");
360 assert_eq!(bump_version("0.1.0-beta", None).unwrap(), "0.2.0");
361
362 // Test stripping existing metadata and bumping
363 assert_eq!(bump_version("0.1.0+old.metadata", None).unwrap(), "0.2.0");
364
365 // Test bumping minor with pre-release
366 assert_eq!(bump_version("0.1.0", Some("alpha")).unwrap(), "0.2.0-alpha");
367
368 // Test bumping minor with complex pre-release identifier
369 assert_eq!(
370 bump_version("0.1.0", Some("test.1")).unwrap(),
371 "0.2.0-test.1"
372 );
373
374 // Test bumping from existing pre-release adds new pre-release
375 assert_eq!(
376 bump_version("0.1.0-alpha", Some("beta")).unwrap(),
377 "0.2.0-beta"
378 );
379
380 // Test bumping and stripping metadata while adding pre-release
381 assert_eq!(
382 bump_version("0.1.0+metadata", Some("alpha")).unwrap(),
383 "0.2.0-alpha"
384 );
385 }
386}