use std::fs;
use std::io::{self, Write as _};
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand, ValueEnum};
use dev_ci::{Generator, PathDep, Target};
#[derive(Debug, Parser)]
#[command(
name = "dev-ci",
version,
about = "Generate calibrated CI pipelines for Rust projects.",
long_about = "Generate calibrated CI pipelines tailored to the dev-* features a project uses. Default output is `.github/workflows/ci.yml`."
)]
struct Cli {
#[command(subcommand)]
command: Cmd,
}
#[derive(Debug, Subcommand)]
enum Cmd {
Generate(GenerateArgs),
}
#[derive(Debug, Parser)]
struct GenerateArgs {
#[arg(long, value_enum, default_value_t = TargetArg::GithubActions)]
target: TargetArg,
#[arg(long)]
output: Option<PathBuf>,
#[arg(long, conflicts_with = "output")]
print: bool,
#[arg(long, default_value = "CI")]
workflow_name: String,
#[arg(long, default_value = "main", value_delimiter = ',')]
branches: Vec<String>,
#[arg(long, default_value = "ubuntu-latest", value_delimiter = ',')]
matrix: Vec<String>,
#[arg(long, value_delimiter = ',')]
with: Vec<String>,
#[arg(long)]
msrv: Option<String>,
#[arg(long)]
features: Option<String>,
#[arg(long)]
no_default_features_build: bool,
#[arg(long)]
all_features_build: bool,
#[arg(long)]
workspace: bool,
#[arg(long)]
no_cache: bool,
#[arg(long = "path-dep", value_name = "NAME=URL")]
path_deps: Vec<String>,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum TargetArg {
#[value(name = "github-actions")]
GithubActions,
}
impl TargetArg {
fn to_lib(self) -> Target {
match self {
Self::GithubActions => Target::GitHubActions,
}
}
fn default_output_path(self) -> PathBuf {
match self {
Self::GithubActions => PathBuf::from(".github/workflows/ci.yml"),
}
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
let res = match cli.command {
Cmd::Generate(args) => run_generate(args),
};
match res {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("dev-ci: {e}");
ExitCode::FAILURE
}
}
}
fn run_generate(args: GenerateArgs) -> Result<(), String> {
let mut gen = Generator::new()
.target(args.target.to_lib())
.workflow_name(args.workflow_name)
.branches(args.branches)
.matrix_os(args.matrix);
if args.no_cache {
gen = gen.with_cache(false);
}
if args.workspace {
gen = gen.with_workspace();
}
if let Some(f) = args.features {
gen = gen.features(f);
}
if args.no_default_features_build {
gen = gen.with_no_default_features_build();
}
if args.all_features_build {
gen = gen.with_all_features_build();
}
for raw in &args.path_deps {
let (name, url) = parse_path_dep(raw)?;
gen = gen.with_path_dep(PathDep::new(name, url));
}
for job in &args.with {
match job.trim().to_ascii_lowercase().as_str() {
"" => {}
"clippy" => gen = gen.with_clippy(),
"fmt" => gen = gen.with_fmt(),
"docs" => gen = gen.with_docs(),
"msrv" => {
let v = args
.msrv
.as_deref()
.ok_or_else(|| "--with msrv requires --msrv <VERSION>".to_string())?;
gen = gen.with_msrv(v);
}
other => return Err(format!("unknown job in --with: {other:?}")),
}
}
if !args.with.iter().any(|j| j.eq_ignore_ascii_case("msrv")) {
if let Some(v) = &args.msrv {
gen = gen.with_msrv(v.clone());
}
}
let yaml = gen.generate();
let stdout_mode =
args.print || matches!(args.output.as_ref().and_then(|p| p.to_str()), Some("-"));
if stdout_mode {
io::stdout()
.write_all(yaml.as_bytes())
.map_err(|e| format!("failed to write to stdout: {e}"))?;
return Ok(());
}
let target_path = args
.output
.unwrap_or_else(|| args.target.default_output_path());
if let Some(parent) = target_path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)
.map_err(|e| format!("create_dir_all({}): {e}", parent.display()))?;
}
}
fs::write(&target_path, yaml).map_err(|e| format!("write {}: {e}", target_path.display()))?;
eprintln!("wrote {}", target_path.display());
Ok(())
}
fn parse_path_dep(raw: &str) -> Result<(&str, &str), String> {
let (name, url) = raw
.split_once('=')
.ok_or_else(|| format!("--path-dep must be name=url; got {raw:?}"))?;
if name.is_empty() || url.is_empty() {
return Err(format!(
"--path-dep name and url must be non-empty; got {raw:?}"
));
}
Ok((name, url))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_path_dep_splits_on_first_equals() {
let (name, url) = parse_path_dep("foo=https://example.com/foo.git").unwrap();
assert_eq!(name, "foo");
assert_eq!(url, "https://example.com/foo.git");
}
#[test]
fn parse_path_dep_rejects_missing_equals() {
assert!(parse_path_dep("foo").is_err());
}
#[test]
fn parse_path_dep_rejects_empty_name_or_url() {
assert!(parse_path_dep("=url").is_err());
assert!(parse_path_dep("name=").is_err());
}
}