Skip to main content

hyperlane_cli/publish/
fn.rs

1use crate::*;
2
3/// Discover all packages in the workspace
4///
5/// # Arguments
6///
7/// - `&Path`: Path to workspace root Cargo.toml
8///
9/// # Returns
10///
11/// - `Result<Vec<Package>, PublishError>`: List of packages or error
12async fn discover_packages(workspace_root: &Path) -> Result<Vec<Package>, PublishError> {
13    let content: String = read_to_string(workspace_root).await?;
14    let doc: toml::Value =
15        toml::from_str(&content).map_err(|_| PublishError::ManifestParseError)?;
16    let mut packages: Vec<Package> = Vec::new();
17    if let Some(workspace) = doc.get("workspace")
18        && let Some(members) = workspace.get("members").and_then(|m| m.as_array())
19    {
20        for member in members {
21            if let Some(pattern) = member.as_str() {
22                let base_path: &Path = workspace_root.parent().unwrap_or(workspace_root);
23                expand_pattern(base_path, pattern, &mut packages).await?;
24            }
25        }
26    }
27    if packages.is_empty() {
28        let package: Package = read_single_package(workspace_root).await?;
29        packages.push(package);
30    }
31    Ok(packages)
32}
33
34/// Expand glob pattern to find package directories
35///
36/// # Arguments
37///
38/// - `&Path`: Base path for expansion
39/// - `&str`: Glob pattern
40/// - `&mut Vec<Package>`: Output vector for found packages
41///
42/// # Returns
43///
44/// - `Result<(), PublishError>`: Success or error
45async fn expand_pattern(
46    base_path: &Path,
47    pattern: &str,
48    packages: &mut Vec<Package>,
49) -> Result<(), PublishError> {
50    if pattern.contains('*') {
51        let parent: &Path = Path::new(pattern).parent().unwrap_or(Path::new("."));
52        let full_parent: PathBuf = base_path.join(parent);
53        if full_parent.is_dir() {
54            let mut entries: ReadDir = read_dir(&full_parent).await?;
55            while let Some(entry) = entries.next_entry().await? {
56                let path: PathBuf = entry.path();
57                if path.is_dir() {
58                    let cargo_toml: PathBuf = path.join("Cargo.toml");
59                    if cargo_toml.exists() {
60                        let package: Package = read_package_manifest(&cargo_toml).await?;
61                        packages.push(package);
62                    }
63                }
64            }
65        }
66    } else {
67        let cargo_toml: PathBuf = base_path.join(pattern).join("Cargo.toml");
68        if cargo_toml.exists() {
69            let package: Package = read_package_manifest(&cargo_toml).await?;
70            packages.push(package);
71        }
72    }
73    Ok(())
74}
75
76/// Read a single package (non-workspace mode)
77///
78/// # Arguments
79///
80/// - `&Path`: Path to Cargo.toml
81///
82/// # Returns
83///
84/// - `Result<Package, PublishError>`: Package info or error
85async fn read_single_package(manifest_path: &Path) -> Result<Package, PublishError> {
86    read_package_manifest(manifest_path).await
87}
88
89/// Read package manifest and extract information
90///
91/// # Arguments
92///
93/// - `&Path`: Path to package Cargo.toml
94///
95/// # Returns
96///
97/// - `Result<Package, PublishError>`: Package info or error
98async fn read_package_manifest(manifest_path: &Path) -> Result<Package, PublishError> {
99    let content: String = read_to_string(manifest_path).await?;
100    let doc: toml::Value =
101        toml::from_str(&content).map_err(|_| PublishError::ManifestParseError)?;
102    let package_table: &toml::Value = doc.get("package").ok_or(PublishError::ManifestParseError)?;
103    let name: String = package_table
104        .get("name")
105        .and_then(|n: &toml::Value| n.as_str())
106        .ok_or(PublishError::ManifestParseError)?
107        .to_string();
108    let version: String = package_table
109        .get("version")
110        .and_then(|v: &toml::Value| v.as_str())
111        .ok_or(PublishError::ManifestParseError)?
112        .to_string();
113    let path: PathBuf = manifest_path
114        .parent()
115        .filter(|p: &&Path| !p.as_os_str().is_empty())
116        .map_or_else(|| PathBuf::from("."), |p: &Path| p.to_path_buf());
117    let local_dependencies: Vec<String> = extract_local_dependencies(&doc, manifest_path)?;
118    Ok(Package {
119        name,
120        version,
121        path,
122        local_dependencies,
123    })
124}
125
126/// Extract local workspace dependencies from manifest
127///
128/// # Arguments
129///
130/// - `&toml::Value`: Parsed manifest
131/// - `&Path`: Path to manifest for resolving relative paths
132///
133/// # Returns
134///
135/// - `Result<Vec<String>, PublishError>`: List of local dependency names
136fn extract_local_dependencies(
137    doc: &toml::Value,
138    _manifest_path: &Path,
139) -> Result<Vec<String>, PublishError> {
140    let mut deps: Vec<String> = Vec::new();
141    let dep_sections: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"];
142    for section in &dep_sections {
143        if let Some(table) = doc.get(section).and_then(|s| s.as_table()) {
144            for (dep_name, dep_value) in table {
145                let is_local: bool = match dep_value {
146                    toml::Value::Table(t) => {
147                        t.get("path").is_some()
148                            || t.get("workspace")
149                                .and_then(|w| w.as_bool())
150                                .unwrap_or(false)
151                    }
152                    _ => false,
153                };
154                if is_local {
155                    deps.push(dep_name.clone());
156                }
157            }
158        }
159    }
160    Ok(deps)
161}
162
163/// Perform topological sort on packages based on dependencies
164///
165/// # Arguments
166///
167/// - `&[Package]`: List of packages to sort
168///
169/// # Returns
170///
171/// - `Result<Vec<Package>, PublishError>`: Sorted packages or error if circular
172fn topological_sort(packages: &[Package]) -> Result<Vec<Package>, PublishError> {
173    let mut in_degree: HashMap<String, usize> = HashMap::new();
174    let mut graph: HashMap<String, Vec<String>> = HashMap::new();
175    let package_map: HashMap<String, Package> = packages
176        .iter()
177        .map(|p| (p.name.clone(), p.clone()))
178        .collect();
179    for package in packages {
180        in_degree.entry(package.name.clone()).or_insert(0);
181        for dep in &package.local_dependencies {
182            if package_map.contains_key(dep) {
183                graph
184                    .entry(dep.clone())
185                    .or_default()
186                    .push(package.name.clone());
187                *in_degree.entry(package.name.clone()).or_insert(0) += 1;
188            }
189        }
190    }
191    let mut queue: VecDeque<String> = VecDeque::new();
192    for (name, degree) in &in_degree {
193        if *degree == 0 {
194            queue.push_back(name.clone());
195        }
196    }
197    let mut result: Vec<Package> = Vec::new();
198    while let Some(name) = queue.pop_front() {
199        if let Some(package) = package_map.get(&name) {
200            result.push(package.clone());
201        }
202        if let Some(dependents) = graph.get(&name) {
203            for dependent in dependents {
204                if let Some(degree) = in_degree.get_mut(dependent) {
205                    *degree -= 1;
206                    if *degree == 0 {
207                        queue.push_back(dependent.clone());
208                    }
209                }
210            }
211        }
212    }
213    if result.len() != packages.len() {
214        return Err(PublishError::CircularDependency);
215    }
216    Ok(result)
217}
218
219/// Publish a single package with retry logic
220///
221/// # Arguments
222///
223/// - `&Package`: Package to publish
224/// - `u32`: Maximum retry attempts
225///
226/// # Returns
227///
228/// - `PublishResult`: Result with success status and retry count
229async fn publish_package_with_retry(package: &Package, max_retries: u32) -> PublishResult {
230    let mut attempt: u32 = 0;
231    let mut last_error: Option<String> = None;
232    while attempt <= max_retries {
233        match publish_single_package(package).await {
234            Ok(()) => {
235                return PublishResult {
236                    package_name: package.name.clone(),
237                    success: true,
238                    error: None,
239                    retries: attempt,
240                };
241            }
242            Err(error) => {
243                last_error = Some(error.to_string());
244                attempt += 1;
245                if attempt <= max_retries {
246                    sleep(Duration::from_secs(2_u64.pow(attempt))).await;
247                }
248            }
249        }
250    }
251    PublishResult {
252        package_name: package.name.clone(),
253        success: false,
254        error: last_error,
255        retries: attempt - 1,
256    }
257}
258
259/// Execute cargo publish command for a single package
260///
261/// # Arguments
262///
263/// - `&Package`: Package to publish
264///
265/// # Returns
266///
267/// - `Result<(), Box<dyn std::error::Error>>`: Success or error
268async fn publish_single_package(package: &Package) -> Result<(), Box<dyn std::error::Error>> {
269    let output: std::process::Output = Command::new("cargo")
270        .arg("publish")
271        .arg("--allow-dirty")
272        .current_dir(&package.path)
273        .stdout(Stdio::piped())
274        .stderr(Stdio::piped())
275        .output()
276        .await?;
277    if output.status.success() {
278        Ok(())
279    } else {
280        let stderr: String = String::from_utf8_lossy(&output.stderr).to_string();
281        Err(stderr.into())
282    }
283}
284
285/// Execute publish command for all packages in workspace
286///
287/// # Arguments
288///
289/// - `&str`: Path to workspace Cargo.toml
290/// - `u32`: Maximum retry attempts per package
291///
292/// # Returns
293///
294/// - `Result<Vec<PublishResult>, PublishError>`: Results for all packages
295pub async fn execute_publish(
296    manifest_path: &str,
297    max_retries: u32,
298) -> Result<Vec<PublishResult>, PublishError> {
299    let path: &Path = Path::new(manifest_path);
300    let packages: Vec<Package> = discover_packages(path).await?;
301    if packages.is_empty() {
302        return Ok(Vec::new());
303    }
304    let sorted_packages: Vec<Package> = topological_sort(&packages)?;
305    let mut results: Vec<PublishResult> = Vec::new();
306    for package in sorted_packages {
307        log::info!("Publishing {} v{}...", package.name, package.version);
308        let result: PublishResult = publish_package_with_retry(&package, max_retries).await;
309        if result.success {
310            if result.retries == 0 {
311                log::info!("Successfully published {}", result.package_name,);
312            } else {
313                log::info!(
314                    "Successfully published {} (retried {} times)",
315                    result.package_name,
316                    result.retries
317                );
318            }
319        } else if let Some(error) = &result.error {
320            log::error!("Failed to publish {}: {error}", result.package_name);
321        } else {
322            log::error!("Failed to publish {}", result.package_name);
323        }
324        results.push(result);
325    }
326    Ok(results)
327}