use crate::config::{RailConfig, SplitConfig as ConfigSplitConfig};
use crate::error::{ConfigError, RailError, RailResult};
use crate::split::SplitParams;
use crate::sync::SyncConfig;
use crate::workspace::WorkspaceContext;
use clap::ValueEnum;
use std::path::PathBuf;
pub(crate) fn format_preview_list(items: &[String], preview_limit: usize) -> String {
if items.is_empty() {
return "none".to_string();
}
let preview_limit = preview_limit.max(1);
let preview = items
.iter()
.take(preview_limit)
.map(String::as_str)
.collect::<Vec<_>>()
.join(", ");
if items.len() <= preview_limit {
preview
} else {
format!("{preview}, ... +{} more", items.len() - preview_limit)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
pub enum OutputFormat {
#[default]
Text,
Json,
#[value(name = "names-only", alias = "names")]
NamesOnly,
#[value(name = "cargo-args", alias = "cargo")]
CargoArgs,
#[value(name = "github")]
GitHub,
#[value(name = "github-matrix")]
GitHubMatrix,
#[value(name = "jsonl", alias = "json-lines")]
JsonLines,
}
impl OutputFormat {
pub fn is_json(&self) -> bool {
matches!(self, Self::Json)
}
pub fn is_json_like(&self) -> bool {
matches!(self, Self::Json | Self::JsonLines | Self::GitHub | Self::GitHubMatrix)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
pub enum UnifyOutputFormat {
#[default]
Text,
Json,
}
impl UnifyOutputFormat {
pub fn is_json(&self) -> bool {
matches!(self, Self::Json)
}
pub fn is_json_like(&self) -> bool {
self.is_json()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
pub enum PlanOutputFormat {
#[default]
Text,
Json,
#[value(name = "github")]
GitHub,
#[value(name = "github-debug")]
GitHubDebug,
}
impl PlanOutputFormat {
pub fn is_json(&self) -> bool {
matches!(self, Self::Json)
}
pub fn is_json_like(&self) -> bool {
matches!(self, Self::Json | Self::GitHub | Self::GitHubDebug)
}
}
pub struct SplitSyncConfigBuilder<'a> {
ctx: &'a WorkspaceContext,
config: &'a RailConfig,
split_configs: Vec<ConfigSplitConfig>,
remote_override: Option<String>,
}
pub fn enforce_safety_gate(
operation: &str,
yes: bool,
plan_path: Option<&std::path::Path>,
prompt_possible: bool,
) -> RailResult<()> {
if yes || plan_path.is_some() || prompt_possible {
return Ok(());
}
Err(RailError::with_help(
format!("{} requires explicit confirmation in non-interactive mode", operation),
"use --yes to confirm explicitly, or pass --plan <PATH> from a prior --check JSON plan",
))
}
impl<'a> SplitSyncConfigBuilder<'a> {
pub fn new(ctx: &'a WorkspaceContext) -> RailResult<Self> {
let config = ctx.require_config()?.as_ref();
Ok(Self {
ctx,
config,
split_configs: Vec::new(),
remote_override: None,
})
}
pub fn with_crate(mut self, crate_name: &str) -> RailResult<Self> {
let all_splits = self.config.build_split_configs();
let split_config = all_splits.iter().find(|s| s.name == crate_name).ok_or_else(|| {
RailError::Config(ConfigError::CrateNotFound {
name: crate_name.to_string(),
})
})?;
self.split_configs = vec![split_config.clone()];
Ok(self)
}
pub fn with_all_crates(mut self) -> Self {
self.split_configs = self.config.build_split_configs();
self
}
pub fn with_crate_or_all(self, crate_name: Option<String>, all: bool) -> RailResult<Self> {
if all {
Ok(self.with_all_crates())
} else if let Some(name) = crate_name {
self.with_crate(&name)
} else {
Err(RailError::with_help(
"must specify a crate name or use --all",
"Try: cargo rail <command> --all OR cargo rail <command> <crate-name>",
))
}
}
pub fn with_remote_override(mut self, remote: Option<String>) -> Self {
self.remote_override = remote;
self
}
pub fn validate(self) -> RailResult<Self> {
for split_config in &self.split_configs {
split_config.validate()?;
}
Ok(self)
}
pub fn all_local(&self) -> bool {
self.split_configs.iter().all(|s| {
self
.remote_override
.as_ref()
.map(|r| crate::utils::is_local_path(r))
.unwrap_or_else(|| s.is_local_testing())
})
}
pub fn count(&self) -> usize {
self.split_configs.len()
}
pub fn build_split_configs(self) -> RailResult<Vec<SplitParams>> {
let mut configs = Vec::new();
for split_config in &self.split_configs {
let crate_paths = split_config.get_paths().into_iter().cloned().collect::<Vec<_>>();
let remote = self
.remote_override
.clone()
.unwrap_or_else(|| split_config.remote.clone());
let target_repo_path = if crate::utils::is_local_path(&remote) {
PathBuf::from(&remote)
} else {
split_config.target_repo_path(self.ctx.workspace_root())
};
configs.push(SplitParams {
crate_name: split_config.name.clone(),
crate_paths,
mode: split_config.mode.clone(),
workspace_mode: split_config.workspace_mode.clone(),
target_repo_path,
branch: split_config.branch.clone(),
remote_url: Some(remote),
include: split_config.include.clone(),
exclude: split_config.exclude.clone(),
});
}
Ok(configs)
}
pub fn build_sync_configs(self) -> RailResult<Vec<(SyncConfig, bool)>> {
let mut configs = Vec::new();
for split_config in &self.split_configs {
let crate_paths = split_config.get_paths().into_iter().cloned().collect::<Vec<_>>();
let remote = self
.remote_override
.clone()
.unwrap_or_else(|| split_config.remote.clone());
let target_repo_path = if crate::utils::is_local_path(&remote) {
PathBuf::from(&remote)
} else {
split_config.target_repo_path(self.ctx.workspace_root())
};
let target_exists = target_repo_path.exists();
configs.push((
SyncConfig {
crate_name: split_config.name.clone(),
crate_paths,
mode: split_config.mode.clone(),
workspace_mode: split_config.workspace_mode.clone(),
target_repo_path,
branch: split_config.branch.clone(),
remote_url: remote,
},
target_exists,
));
}
Ok(configs)
}
}
#[cfg(test)]
mod tests {
use super::format_preview_list;
#[test]
fn preview_list_keeps_short_lists() {
let items = vec!["rail-a".to_string(), "rail-b".to_string(), "rail-c".to_string()];
assert_eq!(format_preview_list(&items, 5), "rail-a, rail-b, rail-c");
}
#[test]
fn preview_list_truncates_large_lists() {
let items = vec![
"rail-a".to_string(),
"rail-b".to_string(),
"rail-c".to_string(),
"rail-d".to_string(),
];
assert_eq!(format_preview_list(&items, 2), "rail-a, rail-b, ... +2 more");
}
}