Skip to main content

crate_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/// - `Option<String>` - Root version, or `None` when the workspace has no
32///   shared version (virtual workspace with per-member versions).
33fn read_root_version(doc: &DocumentMut) -> Option<String> {
34    let workspace_version: Option<&str> = doc
35        .get(TOML_WORKSPACE)
36        .and_then(|workspace: &Item| workspace.get(TOML_PACKAGE))
37        .and_then(|package: &Item| package.get(TOML_VERSION))
38        .and_then(|version_item: &Item| version_item.as_str());
39    let package_version: Option<&str> = doc
40        .get(TOML_PACKAGE)
41        .and_then(|package: &Item| package.get(TOML_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}
47
48/// Resolve a member's own version: literal `[package].version`, or the
49/// shared workspace version when the member uses `version.workspace = true`.
50///
51/// # Arguments
52///
53/// - `&DocumentMut` - Parsed member manifest.
54/// - `Option<&str>` - Shared workspace version, if any.
55///
56/// # Returns
57///
58/// - `Result<String, SyncError>` - The member's effective version.
59fn read_member_version(
60    member_doc: &DocumentMut,
61    workspace_version: Option<&str>,
62) -> Result<String, SyncError> {
63    let version_item: &Item = member_doc
64        .get(TOML_PACKAGE)
65        .and_then(|package: &Item| package.get(TOML_VERSION))
66        .ok_or_else(|| SyncError::MemberNameMissing(CARGO_TOML.to_string()))?;
67    if let Some(version) = version_item.as_str() {
68        return Ok(version.to_string());
69    }
70    let inherits: bool = version_item
71        .get(TOML_WORKSPACE)
72        .and_then(|workspace_item: &Item| workspace_item.as_bool())
73        .unwrap_or(false);
74    if inherits && let Some(version) = workspace_version {
75        return Ok(version.to_string());
76    }
77    Err(SyncError::WorkspaceVersionMissing(CARGO_TOML.to_string()))
78}
79
80/// Read `[workspace.members]` list from a workspace manifest.
81///
82/// # Arguments
83///
84/// - `&DocumentMut` - Parsed manifest.
85///
86/// # Returns
87///
88/// - `Result<Vec<String>, SyncError>` - Member path list, or an error.
89fn read_workspace_members(doc: &DocumentMut) -> Result<Vec<String>, SyncError> {
90    let members: Vec<String> = doc
91        .get(TOML_WORKSPACE)
92        .and_then(|workspace: &Item| workspace.get(TOML_MEMBERS))
93        .and_then(|members_item: &Item| members_item.as_array())
94        .ok_or_else(|| SyncError::WorkspaceMembersMissing(CARGO_TOML.to_string()))?
95        .iter()
96        .filter_map(|member: &toml_edit::Value| member.as_str().map(|s: &str| s.to_string()))
97        .collect();
98    Ok(members)
99}
100
101/// Read `[package].name` from a single member crate's Cargo.toml.
102///
103/// # Arguments
104///
105/// - `&DocumentMut` - Parsed member manifest.
106///
107/// # Returns
108///
109/// - `Result<String, SyncError>` - Crate name, or an error if missing.
110fn read_member_crate_name(doc: &DocumentMut) -> Result<String, SyncError> {
111    let name: String = doc
112        .get(TOML_PACKAGE)
113        .and_then(|package: &Item| package.get(TOML_NAME))
114        .and_then(|name_item: &Item| name_item.as_str())
115        .ok_or_else(|| SyncError::MemberNameMissing(CARGO_TOML.to_string()))?
116        .to_string();
117    Ok(name)
118}
119
120/// Find the dep LHS name whose entry points at `member_path`.
121///
122/// # Arguments
123///
124/// - `&dyn TableLike` - Parsed `[workspace.dependencies]` table.
125/// - `&str` - Member path to look up (e.g. `\"type\"`).
126///
127/// # Returns
128///
129/// - `Option<String>` - The current dep LHS, if any entry references
130///   `path = \"member_path\"`.
131fn find_dep_alias_for_member_path(deps: &dyn TableLike, member_path: &str) -> Option<String> {
132    for (alias, entry) in deps.iter() {
133        if let Some(path) = entry
134            .get(TOML_PATH)
135            .and_then(|path_item: &Item| path_item.as_str())
136            && path == member_path
137        {
138            return Some(alias.to_string());
139        }
140    }
141    None
142}
143
144/// Read the current dep alias and version literal for `member_path` from
145/// the workspace manifest, without mutating anything.
146///
147/// # Arguments
148///
149/// - `&DocumentMut` - Parsed workspace manifest.
150/// - `&str` - Member path.
151///
152/// # Returns
153///
154/// - `Option<(String, Option<String>)>` - `(current_alias,
155///   existing_version)` if an entry references `path = \"member_path\"`.
156fn scan_dep_entry(doc: &DocumentMut, member_path: &str) -> Option<(String, Option<String>)> {
157    let deps: &dyn TableLike = doc
158        .get(TOML_WORKSPACE)
159        .and_then(|workspace: &Item| workspace.get(TOML_DEPENDENCIES))
160        .and_then(|deps_item: &Item| deps_item.as_table_like())?;
161    let current_alias: String = find_dep_alias_for_member_path(deps, member_path)?;
162    let existing_version: Option<String> = deps
163        .get(&current_alias)
164        .and_then(|entry: &Item| entry.get(TOML_VERSION))
165        .and_then(|version_item: &Item| version_item.as_str())
166        .map(|version: &str| version.to_string());
167    Some((current_alias, existing_version))
168}
169
170/// Set the entry's `version` field to `workspace_version`, in-place and
171/// decor-preserving when the field already exists.
172///
173/// # Arguments
174///
175/// - `&mut dyn TableLike` - Mutable `[workspace.dependencies]` table.
176/// - `&str` - Dep LHS alias of the entry to update.
177/// - `&str` - Workspace version to write into the entry's `version` field.
178fn rewrite_entry_version(deps: &mut dyn TableLike, current_alias: &str, workspace_version: &str) {
179    let Some(entry) = deps.get_mut(current_alias) else {
180        return;
181    };
182    match entry.get_mut(TOML_VERSION) {
183        Some(version_slot) => set_item_string_preserving_decor(version_slot, workspace_version),
184        None => {
185            if let Some(entry_table) = entry.as_table_like_mut() {
186                entry_table.insert(TOML_VERSION, value(workspace_version));
187            }
188        }
189    }
190}
191
192/// Align every local path entry under `[workspace.dependencies]` with the
193/// workspace root version, and (optionally) the dep alias with the member
194/// crate's actual `[package].name`.
195///
196/// # Arguments
197///
198/// - `&str` - Path to the workspace root Cargo.toml.
199///
200/// # Returns
201///
202/// - `Result<SyncReport, SyncError>` - Summary of what was rewritten.
203///
204/// # Behavior
205///
206/// * Reads the root version via `read_root_version` (workspace package
207///   version first, plain package version as fallback). Errors out if
208///   neither exists.
209/// * Reads `[workspace.members]`. Errors out if it is missing.
210/// * When the root manifest also has `[package]` (monorepo with a root
211///   package), the root package itself is processed first as member path
212///   the workspace root itself, so a `path` entry pointing at the root
213///   stays aligned too.
214/// * For each member path:
215///     1. Opens `<member_path>/Cargo.toml` and reads its `[package].name`.
216///     2. Locates the existing `[workspace.dependencies]` entry whose
217///        `path = \"<member_path>\"` and captures its current LHS alias.
218///     3. If the entry is already aligned (alias + version both
219///        correct), skip it.
220///     4. Otherwise rewrite the entry in-place: align `version` to the
221///        root version, and rename the LHS alias to match the crate's
222///        `[package].name` when the alias differs.
223/// * Edits are applied on a `toml_edit::DocumentMut`, so comments, key
224///   order and inline-table formatting outside the edited values are
225///   preserved byte-for-byte.
226/// * `file_changed` is true iff at least one rename or version-rewrite
227///   actually happened (so a no-op run is genuinely idempotent and
228///   does not write the file).
229pub async fn execute_sync(manifest_path: &str) -> Result<SyncReport, SyncError> {
230    let path: &Path = Path::new(manifest_path);
231    let content: String = read_to_string(path).await?;
232    let mut doc: DocumentMut = content
233        .parse()
234        .map_err(|_error: TomlError| SyncError::ManifestParseError)?;
235    let workspace_version: Option<String> = read_root_version(&doc);
236    let members: Vec<String> = read_workspace_members(&doc)?;
237    if members.is_empty() {
238        log::info!("sync: no workspace members, nothing to do");
239        return Ok(SyncReport {
240            workspace_version: workspace_version.unwrap_or_default(),
241            renamed_entries: Vec::new(),
242            versioned_entries: Vec::new(),
243            file_changed: false,
244        });
245    }
246    let mut renamed_entries: Vec<(String, String)> = Vec::new();
247    let mut versioned_entries: Vec<(String, String)> = Vec::new();
248    let mut needs_rewrite: bool = false;
249    // A root package (monorepo with `[package]` at the workspace root) can
250    // itself be referenced as `path = "."` in `[workspace.dependencies]`;
251    // include it so its entry stays aligned with the workspace version.
252    let mut member_paths: Vec<String> = Vec::new();
253    if doc.get(TOML_PACKAGE).is_some() {
254        member_paths.push(".".to_string());
255    }
256    member_paths.extend(members.iter().cloned());
257    for member_path in &member_paths {
258        let member_manifest_path: PathBuf = path
259            .parent()
260            .unwrap_or_else(|| Path::new("."))
261            .join(member_path)
262            .join(CARGO_TOML);
263        if !member_manifest_path.exists() {
264            return Err(SyncError::MemberManifestMissing(
265                member_manifest_path.display().to_string(),
266            ));
267        }
268        let member_content: String = read_to_string(&member_manifest_path).await?;
269        let member_doc: DocumentMut = member_content
270            .parse()
271            .map_err(|_error: TomlError| SyncError::ManifestParseError)?;
272        let canonical_alias: String = read_member_crate_name(&member_doc)?;
273        // Homogeneous workspaces (root version exists) align every entry to
274        // the root version; heterogeneous virtual workspaces align each
275        // entry to the member's own version.
276        let target_version: String = match &workspace_version {
277            Some(version) => version.clone(),
278            None => read_member_version(&member_doc, None)?,
279        };
280        let (current_alias, existing_version): (String, Option<String>) =
281            match scan_dep_entry(&doc, member_path) {
282                Some(scanned) => scanned,
283                None => {
284                    log::info!(
285                        "sync: {} -> no [workspace.dependencies] entry, skipping",
286                        member_path
287                    );
288                    continue;
289                }
290            };
291        let alias_needs_rename: bool = current_alias != canonical_alias;
292        let version_needs_rewrite: bool =
293            existing_version.as_deref() != Some(target_version.as_str());
294        if !alias_needs_rename && !version_needs_rewrite {
295            continue;
296        }
297        needs_rewrite = true;
298        let deps: &mut dyn TableLike = doc
299            .get_mut(TOML_WORKSPACE)
300            .and_then(|workspace: &mut Item| workspace.get_mut(TOML_DEPENDENCIES))
301            .and_then(|deps_item: &mut Item| deps_item.as_table_like_mut())
302            .ok_or(SyncError::ManifestParseError)?;
303        rewrite_entry_version(deps, &current_alias, &target_version);
304        if alias_needs_rename {
305            if let Some(entry) = deps.remove(&current_alias) {
306                deps.insert(&canonical_alias, entry);
307            }
308            log::info!(
309                "sync: {} renamed {} -> {}",
310                member_path,
311                current_alias,
312                canonical_alias
313            );
314            renamed_entries.push((current_alias, canonical_alias.clone()));
315        } else {
316            log::info!(
317                "sync: {} -> {} v{}",
318                member_path,
319                canonical_alias,
320                target_version
321            );
322        }
323        versioned_entries.push((member_path.clone(), canonical_alias));
324    }
325    let file_changed: bool = needs_rewrite;
326    let report_version: String =
327        workspace_version.unwrap_or_else(|| REPORT_PER_MEMBER_VERSION.to_string());
328    if file_changed {
329        write(path, doc.to_string()).await?;
330        log::info!(
331            "sync: wrote {} entries to v{}",
332            versioned_entries.len(),
333            report_version
334        );
335    } else {
336        log::info!("sync: already in sync: v{}", report_version);
337    }
338    Ok(SyncReport {
339        workspace_version: report_version,
340        renamed_entries,
341        versioned_entries,
342        file_changed,
343    })
344}