1use std::collections::BTreeMap;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use crate::commit::{CommitType, DEFAULT_COMMIT_PATTERN, default_commit_types};
7use crate::error::ReleaseError;
8use crate::version::BumpLevel;
9use crate::version_files::detect_version_files;
10
11pub const DEFAULT_CONFIG_FILE: &str = "sr.yaml";
13
14pub const LEGACY_CONFIG_FILE: &str = ".urmzd.sr.yml";
16
17pub const CONFIG_CANDIDATES: &[&str] = &["sr.yaml", "sr.yml", LEGACY_CONFIG_FILE];
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(default)]
22pub struct ReleaseConfig {
23 pub branches: Vec<String>,
24 pub tag_prefix: String,
25 pub commit_pattern: String,
26 pub breaking_section: String,
27 pub misc_section: String,
28 pub types: Vec<CommitType>,
29 pub changelog: ChangelogConfig,
30 pub version_files: Vec<String>,
31 pub version_files_strict: bool,
32 pub artifacts: Vec<String>,
33 pub floating_tags: bool,
34 pub build_command: Option<String>,
35 pub stage_files: Vec<String>,
37 pub prerelease: Option<String>,
40 pub pre_release_command: Option<String>,
42 pub post_release_command: Option<String>,
44 pub sign_tags: bool,
46 pub draft: bool,
48 pub release_name_template: Option<String>,
52 pub hooks: HooksConfig,
54 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 pub packages: Vec<PackageConfig>,
57 #[serde(skip)]
59 pub path_filter: Option<String>,
60}
61
62impl Default for ReleaseConfig {
63 fn default() -> Self {
64 Self {
65 branches: vec!["main".into(), "master".into()],
66 tag_prefix: "v".into(),
67 commit_pattern: DEFAULT_COMMIT_PATTERN.into(),
68 breaking_section: "Breaking Changes".into(),
69 misc_section: "Miscellaneous".into(),
70 types: default_commit_types(),
71 changelog: ChangelogConfig::default(),
72 version_files: vec![],
73 version_files_strict: false,
74 artifacts: vec![],
75 floating_tags: false,
76 build_command: None,
77 stage_files: vec![],
78 prerelease: None,
79 pre_release_command: None,
80 post_release_command: None,
81 sign_tags: false,
82 draft: false,
83 release_name_template: None,
84 hooks: HooksConfig::with_defaults(),
85 packages: vec![],
86 path_filter: None,
87 }
88 }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct PackageConfig {
107 pub name: String,
109 pub path: String,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub tag_prefix: Option<String>,
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 pub version_files: Vec<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub changelog: Option<ChangelogConfig>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub build_command: Option<String>,
123 #[serde(default, skip_serializing_if = "Vec::is_empty")]
125 pub stage_files: Vec<String>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, Default)]
145#[serde(transparent)]
146pub struct HooksConfig {
147 pub hooks: BTreeMap<String, Vec<String>>,
148}
149
150impl HooksConfig {
151 pub fn with_defaults() -> Self {
152 let mut hooks = BTreeMap::new();
153 hooks.insert("commit-msg".into(), vec!["sr hook commit-msg".into()]);
154 Self { hooks }
155 }
156}
157
158#[derive(Debug, Clone, Default, Serialize, Deserialize)]
159#[serde(default)]
160pub struct ChangelogConfig {
161 pub file: Option<String>,
162 pub template: Option<String>,
163}
164
165impl ReleaseConfig {
166 pub fn find_config(dir: &Path) -> Option<(std::path::PathBuf, bool)> {
169 for &candidate in CONFIG_CANDIDATES {
170 let path = dir.join(candidate);
171 if path.exists() {
172 let is_legacy = candidate == LEGACY_CONFIG_FILE;
173 return Some((path, is_legacy));
174 }
175 }
176 None
177 }
178
179 pub fn load(path: &Path) -> Result<Self, ReleaseError> {
181 if !path.exists() {
182 return Ok(Self::default());
183 }
184
185 let contents =
186 std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
187
188 serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))
189 }
190
191 pub fn resolve_package(&self, pkg: &PackageConfig) -> Self {
193 let mut config = self.clone();
194 config.tag_prefix = pkg
195 .tag_prefix
196 .clone()
197 .unwrap_or_else(|| format!("{}/v", pkg.name));
198 config.path_filter = Some(pkg.path.clone());
199 if !pkg.version_files.is_empty() {
200 config.version_files = pkg.version_files.clone();
201 } else if config.version_files.is_empty() {
202 let detected = detect_version_files(Path::new(&pkg.path));
204 if !detected.is_empty() {
205 config.version_files = detected
206 .into_iter()
207 .map(|f| format!("{}/{f}", pkg.path))
208 .collect();
209 }
210 }
211 if let Some(ref cl) = pkg.changelog {
212 config.changelog = cl.clone();
213 }
214 if let Some(ref cmd) = pkg.build_command {
215 config.build_command = Some(cmd.clone());
216 }
217 if !pkg.stage_files.is_empty() {
218 config.stage_files = pkg.stage_files.clone();
219 }
220 config.packages = vec![];
222 config
223 }
224
225 pub fn find_package(&self, name: &str) -> Result<&PackageConfig, ReleaseError> {
227 self.packages
228 .iter()
229 .find(|p| p.name == name)
230 .ok_or_else(|| {
231 let available: Vec<&str> = self.packages.iter().map(|p| p.name.as_str()).collect();
232 ReleaseError::Config(format!(
233 "package '{name}' not found. Available: {}",
234 if available.is_empty() {
235 "(none — no packages configured)".to_string()
236 } else {
237 available.join(", ")
238 }
239 ))
240 })
241 }
242}
243
244impl<'de> Deserialize<'de> for BumpLevel {
246 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
247 where
248 D: serde::Deserializer<'de>,
249 {
250 let s = String::deserialize(deserializer)?;
251 match s.as_str() {
252 "major" => Ok(BumpLevel::Major),
253 "minor" => Ok(BumpLevel::Minor),
254 "patch" => Ok(BumpLevel::Patch),
255 _ => Err(serde::de::Error::custom(format!("unknown bump level: {s}"))),
256 }
257 }
258}
259
260impl Serialize for BumpLevel {
261 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
262 where
263 S: serde::Serializer,
264 {
265 let s = match self {
266 BumpLevel::Major => "major",
267 BumpLevel::Minor => "minor",
268 BumpLevel::Patch => "patch",
269 };
270 serializer.serialize_str(s)
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use std::io::Write;
278
279 #[test]
280 fn default_values() {
281 let config = ReleaseConfig::default();
282 assert_eq!(config.branches, vec!["main", "master"]);
283 assert_eq!(config.tag_prefix, "v");
284 assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
285 assert_eq!(config.breaking_section, "Breaking Changes");
286 assert_eq!(config.misc_section, "Miscellaneous");
287 assert!(!config.types.is_empty());
288 assert!(!config.version_files_strict);
289 assert!(config.artifacts.is_empty());
290 assert!(!config.floating_tags);
291 }
292
293 #[test]
294 fn load_missing_file() {
295 let dir = tempfile::tempdir().unwrap();
296 let path = dir.path().join("nonexistent.yml");
297 let config = ReleaseConfig::load(&path).unwrap();
298 assert_eq!(config.tag_prefix, "v");
299 }
300
301 #[test]
302 fn load_valid_yaml() {
303 let dir = tempfile::tempdir().unwrap();
304 let path = dir.path().join("config.yml");
305 let mut f = std::fs::File::create(&path).unwrap();
306 writeln!(f, "branches:\n - develop\ntag_prefix: release-").unwrap();
307
308 let config = ReleaseConfig::load(&path).unwrap();
309 assert_eq!(config.branches, vec!["develop"]);
310 assert_eq!(config.tag_prefix, "release-");
311 }
312
313 #[test]
314 fn load_partial_yaml() {
315 let dir = tempfile::tempdir().unwrap();
316 let path = dir.path().join("config.yml");
317 std::fs::write(&path, "tag_prefix: rel-\n").unwrap();
318
319 let config = ReleaseConfig::load(&path).unwrap();
320 assert_eq!(config.tag_prefix, "rel-");
321 assert_eq!(config.branches, vec!["main", "master"]);
322 assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
324 assert_eq!(config.breaking_section, "Breaking Changes");
325 assert!(!config.types.is_empty());
326 }
327
328 #[test]
329 fn load_yaml_with_artifacts() {
330 let dir = tempfile::tempdir().unwrap();
331 let path = dir.path().join("config.yml");
332 std::fs::write(
333 &path,
334 "artifacts:\n - \"dist/*.tar.gz\"\n - \"build/output-*\"\n",
335 )
336 .unwrap();
337
338 let config = ReleaseConfig::load(&path).unwrap();
339 assert_eq!(config.artifacts, vec!["dist/*.tar.gz", "build/output-*"]);
340 assert_eq!(config.tag_prefix, "v");
342 }
343
344 #[test]
345 fn load_yaml_with_floating_tags() {
346 let dir = tempfile::tempdir().unwrap();
347 let path = dir.path().join("config.yml");
348 std::fs::write(&path, "floating_tags: true\n").unwrap();
349
350 let config = ReleaseConfig::load(&path).unwrap();
351 assert!(config.floating_tags);
352 assert_eq!(config.tag_prefix, "v");
354 }
355
356 #[test]
357 fn bump_level_roundtrip() {
358 for (level, expected) in [
359 (BumpLevel::Major, "major"),
360 (BumpLevel::Minor, "minor"),
361 (BumpLevel::Patch, "patch"),
362 ] {
363 let yaml = serde_yaml_ng::to_string(&level).unwrap();
364 assert!(yaml.contains(expected));
365 let parsed: BumpLevel = serde_yaml_ng::from_str(&yaml).unwrap();
366 assert_eq!(parsed, level);
367 }
368 }
369
370 #[test]
371 fn types_roundtrip() {
372 let config = ReleaseConfig::default();
373 let yaml = serde_yaml_ng::to_string(&config).unwrap();
374 let parsed: ReleaseConfig = serde_yaml_ng::from_str(&yaml).unwrap();
375 assert_eq!(parsed.types.len(), config.types.len());
376 assert_eq!(parsed.types[0].name, "feat");
377 assert_eq!(parsed.commit_pattern, config.commit_pattern);
378 assert_eq!(parsed.breaking_section, config.breaking_section);
379 }
380
381 #[test]
382 fn load_yaml_with_packages() {
383 let dir = tempfile::tempdir().unwrap();
384 let path = dir.path().join("config.yml");
385 std::fs::write(
386 &path,
387 r#"
388packages:
389 - name: core
390 path: crates/core
391 version_files:
392 - crates/core/Cargo.toml
393 - name: cli
394 path: crates/cli
395 tag_prefix: "cli-v"
396"#,
397 )
398 .unwrap();
399
400 let config = ReleaseConfig::load(&path).unwrap();
401 assert_eq!(config.packages.len(), 2);
402 assert_eq!(config.packages[0].name, "core");
403 assert_eq!(config.packages[0].path, "crates/core");
404 assert_eq!(config.packages[1].tag_prefix.as_deref(), Some("cli-v"));
405 }
406
407 #[test]
408 fn resolve_package_defaults() {
409 let mut config = ReleaseConfig::default();
410 config.packages = vec![PackageConfig {
411 name: "core".into(),
412 path: "crates/core".into(),
413 tag_prefix: None,
414 version_files: vec![],
415 changelog: None,
416 build_command: None,
417 stage_files: vec![],
418 }];
419
420 let resolved = config.resolve_package(&config.packages[0]);
421 assert_eq!(resolved.tag_prefix, "core/v");
422 assert_eq!(resolved.path_filter.as_deref(), Some("crates/core"));
423 assert_eq!(resolved.branches, config.branches);
425 assert!(resolved.packages.is_empty());
426 }
427
428 #[test]
429 fn resolve_package_overrides() {
430 let mut config = ReleaseConfig::default();
431 config.version_files = vec!["Cargo.toml".into()];
432 config.packages = vec![PackageConfig {
433 name: "cli".into(),
434 path: "crates/cli".into(),
435 tag_prefix: Some("cli-v".into()),
436 version_files: vec!["crates/cli/Cargo.toml".into()],
437 changelog: Some(ChangelogConfig {
438 file: Some("crates/cli/CHANGELOG.md".into()),
439 template: None,
440 }),
441 build_command: Some("cargo build -p cli".into()),
442 stage_files: vec!["crates/cli/Cargo.lock".into()],
443 }];
444
445 let resolved = config.resolve_package(&config.packages[0]);
446 assert_eq!(resolved.tag_prefix, "cli-v");
447 assert_eq!(resolved.version_files, vec!["crates/cli/Cargo.toml"]);
448 assert_eq!(
449 resolved.changelog.file.as_deref(),
450 Some("crates/cli/CHANGELOG.md")
451 );
452 assert_eq!(
453 resolved.build_command.as_deref(),
454 Some("cargo build -p cli")
455 );
456 assert_eq!(resolved.stage_files, vec!["crates/cli/Cargo.lock"]);
457 }
458
459 #[test]
460 fn find_package_found() {
461 let mut config = ReleaseConfig::default();
462 config.packages = vec![PackageConfig {
463 name: "core".into(),
464 path: "crates/core".into(),
465 tag_prefix: None,
466 version_files: vec![],
467 changelog: None,
468 build_command: None,
469 stage_files: vec![],
470 }];
471
472 let pkg = config.find_package("core").unwrap();
473 assert_eq!(pkg.name, "core");
474 }
475
476 #[test]
477 fn find_package_not_found() {
478 let config = ReleaseConfig::default();
479 let err = config.find_package("nonexistent").unwrap_err();
480 assert!(err.to_string().contains("nonexistent"));
481 assert!(err.to_string().contains("no packages configured"));
482 }
483
484 #[test]
485 fn packages_not_serialized_when_empty() {
486 let config = ReleaseConfig::default();
487 let yaml = serde_yaml_ng::to_string(&config).unwrap();
488 assert!(!yaml.contains("packages"));
489 }
490}