use serde_json::{Map, Value as JsonValue};
use serde_yaml::Value as YamlValue;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default)]
pub struct PnpmSettingsMigration {
pub changed_paths: Vec<PathBuf>,
pub notes: Vec<String>,
}
pub fn migrate_package_json_pnpm_settings(
project_root: &Path,
) -> Result<PnpmSettingsMigration, String> {
let package_json_path = project_root.join("package.json");
if !package_json_path.is_file() {
return Ok(PnpmSettingsMigration::default());
}
let raw = fs::read_to_string(&package_json_path)
.map_err(|e| format!("Failed to read {}: {}", package_json_path.display(), e))?;
let mut package: JsonValue = serde_json::from_str(&raw)
.map_err(|e| format!("Failed to parse {}: {}", package_json_path.display(), e))?;
let Some(pnpm_field) = package.get("pnpm").cloned() else {
return Ok(PnpmSettingsMigration::default());
};
let Some(pnpm_obj) = pnpm_field.as_object() else {
return Ok(PnpmSettingsMigration::default());
};
if pnpm_obj.is_empty() {
return Ok(PnpmSettingsMigration::default());
}
let workspace_path = project_root.join("pnpm-workspace.yaml");
let mut workspace = load_workspace_yaml(&workspace_path)?;
let mut allow_builds = extract_allow_builds_map(&workspace);
let mut notes = Vec::new();
let mut moved_keys = Vec::new();
let mut remaining = Map::new();
for (key, value) in pnpm_obj {
match key.as_str() {
"onlyBuiltDependencies" => {
append_build_list(&mut allow_builds, value, true);
moved_keys.push(key.clone());
}
"neverBuiltDependencies" | "ignoredBuiltDependencies" => {
append_build_list(&mut allow_builds, value, false);
moved_keys.push(key.clone());
}
"onlyBuiltDependenciesFile" | "ignoreDepScripts" => {
moved_keys.push(key.clone());
notes.push(format!(
"Dropped obsolete package.json#pnpm.{key} (use allowBuilds in pnpm-workspace.yaml)."
));
}
"allowBuilds" => {
merge_allow_builds_from_json(&mut allow_builds, value);
moved_keys.push(key.clone());
}
other => {
if let Some(yaml_value) = json_to_yaml(value) {
if let YamlValue::Mapping(map) = &mut workspace {
map.insert(YamlValue::String(other.to_string()), yaml_value);
}
moved_keys.push(key.clone());
} else {
remaining.insert(key.clone(), value.clone());
}
}
}
}
if moved_keys.is_empty() && remaining.len() == pnpm_obj.len() {
return Ok(PnpmSettingsMigration::default());
}
if !allow_builds.is_empty() {
write_allow_builds(&mut workspace, &allow_builds);
notes.push(format!(
"Migrated build-script settings to allowBuilds ({} entries).",
allow_builds.len()
));
}
if remaining.is_empty() {
if let JsonValue::Object(root) = &mut package {
root.remove("pnpm");
}
} else if let JsonValue::Object(root) = &mut package {
root.insert("pnpm".to_string(), JsonValue::Object(remaining));
}
let package_out = format!(
"{}\n",
serde_json::to_string_pretty(&package)
.map_err(|e| format!("Failed to serialize package.json: {e}"))?
);
fs::write(&package_json_path, package_out)
.map_err(|e| format!("Failed to write {}: {e}", package_json_path.display()))?;
let workspace_out = serde_yaml::to_string(&workspace)
.map_err(|e| format!("Failed to serialize pnpm-workspace.yaml: {e}"))?;
fs::write(&workspace_path, workspace_out)
.map_err(|e| format!("Failed to write {}: {e}", workspace_path.display()))?;
notes.push(format!(
"Moved package.json#pnpm keys ({}) into pnpm-workspace.yaml.",
moved_keys.join(", ")
));
Ok(PnpmSettingsMigration {
changed_paths: vec![package_json_path, workspace_path],
notes,
})
}
fn load_workspace_yaml(path: &Path) -> Result<YamlValue, String> {
if !path.is_file() {
let mut map = serde_yaml::Mapping::new();
map.insert(
YamlValue::String("packages".to_string()),
YamlValue::Sequence(vec![YamlValue::String(".".to_string())]),
);
return Ok(YamlValue::Mapping(map));
}
let raw = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
if raw.trim().is_empty() {
return Ok(YamlValue::Mapping(serde_yaml::Mapping::new()));
}
serde_yaml::from_str(&raw).map_err(|e| format!("Failed to parse {}: {e}", path.display()))
}
fn extract_allow_builds_map(workspace: &YamlValue) -> BTreeMap<String, bool> {
let mut out = BTreeMap::new();
let Some(map) = workspace.as_mapping() else {
return out;
};
let Some(allow) = map.get(YamlValue::String("allowBuilds".to_string())) else {
return out;
};
match allow {
YamlValue::Mapping(entries) => {
for (key, value) in entries {
if let Some(name) = key.as_str() {
let allowed = match value {
YamlValue::Bool(b) => *b,
YamlValue::String(s) => s != "false" && s != "0",
_ => true,
};
out.insert(name.to_string(), allowed);
}
}
}
YamlValue::Sequence(list) => {
for item in list {
if let Some(name) = item.as_str() {
out.insert(name.to_string(), true);
}
}
}
_ => {}
}
out
}
fn append_build_list(map: &mut BTreeMap<String, bool>, value: &JsonValue, allowed: bool) {
match value {
JsonValue::Array(items) => {
for item in items {
if let Some(name) = item.as_str() {
if !name.trim().is_empty() {
map.insert(name.trim().to_string(), allowed);
}
}
}
}
JsonValue::Object(obj) => {
for (name, entry) in obj {
let flag = entry.as_bool().unwrap_or(allowed);
map.insert(name.clone(), flag);
}
}
JsonValue::String(name) if !name.trim().is_empty() => {
map.insert(name.trim().to_string(), allowed);
}
_ => {}
}
}
fn merge_allow_builds_from_json(map: &mut BTreeMap<String, bool>, value: &JsonValue) {
append_build_list(map, value, true);
}
fn write_allow_builds(workspace: &mut YamlValue, allow_builds: &BTreeMap<String, bool>) {
let map = match workspace {
YamlValue::Mapping(map) => map,
_ => {
*workspace = YamlValue::Mapping(serde_yaml::Mapping::new());
workspace.as_mapping_mut().expect("mapping")
}
};
let mut allow_map = serde_yaml::Mapping::new();
for (name, allowed) in allow_builds {
allow_map.insert(
YamlValue::String(name.clone()),
YamlValue::Bool(*allowed),
);
}
map.insert(
YamlValue::String("allowBuilds".to_string()),
YamlValue::Mapping(allow_map),
);
if !map.contains_key(YamlValue::String("packages".to_string())) {
map.insert(
YamlValue::String("packages".to_string()),
YamlValue::Sequence(vec![YamlValue::String(".".to_string())]),
);
}
}
fn json_to_yaml(value: &JsonValue) -> Option<YamlValue> {
let as_yaml_text = serde_json::to_string(value).ok()?;
serde_yaml::from_str(&as_yaml_text).ok()
}
#[cfg(test)]
mod tests {
use super::migrate_package_json_pnpm_settings;
use std::fs;
use std::path::PathBuf;
fn temp_dir(label: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos();
let dir = std::env::temp_dir().join(format!("xbp-pnpm-migrate-{label}-{nanos}"));
fs::create_dir_all(&dir).expect("temp dir");
dir
}
#[test]
fn migrates_only_built_dependencies_to_allow_builds() {
let root = temp_dir("only-built");
fs::write(
root.join("package.json"),
r#"{
"name": "demo",
"pnpm": {
"onlyBuiltDependencies": ["fsevents", "esbuild"]
}
}
"#,
)
.expect("write package");
let result = migrate_package_json_pnpm_settings(&root).expect("migrate");
assert_eq!(result.changed_paths.len(), 2);
let package: serde_json::Value =
serde_json::from_str(&fs::read_to_string(root.join("package.json")).unwrap()).unwrap();
assert!(package.get("pnpm").is_none());
let workspace = fs::read_to_string(root.join("pnpm-workspace.yaml")).unwrap();
assert!(workspace.contains("allowBuilds:"));
assert!(workspace.contains("fsevents: true"));
assert!(workspace.contains("esbuild: true"));
assert!(workspace.contains("packages:"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn noops_when_no_pnpm_field() {
let root = temp_dir("noop");
fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
let result = migrate_package_json_pnpm_settings(&root).expect("migrate");
assert!(result.changed_paths.is_empty());
let _ = fs::remove_dir_all(root);
}
#[test]
fn merges_never_built_as_false() {
let root = temp_dir("never");
fs::write(
root.join("package.json"),
r#"{
"name": "demo",
"pnpm": {
"onlyBuiltDependencies": ["esbuild"],
"neverBuiltDependencies": ["core-js"]
}
}
"#,
)
.unwrap();
migrate_package_json_pnpm_settings(&root).unwrap();
let workspace = fs::read_to_string(root.join("pnpm-workspace.yaml")).unwrap();
assert!(workspace.contains("esbuild: true"));
assert!(workspace.contains("core-js: false"));
let _ = fs::remove_dir_all(root);
}
}