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