use crate::config::release::ChangelogConfig;
use crate::config::unify::default_true;
use crate::error::{ConfigError, RailError, RailResult};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrateSplitConfig {
pub remote: String,
pub branch: String,
pub mode: SplitMode,
#[serde(default)]
pub workspace_mode: WorkspaceMode,
#[serde(default)]
pub members: Vec<String>,
#[serde(default, rename = "paths", skip_serializing_if = "Vec::is_empty")]
pub legacy_paths: Vec<CratePath>,
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SplitConfig {
pub name: String,
pub remote: String,
pub branch: String,
pub mode: SplitMode,
#[serde(default)]
pub workspace_mode: WorkspaceMode,
pub members: Vec<String>,
pub legacy_paths: Vec<CratePath>,
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default = "default_true")]
pub publish: bool,
#[serde(default)]
pub changelog_path: Option<PathBuf>,
}
impl SplitConfig {
pub fn target_repo_path(&self, workspace_root: &std::path::Path) -> PathBuf {
if crate::utils::is_local_path(&self.remote) {
PathBuf::from(&self.remote)
} else {
let remote_name = self
.remote
.rsplit('/')
.next()
.unwrap_or(&self.name)
.trim_end_matches(".git");
workspace_root.join("..").join(remote_name)
}
}
pub fn is_local_testing(&self) -> bool {
crate::utils::is_local_path(&self.remote)
}
pub fn validate(&self) -> RailResult<()> {
if !self.legacy_paths.is_empty() {
return Err(RailError::with_help(
format!("Split '{}' still selects Cargo members by path", self.name),
"run 'cargo rail config migrate' to replace split.paths with member names",
));
}
if self.members.is_empty() {
return Err(RailError::with_help(
format!("Split '{}' must own at least one Cargo member", self.name),
format!("Add members under [crates.{}.split]", self.name),
));
}
let mut unique_members = std::collections::HashSet::with_capacity(self.members.len());
if let Some(member) = self
.members
.iter()
.find(|member| !unique_members.insert(member.as_str()))
{
return Err(RailError::with_help(
format!("Split '{}' selects Cargo member '{}' more than once", self.name, member),
"list each split member exactly once",
));
}
if !self.exclude.is_empty() && self.include.is_empty() {
return Err(RailError::with_help(
format!("Split '{}' excludes assets without including any", self.name),
"split.exclude only narrows explicit non-Cargo assets selected by split.include",
));
}
if self.remote.is_empty() {
return Err(RailError::Config(ConfigError::MissingField {
field: format!("remote for split '{}'", self.name),
}));
}
match self.mode {
SplitMode::Single => {
if self.members.len() != 1 {
return Err(RailError::with_help(
format!(
"Single mode split '{}' must have exactly one member (found {})",
self.name,
self.members.len()
),
"change mode to 'combined' or remove extra members",
));
}
}
SplitMode::Combined => {
if self.members.len() < 2 {
return Err(RailError::with_help(
format!(
"Combined mode split '{}' should have multiple members (found {})",
self.name,
self.members.len()
),
"change mode to 'single' or add more Cargo members",
));
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CratePath {
#[serde(rename = "crate")]
pub path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum SplitMode {
#[default]
Single,
Combined,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum WorkspaceMode {
#[default]
Standalone,
Workspace,
}
pub fn build_split_config(
name: String,
split: &CrateSplitConfig,
release_publish: Option<bool>,
changelog: Option<&ChangelogConfig>,
) -> SplitConfig {
let members = if split.members.is_empty() {
vec![name.clone()]
} else {
split.members.clone()
};
SplitConfig {
name,
remote: split.remote.clone(),
branch: split.branch.clone(),
mode: split.mode.clone(),
workspace_mode: split.workspace_mode.clone(),
members,
legacy_paths: split.legacy_paths.clone(),
include: split.include.clone(),
exclude: split.exclude.clone(),
publish: release_publish.unwrap_or(true),
changelog_path: changelog.and_then(|c| c.path.clone()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_config_validate_empty_members() {
let config = SplitConfig {
name: "test-crate".to_string(),
remote: "git@github.com:user/test.git".to_string(),
branch: "main".to_string(),
mode: SplitMode::Single,
workspace_mode: WorkspaceMode::default(),
members: vec![],
legacy_paths: vec![],
include: vec![],
exclude: vec![],
publish: true,
changelog_path: None,
};
let result = config.validate();
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("at least one Cargo member"));
}
#[test]
fn test_split_config_validate_empty_remote() {
let config = SplitConfig {
name: "test-crate".to_string(),
remote: "".to_string(),
branch: "main".to_string(),
mode: SplitMode::Single,
workspace_mode: WorkspaceMode::default(),
members: vec!["test-crate".to_string()],
legacy_paths: vec![],
include: vec![],
exclude: vec![],
publish: true,
changelog_path: None,
};
let result = config.validate();
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("remote"));
}
#[test]
fn test_split_config_validate_single_mode_multiple_members() {
let config = SplitConfig {
name: "test-crate".to_string(),
remote: "git@github.com:user/test.git".to_string(),
branch: "main".to_string(),
mode: SplitMode::Single,
workspace_mode: WorkspaceMode::default(),
members: vec!["a".to_string(), "b".to_string()],
legacy_paths: vec![],
include: vec![],
exclude: vec![],
publish: true,
changelog_path: None,
};
let result = config.validate();
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Single mode"));
assert!(err_msg.contains("exactly one member"));
}
#[test]
fn test_split_config_validate_combined_mode_single_member() {
let config = SplitConfig {
name: "test-crate".to_string(),
remote: "git@github.com:user/test.git".to_string(),
branch: "main".to_string(),
mode: SplitMode::Combined,
workspace_mode: WorkspaceMode::default(),
members: vec!["a".to_string()],
legacy_paths: vec![],
include: vec![],
exclude: vec![],
publish: true,
changelog_path: None,
};
let result = config.validate();
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Combined mode"));
assert!(err_msg.contains("multiple members"));
}
#[test]
fn test_split_config_validate_valid() {
let config = SplitConfig {
name: "test-crate".to_string(),
remote: "git@github.com:user/test.git".to_string(),
branch: "main".to_string(),
mode: SplitMode::Single,
workspace_mode: WorkspaceMode::default(),
members: vec!["test-crate".to_string()],
legacy_paths: vec![],
include: vec![],
exclude: vec![],
publish: true,
changelog_path: None,
};
assert!(config.validate().is_ok());
}
}