Skip to main content

crate_cli/publish/
fn.rs

1use super::*;
2
3/// Discover all packages in the workspace: every `[workspace.members]`
4/// entry in declaration order, with the root package (when the workspace
5/// root manifest also declares `[package]`) appended last.
6///
7/// # Arguments
8///
9/// - `&Path` - Path to workspace root Cargo.toml
10///
11/// # Returns
12///
13/// - `Result<(Vec<Package>, bool), PublishError>` - Packages and whether a
14///   root package was appended
15async fn discover_packages(
16    workspace_manifest: &Path,
17) -> Result<(Vec<Package>, bool), PublishError> {
18    let content: String = read_to_string(workspace_manifest).await?;
19    let doc: Value = toml::from_str(&content)
20        .map_err(|_error: toml::de::Error| PublishError::ManifestParseError)?;
21    let workspace_version: Option<String> = doc
22        .get(TOML_WORKSPACE)
23        .and_then(|workspace: &Value| workspace.get(TOML_PACKAGE))
24        .and_then(|package: &Value| package.get(TOML_VERSION))
25        .and_then(|version: &Value| version.as_str())
26        .map(|version: &str| version.to_string());
27    let mut packages: Vec<Package> = Vec::new();
28    if let Some(workspace) = doc.get(TOML_WORKSPACE)
29        && let Some(members) = workspace
30            .get(TOML_MEMBERS)
31            .and_then(|members_value: &Value| members_value.as_array())
32    {
33        for member in members {
34            if let Some(pattern) = member.as_str() {
35                let base_path: &Path = workspace_manifest.parent().unwrap_or(workspace_manifest);
36                expand_pattern(
37                    base_path,
38                    pattern,
39                    &mut packages,
40                    workspace_version.as_deref(),
41                )
42                .await?;
43            }
44        }
45    }
46    let has_root_package: bool = doc.get(TOML_PACKAGE).is_some();
47    if has_root_package {
48        let root_package: Package =
49            read_package_manifest(workspace_manifest, workspace_version.as_deref()).await?;
50        packages.push(root_package);
51    }
52    Ok((packages, has_root_package))
53}
54
55/// Expand glob pattern to find package directories
56///
57/// # Arguments
58///
59/// - `&Path` - Base path for expansion
60/// - `&str` - Glob pattern
61/// - `&mut Vec<Package>` - Output vector for found packages
62/// - `Option<&str>` - Workspace root version for `version.workspace = true`
63///
64/// # Returns
65///
66/// - `Result<(), PublishError>` - Success or error
67async fn expand_pattern(
68    base_path: &Path,
69    pattern: &str,
70    packages: &mut Vec<Package>,
71    workspace_version: Option<&str>,
72) -> Result<(), PublishError> {
73    if pattern.contains('*') {
74        let parent: &Path = Path::new(pattern).parent().unwrap_or(Path::new("."));
75        let full_parent: PathBuf = base_path.join(parent);
76        if full_parent.is_dir() {
77            let mut entries: ReadDir = read_dir(&full_parent).await?;
78            while let Some(entry) = entries.next_entry().await? {
79                let path: PathBuf = entry.path();
80                if path.is_dir() {
81                    let cargo_toml: PathBuf = path.join(CARGO_TOML);
82                    if cargo_toml.exists() {
83                        let package: Package =
84                            read_package_manifest(&cargo_toml, workspace_version).await?;
85                        packages.push(package);
86                    }
87                }
88            }
89        }
90    } else {
91        let cargo_toml: PathBuf = base_path.join(pattern).join(CARGO_TOML);
92        if cargo_toml.exists() {
93            let package: Package = read_package_manifest(&cargo_toml, workspace_version).await?;
94            packages.push(package);
95        }
96    }
97    Ok(())
98}
99
100/// Read package manifest and extract information
101///
102/// `version.workspace = true` resolves to the workspace root version
103/// passed in `workspace_version`.
104///
105/// # Arguments
106///
107/// - `&Path` - Path to package Cargo.toml
108/// - `Option<&str>` - Workspace root `[workspace.package].version`, if any
109///
110/// # Returns
111///
112/// - `Result<Package, PublishError>` - Package info or error
113async fn read_package_manifest(
114    manifest_path: &Path,
115    workspace_version: Option<&str>,
116) -> Result<Package, PublishError> {
117    let content: String = read_to_string(manifest_path).await?;
118    let doc: Value = toml::from_str(&content)
119        .map_err(|_error: toml::de::Error| PublishError::ManifestParseError)?;
120    let package_table: &Value = doc
121        .get(TOML_PACKAGE)
122        .ok_or(PublishError::ManifestParseError)?;
123    let name: String = package_table
124        .get(TOML_NAME)
125        .and_then(|n: &Value| n.as_str())
126        .ok_or(PublishError::ManifestParseError)?
127        .to_string();
128    let version: String = match package_table.get(TOML_VERSION) {
129        Some(version_value) => {
130            if let Some(version_str) = version_value.as_str() {
131                version_str.to_string()
132            } else if version_value
133                .get(TOML_WORKSPACE)
134                .and_then(|workspace_value: &Value| workspace_value.as_bool())
135                .unwrap_or(false)
136            {
137                workspace_version
138                    .ok_or(PublishError::ManifestParseError)?
139                    .to_string()
140            } else {
141                return Err(PublishError::ManifestParseError);
142            }
143        }
144        None => workspace_version
145            .ok_or(PublishError::ManifestParseError)?
146            .to_string(),
147    };
148    let publish: bool = package_table
149        .get(TOML_PUBLISH_KEY)
150        .and_then(|publish_value: &Value| publish_value.as_bool())
151        .unwrap_or(true);
152    let path: PathBuf = manifest_path
153        .parent()
154        .filter(|p: &&Path| !p.as_os_str().is_empty())
155        .map_or_else(|| PathBuf::from("."), |p: &Path| p.to_path_buf());
156    let local_dependencies: Vec<String> = extract_local_dependencies(&doc, manifest_path)?;
157    Ok(Package {
158        name,
159        version,
160        path,
161        local_dependencies,
162        publish,
163    })
164}
165
166/// Extract local workspace dependencies that constrain publish order
167///
168/// `[dependencies]` and `[build-dependencies]` entries with `path` or
169/// `workspace = true` always constrain. `[dev-dependencies]` are stripped
170/// from the published manifest, so they constrain only when they carry a
171/// `version` field (cargo publish registry-checks versioned dev-deps);
172/// path-only dev-deps skip the registry and impose no order constraint.
173///
174/// # Arguments
175///
176/// - `&Value` - Parsed manifest
177/// - `&Path` - Path to manifest for resolving relative paths
178///
179/// # Returns
180///
181/// - `Result<Vec<String>, PublishError>` - List of local dependency names
182fn extract_local_dependencies(
183    doc: &Value,
184    _manifest_path: &Path,
185) -> Result<Vec<String>, PublishError> {
186    let mut deps: Vec<String> = Vec::new();
187    let dep_sections: [&str; 3] = [
188        TOML_DEPENDENCIES,
189        TOML_BUILD_DEPENDENCIES,
190        TOML_DEV_DEPENDENCIES,
191    ];
192    for section in &dep_sections {
193        if let Some(table) = doc
194            .get(section)
195            .and_then(|section_value: &Value| section_value.as_table())
196        {
197            for (dep_name, dep_value) in table {
198                let is_local: bool = match dep_value {
199                    Value::Table(t) => {
200                        let has_path_or_workspace: bool = t.get(TOML_PATH).is_some()
201                            || t.get(TOML_WORKSPACE)
202                                .and_then(|workspace_value: &Value| workspace_value.as_bool())
203                                .unwrap_or(false);
204                        let versioned: bool = t.get(TOML_VERSION).is_some();
205                        has_path_or_workspace && (*section != TOML_DEV_DEPENDENCIES || versioned)
206                    }
207                    _ => false,
208                };
209                if is_local {
210                    deps.push(dep_name.clone());
211                }
212            }
213        }
214    }
215    Ok(deps)
216}
217
218/// Validate that the publish order satisfies every package's local
219/// dependency constraints: a package must never appear before a
220/// workspace-local dependency of its own.
221///
222/// # Arguments
223///
224/// - `&[Package]` - Packages in intended publish order
225///
226/// # Returns
227///
228/// - `Result<(), PublishError>` - `InvalidPublishOrder` naming the first
229///   offending pair when the order violates a local dependency.
230fn validate_publish_order(packages: &[Package]) -> Result<(), PublishError> {
231    let position: HashMap<String, usize> = packages
232        .iter()
233        .enumerate()
234        .map(|(index, package): (usize, &Package)| (package.name.clone(), index))
235        .collect();
236    for package in packages {
237        let Some(package_position) = position.get(&package.name) else {
238            continue;
239        };
240        for dep in &package.local_dependencies {
241            if let Some(dep_position) = position.get(dep)
242                && dep_position > package_position
243            {
244                return Err(PublishError::InvalidPublishOrder(format!(
245                    "{} depends on {} but is listed before it in [workspace.members]",
246                    package.name, dep
247                )));
248            }
249        }
250    }
251    Ok(())
252}
253
254/// Move the root package (appended last by `discover_packages`) to its
255/// topological position when workspace members depend on it
256///
257/// When no member depends on the root package the root stays last (the
258/// conventional facade-last layout). When members do depend on the root
259/// (e.g. `ui` / `engine` crates depending on a root facade crate), the
260/// root is inserted right before the earliest such member, provided all
261/// of the root's own local dependencies appear earlier in the members
262/// order; otherwise the members order cannot satisfy both constraints
263/// and `InvalidPublishOrder` is returned.
264///
265/// # Arguments
266///
267/// - `&mut Vec<Package>` - Packages with the root package as last element
268///
269/// # Returns
270///
271/// - `Result<(), PublishError>` - Success or `InvalidPublishOrder`
272fn position_root_package(packages: &mut Vec<Package>) -> Result<(), PublishError> {
273    let Some(root) = packages.pop() else {
274        return Ok(());
275    };
276    let earliest_dependent: Option<usize> = packages
277        .iter()
278        .enumerate()
279        .filter(|(_index, package): &(usize, &Package)| {
280            package.local_dependencies.contains(&root.name)
281        })
282        .map(|(index, _item): (usize, &Package)| index)
283        .min();
284    let Some(earliest) = earliest_dependent else {
285        packages.push(root);
286        return Ok(());
287    };
288    let member_positions: HashMap<&str, usize> = packages
289        .iter()
290        .enumerate()
291        .map(|(index, package): (usize, &Package)| (package.name.as_str(), index))
292        .collect();
293    if let Some(max_dep) = root
294        .local_dependencies
295        .iter()
296        .filter_map(|dep: &String| member_positions.get(dep.as_str()))
297        .max()
298        && max_dep >= &earliest
299    {
300        return Err(PublishError::InvalidPublishOrder(format!(
301            "{} must publish after its dependency at members position {} but before dependent at position {}; reorder [workspace.members]",
302            root.name, max_dep, earliest
303        )));
304    }
305    packages.insert(earliest, root);
306    Ok(())
307}
308
309/// Resolve the publish order for a workspace: `[workspace.members]`
310/// declaration order, with the root package (if any) placed at its
311/// topological position, validated against local dependency constraints.
312///
313/// # Arguments
314///
315/// - `&str` - Path to the workspace root Cargo.toml
316///
317/// # Returns
318///
319/// - `Result<Vec<Package>, PublishError>` - Ordered packages, or an
320///   error when the members order violates a local dependency.
321pub async fn resolve_publish_order(manifest_path: &str) -> Result<Vec<Package>, PublishError> {
322    let workspace_manifest: &Path = Path::new(manifest_path);
323    let (mut packages, has_root_package) = discover_packages(workspace_manifest).await?;
324    if has_root_package {
325        position_root_package(&mut packages)?;
326    }
327    validate_publish_order(&packages)?;
328    Ok(packages)
329}
330
331/// Check whether `cargo publish` stderr indicates the package version is
332/// already present on the registry (a success case for idempotent
333/// re-runs).
334///
335/// # Arguments
336///
337/// - `&str` - cargo publish stderr output
338///
339/// # Returns
340///
341/// - `bool` - True when the output reports the version is already published
342pub fn is_already_published(stderr: &str) -> bool {
343    stderr.contains(STDERR_ALREADY_BEEN_UPLOADED)
344        || stderr.contains(STDERR_IS_ALREADY_PUBLISHED)
345        || stderr.contains(STDERR_ALREADY_ON_INDEX)
346}
347
348/// Publish a single package with retry logic
349///
350/// # Arguments
351///
352/// - `&Package` - Package to publish
353/// - `u32` - Maximum retry attempts
354///
355/// # Returns
356///
357/// - `PublishResult` - Result with success status and retry count
358async fn publish_package_with_retry(package: &Package, max_retries: u32) -> PublishResult {
359    let mut attempt: u32 = 0;
360    let mut last_error: Option<String> = None;
361    while attempt <= max_retries {
362        match publish_single_package(package).await {
363            Ok(()) => {
364                return PublishResult {
365                    package_name: package.name.clone(),
366                    success: true,
367                    error: None,
368                    retries: attempt,
369                };
370            }
371            Err(error) => {
372                last_error = Some(error.to_string());
373                attempt += 1;
374                if attempt <= max_retries {
375                    sleep(Duration::from_secs(2_u64.pow(attempt))).await;
376                }
377            }
378        }
379    }
380    PublishResult {
381        package_name: package.name.clone(),
382        success: false,
383        error: last_error,
384        retries: attempt - 1,
385    }
386}
387
388/// Execute cargo publish command for a single package
389///
390/// # Arguments
391///
392/// - `&Package` - Package to publish
393///
394/// # Returns
395///
396/// - `Result<(), Box<dyn std::error::Error>>` - Success or error
397async fn publish_single_package(package: &Package) -> Result<(), Box<dyn std::error::Error>> {
398    let output: std::process::Output = Command::new(CARGO)
399        .arg(CARGO_PUBLISH)
400        .arg(CLI_FLAG_ALLOW_DIRTY)
401        .arg(CLI_FLAG_NO_VERIFY)
402        .current_dir(&package.path)
403        .stdout(Stdio::piped())
404        .stderr(Stdio::piped())
405        .output()
406        .await?;
407    if output.status.success() {
408        return Ok(());
409    }
410    let stderr: String = String::from_utf8_lossy(&output.stderr).to_string();
411    if is_already_published(&stderr) {
412        log::info!("{} is already published, treating as success", package.name);
413        return Ok(());
414    }
415    Err(stderr.into())
416}
417
418/// Execute publish command for all packages in workspace
419///
420/// Publishes in `[workspace.members]` declaration order with the root
421/// package (if any) last, after validating the order against local
422/// dependency constraints.
423///
424/// # Arguments
425///
426/// - `&str` - Path to workspace Cargo.toml
427/// - `u32` - Maximum retry attempts per package
428///
429/// # Returns
430///
431/// - `Result<Vec<PublishResult>, PublishError>` - Results for all packages
432pub async fn execute_publish(
433    manifest_path: &str,
434    max_retries: u32,
435) -> Result<Vec<PublishResult>, PublishError> {
436    let path: &Path = Path::new(manifest_path);
437    let path: &Path = match path.parent() {
438        Some(parent) if !parent.as_os_str().is_empty() => parent,
439        _ => Path::new("."),
440    };
441    let workspace_manifest: PathBuf = path.join(CARGO_TOML);
442    let sync_report: SyncReport =
443        match execute_sync(workspace_manifest.to_str().unwrap_or(CARGO_TOML)).await {
444            Ok(report) => report,
445            Err(error) => return Err(PublishError::SyncFailed(error)),
446        };
447    if sync_report.file_changed {
448        log::info!(
449            "publish: synced workspace dependencies ({} renamed, {} versioned) to v{}",
450            sync_report.renamed_entries.len(),
451            sync_report.versioned_entries.len(),
452            sync_report.workspace_version,
453        );
454    }
455    let ordered_packages: Vec<Package> =
456        resolve_publish_order(workspace_manifest.to_str().unwrap_or(CARGO_TOML)).await?;
457    if ordered_packages.is_empty() {
458        return Ok(Vec::new());
459    }
460    let mut results: Vec<PublishResult> = Vec::new();
461    for package in ordered_packages {
462        if !package.publish {
463            log::info!("Skipping {} (publish = false)", package.name);
464            continue;
465        }
466        log::info!("Publishing {} v{}...", package.name, package.version);
467        let result: PublishResult = publish_package_with_retry(&package, max_retries).await;
468        if result.success {
469            if result.retries == 0 {
470                log::info!("Successfully published {}", result.package_name,);
471            } else {
472                log::info!(
473                    "Successfully published {} (retried {} times)",
474                    result.package_name,
475                    result.retries
476                );
477            }
478        } else if let Some(error) = &result.error {
479            log::error!("Failed to publish {}: {error}", result.package_name);
480        } else {
481            log::error!("Failed to publish {}", result.package_name);
482        }
483        results.push(result);
484    }
485    Ok(results)
486}