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, ReleaseConfig, ReleaseRegistryPublication, ReleaseRemoteEffects, ReleaseSource,
RequireChangeFiles, SemverCheckPolicy,
};
pub use split::{CratePath, 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};
pub(crate) const REMOVED_RUN_CONFIG_MESSAGE: &str = "[run] configuration is no longer supported; execute Cargo and cargo-nextest directly, keep repository tasks in Just or CI, and consume typed package scope from `cargo rail plan`";
pub(crate) const REMOVED_CACHE_CONFIG_MESSAGE: &str = "[cache] repository configuration is no longer supported; select optional L2 authority from machine or CI state with CARGO_RAIL_CACHE_REMOTE";
pub(crate) const REMOVED_CHANGE_DETECTION_CONFIG_MESSAGE: &str = "[change-detection] configuration is no longer supported; declare positive inputs for repository-owned work under [plan.work.NAME], use per-work evidence completeness for Cargo work, then run `cargo rail config migrate` to remove the retired table";
pub(crate) fn reject_removed_configuration(doc: &toml_edit::DocumentMut) -> Result<(), String> {
for (name, message) in [
("run", REMOVED_RUN_CONFIG_MESSAGE),
("cache", REMOVED_CACHE_CONFIG_MESSAGE),
("change-detection", REMOVED_CHANGE_DETECTION_CONFIG_MESSAGE),
] {
if doc.as_table().contains_key(name) {
return Err(message.to_string());
}
}
Ok(())
}
#[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,
}
impl RailConfig {
fn from_document(doc: toml_edit::DocumentMut) -> Result<Self, String> {
reject_removed_configuration(&doc)?;
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}'"));
}
toml_edit::de::from_document(doc).map_err(|error| error.to_string())
}
pub(crate) fn parse_bytes(bytes: &[u8]) -> Result<Self, String> {
let content = std::str::from_utf8(bytes).map_err(|error| format!("file is not valid UTF-8: {error}"))?;
let doc: toml_edit::DocumentMut = content
.parse()
.map_err(|error: toml_edit::TomlError| error.to_string())?;
Self::from_document(doc)
}
pub(crate) fn parse_historical_planning_bytes(bytes: &[u8]) -> Result<Self, String> {
let content = std::str::from_utf8(bytes).map_err(|error| format!("file is not valid UTF-8: {error}"))?;
let mut doc: toml_edit::DocumentMut = content
.parse()
.map_err(|error: toml_edit::TomlError| error.to_string())?;
for retired in ["run", "cache", "change-detection"] {
doc.as_table_mut().remove(retired);
}
Self::parse_bytes(doc.to_string().as_bytes())
}
fn parse_path(path: &Path) -> Result<(Self, Vec<u8>), String> {
let bytes = fs::read(path).map_err(|error| format!("failed to read file: {error}"))?;
let content = std::str::from_utf8(&bytes).map_err(|error| format!("file is not valid UTF-8: {error}"))?;
let doc: toml_edit::DocumentMut = content
.parse()
.map_err(|error: toml_edit::TomlError| error.to_string())?;
for deprecation in schema::present_deprecations(&doc) {
if let Some(message) = deprecation.spec.deprecation {
crate::warn!("{} in {}: {}", deprecation.path, path.display(), message);
}
}
let config = Self::from_document(doc)?;
Ok((config, bytes))
}
pub(crate) fn validate(
&self,
workspace_root: &Path,
workspace_members: Option<&[String]>,
) -> RailResult<Vec<String>> {
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_root).map_err(RailError::Config)?;
self.release
.changelog
.filters
.validate("release.changelog.filters")
.map_err(RailError::Config)?;
for (crate_name, crate_config) in &self.crates {
if let Some(filters) = crate_config
.changelog
.as_ref()
.and_then(|changelog| changelog.filters.as_ref())
{
filters
.validate(&format!("crates.{crate_name}.changelog.filters"))
.map_err(RailError::Config)?;
}
}
for split in self.build_split_configs() {
split.validate()?;
}
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>)> {
Self::parse_path(path).map_err(|message| {
RailError::Config(ConfigError::ParseError {
path: path.to_path_buf(),
message,
})
})
}
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| p.exists()) {
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| p.exists()) {
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::parse_path(&config_path) {
Ok((config, _)) => ConfigLoadResult::Loaded(Box::new(config)),
Err(message) => ConfigLoadResult::ParseError {
path: config_path,
message,
},
}
}
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()
}
}