pub(crate) mod plan;
mod release;
pub(crate) mod schema;
mod split;
mod surface;
mod unify;
pub use plan::{
CargoPrerequisiteConfig, CargoRootConfig, CargoTargetRootConfig, PlanConfig, PlanWorkConfig, PlanWorkScope,
};
pub use release::{
ChangelogConfig, ChangelogFilters, ChangelogRelativeTo, ChangelogShape, CommitPolicy, CrateReleaseConfig,
GroupSpec, Pre1BreakingBump, ReleaseArtifacts, ReleaseAsset, ReleaseConfig, ReleaseRegistryPublication,
ReleaseRemoteEffects, ReleaseSource, RequireChangeFiles, SemverCheckPolicy,
};
pub use split::{CrateSplitConfig, SplitConfig, SplitMode, WorkspaceMode};
pub use surface::{
SurfaceConfig, SurfaceConsumerScope, SurfaceCrateVisibility, SurfaceDoctest, SurfaceDoctestCoverage,
SurfaceExclude, SurfaceExternal, SurfaceFeatureProfile, SurfaceLintDirective, SurfaceLintLevel, SurfaceOverride,
SurfaceProduct, SurfaceTargetSelection,
};
pub use unify::{
ConsumerScope, ExactPinHandling, MajorVersionConflict, MsrvPolicy, MsrvSource, TransitiveFeatureHost,
TransitivePinning, UnifyConfig,
};
use crate::error::{ConfigError, RailError, RailResult};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RailConfig {
#[serde(default)]
pub targets: Vec<String>,
#[serde(default)]
pub unify: UnifyConfig,
#[serde(default)]
pub release: ReleaseConfig,
#[serde(default)]
pub surface: SurfaceConfig,
#[serde(default)]
pub plan: PlanConfig,
#[serde(default)]
pub crates: BTreeMap<String, CrateConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CrateConfig {
pub split: Option<CrateSplitConfig>,
pub release: Option<CrateReleaseConfig>,
pub changelog: Option<ChangelogConfig>,
}
#[derive(Debug)]
pub enum ConfigLoadResult {
Loaded(Box<RailConfig>),
ParseError {
path: PathBuf,
message: String,
},
NotFound,
}
#[derive(Debug)]
pub(crate) struct DecodedConfig {
pub(crate) config: RailConfig,
pub(crate) document: toml_edit::DocumentMut,
}
pub(crate) fn decode(bytes: &[u8]) -> RailResult<DecodedConfig> {
let content = std::str::from_utf8(bytes)
.map_err(|error| RailError::message(format!("configuration is not valid UTF-8: {error}")))?;
let document: toml_edit::DocumentMut = content
.parse()
.map_err(|error: toml_edit::TomlError| RailError::message(error.to_string()))?;
let config = RailConfig::from_document(document.clone()).map_err(RailError::message)?;
Ok(DecodedConfig { config, document })
}
pub(crate) fn load_decoded(path: &Path) -> RailResult<(DecodedConfig, Vec<u8>)> {
let bytes =
fs::read(path).map_err(|error| RailError::message(format!("failed to read {}: {error}", path.display())))?;
let decoded = decode(&bytes).map_err(|error| error.context(format!("configuration {}", path.display())))?;
Ok((decoded, bytes))
}
pub(crate) fn workspace_context_required(field: &str) -> RailError {
RailError::message(format!(
"{field} requires Cargo workspace context; inspect a configuration file in its owning workspace"
))
}
impl RailConfig {
fn from_document(doc: toml_edit::DocumentMut) -> Result<Self, String> {
if let Some(path) = schema::document_paths(&doc)
.into_iter()
.find(|path| !schema::is_known_config_path(path))
{
return Err(format!(
"unknown configuration key '{path}'; run `cargo rail config explain --all` with supported configuration to inspect current fields"
));
}
toml_edit::de::from_document(doc).map_err(|error| error.to_string())
}
pub(crate) fn validate_policy(&self) -> RailResult<()> {
self.plan.validate().map_err(RailError::Config)?;
self.surface.validate().map_err(RailError::Config)?;
self.surface
.validate_workspace_targets(&self.targets)
.map_err(RailError::Config)?;
self.unify
.validate_workspace_targets(&self.targets)
.map_err(RailError::Config)?;
self.unify.validate_policy().map_err(RailError::Config)?;
self.release.validate_policy().map_err(RailError::Config)?;
for split in self.build_split_configs() {
split.validate()?;
}
crate::release::changelog::ChangelogSpec::resolve(&self.release.changelog, None)?;
for config in self.crates.values().filter_map(|config| config.changelog.as_ref()) {
crate::release::changelog::ChangelogSpec::resolve(&self.release.changelog, Some(config))?;
}
Ok(())
}
pub(crate) fn validate_without_workspace(&self) -> RailResult<()> {
self.validate_policy()?;
if self.unify.transitive_host_path().is_some() {
return Err(workspace_context_required("unify.transitive_pinning.host"));
}
if self
.crates
.values()
.any(|config| config.split.is_some() || config.release.is_some() || config.changelog.is_some())
|| !self.release.version_groups.is_empty()
|| matches!(&self.release.require_change_files, RequireChangeFiles::Crates(names) if !names.is_empty())
{
return Err(workspace_context_required("package selection"));
}
if !self.targets.is_empty() {
crate::targets::validate_targets(&self.targets)?;
}
Ok(())
}
pub(crate) fn validate(
&self,
workspace_root: &Path,
workspace_members: Option<&[String]>,
) -> RailResult<Vec<String>> {
self.validate_policy()?;
self.unify.validate_host(workspace_root).map_err(RailError::Config)?;
let Some(workspace_members) = workspace_members else {
return Ok(Vec::new());
};
if !self.targets.is_empty() {
crate::targets::validate_targets(&self.targets)?;
}
let warnings = self.release.validate(workspace_members).map_err(RailError::Config)?;
for (crate_name, crate_config) in &self.crates {
if crate_config.split.is_none()
&& (crate_config.release.is_some() || crate_config.changelog.is_some())
&& !workspace_members.contains(crate_name)
{
return Err(RailError::Config(ConfigError::CrateNotFound {
name: crate_name.clone(),
}));
}
}
for split in self.build_split_configs() {
if let Some(member) = split.members.iter().find(|member| !workspace_members.contains(*member)) {
return Err(RailError::Config(ConfigError::CrateNotFound { name: member.clone() }));
}
}
Ok(warnings)
}
pub(crate) fn load_path_with_bytes(path: &Path) -> RailResult<(Self, Vec<u8>)> {
let (decoded, bytes) = load_decoded(path)?;
Ok((decoded.config, bytes))
}
pub fn find_config_path(path: &Path) -> Option<PathBuf> {
let candidates = [
path.join("rail.toml"),
path.join(".rail.toml"),
path.join(".cargo").join("rail.toml"),
path.join(".config").join("rail.toml"),
];
if let Some(found) = candidates.iter().find(|p| match p.symlink_metadata() {
Ok(_) => true,
Err(error) => error.kind() != std::io::ErrorKind::NotFound,
}) {
return Some(found.to_path_buf());
}
#[cfg(target_os = "windows")]
{
if let Ok(canonical) = path.canonicalize() {
let canonical_candidates = [
canonical.join("rail.toml"),
canonical.join(".rail.toml"),
canonical.join(".cargo").join("rail.toml"),
canonical.join(".config").join("rail.toml"),
];
if let Some(found) = canonical_candidates.iter().find(|p| match p.symlink_metadata() {
Ok(_) => true,
Err(error) => error.kind() != std::io::ErrorKind::NotFound,
}) {
return Some(found.to_path_buf());
}
}
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
let file_name = entry.file_name();
let file_name_str = file_name.to_string_lossy();
if file_name_str == "rail.toml" || file_name_str == ".rail.toml" {
return Some(entry.path());
}
}
}
for subdir in &[".cargo", ".config"] {
let subdir_path = path.join(subdir);
if let Ok(entries) = std::fs::read_dir(&subdir_path) {
for entry in entries.flatten() {
let file_name = entry.file_name();
if file_name.to_string_lossy() == "rail.toml" {
return Some(entry.path());
}
}
}
}
}
None
}
pub fn load(path: &Path) -> RailResult<Self> {
match Self::try_load(path) {
ConfigLoadResult::Loaded(config) => Ok(*config),
ConfigLoadResult::ParseError { path, message } => {
Err(RailError::Config(ConfigError::ParseError { path, message }))
}
ConfigLoadResult::NotFound => Err(RailError::Config(ConfigError::NotFound {
workspace_root: path.to_path_buf(),
})),
}
}
pub fn try_load(path: &Path) -> ConfigLoadResult {
let config_path = match Self::find_config_path(path) {
Some(p) => p,
None => return ConfigLoadResult::NotFound,
};
match Self::load_path_with_bytes(&config_path) {
Ok((config, _)) => ConfigLoadResult::Loaded(Box::new(config)),
Err(error) => ConfigLoadResult::ParseError {
path: config_path,
message: error.to_string(),
},
}
}
pub fn get_split_crates(&self) -> Vec<(&str, &CrateSplitConfig)> {
self.crates
.iter()
.filter_map(|(name, config)| config.split.as_ref().map(|split| (name.as_str(), split)))
.collect()
}
pub fn build_split_configs(&self) -> Vec<SplitConfig> {
self.crates
.iter()
.filter_map(|(name, config)| {
config.split.as_ref().map(|split_cfg| {
split::build_split_config(
name.clone(),
split_cfg,
config.release.as_ref().map(|r| r.publish),
config.changelog.as_ref(),
)
})
})
.collect()
}
}