Skip to main content

hyperlane_cli/sync/
fn.rs

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