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("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}
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("package")
65        .and_then(|package: &Item| package.get("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("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("workspace")
92        .and_then(|workspace: &Item| workspace.get("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: &TomlEditValue| 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("package")
113        .and_then(|package: &Item| package.get("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("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("workspace")
159        .and_then(|workspace: &Item| workspace.get("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("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("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("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///   `"."`, so a `path = "."` entry stays aligned too.
213/// * For each member path:
214///     1. Opens `<member_path>/Cargo.toml` and reads its `[package].name`.
215///     2. Locates the existing `[workspace.dependencies]` entry whose
216///        `path = \"<member_path>\"` and captures its current LHS alias.
217///     3. If the entry is already aligned (alias + version both
218///        correct), skip it.
219///     4. Otherwise rewrite the entry in-place: align `version` to the
220///        root version, and rename the LHS alias to match the crate's
221///        `[package].name` when the alias differs.
222/// * Edits are applied on a `toml_edit::DocumentMut`, so comments, key
223///   order and inline-table formatting outside the edited values are
224///   preserved byte-for-byte.
225/// * `file_changed` is true iff at least one rename or version-rewrite
226///   actually happened (so a no-op run is genuinely idempotent and
227///   does not write the file).
228pub async fn execute_sync(manifest_path: &str) -> Result<SyncReport, SyncError> {
229    let path: &Path = Path::new(manifest_path);
230    let content: String = read_to_string(path).await?;
231    let mut doc: DocumentMut = content.parse().map_err(|_| SyncError::ManifestParseError)?;
232    let workspace_version: Option<String> = read_root_version(&doc);
233    let members: Vec<String> = read_workspace_members(&doc)?;
234    if members.is_empty() {
235        log::info!("sync: no workspace members, nothing to do");
236        return Ok(SyncReport {
237            workspace_version: workspace_version.unwrap_or_default(),
238            renamed_entries: Vec::new(),
239            versioned_entries: Vec::new(),
240            file_changed: false,
241        });
242    }
243    let mut renamed_entries: Vec<(String, String)> = Vec::new();
244    let mut versioned_entries: Vec<(String, String)> = Vec::new();
245    let mut needs_rewrite: bool = false;
246    // A root package (monorepo with `[package]` at the workspace root) can
247    // itself be referenced as `path = "."` in `[workspace.dependencies]`;
248    // include it so its entry stays aligned with the workspace version.
249    let mut member_paths: Vec<String> = Vec::new();
250    if doc.get("package").is_some() {
251        member_paths.push(".".to_string());
252    }
253    member_paths.extend(members.iter().cloned());
254    for member_path in &member_paths {
255        let member_manifest_path: PathBuf = path
256            .parent()
257            .unwrap_or_else(|| Path::new("."))
258            .join(member_path)
259            .join("Cargo.toml");
260        if !member_manifest_path.exists() {
261            return Err(SyncError::MemberManifestMissing(
262                member_manifest_path.display().to_string(),
263            ));
264        }
265        let member_content: String = read_to_string(&member_manifest_path).await?;
266        let member_doc: DocumentMut = member_content
267            .parse()
268            .map_err(|_| SyncError::ManifestParseError)?;
269        let canonical_alias: String = read_member_crate_name(&member_doc)?;
270        // Homogeneous workspaces (root version exists) align every entry to
271        // the root version; heterogeneous virtual workspaces align each
272        // entry to the member's own version.
273        let target_version: String = match &workspace_version {
274            Some(version) => version.clone(),
275            None => read_member_version(&member_doc, None)?,
276        };
277        let (current_alias, existing_version): (String, Option<String>) =
278            match scan_dep_entry(&doc, member_path) {
279                Some(scanned) => scanned,
280                None => {
281                    log::info!(
282                        "sync: {} -> no [workspace.dependencies] entry, skipping",
283                        member_path
284                    );
285                    continue;
286                }
287            };
288        let alias_needs_rename: bool = current_alias != canonical_alias;
289        let version_needs_rewrite: bool =
290            existing_version.as_deref() != Some(target_version.as_str());
291        if !alias_needs_rename && !version_needs_rewrite {
292            continue;
293        }
294        needs_rewrite = true;
295        let deps: &mut dyn TableLike = doc
296            .get_mut("workspace")
297            .and_then(|workspace: &mut Item| workspace.get_mut("dependencies"))
298            .and_then(|deps_item: &mut Item| deps_item.as_table_like_mut())
299            .ok_or(SyncError::ManifestParseError)?;
300        rewrite_entry_version(deps, &current_alias, &target_version);
301        if alias_needs_rename {
302            if let Some(entry) = deps.remove(&current_alias) {
303                deps.insert(&canonical_alias, entry);
304            }
305            log::info!(
306                "sync: {} renamed {} -> {}",
307                member_path,
308                current_alias,
309                canonical_alias
310            );
311            renamed_entries.push((current_alias, canonical_alias.clone()));
312        } else {
313            log::info!(
314                "sync: {} -> {} v{}",
315                member_path,
316                canonical_alias,
317                target_version
318            );
319        }
320        versioned_entries.push((member_path.clone(), canonical_alias));
321    }
322    let file_changed: bool = needs_rewrite;
323    let report_version: String = workspace_version.unwrap_or_else(|| "per-member".to_string());
324    if file_changed {
325        write(path, doc.to_string()).await?;
326        log::info!(
327            "sync: wrote {} entries to v{}",
328            versioned_entries.len(),
329            report_version
330        );
331    } else {
332        log::info!("sync: already in sync: v{}", report_version);
333    }
334    Ok(SyncReport {
335        workspace_version: report_version,
336        renamed_entries,
337        versioned_entries,
338        file_changed,
339    })
340}