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}