changepacks-core 0.3.2

Core types and traits for changepacks workspace and package management
Documentation
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

/// Loaded from `.changepacks/config.json`, controls ignore patterns, base branch, publish commands, and update-on rules.
///
/// Configuration can specify custom publish commands per language or per project path,
/// ignore patterns using globs, and forced update rules for dependent packages.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Config {
    /// Glob patterns for files/projects to ignore (e.g., "examples/**")
    #[serde(default)]
    pub ignore: Vec<String>,

    /// Base branch to compare against for change detection (default: "main")
    #[serde(default = "default_base_branch")]
    pub base_branch: String,

    /// Optional path to the default main package for versioning
    #[serde(default)]
    pub latest_package: Option<String>,

    /// Custom publish commands by language key or project path
    #[serde(default)]
    pub publish: BTreeMap<String, String>,

    /// Custom dry-run publish commands by language key or project path.
    ///
    /// `changepacks publish --dry-run` resolves its command in three steps:
    /// this map keyed by the repo-relative project path, then this map keyed by
    /// the language key, then the language crate's own built-in dry-run command
    /// (e.g., `npm publish --dry-run`, `cargo publish --dry-run`). A custom
    /// [`Config::publish`] entry does not change the dry-run command; only this
    /// map does. Use it for ecosystems whose publish tool has no built-in
    /// `--dry-run` (e.g., `dotnet nuget push`) or when a custom verification
    /// flow is preferred.
    #[serde(default)]
    pub publish_dry_run: BTreeMap<String, String>,

    /// Dependency rules for forced updates.
    /// Key: glob pattern for trigger packages (e.g., "crates/*")
    /// Value: list of package paths that must be updated when trigger matches
    #[serde(default)]
    pub update_on: BTreeMap<String, Vec<String>>,
}

fn default_base_branch() -> String {
    "main".to_string()
}

impl Default for Config {
    fn default() -> Self {
        Self {
            ignore: Vec::new(),
            base_branch: default_base_branch(),
            latest_package: None,
            publish: BTreeMap::new(),
            publish_dry_run: BTreeMap::new(),
            update_on: BTreeMap::new(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_config_default() {
        let config = Config::default();
        assert!(config.ignore.is_empty());
        assert_eq!(config.base_branch, "main");
        assert!(config.latest_package.is_none());
        assert!(config.publish.is_empty());
        assert!(config.publish_dry_run.is_empty());
        assert!(config.update_on.is_empty());
    }

    #[test]
    fn test_config_publish_dry_run_map() {
        let json = r#"{
            "publishDryRun": {
                "node": "npm publish --dry-run",
                "csharp": "dotnet pack -c Release",
                "bridge/node/package.json": "npm publish --dry-run --access public"
            }
        }"#;
        let config: Config = serde_json::from_str(json).unwrap();
        assert_eq!(config.publish_dry_run.len(), 3);
        assert_eq!(
            config.publish_dry_run.get("node").unwrap(),
            "npm publish --dry-run"
        );
        assert_eq!(
            config.publish_dry_run.get("csharp").unwrap(),
            "dotnet pack -c Release"
        );
        assert_eq!(
            config
                .publish_dry_run
                .get("bridge/node/package.json")
                .unwrap(),
            "npm publish --dry-run --access public"
        );
    }

    #[test]
    fn test_config_deserialize_full() {
        let json = r#"{
            "ignore": ["examples/**", "docs/**"],
            "baseBranch": "develop",
            "latestPackage": "crates/core/Cargo.toml",
            "publish": {
                "node": "npm publish --access public",
                "rust": "cargo publish"
            },
            "updateOn": {
                "crates/core/Cargo.toml": ["bridge/node/package.json", "bridge/python/pyproject.toml"]
            }
        }"#;
        let config: Config = serde_json::from_str(json).unwrap();
        assert_eq!(config.ignore, vec!["examples/**", "docs/**"]);
        assert_eq!(config.base_branch, "develop");
        assert_eq!(
            config.latest_package.as_deref(),
            Some("crates/core/Cargo.toml")
        );
        assert_eq!(config.publish.len(), 2);
        assert_eq!(
            config.publish.get("node").unwrap(),
            "npm publish --access public"
        );
        assert_eq!(config.publish.get("rust").unwrap(), "cargo publish");
        assert_eq!(config.update_on.len(), 1);
        let update_targets = config.update_on.get("crates/core/Cargo.toml").unwrap();
        assert_eq!(update_targets.len(), 2);
        assert!(update_targets.contains(&"bridge/node/package.json".to_string()));
        assert!(update_targets.contains(&"bridge/python/pyproject.toml".to_string()));
    }

    #[test]
    fn test_config_deserialize_partial() {
        let json = r#"{ "baseBranch": "release" }"#;
        let config: Config = serde_json::from_str(json).unwrap();
        assert!(config.ignore.is_empty());
        assert_eq!(config.base_branch, "release");
        assert!(config.latest_package.is_none());
        assert!(config.publish.is_empty());
        assert!(config.publish_dry_run.is_empty());
        assert!(config.update_on.is_empty());
    }

    #[test]
    fn test_config_deserialize_empty_object() {
        let json = r"{}";
        let config: Config = serde_json::from_str(json).unwrap();
        assert_eq!(config.base_branch, "main");
        assert!(config.ignore.is_empty());
        assert!(config.latest_package.is_none());
        assert!(config.publish.is_empty());
        assert!(config.publish_dry_run.is_empty());
        assert!(config.update_on.is_empty());
    }

    // `Config` deliberately does NOT use `#[serde(deny_unknown_fields)]`.
    // The on-disk `.changepacks/config.json` format is a hard backward
    // compatibility constraint: a newer changepacks release may add config
    // keys, and an OLDER binary must still be able to read that file. Adding
    // `deny_unknown_fields` would turn every newly introduced key into a hard
    // parse error on older binaries, so unknown keys must stay silently
    // ignored. This test pins that forward-compatibility contract.
    #[test]
    fn test_config_ignores_unknown_keys_for_forward_compatibility() {
        let json = r#"{
            "baseBranch": "develop",
            "futureFeature": { "enabled": true, "targets": ["a", "b"] },
            "unknownScalar": 42,
            "someFutureFlag": "on"
        }"#;
        let config: Config =
            serde_json::from_str(json).expect("unknown config keys must not fail deserialization");

        // The known key is still honoured.
        assert_eq!(config.base_branch, "develop");

        // Everything else falls back to its default, unaffected by the
        // unrecognized keys.
        assert!(config.ignore.is_empty());
        assert!(config.latest_package.is_none());
        assert!(config.publish.is_empty());
        assert!(config.publish_dry_run.is_empty());
        assert!(config.update_on.is_empty());
    }

    #[test]
    fn test_config_ignore_patterns() {
        let json = r#"{ "ignore": ["**/*", "!crates/changepacks/Cargo.toml", "!bridge/**"] }"#;
        let config: Config = serde_json::from_str(json).unwrap();
        assert_eq!(config.ignore.len(), 3);
        assert_eq!(config.ignore[0], "**/*");
        assert_eq!(config.ignore[1], "!crates/changepacks/Cargo.toml");
        assert_eq!(config.ignore[2], "!bridge/**");
    }

    #[test]
    fn test_config_publish_map() {
        let json = r#"{
            "publish": {
                "node": "npm publish",
                "python": "uv publish",
                "rust": "cargo publish",
                "dart": "dart pub publish",
                "bridge/node/package.json": "npm publish --access public"
            }
        }"#;
        let config: Config = serde_json::from_str(json).unwrap();
        assert_eq!(config.publish.len(), 5);
        assert_eq!(config.publish.get("node").unwrap(), "npm publish");
        assert_eq!(config.publish.get("python").unwrap(), "uv publish");
        assert_eq!(config.publish.get("rust").unwrap(), "cargo publish");
        assert_eq!(config.publish.get("dart").unwrap(), "dart pub publish");
        assert_eq!(
            config.publish.get("bridge/node/package.json").unwrap(),
            "npm publish --access public"
        );
    }

    #[test]
    fn test_config_update_on_map() {
        let json = r#"{
            "updateOn": {
                "crates/changepacks/Cargo.toml": ["bridge/node/package.json"],
                "crates/core/Cargo.toml": ["bridge/python/pyproject.toml", "bridge/node/package.json"]
            }
        }"#;
        let config: Config = serde_json::from_str(json).unwrap();
        assert_eq!(config.update_on.len(), 2);

        let changepacks_targets = config
            .update_on
            .get("crates/changepacks/Cargo.toml")
            .unwrap();
        assert_eq!(changepacks_targets.len(), 1);
        assert_eq!(changepacks_targets[0], "bridge/node/package.json");

        let core_targets = config.update_on.get("crates/core/Cargo.toml").unwrap();
        assert_eq!(core_targets.len(), 2);
    }

    #[test]
    fn test_config_serialize_roundtrip() {
        let mut config = Config {
            ignore: vec!["test/**".to_string()],
            base_branch: "develop".to_string(),
            latest_package: Some("Cargo.toml".to_string()),
            ..Config::default()
        };
        config
            .publish
            .insert("rust".to_string(), "cargo publish".to_string());
        config.update_on.insert(
            "Cargo.toml".to_string(),
            vec!["bridge/package.json".to_string()],
        );

        let json = serde_json::to_string(&config).unwrap();
        let deserialized: Config = serde_json::from_str(&json).unwrap();
        assert_eq!(config, deserialized);
    }

    // With BTreeMap-backed maps, two `Config` values carrying the SAME
    // entries inserted in REVERSED order must serialize to byte-identical
    // JSON, because BTreeMap always emits keys in sorted order. This is what
    // makes `changepacks config` output deterministic across runs. A HashMap
    // would have randomized key order per process (via RandomState), breaking
    // this equality.
    #[test]
    fn test_config_serialization_is_deterministic_regardless_of_insertion_order() {
        let mut forward = Config::default();
        forward
            .publish
            .insert("node".to_string(), "npm publish".to_string());
        forward
            .publish
            .insert("rust".to_string(), "cargo publish".to_string());
        forward
            .publish
            .insert("dart".to_string(), "dart pub publish".to_string());
        forward
            .publish_dry_run
            .insert("node".to_string(), "npm publish --dry-run".to_string());
        forward
            .publish_dry_run
            .insert("csharp".to_string(), "dotnet pack -c Release".to_string());
        forward.update_on.insert(
            "crates/core/Cargo.toml".to_string(),
            vec!["bridge/node/package.json".to_string()],
        );
        forward.update_on.insert(
            "crates/changepacks/Cargo.toml".to_string(),
            vec!["bridge/python/pyproject.toml".to_string()],
        );

        let mut reversed = Config::default();
        reversed
            .publish
            .insert("dart".to_string(), "dart pub publish".to_string());
        reversed
            .publish
            .insert("rust".to_string(), "cargo publish".to_string());
        reversed
            .publish
            .insert("node".to_string(), "npm publish".to_string());
        reversed
            .publish_dry_run
            .insert("csharp".to_string(), "dotnet pack -c Release".to_string());
        reversed
            .publish_dry_run
            .insert("node".to_string(), "npm publish --dry-run".to_string());
        reversed.update_on.insert(
            "crates/changepacks/Cargo.toml".to_string(),
            vec!["bridge/python/pyproject.toml".to_string()],
        );
        reversed.update_on.insert(
            "crates/core/Cargo.toml".to_string(),
            vec!["bridge/node/package.json".to_string()],
        );

        // Same logical content, opposite insertion order.
        assert_eq!(forward, reversed);
        assert_eq!(
            serde_json::to_string(&forward).unwrap(),
            serde_json::to_string(&reversed).unwrap(),
            "BTreeMap-backed Config must serialize identically regardless of insertion order"
        );
    }
}