patch_crate/
lib.rs

1//!
2//! patch-crate lets rust app developer instantly make and keep fixes to crate dependencies.
3//! It's a vital band-aid for those of us living on the bleeding edge.
4//!
5//! # Installation
6//!
7//! Simply run:
8//! ```sh
9//! cargo install patch-crate
10//! ```
11//!
12//! # Usage
13//!
14//! To patch dependency one has to add the following
15//! to `Cargo.toml`
16//!
17//! ```toml
18//! [package.metadata.patch]
19//! crates = ["serde"]
20//! ```
21//!
22//! It specifies which dependency to patch (in this case
23//! serde). Running:
24//!
25//! ```sh
26//! cargo patch-crate
27//! ```
28//!
29//! will download the sede package specified in the
30//! dpendency section to the `target/patch` folder.
31//!
32//! Then override the dependency using
33//! `replace` like this
34//!
35//! ```toml
36//! [patch.crates-io]
37//! serde = { path = './target/patch/serde-1.0.110' }
38//! ```
39//!
40//! fix a bug in './target/patch/serde-1.0.110' directly.
41//!
42//! run following to create a `patches/serde+1.0.110.patch` file
43//! ```sh
44//! cargo patch-crate serde
45//! ```
46//!
47//! commit the patch file to share the fix with your team
48//! ```sh
49//! git add patches/serde+1.0.110.patch
50//! git commit -m "fix broken-serde in serde"
51//! ```
52
53use anyhow::{anyhow, Ok, Result};
54use cargo::{
55    core::{
56        package::{Package, PackageSet},
57        registry::PackageRegistry,
58        resolver::{features::CliFeatures, HasDevUnits},
59        Resolve, Workspace,
60    },
61    ops::{get_resolved_packages, load_pkg_lockfile, resolve_with_previous},
62    sources::SourceConfigMap,
63    util::{cache_lock::CacheLockMode, important_paths::find_root_manifest_for_wd, GlobalContext},
64};
65use clap::Parser;
66use fs_extra::dir::{copy, CopyOptions};
67use log::*;
68use std::{
69    collections::HashSet,
70    ffi::OsStr,
71    fs,
72    path::{Path, PathBuf},
73};
74
75const PATCH_EXT: &str = "patch";
76
77#[derive(Parser, Debug)]
78#[command(author, version, about, long_about = None)]
79struct Cli {
80    crates: Vec<String>,
81    #[arg(short, long)]
82    force: bool,
83}
84
85trait PackageExt {
86    fn slug(&self) -> Result<&str>;
87    fn patch_target_path(&self, workspace: &Workspace<'_>) -> Result<PathBuf>;
88}
89
90impl PackageExt for Package {
91    fn slug(&self) -> Result<&str> {
92        if let Some(name) = self.root().file_name().and_then(|s| s.to_str()) {
93            Ok(name)
94        } else {
95            Err(anyhow!("Dependency Folder does not have a name"))
96        }
97    }
98
99    fn patch_target_path(&self, workspace: &Workspace<'_>) -> Result<PathBuf> {
100        let slug = self.slug()?;
101        let patch_target_path = workspace.patch_target_folder().join(slug);
102        Ok(patch_target_path)
103    }
104}
105
106trait WorkspaceExt {
107    fn patches_folder(&self) -> PathBuf;
108    fn patch_target_folder(&self) -> PathBuf;
109    fn patch_target_tmp_folder(&self) -> PathBuf;
110    fn clean_patch_folder(&self) -> Result<()>;
111}
112
113impl WorkspaceExt for Workspace<'_> {
114    fn patches_folder(&self) -> PathBuf {
115        self.root().join("patches/")
116    }
117    fn patch_target_folder(&self) -> PathBuf {
118        self.root().join("target/patch/")
119    }
120    fn patch_target_tmp_folder(&self) -> PathBuf {
121        self.root().join("target/patch-tmp/")
122    }
123
124    fn clean_patch_folder(&self) -> Result<()> {
125        let path = self.patch_target_folder();
126        if path.exists() {
127            fs::remove_dir_all(self.patch_target_folder())?;
128        }
129        Ok(())
130    }
131}
132
133fn resolve_ws<'a>(ws: &Workspace<'a>) -> Result<(PackageSet<'a>, Resolve)> {
134    let mut registry =
135        PackageRegistry::new_with_source_config(ws.gctx(), SourceConfigMap::new(ws.gctx())?)?;
136    registry.lock_patches();
137    let resolve = {
138        let prev = load_pkg_lockfile(ws)?;
139        let resolve: Resolve = resolve_with_previous(
140            &mut registry,
141            ws,
142            &CliFeatures::new_all(true),
143            HasDevUnits::No,
144            prev.as_ref(),
145            None,
146            &[],
147            false,
148        )?;
149        resolve
150    };
151    let packages = get_resolved_packages(&resolve, registry)?;
152    Ok((packages, resolve))
153}
154
155fn copy_package(pkg: &Package, patch_target_folder: &Path, overwrite: bool) -> Result<PathBuf> {
156    fs::create_dir_all(patch_target_folder)?;
157    let options = CopyOptions::new();
158    let patch_target_path = patch_target_folder.join(pkg.slug()?);
159    if patch_target_path.exists() {
160        if overwrite {
161            info!("crate: {}, copy to {:?}", pkg.name(), &patch_target_folder);
162            fs::remove_dir_all(&patch_target_path)?;
163        } else {
164            info!(
165                "crate: {}, skip, {:?} already exists.",
166                pkg.name(),
167                &patch_target_path
168            );
169            return Ok(patch_target_path);
170        }
171    }
172    let _ = copy(pkg.root(), patch_target_folder, &options)?;
173    Ok(patch_target_path)
174}
175
176fn find_cargo_toml(path: &Path) -> Result<PathBuf> {
177    let path = fs::canonicalize(path)?;
178    find_root_manifest_for_wd(&path)
179}
180
181pub fn run() -> anyhow::Result<()> {
182    let args = {
183        let mut args = Cli::parse();
184        if let Some(idx) = args.crates.iter().position(|c| c == "patch-crate") {
185            args.crates.remove(idx);
186        }
187        args
188    };
189
190    let gctx = GlobalContext::default()?;
191    let _lock = gctx.acquire_package_cache_lock(CacheLockMode::Shared)?;
192
193    let cargo_toml_path = find_cargo_toml(&PathBuf::from("."))?;
194
195    let workspace = Workspace::new(&cargo_toml_path, &gctx)?;
196
197    let patches_folder = workspace.patches_folder();
198
199    let patch_target_folder = workspace.patch_target_folder();
200    let patch_target_tmp_folder = workspace.patch_target_tmp_folder();
201
202    let (pkg_set, resolve) = resolve_ws(&workspace)?;
203
204    if !args.crates.is_empty() {
205        info!("starting patch creation.");
206        if !patches_folder.exists() {
207            fs::create_dir_all(&patches_folder)?;
208        }
209        for n in args.crates.iter() {
210            // make patch
211            info!("crate: {}, starting patch creation.", n);
212            let pkg_id = resolve.query(n)?;
213            let pkg = pkg_set.get_one(pkg_id)?;
214            let patched_crate_path = pkg.patch_target_path(&workspace)?;
215
216            // clone the original crate to a temporary folder
217            let original_crate_path = copy_package(pkg, &patch_target_tmp_folder, true)?;
218            git::init(&original_crate_path)?;
219
220            let original_crate_git_path = original_crate_path.join(".git");
221            let patched_crate_git_path = patched_crate_path.join(".git");
222
223            // destroy the .git folder in the patched crate, and copy the .git folder from the original crate
224            // for diffing
225            git::destroy(&patched_crate_path)?;
226            copy(
227                &original_crate_git_path,
228                &patched_crate_git_path,
229                &CopyOptions::new().overwrite(true).copy_inside(true),
230            )?;
231
232            let patch_file = patches_folder.join(format!(
233                "{}+{}.{}",
234                pkg_id.name(),
235                pkg_id.version(),
236                PATCH_EXT
237            ));
238            git::create_patch(&patched_crate_path, &patch_file)?;
239            fs::remove_dir_all(&patch_target_tmp_folder)?;
240
241            git::destroy(&patched_crate_path)?;
242            info!("crate: {}, create patch successfully, {:?}", n, &patch_file);
243        }
244    } else {
245        // apply patch
246        info!("applying patch");
247
248        let custom_metadata = workspace.custom_metadata().into_iter().chain(
249            workspace
250                .members()
251                .flat_map(|member| member.manifest().custom_metadata()),
252        );
253
254        let mut crates_to_patch = custom_metadata
255            .flat_map(|m| {
256                m.as_table()
257                    .and_then(|table| table.get("patch"))
258                    .into_iter()
259                    .flat_map(|patch| patch.as_table())
260                    .flat_map(|patch| patch.get("crates"))
261                    .filter_map(|crates| crates.as_array())
262            })
263            .flatten()
264            .flat_map(|s| s.as_str())
265            .map(|n| resolve.query(n).and_then(|id| pkg_set.get_one(id)))
266            .collect::<Result<HashSet<_>>>()?;
267
268        if args.force {
269            info!("Cleaning up patch folder.");
270            workspace.clean_patch_folder()?;
271        }
272
273        if patches_folder.exists() {
274            for entry in fs::read_dir(patches_folder)? {
275                let entry = entry?;
276                if entry.metadata()?.is_file()
277                    && entry.path().extension() == Some(OsStr::new(PATCH_EXT))
278                {
279                    let patch_file = entry.path();
280                    let filename = patch_file
281                        .file_stem()
282                        .and_then(|s| s.to_str())
283                        .ok_or(anyhow!("Patch file does not have a name"))?;
284
285                    if let Some((pkg_name, version)) = filename.split_once('+') {
286                        let pkg_id = resolve.query(format!("{}@{}", pkg_name, version).as_str())?;
287                        let pkg = pkg_set.get_one(pkg_id)?;
288                        if !crates_to_patch.contains(&pkg) {
289                            warn!(
290                                "crate: {}, {} is not in the [package.metadata.patch] or [workspace.metadata.patch] section of Cargo.toml. Did you forget to add it?",
291                                pkg_name, pkg_name
292                            );
293                            continue;
294                        }
295
296                        let patch_target_path = pkg.patch_target_path(&workspace)?;
297                        if !patch_target_path.exists() {
298                            copy_package(pkg, &patch_target_folder, args.force)?;
299                            info!("crate: {}, applying patch started.", pkg_name);
300                            git::init(&patch_target_path)?;
301                            git::apply(&patch_target_path, &patch_file)?;
302                            git::destroy(&patch_target_path)?;
303                            info!(
304                                "crate: {}, successfully applied patch {:?}.",
305                                pkg_name, patch_file
306                            );
307                        } else {
308                            info!("crate: {}, skip applying patch, {:?} already exists. Did you forget to add `--force`?", pkg_name, patch_target_path);
309                        }
310                        crates_to_patch.remove(pkg);
311                    }
312                }
313            }
314        }
315        for pkg in crates_to_patch {
316            copy_package(pkg, &patch_target_folder, args.force)?;
317        }
318    }
319
320    info!("Done");
321    Ok(())
322}
323
324mod log {
325    pub use paris::*;
326}
327
328mod git {
329    use std::{ffi::OsStr, fs, path::Path, process::Command};
330
331    pub fn init(repo_dir: &Path) -> anyhow::Result<()> {
332        Command::new("git")
333            .current_dir(repo_dir)
334            .args(["init"])
335            .output()?;
336        Command::new("git")
337            .current_dir(repo_dir)
338            .args(["add", "."])
339            .output()?;
340        Command::new("git")
341            .current_dir(repo_dir)
342            .args(["commit", "-m", "zero"])
343            .output()?;
344        Ok(())
345    }
346
347    pub fn apply(repo_dir: &Path, patch_file: &Path) -> anyhow::Result<()> {
348        #[cfg(target_os = "windows")]
349        let patch_file = patch_file
350            .to_string_lossy()
351            .to_string()
352            .trim_start_matches(r#"\\?\"#)
353            .to_string();
354        #[cfg(not(target_os = "windows"))]
355        let patch_file = patch_file.to_string_lossy().to_string();
356
357        let out = Command::new("git")
358            .current_dir(repo_dir)
359            .args([
360                "apply",
361                "--ignore-space-change",
362                "--ignore-whitespace",
363                "--whitespace=nowarn",
364                &patch_file,
365            ])
366            .output()?;
367
368        if !out.status.success() {
369            anyhow::bail!(String::from_utf8(out.stderr)?)
370        }
371        Ok(())
372    }
373    pub fn destroy(repo_dir: &Path) -> anyhow::Result<()> {
374        let git_dir = repo_dir.join(".git");
375        if git_dir.exists() {
376            fs::remove_dir_all(git_dir)?;
377        }
378        Ok(())
379    }
380    pub fn create_patch(repo_dir: &Path, patch_file: &Path) -> anyhow::Result<()> {
381        Command::new("git")
382            .current_dir(repo_dir)
383            .args(["add", "."])
384            .output()?;
385
386        let out = Command::new("git")
387            .current_dir(repo_dir)
388            .args([OsStr::new("diff"), OsStr::new("--staged")])
389            .output()?;
390
391        if out.status.success() {
392            fs::write(patch_file, out.stdout)?;
393        }
394        Ok(())
395    }
396}