Skip to main content

hyperlane_cli/sync/
fn.rs

1use super::*;
2
3/// Read `[workspace.package].version` from a workspace Cargo.toml.
4///
5/// # Arguments
6///
7/// - `&Value`: Parsed manifest.
8///
9/// # Returns
10///
11/// - `Result<String, SyncError>`: Workspace version, or an error if the
12///   field is missing.
13fn read_workspace_version(doc: &Value) -> Result<String, SyncError> {
14    let version: String = doc
15        .get("workspace")
16        .and_then(|workspace: &Value| workspace.get("package"))
17        .and_then(|package: &Value| package.get("version"))
18        .and_then(|version_value: &Value| version_value.as_str())
19        .ok_or_else(|| SyncError::WorkspaceVersionMissing("Cargo.toml".to_string()))?
20        .to_string();
21    Ok(version)
22}
23
24/// Read `[workspace.members]` list from a workspace Cargo.toml.
25///
26/// # Arguments
27///
28/// - `&Value`: Parsed manifest.
29///
30/// # Returns
31///
32/// - `Result<Vec<String>, SyncError>`: Member path list, or an error.
33fn read_workspace_members(doc: &Value) -> Result<Vec<String>, SyncError> {
34    let members: Vec<String> = doc
35        .get("workspace")
36        .and_then(|workspace: &Value| workspace.get("members"))
37        .and_then(|members_value: &Value| members_value.as_array())
38        .ok_or_else(|| SyncError::WorkspaceMembersMissing("Cargo.toml".to_string()))?
39        .iter()
40        .filter_map(|member: &Value| member.as_str().map(|s: &str| s.to_string()))
41        .collect();
42    Ok(members)
43}
44
45/// Read `[package].name` from a single member crate's Cargo.toml.
46///
47/// # Arguments
48///
49/// - `&Value`: Parsed member manifest.
50///
51/// # Returns
52///
53/// - `Result<String, SyncError>`: Crate name, or an error if missing.
54fn read_member_crate_name(doc: &Value) -> Result<String, SyncError> {
55    let name: String = doc
56        .get("package")
57        .and_then(|package: &Value| package.get("name"))
58        .and_then(|name_value: &Value| name_value.as_str())
59        .ok_or_else(|| SyncError::MemberNameMissing("Cargo.toml".to_string()))?
60        .to_string();
61    Ok(name)
62}
63
64/// Find the dep LHS name whose entry points at `member_path`.
65///
66/// # Arguments
67///
68/// - `&Value`: Parsed `[workspace.dependencies]` table.
69/// - `&str`: Member path to look up (e.g. `"type"`).
70///
71/// # Returns
72///
73/// - `Option<String>`: The current dep LHS, if any entry references
74///   `path = "member_path"`.
75fn find_dep_alias_for_member_path(deps: &Value, member_path: &str) -> Option<String> {
76    let table: &toml::map::Map<String, Value> = deps.as_table()?;
77    for (alias, entry) in table {
78        if let Some(entry_table) = entry.as_table()
79            && let Some(path) = entry_table.get("path")
80            && path.as_str() == Some(member_path)
81        {
82            return Some(alias.clone());
83        }
84    }
85    None
86}
87
88/// Apply both the version literal and (if needed) the LHS alias rewrite
89/// to a single `[workspace.dependencies]` entry.
90///
91/// # Arguments
92///
93/// - `&mut toml::map::Map<String, Value>`: Mutable `[workspace.dependencies]`
94///   table to update.
95/// - `&str`: Member path.
96/// - `&str`: Current LHS alias to look up.
97/// - `&str`: Canonical LHS alias (the member crate's `[package].name`).
98/// - `&str`: Workspace version to write into the entry's `version` field.
99///
100/// # Returns
101///
102/// - `Option<(String, String)>`: `Some((old_alias, new_alias))` if the
103///   alias was renamed; `None` otherwise. The function mutates the
104///   table in-place regardless: at minimum it sets `version` to
105///   `workspace_version`.
106fn rewrite_dep_entry(
107    deps: &mut toml::map::Map<String, Value>,
108    member_path: &str,
109    current_alias: &str,
110    canonical_alias: &str,
111    workspace_version: &str,
112) -> Option<(String, String)> {
113    let entry: &mut Value = deps.get_mut(current_alias)?;
114    let entry_table: &mut toml::map::Map<String, Value> = entry.as_table_mut()?;
115    let path_matches: bool = entry_table
116        .get("path")
117        .and_then(|path_value: &Value| path_value.as_str())
118        .map(|s: &str| s == member_path)
119        .unwrap_or(false);
120    if !path_matches {
121        return None;
122    }
123    entry_table.insert(
124        "version".to_string(),
125        Value::String(workspace_version.to_string()),
126    );
127    if current_alias != canonical_alias {
128        let renamed: (String, String) = (current_alias.to_string(), canonical_alias.to_string());
129        let new_entry: Value = entry.clone();
130        deps.remove(current_alias);
131        let entry_to_set: &mut Value = deps.entry(canonical_alias.to_string()).or_insert(new_entry);
132        let canonical_table: &mut toml::map::Map<String, Value> =
133            entry_to_set.as_table_mut().unwrap();
134        canonical_table.insert("path".to_string(), Value::String(member_path.to_string()));
135        canonical_table.insert(
136            "version".to_string(),
137            Value::String(workspace_version.to_string()),
138        );
139        Some(renamed)
140    } else {
141        None
142    }
143}
144
145/// Align every local path-only entry under `[workspace.dependencies]`
146/// with the workspace version, and (optionally) the dep alias with the
147/// member crate's actual `[package].name`.
148///
149/// # Arguments
150///
151/// - `&str`: Path to the workspace root Cargo.toml.
152///
153/// # Returns
154///
155/// - `Result<SyncReport, SyncError>`: Summary of what was rewritten.
156///
157/// # Behavior
158///
159/// * Reads `[workspace.package].version`. Errors out if it is missing.
160/// * Reads `[workspace.members]`. Errors out if it is missing.
161/// * For each member path:
162///     1. Opens `<member_path>/Cargo.toml` and reads its `[package].name`.
163///     2. Locates the existing `[workspace.dependencies]` entry whose
164///        `path = "<member_path>"` and captures its current LHS alias.
165///     3. If the entry is already aligned (alias + version both
166///        correct), skip it.
167///     4. Otherwise rewrite the entry: align `version` to the workspace
168///        version, and rename the LHS alias to match the crate's
169///        `[package].name` when the alias differs.
170/// * `file_changed` is true iff at least one rename or version-rewrite
171///   actually happened (so a no-op run is genuinely idempotent and
172///   does not write the file).
173pub async fn execute_sync(manifest_path: &str) -> Result<SyncReport, SyncError> {
174    let path: &Path = Path::new(manifest_path);
175    let content: String = read_to_string(path).await?;
176    let mut doc: Value = toml::from_str(&content).map_err(|_| SyncError::ManifestParseError)?;
177    let workspace_version: String = read_workspace_version(&doc)?;
178    let members: Vec<String> = read_workspace_members(&doc)?;
179    if members.is_empty() {
180        log::info!("sync: no workspace members, nothing to do");
181        return Ok(SyncReport {
182            workspace_version,
183            renamed_entries: Vec::new(),
184            versioned_entries: Vec::new(),
185            file_changed: false,
186        });
187    }
188    let mut renamed_entries: Vec<(String, String)> = Vec::new();
189    let mut versioned_entries: Vec<(String, String)> = Vec::new();
190    let mut needs_rewrite: bool = false;
191    let deps_value: Option<&Value> = doc
192        .get("workspace")
193        .and_then(|workspace: &Value| workspace.get("dependencies"));
194    let mut deps: toml::map::Map<String, Value> = deps_value
195        .and_then(|value: &Value| value.as_table())
196        .cloned()
197        .unwrap_or_default();
198    for member_path in &members {
199        let member_manifest_path: PathBuf = path
200            .parent()
201            .unwrap_or_else(|| Path::new("."))
202            .join(member_path)
203            .join("Cargo.toml");
204        if !member_manifest_path.exists() {
205            return Err(SyncError::MemberManifestMissing(
206                member_manifest_path.display().to_string(),
207            ));
208        }
209        let member_content: String = read_to_string(&member_manifest_path).await?;
210        let member_doc: Value =
211            toml::from_str(&member_content).map_err(|_| SyncError::ManifestParseError)?;
212        let canonical_alias: String = read_member_crate_name(&member_doc)?;
213        let current_alias: Option<String> =
214            find_dep_alias_for_member_path(&Value::Table(deps.clone()), member_path);
215        let current_alias: String = match current_alias {
216            Some(alias) => alias,
217            None => {
218                log::info!(
219                    "sync: {} -> no [workspace.dependencies] entry, skipping",
220                    member_path
221                );
222                continue;
223            }
224        };
225        let existing_version: Option<String> = deps
226            .get(&current_alias)
227            .and_then(|entry: &Value| entry.as_table())
228            .and_then(|table: &toml::map::Map<String, Value>| table.get("version"))
229            .and_then(|version_value: &Value| version_value.as_str())
230            .map(|s: &str| s.to_string());
231        let alias_needs_rename: bool = current_alias != canonical_alias;
232        let version_needs_rewrite: bool = existing_version
233            .as_deref()
234            .map(|existing: &str| existing != workspace_version)
235            .unwrap_or(true);
236        if !alias_needs_rename && !version_needs_rewrite {
237            continue;
238        }
239        needs_rewrite = true;
240        let renamed: Option<(String, String)> = rewrite_dep_entry(
241            &mut deps,
242            member_path,
243            &current_alias,
244            &canonical_alias,
245            &workspace_version,
246        );
247        if let Some((old, new)) = &renamed {
248            log::info!("sync: {} renamed {} -> {}", member_path, old, new);
249            renamed_entries.push((old.clone(), new.clone()));
250        } else {
251            log::info!(
252                "sync: {} -> {} v{}",
253                member_path,
254                canonical_alias,
255                workspace_version
256            );
257        }
258        versioned_entries.push((member_path.clone(), canonical_alias));
259    }
260    let file_changed: bool = needs_rewrite;
261    if file_changed {
262        if let Some(workspace_table) = doc
263            .get_mut("workspace")
264            .and_then(|workspace: &mut Value| workspace.as_table_mut())
265        {
266            workspace_table.insert("dependencies".to_string(), Value::Table(deps.clone()));
267        }
268        let updated_content: String =
269            toml::to_string(&doc).map_err(|_| SyncError::ManifestSerializeError)?;
270        write(path, updated_content).await?;
271        log::info!(
272            "sync: wrote {} entries to v{}",
273            versioned_entries.len(),
274            workspace_version
275        );
276    } else {
277        log::info!("sync: already in sync: v{}", workspace_version);
278    }
279    Ok(SyncReport {
280        workspace_version,
281        renamed_entries,
282        versioned_entries,
283        file_changed,
284    })
285}