pub(crate) mod migration;
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::{
CompilerTargetSelection, 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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RetiredKeys {
Reject,
Strip,
}
pub(crate) fn decode(bytes: &[u8]) -> RailResult<DecodedConfig> {
decode_with(bytes, RetiredKeys::Reject)
}
pub(crate) fn decode_with(bytes: &[u8], retired: RetiredKeys) -> RailResult<DecodedConfig> {
decode_all(bytes, retired).map_err(|errors| match combine_errors(errors) {
Err(error) => error,
Ok(()) => RailError::message("configuration failed to decode"),
})
}
pub(crate) fn decode_all(bytes: &[u8], retired: RetiredKeys) -> Result<DecodedConfig, Vec<RailError>> {
let content = std::str::from_utf8(bytes)
.map_err(|error| vec![RailError::message(format!("configuration is not valid UTF-8: {error}"))])?;
let mut document: toml_edit::DocumentMut = content
.parse()
.map_err(|error: toml_edit::TomlError| vec![RailError::message(error.to_string())])?;
if retired == RetiredKeys::Strip {
migration::strip_retired(&mut document);
}
let config = RailConfig::from_document(document.clone())
.map_err(|errors| errors.into_iter().map(RailError::message).collect::<Vec<_>>())?;
Ok(DecodedConfig { config, document })
}
pub(crate) fn load_decoded(path: &Path) -> RailResult<(DecodedConfig, Vec<u8>)> {
load_decoded_with(path, RetiredKeys::Reject)
}
pub(crate) fn load_decoded_with(path: &Path, retired: RetiredKeys) -> RailResult<(DecodedConfig, Vec<u8>)> {
let bytes =
fs::read(path).map_err(|error| RailError::message(format!("failed to read {}: {error}", path.display())))?;
let decoded =
decode_with(&bytes, retired).map_err(|error| error.context(format!("configuration {}", path.display())))?;
Ok((decoded, bytes))
}
pub(crate) fn combine_errors(mut errors: Vec<RailError>) -> RailResult<()> {
match errors.len() {
0 => Ok(()),
1 => Err(errors.remove(0)),
count => Err(RailError::message(format!(
"{count} configuration errors:\n{}",
errors
.iter()
.map(|error| format!(" - {error}"))
.collect::<Vec<_>>()
.join("\n")
))),
}
}
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, Vec<String>> {
let mut errors = Vec::new();
let mut reported = Vec::<schema::ConfigPath>::new();
for path in schema::document_paths(&doc) {
if schema::is_known_config_path(&path)
|| reported
.iter()
.any(|parent| path.segments().starts_with(parent.segments()))
{
continue;
}
if let Some((retired, key)) = schema::retired_key(&path) {
let action = if retired.removable {
"`cargo rail config migrate` previews its removal"
} else {
"change it by hand"
};
errors.push(format!(
"configuration key '{key}' was removed in Cargo-Rail {}; {}; {action}",
retired.removed_in, retired.instead
));
reported.push(key);
} else {
errors.push(format!(
"unknown configuration key '{path}'; run `cargo rail config explain --all` with supported configuration to inspect current fields"
));
reported.push(path);
}
}
if !errors.is_empty() {
return Err(errors);
}
toml_edit::de::from_document(doc).map_err(|error| vec![error.to_string()])
}
pub(crate) fn validate_policy(&self) -> RailResult<()> {
combine_errors(self.policy_errors())
}
pub(crate) fn policy_errors(&self) -> Vec<RailError> {
let mut checks = vec![
self.plan.validate().map_err(RailError::Config),
self.surface
.validate()
.and_then(|()| self.surface.validate_workspace_targets(&self.targets))
.map_err(RailError::Config),
self.unify
.validate_workspace_targets(&self.targets)
.and_then(|()| self.unify.validate_policy())
.map_err(RailError::Config),
self.release.validate_policy().map_err(RailError::Config),
crate::release::changelog::ChangelogSpec::resolve(&self.release.changelog, None).map(drop),
];
checks.extend(self.build_split_configs().iter().map(SplitConfig::validate));
checks.extend(
self.crates
.values()
.filter_map(|config| config.changelog.as_ref())
.map(|config| {
crate::release::changelog::ChangelogSpec::resolve(&self.release.changelog, Some(config)).map(drop)
}),
);
for (name, config) in &self.crates {
if config
.release
.as_ref()
.and_then(|config| config.tag_format.as_deref())
.is_some_and(|format| format.trim().is_empty())
{
checks.push(Err(RailError::Config(ConfigError::InvalidField {
field: format!("crates.{name}.release.tag_format"),
reason: "tag_format cannot be empty".into(),
})));
}
}
checks.into_iter().filter_map(Result::err).collect()
}
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.validate_workspace(workspace_root, workspace_members)
}
pub(crate) fn release_warnings(&self, members: &[String]) -> RailResult<Vec<String>> {
let mut warnings = self.release.validate(members).map_err(RailError::Config)?;
let mut namespaces = std::collections::HashSet::new();
let collision = members.iter().any(|name| {
!namespaces.insert(
self.release_tag_format_override(name)
.unwrap_or(&self.release.tag_format)
.replace("{crate}", name)
.replace("{prefix}", &self.release.tag_prefix)
.replace("{version}", "*"),
)
});
if !collision {
warnings.retain(|warning| !warning.starts_with("release.tag_format does not contain {crate}"));
} else if self.release.tag_format.contains("{crate}") {
warnings
.push("crate release tag formats share a namespace; choose a distinct tag line for each crate".into());
}
for (name, config) in &self.crates {
if let Some(format) = config.release.as_ref().and_then(|config| config.tag_format.as_ref())
&& !format.contains("{version}")
{
warnings.push(format!(
"crates.{name}.release.tag_format does not contain {{version}}; tags may not identify releases"
));
}
}
Ok(warnings)
}
pub(crate) fn release_tag_format_override(&self, name: &str) -> Option<&str> {
self.crates
.get(name)
.and_then(|config| config.release.as_ref())
.and_then(|config| config.tag_format.as_deref())
}
pub(crate) fn validate_workspace(
&self,
workspace_root: &Path,
workspace_members: Option<&[String]>,
) -> RailResult<Vec<String>> {
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_warnings(workspace_members)?;
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()
}
}