Skip to main content

cargo_wsdeps/
diff.rs

1use cargo_metadata::{
2    Dependency, DependencyKind, Metadata,
3    camino::{Utf8Path, Utf8PathBuf},
4    semver::VersionReq,
5};
6use patcher::{DiffAlgorithm, Differ, MultifilePatch};
7use pathdiff::diff_utf8_paths;
8use std::collections::{BTreeMap, BTreeSet};
9use std::fs::read_to_string;
10use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, table, value};
11
12use crate::MemberDependency;
13
14/// Render a `VersionReq` using Cargo's preferred syntax: drop a leading
15/// caret for single-comparator caret reqs (e.g. `^1.2` -> `1.2`).
16fn format_req(req: &VersionReq) -> String {
17    let s = req.to_string();
18    if let Some(rest) = s.strip_prefix('^')
19        && !rest.contains([',', ' ', '<', '>', '=', '~', '^', '*'])
20    {
21        rest.to_string()
22    } else {
23        s
24    }
25}
26
27/// Score a `VersionReq` by its first comparator's (major, minor, patch)
28/// so divergent member reqs can be reconciled by picking the highest
29/// minimum. Complex multi-comparator reqs fall back to (0,0,0); the
30/// existing iteration order then picks the first such entry.
31fn req_floor(req: &VersionReq) -> (u64, u64, u64) {
32    req.comparators
33        .first()
34        .map(|c| (c.major, c.minor.unwrap_or(0), c.patch.unwrap_or(0)))
35        .unwrap_or((0, 0, 0))
36}
37
38#[derive(Default)]
39struct MemberChanges {
40    to_workspace: Vec<Dependency>,
41    to_inline: Vec<(String, Item, DependencyKind)>,
42}
43
44pub fn generate_diff(
45    add: &BTreeMap<String, Vec<MemberDependency>>,
46    remove: &BTreeSet<String>,
47    inline: &BTreeMap<String, MemberDependency>,
48    metadata: &Metadata,
49    dotted: bool,
50) -> anyhow::Result<()> {
51    let mut changes = Vec::with_capacity(add.len() + inline.len() + 1);
52
53    let workspace_path = metadata.workspace_root.join("Cargo.toml");
54    let workspace_content = read_to_string(&workspace_path)?;
55    let mut workspace_doc: DocumentMut = workspace_content.parse()?;
56
57    // Capture workspace dep items before removing them (needed for inline)
58    let mut inline_items: BTreeMap<String, Item> = BTreeMap::new();
59    if let Some(workspace_dependencies) = workspace_doc
60        .get("workspace")
61        .and_then(|w| w.get("dependencies"))
62        .and_then(|d| d.as_table_like())
63    {
64        for name in inline.keys() {
65            if let Some(item) = workspace_dependencies.get(name) {
66                inline_items.insert(name.clone(), item.clone());
67            }
68        }
69    }
70
71    if let Some(workspace_dependencies) = workspace_doc
72        .get_mut("workspace")
73        .and_then(|w| w.get_mut("dependencies"))
74        .and_then(|d| d.as_table_like_mut())
75    {
76        for name in remove {
77            workspace_dependencies.remove(name);
78        }
79    }
80
81    let mut member_changes: BTreeMap<Utf8PathBuf, MemberChanges> = BTreeMap::new();
82
83    if !add.is_empty() {
84        let Some(workspace_table) = workspace_doc
85            .as_table_mut()
86            .entry("workspace")
87            .or_insert_with(table)
88            .as_table_mut()
89        else {
90            anyhow::bail!("Invalid [workspace] entry");
91        };
92
93        // `Table::entry(...).or_insert(table())` does not reliably promote a
94        // missing key into a real `Item::Table` for top-level workspace
95        // sub-tables, so insert explicitly when missing.
96        if !workspace_table.contains_key("dependencies") {
97            workspace_table.insert("dependencies", table());
98        }
99
100        let Some(workspace_dependencies) = workspace_table
101            .get_mut("dependencies")
102            .and_then(|d| d.as_table_mut())
103        else {
104            anyhow::bail!("Invalid workspace dependencies entry");
105        };
106
107        for (name, members) in add {
108            // Reconcile divergent member version reqs by picking the one
109            // with the highest minimum version. This avoids silently
110            // downgrading a member that pinned a newer floor.
111            let mut dependency: Option<&Dependency> = None;
112            let mut no_default_features = false;
113            let mut features = BTreeSet::new();
114            for member in members {
115                match dependency {
116                    None => dependency = Some(&member.dependency),
117                    Some(current)
118                        if req_floor(&member.dependency.req) > req_floor(&current.req) =>
119                    {
120                        dependency = Some(&member.dependency);
121                    }
122                    _ => {}
123                }
124
125                features.extend(member.dependency.features.iter().cloned());
126                no_default_features |= !member.dependency.uses_default_features;
127
128                member_changes
129                    .entry(member.manifest_path.clone())
130                    .or_default()
131                    .to_workspace
132                    .push(member.dependency.clone());
133            }
134
135            if let Some(dependency) = dependency {
136                let req_str = format_req(&dependency.req);
137                let value = if no_default_features || !features.is_empty() {
138                    let mut entry = InlineTable::new();
139                    entry.insert("version", req_str.into());
140
141                    if no_default_features {
142                        entry.insert("default-features", false.into());
143                    }
144
145                    if !features.is_empty() {
146                        entry.insert("features", Array::from_iter(features).into());
147                    }
148
149                    entry.into()
150                } else {
151                    value(req_str)
152                };
153
154                // The dep may already exist in [workspace.dependencies] when
155                // `add[name]` only contains inline holdouts being consolidated
156                // onto an existing entry; preserve the existing entry (and its
157                // features/default-features/etc.) in that case.
158                if !workspace_dependencies.contains_key(name) {
159                    workspace_dependencies.insert(name, value);
160                }
161            }
162        }
163
164        // Keep the workspace dependency table alphabetically sorted so newly
165        // inserted entries land in their proper place rather than appended.
166        workspace_dependencies.sort_values();
167    }
168
169    for (name, member) in inline {
170        if let Some(item) = inline_items.remove(name) {
171            member_changes
172                .entry(member.manifest_path.clone())
173                .or_default()
174                .to_inline
175                .push((name.clone(), item, member.dependency.kind));
176        }
177    }
178
179    for (path, mc) in member_changes {
180        update_member(&path, &mc, dotted, &metadata.workspace_root, &mut changes)?;
181    }
182
183    changes.push((workspace_path, workspace_content, workspace_doc.to_string()));
184
185    let mut patches = Vec::new();
186
187    for (path, original, modified) in changes {
188        if original == modified {
189            continue;
190        }
191        let differ = Differ::new(&original, &modified);
192        let mut patch = differ.generate();
193
194        let relative_path = diff_utf8_paths(&path, &metadata.workspace_root).unwrap_or(path);
195        patch.old_file = relative_path.to_string();
196        patch.new_file = relative_path.to_string();
197
198        patches.push(patch);
199    }
200
201    let multi_patch = MultifilePatch::new(patches);
202
203    println!("{multi_patch}");
204    Ok(())
205}
206
207fn update_member(
208    path: &Utf8Path,
209    mc: &MemberChanges,
210    dotted: bool,
211    workspace_root: &Utf8Path,
212    changes: &mut Vec<(Utf8PathBuf, String, String)>,
213) -> anyhow::Result<()> {
214    let member_content = read_to_string(path)?;
215    let mut member_doc: DocumentMut = member_content.parse()?;
216    let member_dir = path.parent().unwrap_or(path);
217
218    for dep in &mc.to_workspace {
219        let memmber_dependencies = match dep.kind {
220            DependencyKind::Normal => member_doc["dependencies"].as_table_mut(),
221            DependencyKind::Development => member_doc["dev-dependencies"].as_table_mut(),
222            DependencyKind::Build => member_doc["build-dependencies"].as_table_mut(),
223            _ => None,
224        };
225
226        if let Some(member_dependencies) = memmber_dependencies {
227            update_dependency(member_dependencies, dep, dotted);
228        }
229    }
230
231    for (name, item, kind) in &mc.to_inline {
232        let memmber_dependencies = match kind {
233            DependencyKind::Normal => member_doc["dependencies"].as_table_mut(),
234            DependencyKind::Development => member_doc["dev-dependencies"].as_table_mut(),
235            DependencyKind::Build => member_doc["build-dependencies"].as_table_mut(),
236            _ => None,
237        };
238
239        if let Some(member_dependencies) = memmber_dependencies {
240            inline_dependency(member_dependencies, name, item, workspace_root, member_dir);
241        }
242    }
243
244    changes.push((path.to_path_buf(), member_content, member_doc.to_string()));
245
246    Ok(())
247}
248
249fn update_dependency(member_dependencies: &mut Table, dep: &Dependency, dotted: bool) {
250    if let Some(entry) = member_dependencies[&dep.name].as_table_like_mut() {
251        entry.remove("version");
252        entry.remove("default-features");
253        // Collect remaining keys so we can re-insert them after `workspace = true`.
254        let rest: Vec<(String, toml_edit::Value)> = entry
255            .iter()
256            .filter(|(k, _)| *k != "workspace")
257            .filter_map(|(k, v)| v.as_value().map(|val| (k.to_string(), val.clone())))
258            .collect();
259
260        entry.clear();
261        entry.insert("workspace", value(true));
262
263        for (k, v) in rest {
264            entry.insert(&k, Item::Value(v));
265        }
266
267        entry.fmt();
268    } else {
269        let mut entry = InlineTable::new();
270        entry.set_dotted(dotted);
271        entry.insert("workspace", true.into());
272        member_dependencies[&dep.name] = entry.into();
273    }
274}
275
276/// Rewrite a `path` value from workspace-root-relative to member-dir-relative.
277/// If the path is absolute or the diff fails, return it unchanged.
278fn rebase_path(ws_path: &str, workspace_root: &Utf8Path, member_dir: &Utf8Path) -> String {
279    let abs = workspace_root.join(ws_path);
280    diff_utf8_paths(&abs, member_dir)
281        .map(|p| p.to_string())
282        .unwrap_or_else(|| ws_path.to_string())
283}
284
285fn inline_dependency(
286    member_dependencies: &mut Table,
287    name: &str,
288    ws_item: &Item,
289    workspace_root: &Utf8Path,
290    member_dir: &Utf8Path,
291) {
292    // Determine if the existing member entry has any extra keys besides `workspace`
293    let mut extras: Vec<(String, Item)> = Vec::new();
294    if let Some(entry) = member_dependencies
295        .get(name)
296        .and_then(|e| e.as_table_like())
297    {
298        for (k, v) in entry.iter() {
299            if k != "workspace" {
300                extras.push((k.to_string(), v.clone()));
301            }
302        }
303    }
304
305    if extras.is_empty() {
306        // Replace member entry with workspace's item directly, rebasing any path.
307        let rebased = rebase_ws_item(ws_item, workspace_root, member_dir);
308        member_dependencies.insert(name, rebased);
309    } else {
310        // Merge workspace item fields with member's extras
311        let mut merged = InlineTable::new();
312        match ws_item {
313            Item::Value(toml_edit::Value::String(s)) => {
314                merged.insert("version", s.value().clone().into());
315            }
316
317            Item::Value(toml_edit::Value::InlineTable(t)) => {
318                for (k, v) in t.iter() {
319                    if k == "path"
320                        && let Some(p) = v.as_str()
321                    {
322                        merged.insert(k, rebase_path(p, workspace_root, member_dir).into());
323                        continue;
324                    }
325
326                    merged.insert(k, v.clone());
327                }
328            }
329
330            Item::Table(t) => {
331                for (k, v) in t.iter() {
332                    if k == "path"
333                        && let Some(p) = v.as_str()
334                    {
335                        merged.insert(k, rebase_path(p, workspace_root, member_dir).into());
336                        continue;
337                    }
338
339                    if let Some(val) = v.as_value() {
340                        merged.insert(k, val.clone());
341                    }
342                }
343            }
344            _ => {}
345        }
346
347        for (k, v) in extras {
348            if let Some(val) = v.as_value() {
349                merged.insert(&k, val.clone());
350            }
351        }
352
353        merged.fmt();
354        member_dependencies[name] = value(merged);
355    }
356}
357
358/// Clone a workspace Item, rebasing any `path` value to be relative to `member_dir`.
359fn rebase_ws_item(item: &Item, workspace_root: &Utf8Path, member_dir: &Utf8Path) -> Item {
360    match item {
361        Item::Value(toml_edit::Value::InlineTable(t)) => {
362            let mut new_t = t.clone();
363            if let Some(p) = t.get("path").and_then(|v| v.as_str()) {
364                new_t.insert("path", rebase_path(p, workspace_root, member_dir).into());
365            }
366
367            Item::Value(toml_edit::Value::InlineTable(new_t))
368        }
369
370        Item::Table(t) => {
371            let mut new_t = t.clone();
372            if let Some(p) = t.get("path").and_then(|v| v.as_str()) {
373                new_t.insert("path", value(rebase_path(p, workspace_root, member_dir)));
374            }
375
376            Item::Table(new_t)
377        }
378
379        // Plain version string — no path to rebase.
380        other => other.clone(),
381    }
382}