Skip to main content

changepacks_core/
config.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4/// Loaded from `.changepacks/config.json`, controls ignore patterns, base branch, publish commands, and update-on rules.
5///
6/// Configuration can specify custom publish commands per language or per project path,
7/// ignore patterns using globs, and forced update rules for dependent packages.
8#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
9#[serde(rename_all = "camelCase")]
10pub struct Config {
11    /// Glob patterns for files/projects to ignore (e.g., "examples/**")
12    #[serde(default)]
13    pub ignore: Vec<String>,
14
15    /// Base branch to compare against for change detection (default: "main")
16    #[serde(default = "default_base_branch")]
17    pub base_branch: String,
18
19    /// Optional path to the default main package for versioning
20    #[serde(default)]
21    pub latest_package: Option<String>,
22
23    /// Custom publish commands by language key or project path
24    #[serde(default)]
25    pub publish: HashMap<String, String>,
26
27    /// Custom dry-run publish commands by language key or project path.
28    ///
29    /// Overrides the default dry-run derivation (appending `--dry-run` to the
30    /// resolved publish command). Use this for ecosystems whose publish tool
31    /// does not support `--dry-run` (e.g., `dotnet nuget push`) or when a
32    /// custom verification flow is preferred.
33    #[serde(default)]
34    pub publish_dry_run: HashMap<String, String>,
35
36    /// Dependency rules for forced updates.
37    /// Key: glob pattern for trigger packages (e.g., "crates/*")
38    /// Value: list of package paths that must be updated when trigger matches
39    #[serde(default)]
40    pub update_on: HashMap<String, Vec<String>>,
41}
42
43fn default_base_branch() -> String {
44    "main".to_string()
45}
46
47impl Default for Config {
48    fn default() -> Self {
49        Self {
50            ignore: Vec::new(),
51            base_branch: default_base_branch(),
52            latest_package: None,
53            publish: HashMap::new(),
54            publish_dry_run: HashMap::new(),
55            update_on: HashMap::new(),
56        }
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    #[test]
65    fn test_config_default() {
66        let config = Config::default();
67        assert!(config.ignore.is_empty());
68        assert_eq!(config.base_branch, "main");
69        assert!(config.latest_package.is_none());
70        assert!(config.publish.is_empty());
71        assert!(config.publish_dry_run.is_empty());
72        assert!(config.update_on.is_empty());
73    }
74
75    #[test]
76    fn test_config_publish_dry_run_map() {
77        let json = r#"{
78            "publishDryRun": {
79                "node": "npm publish --dry-run",
80                "csharp": "dotnet pack -c Release",
81                "bridge/node/package.json": "npm publish --dry-run --access public"
82            }
83        }"#;
84        let config: Config = serde_json::from_str(json).unwrap();
85        assert_eq!(config.publish_dry_run.len(), 3);
86        assert_eq!(
87            config.publish_dry_run.get("node").unwrap(),
88            "npm publish --dry-run"
89        );
90        assert_eq!(
91            config.publish_dry_run.get("csharp").unwrap(),
92            "dotnet pack -c Release"
93        );
94        assert_eq!(
95            config
96                .publish_dry_run
97                .get("bridge/node/package.json")
98                .unwrap(),
99            "npm publish --dry-run --access public"
100        );
101    }
102
103    #[test]
104    fn test_config_deserialize_full() {
105        let json = r#"{
106            "ignore": ["examples/**", "docs/**"],
107            "baseBranch": "develop",
108            "latestPackage": "crates/core/Cargo.toml",
109            "publish": {
110                "node": "npm publish --access public",
111                "rust": "cargo publish"
112            },
113            "updateOn": {
114                "crates/core/Cargo.toml": ["bridge/node/package.json", "bridge/python/pyproject.toml"]
115            }
116        }"#;
117        let config: Config = serde_json::from_str(json).unwrap();
118        assert_eq!(config.ignore, vec!["examples/**", "docs/**"]);
119        assert_eq!(config.base_branch, "develop");
120        assert_eq!(
121            config.latest_package.as_deref(),
122            Some("crates/core/Cargo.toml")
123        );
124        assert_eq!(config.publish.len(), 2);
125        assert_eq!(
126            config.publish.get("node").unwrap(),
127            "npm publish --access public"
128        );
129        assert_eq!(config.publish.get("rust").unwrap(), "cargo publish");
130        assert_eq!(config.update_on.len(), 1);
131        let update_targets = config.update_on.get("crates/core/Cargo.toml").unwrap();
132        assert_eq!(update_targets.len(), 2);
133        assert!(update_targets.contains(&"bridge/node/package.json".to_string()));
134        assert!(update_targets.contains(&"bridge/python/pyproject.toml".to_string()));
135    }
136
137    #[test]
138    fn test_config_deserialize_partial() {
139        let json = r#"{ "baseBranch": "release" }"#;
140        let config: Config = serde_json::from_str(json).unwrap();
141        assert!(config.ignore.is_empty());
142        assert_eq!(config.base_branch, "release");
143        assert!(config.latest_package.is_none());
144        assert!(config.publish.is_empty());
145        assert!(config.update_on.is_empty());
146    }
147
148    #[test]
149    fn test_config_deserialize_empty_object() {
150        let json = r#"{}"#;
151        let config: Config = serde_json::from_str(json).unwrap();
152        assert_eq!(config.base_branch, "main");
153        assert!(config.ignore.is_empty());
154        assert!(config.latest_package.is_none());
155        assert!(config.publish.is_empty());
156        assert!(config.update_on.is_empty());
157    }
158
159    #[test]
160    fn test_config_ignore_patterns() {
161        let json = r#"{ "ignore": ["**/*", "!crates/changepacks/Cargo.toml", "!bridge/**"] }"#;
162        let config: Config = serde_json::from_str(json).unwrap();
163        assert_eq!(config.ignore.len(), 3);
164        assert_eq!(config.ignore[0], "**/*");
165        assert_eq!(config.ignore[1], "!crates/changepacks/Cargo.toml");
166        assert_eq!(config.ignore[2], "!bridge/**");
167    }
168
169    #[test]
170    fn test_config_publish_map() {
171        let json = r#"{
172            "publish": {
173                "node": "npm publish",
174                "python": "uv publish",
175                "rust": "cargo publish",
176                "dart": "dart pub publish",
177                "bridge/node/package.json": "npm publish --access public"
178            }
179        }"#;
180        let config: Config = serde_json::from_str(json).unwrap();
181        assert_eq!(config.publish.len(), 5);
182        assert_eq!(config.publish.get("node").unwrap(), "npm publish");
183        assert_eq!(config.publish.get("python").unwrap(), "uv publish");
184        assert_eq!(config.publish.get("rust").unwrap(), "cargo publish");
185        assert_eq!(config.publish.get("dart").unwrap(), "dart pub publish");
186        assert_eq!(
187            config.publish.get("bridge/node/package.json").unwrap(),
188            "npm publish --access public"
189        );
190    }
191
192    #[test]
193    fn test_config_update_on_map() {
194        let json = r#"{
195            "updateOn": {
196                "crates/changepacks/Cargo.toml": ["bridge/node/package.json"],
197                "crates/core/Cargo.toml": ["bridge/python/pyproject.toml", "bridge/node/package.json"]
198            }
199        }"#;
200        let config: Config = serde_json::from_str(json).unwrap();
201        assert_eq!(config.update_on.len(), 2);
202
203        let changepacks_targets = config
204            .update_on
205            .get("crates/changepacks/Cargo.toml")
206            .unwrap();
207        assert_eq!(changepacks_targets.len(), 1);
208        assert_eq!(changepacks_targets[0], "bridge/node/package.json");
209
210        let core_targets = config.update_on.get("crates/core/Cargo.toml").unwrap();
211        assert_eq!(core_targets.len(), 2);
212    }
213
214    #[test]
215    fn test_config_serialize_roundtrip() {
216        let mut config = Config {
217            ignore: vec!["test/**".to_string()],
218            base_branch: "develop".to_string(),
219            latest_package: Some("Cargo.toml".to_string()),
220            ..Config::default()
221        };
222        config
223            .publish
224            .insert("rust".to_string(), "cargo publish".to_string());
225        config.update_on.insert(
226            "Cargo.toml".to_string(),
227            vec!["bridge/package.json".to_string()],
228        );
229
230        let json = serde_json::to_string(&config).unwrap();
231        let deserialized: Config = serde_json::from_str(&json).unwrap();
232        assert_eq!(config, deserialized);
233    }
234}