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