xbp 10.40.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Migrate deprecated `package.json#pnpm` settings for pnpm v11+.
//!
//! Since pnpm 11 the `pnpm` field in package.json is ignored. Settings must live
//! in `pnpm-workspace.yaml`. Build-script allowlists (`onlyBuiltDependencies`,
//! etc.) are consolidated into `allowBuilds`.

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};

/// Result of a successful or no-op migration.
#[derive(Debug, Clone, Default)]
pub struct PnpmSettingsMigration {
    pub changed_paths: Vec<PathBuf>,
    pub notes: Vec<String>,
}

/// Move deprecated `package.json#pnpm` settings into `pnpm-workspace.yaml`.
///
/// Returns paths that were written (for git staging). No-op when nothing to do.
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" => {
                // Dropped / superseded by allowBuilds in pnpm 11; skip writing.
                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 => {
                // General settings: move wholesale into workspace yaml.
                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));
    }

    // Preserve pretty formatting with trailing newline.
    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() {
        // Minimal single-package workspace so settings are valid.
        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),
    );

    // Ensure packages exists for a valid workspace file when we create it.
    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> {
    // Round-trip through YAML string for structural conversion.
    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);
    }
}