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 /// Perform a dry-run and wait for user confirmation before each publish
11 #[arg(long)]
12 dry_run: bool,
13
14 /// Skip to a specific package (by package name or crate name) and start from there
15 #[arg(long)]
16 skip_to: Option<String>,
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
28 if args.skip_to.is_none() {
29 check_git_clean()?;
30 } else {
31 println!("Skipping git clean check due to --skip-to flag");
32 }
33
34 let version = read_gpui_version()?;
35 println!("Updating GPUI to version: {}", version);
36 publish_dependencies(&version, args.dry_run, args.skip_to.as_deref())?;
37 publish_gpui(&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 publish_dependencies(new_version: &str, dry_run: bool, skip_to: Option<&str>) -> Result<()> {
60 let gpui_dependencies = vec![
61 ("collections", "gpui_collections", "crates"),
62 ("perf", "gpui_perf", "tooling"),
63 ("util_macros", "gpui_util_macros", "crates"),
64 ("util", "gpui_util", "crates"),
65 ("gpui_macros", "gpui-macros", "crates"),
66 ("http_client", "gpui_http_client", "crates"),
67 (
68 "derive_refineable",
69 "gpui_derive_refineable",
70 "crates/refineable",
71 ),
72 ("refineable", "gpui_refineable", "crates"),
73 ("semantic_version", "gpui_semantic_version", "crates"),
74 ("sum_tree", "gpui_sum_tree", "crates"),
75 ("media", "gpui_media", "crates"),
76 ];
77
78 let mut should_skip = skip_to.is_some();
79 let skip_target = skip_to.unwrap_or("");
80
81 for (package_name, crate_name, package_dir) in gpui_dependencies {
82 if should_skip {
83 if package_name == skip_target || crate_name == skip_target {
84 println!("Found skip target: {} ({})", crate_name, package_name);
85 should_skip = false;
86 } else {
87 println!("Skipping: {} ({})", crate_name, package_name);
88 continue;
89 }
90 }
91
92 println!(
93 "Publishing dependency: {} (package: {})",
94 crate_name, package_name
95 );
96
97 update_crate_cargo_toml(package_name, crate_name, package_dir, new_version)?;
98 update_workspace_dependency_version(package_name, crate_name, new_version)?;
99 publish_crate(crate_name, dry_run)?;
100 }
101
102 if should_skip {
103 bail!(
104 "Could not find package or crate named '{}' to skip to",
105 skip_target
106 );
107 }
108
109 Ok(())
110}
111
112fn publish_gpui(new_version: &str, dry_run: bool) -> Result<()> {
113 update_crate_cargo_toml("gpui", "gpui", "crates", new_version)?;
114
115 publish_crate("gpui", dry_run)?;
116
117 Ok(())
118}
119
120fn update_crate_cargo_toml(
121 package_name: &str,
122 crate_name: &str,
123 package_dir: &str,
124 new_version: &str,
125) -> Result<()> {
126 let cargo_toml_path = format!("{}/{}/Cargo.toml", package_dir, package_name);
127 let contents = std::fs::read_to_string(&cargo_toml_path)
128 .context(format!("Failed to read {}", cargo_toml_path))?;
129
130 let updated = update_crate_package_fields(&contents, crate_name, new_version)?;
131
132 std::fs::write(&cargo_toml_path, updated)
133 .context(format!("Failed to write {}", cargo_toml_path))?;
134
135 Ok(())
136}
137
138fn update_crate_package_fields(
139 toml_contents: &str,
140 crate_name: &str,
141 new_version: &str,
142) -> Result<String> {
143 let mut doc = toml_contents
144 .parse::<toml_edit::DocumentMut>()
145 .context("Failed to parse TOML")?;
146
147 let package = doc
148 .get_mut("package")
149 .and_then(|p| p.as_table_like_mut())
150 .context("Failed to find [package] section")?;
151
152 package.insert("name", toml_edit::value(crate_name));
153 package.insert("version", toml_edit::value(new_version));
154 package.insert("publish", toml_edit::value(true));
155
156 Ok(doc.to_string())
157}
158
159fn publish_crate(crate_name: &str, dry_run: bool) -> Result<()> {
160 let publish_crate_impl = |crate_name, dry_run| {
161 let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
162
163 let mut command = Command::new(&cargo);
164 command
165 .arg("publish")
166 .arg("--allow-dirty")
167 .args(["-p", crate_name]);
168
169 if dry_run {
170 command.arg("--dry-run");
171 }
172
173 run_command(&mut command)?;
174
175 anyhow::Ok(())
176 };
177
178 if dry_run {
179 publish_crate_impl(crate_name, true)?;
180
181 print!("Press Enter to publish for real (or ctrl-c to abort)...");
182 io::stdout().flush()?;
183
184 let mut input = String::new();
185 io::stdin().read_line(&mut input)?;
186 }
187
188 publish_crate_impl(crate_name, false)?;
189
190 Ok(())
191}
192
193fn update_workspace_dependency_version(
194 package_name: &str,
195 crate_name: &str,
196 new_version: &str,
197) -> Result<()> {
198 let workspace_cargo_toml_path = "Cargo.toml";
199 let contents = std::fs::read_to_string(workspace_cargo_toml_path)
200 .context("Failed to read workspace Cargo.toml")?;
201
202 let mut doc = contents
203 .parse::<toml_edit::DocumentMut>()
204 .context("Failed to parse TOML")?;
205
206 update_dependency_version_in_doc(&mut doc, package_name, crate_name, new_version)?;
207 update_profile_override_in_doc(&mut doc, package_name, crate_name)?;
208
209 std::fs::write(workspace_cargo_toml_path, doc.to_string())
210 .context("Failed to write workspace Cargo.toml")?;
211
212 Ok(())
213}
214
215fn update_dependency_version_in_doc(
216 doc: &mut toml_edit::DocumentMut,
217 package_name: &str,
218 crate_name: &str,
219 new_version: &str,
220) -> Result<()> {
221 let dependency = doc
222 .get_mut("workspace")
223 .and_then(|w| w.get_mut("dependencies"))
224 .and_then(|d| d.get_mut(package_name))
225 .context(format!(
226 "Failed to find {} in workspace dependencies",
227 package_name
228 ))?;
229
230 if let Some(dep_table) = dependency.as_table_like_mut() {
231 dep_table.insert("version", toml_edit::value(new_version));
232 dep_table.insert("package", toml_edit::value(crate_name));
233 } else {
234 bail!("{} is not a table in workspace dependencies", package_name);
235 }
236
237 Ok(())
238}
239
240fn update_profile_override_in_doc(
241 doc: &mut toml_edit::DocumentMut,
242 package_name: &str,
243 crate_name: &str,
244) -> Result<()> {
245 if let Some(profile_dev_package) = doc
246 .get_mut("profile")
247 .and_then(|p| p.get_mut("dev"))
248 .and_then(|d| d.get_mut("package"))
249 .and_then(|p| p.as_table_like_mut())
250 {
251 if let Some(old_entry) = profile_dev_package.get(package_name) {
252 let old_entry_clone = old_entry.clone();
253 profile_dev_package.remove(package_name);
254 profile_dev_package.insert(crate_name, old_entry_clone);
255 }
256 }
257
258 Ok(())
259}
260
261fn check_workspace_root() -> Result<()> {
262 let cwd = std::env::current_dir().context("Failed to get current directory")?;
263
264 // Check if Cargo.toml exists in the current directory
265 let cargo_toml_path = cwd.join("Cargo.toml");
266 if !cargo_toml_path.exists() {
267 bail!(
268 "Cargo.toml not found in current directory. Please run this command from the workspace root."
269 );
270 }
271
272 // Check if it's a workspace by looking for [workspace] section
273 let contents =
274 std::fs::read_to_string(&cargo_toml_path).context("Failed to read Cargo.toml")?;
275
276 if !contents.contains("[workspace]") {
277 bail!(
278 "Current directory does not appear to be a workspace root. Please run this command from the workspace root."
279 );
280 }
281
282 Ok(())
283}
284
285fn check_git_clean() -> Result<()> {
286 let output = run_command(
287 Command::new("git")
288 .args(["status", "--porcelain"])
289 .stdout(Stdio::piped())
290 .stderr(Stdio::piped()),
291 )?;
292
293 if !output.status.success() {
294 bail!("git status command failed");
295 }
296
297 let stdout = String::from_utf8_lossy(&output.stdout);
298 if !stdout.trim().is_empty() {
299 bail!(
300 "Working directory is not clean. Please commit or stash your changes before publishing."
301 );
302 }
303
304 Ok(())
305}
306
307fn run_command(command: &mut Command) -> Result<Output> {
308 let command_str = {
309 let program = command.get_program().to_string_lossy();
310 let args = command
311 .get_args()
312 .map(|arg| arg.to_string_lossy())
313 .collect::<Vec<_>>()
314 .join(" ");
315
316 if args.is_empty() {
317 program.to_string()
318 } else {
319 format!("{} {}", program, args)
320 }
321 };
322 eprintln!("+ {}", command_str);
323
324 let output = command
325 .spawn()
326 .context("failed to spawn child process")?
327 .wait_with_output()
328 .context("failed to wait for child process")?;
329
330 if !output.status.success() {
331 bail!("Command failed with status {}", output.status);
332 }
333
334 Ok(output)
335}
336
337#[cfg(test)]
338mod tests {
339 use indoc::indoc;
340
341 use super::*;
342
343 #[test]
344 fn test_update_dependency_version_in_toml() {
345 let input = indoc! {r#"
346 [workspace]
347 resolver = "2"
348
349 [workspace.dependencies]
350 # here's a comment
351 collections = { path = "crates/collections" }
352
353 util = { path = "crates/util", package = "zed-util", version = "0.1.0" }
354 "#};
355
356 let mut doc = input.parse::<toml_edit::DocumentMut>().unwrap();
357
358 update_dependency_version_in_doc(&mut doc, "collections", "gpui_collections", "0.2.0")
359 .unwrap();
360
361 let result = doc.to_string();
362
363 let output = indoc! {r#"
364 [workspace]
365 resolver = "2"
366
367 [workspace.dependencies]
368 # here's a comment
369 collections = { path = "crates/collections" , version = "0.2.0", package = "gpui_collections" }
370
371 util = { path = "crates/util", package = "zed-util", version = "0.1.0" }
372 "#};
373
374 assert_eq!(result, output);
375 }
376
377 #[test]
378 fn test_update_crate_package_fields() {
379 let input = indoc! {r#"
380 [package]
381 name = "collections"
382 version = "0.1.0"
383 edition = "2021"
384 publish = false
385 # some comment about the license
386 license = "GPL-3.0-or-later"
387
388 [dependencies]
389 serde = "1.0"
390 "#};
391
392 let result = update_crate_package_fields(input, "gpui_collections", "0.2.0").unwrap();
393
394 let output = indoc! {r#"
395 [package]
396 name = "gpui_collections"
397 version = "0.2.0"
398 edition = "2021"
399 publish = true
400 # some comment about the license
401 license = "GPL-3.0-or-later"
402
403 [dependencies]
404 serde = "1.0"
405 "#};
406
407 assert_eq!(result, output);
408 }
409
410 #[test]
411 fn test_update_profile_override_in_toml() {
412 let input = indoc! {r#"
413 [profile.dev]
414 split-debuginfo = "unpacked"
415
416 [profile.dev.package]
417 taffy = { opt-level = 3 }
418 collections = { codegen-units = 256 }
419 refineable = { codegen-units = 256 }
420 util = { codegen-units = 256 }
421 "#};
422
423 let mut doc = input.parse::<toml_edit::DocumentMut>().unwrap();
424
425 update_profile_override_in_doc(&mut doc, "collections", "gpui_collections").unwrap();
426
427 let result = doc.to_string();
428
429 let output = indoc! {r#"
430 [profile.dev]
431 split-debuginfo = "unpacked"
432
433 [profile.dev.package]
434 taffy = { opt-level = 3 }
435 refineable = { codegen-units = 256 }
436 util = { codegen-units = 256 }
437 gpui_collections = { codegen-units = 256 }
438 "#};
439
440 assert_eq!(result, output);
441 }
442}