Skip to main content

hyperlane_cli/publish/
fn.rs

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