Skip to main content

dnx_core/
patch.rs

1use crate::errors::{DnxError, Result};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5/// Manages patched dependencies.
6/// Patches are stored as .patch files in a `patches/` directory and applied during install.
7pub struct PatchManager {
8    project_root: PathBuf,
9    /// Map of "pkg@version" → patch file path
10    patches: HashMap<String, String>,
11}
12
13impl PatchManager {
14    pub fn new(project_root: PathBuf, patches: HashMap<String, String>) -> Self {
15        Self {
16            project_root,
17            patches,
18        }
19    }
20
21    /// Prepare a package for patching by extracting it to a temp directory.
22    /// Returns the path to the temp directory where the user can make changes.
23    pub fn prepare_patch(&self, package_name: &str, version: &str) -> Result<PathBuf> {
24        let pkg_key = format!("{}@{}", package_name, version);
25        let node_modules = self.project_root.join("node_modules");
26        let dnx_dir = node_modules.join(".dnx");
27        let pkg_dir = dnx_dir
28            .join(&pkg_key)
29            .join("node_modules")
30            .join(package_name);
31
32        if !pkg_dir.exists() {
33            return Err(DnxError::Linker(format!(
34                "Package {} is not installed. Run `dnx install` first.",
35                pkg_key
36            )));
37        }
38
39        // Create a temp directory and copy the package into it
40        let patch_dir = self.project_root.join(".dnx-patches");
41        std::fs::create_dir_all(&patch_dir)
42            .map_err(|e| DnxError::Io(format!("Failed to create patch directory: {}", e)))?;
43
44        let target = patch_dir.join(&pkg_key);
45        if target.exists() {
46            std::fs::remove_dir_all(&target)
47                .map_err(|e| DnxError::Io(format!("Failed to clean patch directory: {}", e)))?;
48        }
49
50        copy_dir_recursive(&pkg_dir, &target)?;
51
52        Ok(target)
53    }
54
55    /// Commit a patch by generating a diff between the original installed package
56    /// and the modified version in the patch directory.
57    /// Writes the patch file to `patches/<pkg>@<version>.patch`.
58    pub fn commit_patch(&self, patch_dir: &Path) -> Result<PathBuf> {
59        // Extract package name and version from the patch dir name
60        let dir_name = patch_dir
61            .file_name()
62            .and_then(|n| n.to_str())
63            .ok_or_else(|| DnxError::Io("Invalid patch directory name".to_string()))?;
64
65        // Parse "name@version" from dir name
66        let (package_name, version) = parse_pkg_key(dir_name)?;
67
68        let pkg_key = format!("{}@{}", package_name, version);
69        let node_modules = self.project_root.join("node_modules");
70        let dnx_dir = node_modules.join(".dnx");
71        let original_dir = dnx_dir
72            .join(&pkg_key)
73            .join("node_modules")
74            .join(&package_name);
75
76        if !original_dir.exists() {
77            return Err(DnxError::Linker(format!(
78                "Original package {} not found",
79                pkg_key
80            )));
81        }
82
83        // Generate unified diff
84        let diff = generate_diff(&original_dir, patch_dir, &package_name)?;
85
86        if diff.is_empty() {
87            return Err(DnxError::Linker(
88                "No changes detected. Make changes to the package before committing.".to_string(),
89            ));
90        }
91
92        // Write patch file
93        let patches_dir = self.project_root.join("patches");
94        std::fs::create_dir_all(&patches_dir)
95            .map_err(|e| DnxError::Io(format!("Failed to create patches directory: {}", e)))?;
96
97        let patch_filename = format!("{}@{}.patch", package_name.replace('/', "+"), version);
98        let patch_path = patches_dir.join(&patch_filename);
99        std::fs::write(&patch_path, &diff)
100            .map_err(|e| DnxError::Io(format!("Failed to write patch file: {}", e)))?;
101
102        // Clean up the temporary patch working directory
103        let _ = std::fs::remove_dir_all(patch_dir);
104
105        Ok(patch_path)
106    }
107
108    /// Apply all configured patches to installed packages.
109    /// Called after linking during install.
110    pub fn apply_patches(&self) -> Result<PatchStats> {
111        let mut stats = PatchStats::default();
112
113        for (pkg_key, patch_file) in &self.patches {
114            let patch_path = self.project_root.join(patch_file);
115            if !patch_path.exists() {
116                eprintln!(
117                    "\x1b[33m⚠  Patch file not found: {}\x1b[0m",
118                    patch_path.display()
119                );
120                stats.failed += 1;
121                continue;
122            }
123
124            let (package_name, _version) = match parse_pkg_key(pkg_key) {
125                Ok(v) => v,
126                Err(_) => {
127                    stats.failed += 1;
128                    continue;
129                }
130            };
131
132            let node_modules = self.project_root.join("node_modules");
133            let dnx_dir = node_modules.join(".dnx");
134            let pkg_dir = dnx_dir
135                .join(pkg_key)
136                .join("node_modules")
137                .join(&package_name);
138
139            if !pkg_dir.exists() {
140                stats.failed += 1;
141                continue;
142            }
143
144            match apply_patch_file(&patch_path, &pkg_dir) {
145                Ok(()) => stats.applied += 1,
146                Err(e) => {
147                    eprintln!(
148                        "\x1b[33m⚠  Failed to apply patch for {}: {}\x1b[0m",
149                        pkg_key, e
150                    );
151                    stats.failed += 1;
152                }
153            }
154        }
155
156        Ok(stats)
157    }
158}
159
160#[derive(Debug, Default)]
161pub struct PatchStats {
162    pub applied: usize,
163    pub failed: usize,
164}
165
166/// Parse a "name@version" key into (name, version).
167/// Handles scoped packages: "@scope/pkg@1.0.0"
168fn parse_pkg_key(key: &str) -> Result<(String, String)> {
169    if let Some(stripped) = key.strip_prefix('@') {
170        // Scoped: find the second '@'
171        if let Some(idx) = stripped.find('@') {
172            return Ok((key[..idx + 1].to_string(), stripped[idx + 1..].to_string()));
173        }
174    } else if let Some(idx) = key.find('@') {
175        return Ok((key[..idx].to_string(), key[idx + 1..].to_string()));
176    }
177    Err(DnxError::Linker(format!("Invalid package key: {}", key)))
178}
179
180/// Generate a unified diff between original and modified directories.
181fn generate_diff(original: &Path, modified: &Path, _pkg_name: &str) -> Result<String> {
182    let mut diff = String::new();
183
184    // Walk modified directory for changed/added files
185    for entry in walkdir::WalkDir::new(modified)
186        .into_iter()
187        .filter_map(|e| e.ok())
188    {
189        if !entry.file_type().is_file() {
190            continue;
191        }
192
193        let relative = entry
194            .path()
195            .strip_prefix(modified)
196            .map_err(|e| DnxError::Io(format!("Failed to get relative path: {}", e)))?;
197
198        let original_file = original.join(relative);
199        let modified_file = entry.path();
200
201        let original_content = if original_file.exists() {
202            std::fs::read_to_string(&original_file).unwrap_or_default()
203        } else {
204            String::new()
205        };
206
207        let modified_content = std::fs::read_to_string(modified_file).unwrap_or_default();
208
209        if original_content != modified_content {
210            let relative_str = relative.to_string_lossy().replace('\\', "/");
211            diff.push_str(&format!("--- a/{}\n", relative_str));
212            diff.push_str(&format!("+++ b/{}\n", relative_str));
213
214            // Simple line-based diff
215            let orig_lines: Vec<&str> = original_content.lines().collect();
216            let mod_lines: Vec<&str> = modified_content.lines().collect();
217
218            diff.push_str(&format!(
219                "@@ -1,{} +1,{} @@\n",
220                orig_lines.len(),
221                mod_lines.len()
222            ));
223
224            for line in &orig_lines {
225                diff.push_str(&format!("-{}\n", line));
226            }
227            for line in &mod_lines {
228                diff.push_str(&format!("+{}\n", line));
229            }
230        }
231    }
232
233    // Check for deleted files
234    for entry in walkdir::WalkDir::new(original)
235        .into_iter()
236        .filter_map(|e| e.ok())
237    {
238        if !entry.file_type().is_file() {
239            continue;
240        }
241        let relative = entry
242            .path()
243            .strip_prefix(original)
244            .map_err(|e| DnxError::Io(format!("Failed to get relative path: {}", e)))?;
245        let modified_file = modified.join(relative);
246        if !modified_file.exists() {
247            let relative_str = relative.to_string_lossy().replace('\\', "/");
248            diff.push_str(&format!("--- a/{}\n", relative_str));
249            diff.push_str("+++ /dev/null\n");
250            let content = std::fs::read_to_string(entry.path()).unwrap_or_default();
251            let lines: Vec<&str> = content.lines().collect();
252            diff.push_str(&format!("@@ -1,{} +0,0 @@\n", lines.len()));
253            for line in &lines {
254                diff.push_str(&format!("-{}\n", line));
255            }
256        }
257    }
258
259    Ok(diff)
260}
261
262/// Apply a patch file to a package directory.
263/// Uses a simple line-based patch application.
264fn apply_patch_file(patch_path: &Path, pkg_dir: &Path) -> Result<()> {
265    let patch_content = std::fs::read_to_string(patch_path)
266        .map_err(|e| DnxError::Io(format!("Failed to read patch file: {}", e)))?;
267
268    let mut current_file: Option<PathBuf> = None;
269    let mut new_content = String::new();
270    let mut in_hunk = false;
271
272    for line in patch_content.lines() {
273        if let Some(relative) = line.strip_prefix("+++ b/") {
274            // Flush previous file
275            if let Some(ref file_path) = current_file {
276                if let Some(parent) = file_path.parent() {
277                    std::fs::create_dir_all(parent)
278                        .map_err(|e| DnxError::Io(format!("Failed to create directory: {}", e)))?;
279                }
280                std::fs::write(file_path, &new_content)
281                    .map_err(|e| DnxError::Io(format!("Failed to write patched file: {}", e)))?;
282            }
283
284            if relative == "/dev/null" {
285                current_file = None;
286            } else {
287                current_file = Some(pkg_dir.join(relative));
288            }
289            new_content.clear();
290            in_hunk = false;
291        } else if line.starts_with("--- a/") {
292            // Skip the old file header
293        } else if line.starts_with("@@") {
294            in_hunk = true;
295        } else if in_hunk {
296            if let Some(stripped) = line.strip_prefix('+') {
297                new_content.push_str(stripped);
298                new_content.push('\n');
299            } else if line.starts_with('-') {
300                // Skip removed lines
301            } else if let Some(stripped) = line.strip_prefix(' ') {
302                new_content.push_str(stripped);
303                new_content.push('\n');
304            }
305        }
306    }
307
308    // Flush last file
309    if let Some(ref file_path) = current_file {
310        if let Some(parent) = file_path.parent() {
311            std::fs::create_dir_all(parent)
312                .map_err(|e| DnxError::Io(format!("Failed to create directory: {}", e)))?;
313        }
314        std::fs::write(file_path, &new_content)
315            .map_err(|e| DnxError::Io(format!("Failed to write patched file: {}", e)))?;
316    }
317
318    // Handle deleted files
319    let patch_content2 = std::fs::read_to_string(patch_path).unwrap_or_default();
320    let mut delete_next = false;
321    for line in patch_content2.lines() {
322        if line.starts_with("+++ /dev/null") {
323            delete_next = true;
324        } else if delete_next && line.starts_with("--- a/") {
325            let relative = &line[6..];
326            let file_path = pkg_dir.join(relative);
327            let _ = std::fs::remove_file(&file_path);
328            delete_next = false;
329        } else {
330            delete_next = false;
331        }
332    }
333
334    Ok(())
335}
336
337/// Copy a directory recursively.
338fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
339    std::fs::create_dir_all(dst).map_err(|e| {
340        DnxError::Io(format!(
341            "Failed to create directory {}: {}",
342            dst.display(),
343            e
344        ))
345    })?;
346
347    for entry in std::fs::read_dir(src)
348        .map_err(|e| DnxError::Io(format!("Failed to read directory {}: {}", src.display(), e)))?
349    {
350        let entry = entry.map_err(|e| DnxError::Io(format!("Failed to read entry: {}", e)))?;
351        let src_path = entry.path();
352        let dst_path = dst.join(entry.file_name());
353
354        if entry
355            .file_type()
356            .map_err(|e| DnxError::Io(format!("Failed to get file type: {}", e)))?
357            .is_dir()
358        {
359            copy_dir_recursive(&src_path, &dst_path)?;
360        } else {
361            std::fs::copy(&src_path, &dst_path).map_err(|e| {
362                DnxError::Io(format!(
363                    "Failed to copy {} to {}: {}",
364                    src_path.display(),
365                    dst_path.display(),
366                    e
367                ))
368            })?;
369        }
370    }
371
372    Ok(())
373}