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    /// Dependency rules for forced updates.
28    /// Key: glob pattern for trigger packages (e.g., "crates/*")
29    /// Value: list of package paths that must be updated when trigger matches
30    #[serde(default)]
31    pub update_on: HashMap<String, Vec<String>>,
32}
33
34fn default_base_branch() -> String {
35    "main".to_string()
36}
37
38impl Default for Config {
39    fn default() -> Self {
40        Self {
41            ignore: Vec::new(),
42            base_branch: default_base_branch(),
43            latest_package: None,
44            publish: HashMap::new(),
45            update_on: HashMap::new(),
46        }
47    }
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    #[test]
55    fn test_config_default() {
56        let config = Config::default();
57        assert!(config.ignore.is_empty());
58        assert_eq!(config.base_branch, "main");
59        assert!(config.latest_package.is_none());
60        assert!(config.publish.is_empty());
61        assert!(config.update_on.is_empty());
62    }
63
64    #[test]
65    fn test_config_deserialize_full() {
66        let json = r#"{
67            "ignore": ["examples/**", "docs/**"],
68            "baseBranch": "develop",
69            "latestPackage": "crates/core/Cargo.toml",
70            "publish": {
71                "node": "npm publish --access public",
72                "rust": "cargo publish"
73            },
74            "updateOn": {
75                "crates/core/Cargo.toml": ["bridge/node/package.json", "bridge/python/pyproject.toml"]
76            }
77        }"#;
78        let config: Config = serde_json::from_str(json).unwrap();
79        assert_eq!(config.ignore, vec!["examples/**", "docs/**"]);
80        assert_eq!(config.base_branch, "develop");
81        assert_eq!(
82            config.latest_package.as_deref(),
83            Some("crates/core/Cargo.toml")
84        );
85        assert_eq!(config.publish.len(), 2);
86        assert_eq!(
87            config.publish.get("node").unwrap(),
88            "npm publish --access public"
89        );
90        assert_eq!(config.publish.get("rust").unwrap(), "cargo publish");
91        assert_eq!(config.update_on.len(), 1);
92        let update_targets = config.update_on.get("crates/core/Cargo.toml").unwrap();
93        assert_eq!(update_targets.len(), 2);
94        assert!(update_targets.contains(&"bridge/node/package.json".to_string()));
95        assert!(update_targets.contains(&"bridge/python/pyproject.toml".to_string()));
96    }
97
98    #[test]
99    fn test_config_deserialize_partial() {
100        let json = r#"{ "baseBranch": "release" }"#;
101        let config: Config = serde_json::from_str(json).unwrap();
102        assert!(config.ignore.is_empty());
103        assert_eq!(config.base_branch, "release");
104        assert!(config.latest_package.is_none());
105        assert!(config.publish.is_empty());
106        assert!(config.update_on.is_empty());
107    }
108
109    #[test]
110    fn test_config_deserialize_empty_object() {
111        let json = r#"{}"#;
112        let config: Config = serde_json::from_str(json).unwrap();
113        assert_eq!(config.base_branch, "main");
114        assert!(config.ignore.is_empty());
115        assert!(config.latest_package.is_none());
116        assert!(config.publish.is_empty());
117        assert!(config.update_on.is_empty());
118    }
119
120    #[test]
121    fn test_config_ignore_patterns() {
122        let json = r#"{ "ignore": ["**/*", "!crates/changepacks/Cargo.toml", "!bridge/**"] }"#;
123        let config: Config = serde_json::from_str(json).unwrap();
124        assert_eq!(config.ignore.len(), 3);
125        assert_eq!(config.ignore[0], "**/*");
126        assert_eq!(config.ignore[1], "!crates/changepacks/Cargo.toml");
127        assert_eq!(config.ignore[2], "!bridge/**");
128    }
129
130    #[test]
131    fn test_config_publish_map() {
132        let json = r#"{
133            "publish": {
134                "node": "npm publish",
135                "python": "uv publish",
136                "rust": "cargo publish",
137                "dart": "dart pub publish",
138                "bridge/node/package.json": "npm publish --access public"
139            }
140        }"#;
141        let config: Config = serde_json::from_str(json).unwrap();
142        assert_eq!(config.publish.len(), 5);
143        assert_eq!(config.publish.get("node").unwrap(), "npm publish");
144        assert_eq!(config.publish.get("python").unwrap(), "uv publish");
145        assert_eq!(config.publish.get("rust").unwrap(), "cargo publish");
146        assert_eq!(config.publish.get("dart").unwrap(), "dart pub publish");
147        assert_eq!(
148            config.publish.get("bridge/node/package.json").unwrap(),
149            "npm publish --access public"
150        );
151    }
152
153    #[test]
154    fn test_config_update_on_map() {
155        let json = r#"{
156            "updateOn": {
157                "crates/changepacks/Cargo.toml": ["bridge/node/package.json"],
158                "crates/core/Cargo.toml": ["bridge/python/pyproject.toml", "bridge/node/package.json"]
159            }
160        }"#;
161        let config: Config = serde_json::from_str(json).unwrap();
162        assert_eq!(config.update_on.len(), 2);
163
164        let changepacks_targets = config
165            .update_on
166            .get("crates/changepacks/Cargo.toml")
167            .unwrap();
168        assert_eq!(changepacks_targets.len(), 1);
169        assert_eq!(changepacks_targets[0], "bridge/node/package.json");
170
171        let core_targets = config.update_on.get("crates/core/Cargo.toml").unwrap();
172        assert_eq!(core_targets.len(), 2);
173    }
174
175    #[test]
176    fn test_config_serialize_roundtrip() {
177        let mut config = Config {
178            ignore: vec!["test/**".to_string()],
179            base_branch: "develop".to_string(),
180            latest_package: Some("Cargo.toml".to_string()),
181            ..Config::default()
182        };
183        config
184            .publish
185            .insert("rust".to_string(), "cargo publish".to_string());
186        config.update_on.insert(
187            "Cargo.toml".to_string(),
188            vec!["bridge/package.json".to_string()],
189        );
190
191        let json = serde_json::to_string(&config).unwrap();
192        let deserialized: Config = serde_json::from_str(&json).unwrap();
193        assert_eq!(config, deserialized);
194    }
195}