use miette::{IntoDiagnostic, Result, miette};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct ResolvedPatch {
pub key: String,
#[allow(dead_code)]
pub name: String,
#[allow(dead_code)]
pub version: String,
#[allow(dead_code)]
pub path: PathBuf,
pub content: String,
}
impl ResolvedPatch {
pub fn content_hash(&self) -> String {
let mut h = Sha256::new();
h.update(self.content.as_bytes());
hex::encode(h.finalize())
}
}
pub fn split_patch_key(key: &str) -> Result<(String, String)> {
let (name, ver) = if let Some(rest) = key.strip_prefix('@') {
let slash = rest
.find('/')
.ok_or_else(|| miette!("invalid patch key {key:?}: scoped name missing slash"))?;
let after = &rest[slash + 1..];
let at = after
.find('@')
.ok_or_else(|| miette!("invalid patch key {key:?}: missing version"))?;
let split = 1 + slash + 1 + at;
(&key[..split], &key[split + 1..])
} else {
let at = key
.find('@')
.ok_or_else(|| miette!("invalid patch key {key:?}: missing version"))?;
(&key[..at], &key[at + 1..])
};
if name.is_empty() || ver.is_empty() {
return Err(miette!("invalid patch key {key:?}"));
}
Ok((name.to_string(), ver.to_string()))
}
pub fn load_patches(cwd: &Path) -> Result<BTreeMap<String, ResolvedPatch>> {
let manifest_path = cwd.join("package.json");
if !manifest_path.exists() {
return Ok(BTreeMap::new());
}
let manifest = aube_manifest::PackageJson::from_path(&manifest_path)
.into_diagnostic()
.map_err(|e| miette!("failed to read package.json: {e}"))?;
let mut out = BTreeMap::new();
for (key, rel) in manifest.pnpm_patched_dependencies() {
let (name, version) = split_patch_key(&key)?;
let path = cwd.join(&rel);
let content = std::fs::read_to_string(&path)
.into_diagnostic()
.map_err(|e| {
miette!(
"failed to read patch file {} for {key}: {e}",
path.display()
)
})?;
out.insert(
key.clone(),
ResolvedPatch {
key,
name,
version,
path,
content,
},
);
}
Ok(out)
}
pub fn upsert_patched_dependency(cwd: &Path, key: &str, rel_patch_path: &str) -> Result<()> {
edit_patched_dependencies(cwd, |map| {
map.insert(
key.to_string(),
serde_json::Value::String(rel_patch_path.to_string()),
);
})
}
pub fn remove_patched_dependency(cwd: &Path, key: &str) -> Result<bool> {
let mut existed = false;
edit_patched_dependencies(cwd, |map| {
existed = map.remove(key).is_some();
})?;
Ok(existed)
}
pub fn read_patched_dependencies(cwd: &Path) -> Result<BTreeMap<String, String>> {
let manifest_path = cwd.join("package.json");
if !manifest_path.exists() {
return Ok(BTreeMap::new());
}
let manifest = aube_manifest::PackageJson::from_path(&manifest_path)
.into_diagnostic()
.map_err(|e| miette!("failed to read package.json: {e}"))?;
Ok(manifest.pnpm_patched_dependencies())
}
fn edit_patched_dependencies<F>(cwd: &Path, f: F) -> Result<()>
where
F: FnOnce(&mut serde_json::Map<String, serde_json::Value>),
{
let manifest_path = cwd.join("package.json");
let raw = std::fs::read_to_string(&manifest_path)
.into_diagnostic()
.map_err(|e| miette!("failed to read package.json: {e}"))?;
let mut value: serde_json::Value = serde_json::from_str(&raw)
.into_diagnostic()
.map_err(|e| miette!("failed to parse package.json: {e}"))?;
let obj = value
.as_object_mut()
.ok_or_else(|| miette!("package.json is not an object"))?;
let pnpm = obj
.entry("pnpm".to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
let pnpm_obj = pnpm
.as_object_mut()
.ok_or_else(|| miette!("`pnpm` field in package.json is not an object"))?;
let patched = pnpm_obj
.entry("patchedDependencies".to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
let patched_obj = patched
.as_object_mut()
.ok_or_else(|| miette!("`pnpm.patchedDependencies` is not an object"))?;
f(patched_obj);
if patched_obj.is_empty() {
pnpm_obj.remove("patchedDependencies");
}
if pnpm_obj.is_empty() {
obj.remove("pnpm");
}
let mut out = serde_json::to_string_pretty(&value)
.into_diagnostic()
.map_err(|e| miette!("failed to serialize package.json: {e}"))?;
out.push('\n');
std::fs::write(&manifest_path, out)
.into_diagnostic()
.map_err(|e| miette!("failed to write package.json: {e}"))?;
Ok(())
}
pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst)
.into_diagnostic()
.map_err(|e| miette!("failed to create {}: {e}", dst.display()))?;
for entry in std::fs::read_dir(src)
.into_diagnostic()
.map_err(|e| miette!("failed to read {}: {e}", src.display()))?
{
let entry = entry
.into_diagnostic()
.map_err(|e| miette!("failed to read entry under {}: {e}", src.display()))?;
let ty = entry
.file_type()
.into_diagnostic()
.map_err(|e| miette!("failed to stat {}: {e}", entry.path().display()))?;
let from = entry.path();
let to = dst.join(entry.file_name());
if ty.is_dir() {
copy_dir_all(&from, &to)?;
} else if ty.is_symlink() {
continue;
} else {
std::fs::copy(&from, &to).into_diagnostic().map_err(|e| {
miette!("failed to copy {} -> {}: {e}", from.display(), to.display())
})?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_simple() {
let (n, v) = split_patch_key("is-positive@3.1.0").unwrap();
assert_eq!(n, "is-positive");
assert_eq!(v, "3.1.0");
}
#[test]
fn split_scoped() {
let (n, v) = split_patch_key("@babel/core@7.0.0").unwrap();
assert_eq!(n, "@babel/core");
assert_eq!(v, "7.0.0");
}
#[test]
fn split_missing_version_errors() {
assert!(split_patch_key("is-positive").is_err());
assert!(split_patch_key("@babel/core").is_err());
}
}