use anyhow::Result;
use clap::ValueEnum;
use std::path::{Path, PathBuf};
use crate::{help_parser, orb_config, orb_generator, output_writer};
pub const DEFAULT_BASE_IMAGE: &str = "debian:13-slim";
pub const MCP_DEFAULT_BASE_IMAGE: &str = "rust:1-slim-trixie";
pub const MCP_APT_PACKAGES: &[&str] = &["gnupg", "libssl-dev", "pkg-config"];
pub(crate) fn mcp_enabled(config: &crate::orb_config::OrbConfig) -> bool {
config.ci.as_ref().and_then(|c| c.mcp).unwrap_or(false)
}
#[derive(Debug, Clone, ValueEnum)]
pub enum InstallMethod {
Binstall,
Apt,
Local,
}
#[derive(Debug, clap::Args)]
pub struct Generate {
#[arg(long)]
pub binary: Option<String>,
#[arg(long = "orb-namespace")]
pub namespaces: Vec<String>,
#[arg(long, default_value = ".")]
pub output: PathBuf,
#[arg(long, value_enum)]
pub install_method: Option<InstallMethod>,
#[arg(long)]
pub base_image: Option<String>,
#[arg(long)]
pub home_url: Option<String>,
#[arg(long)]
pub source_url: Option<String>,
#[arg(long)]
pub orb_dir: Option<String>,
#[arg(long = "git-push-subcommand")]
pub git_push_subcommands: Vec<String>,
#[arg(long)]
pub circleci_cli_version: Option<String>,
#[arg(long = "apt-packages")]
pub apt_packages: Vec<String>,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub config: Option<PathBuf>,
}
pub(crate) fn normalize_git_remote_url(url: &str) -> String {
let url = if let Some(rest) = url.strip_prefix("git@") {
let normalized = rest.replacen(':', "/", 1);
format!("https://{normalized}")
} else {
url.to_string()
};
url.strip_suffix(".git").unwrap_or(&url).to_string()
}
pub(crate) fn detect_source_url() -> Option<String> {
let output = std::process::Command::new("git")
.args(["remote", "get-url", "origin"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let raw = String::from_utf8(output.stdout).ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
Some(normalize_git_remote_url(trimmed))
}
pub(crate) fn check_orb_dir(orb_root: &Path) -> Result<()> {
if !orb_root.exists() {
return Ok(());
}
if orb_root.join("src/@orb.yml").exists() {
return Ok(());
}
let has_content = std::fs::read_dir(orb_root)?.next().is_some();
if has_content {
anyhow::bail!(
"Directory '{}' already exists but does not appear to contain a CircleCI orb \
(no src/@orb.yml found). Refusing to write into it to avoid mixing orb source \
with unrelated code. Use --orb-dir to specify a different subdirectory.",
orb_root.display()
);
}
Ok(())
}
pub(crate) fn resolve_binary(
cli: Option<&str>,
config: &crate::orb_config::OrbConfig,
) -> Result<String> {
if let Some(b) = cli.filter(|s| !s.is_empty()) {
return Ok(b.to_string());
}
config
.orb
.as_ref()
.and_then(|o| o.binary.clone())
.ok_or_else(|| {
anyhow::anyhow!(
"binary name is required — pass --binary <NAME> or add `binary = \"<name>\"` \
to the [orb] section of gen-circleci-orb.toml"
)
})
}
pub(crate) fn resolve_namespaces(
cli: &[String],
config: &crate::orb_config::OrbConfig,
) -> Result<Vec<String>> {
if !cli.is_empty() {
return Ok(cli.to_vec());
}
config
.orb
.as_ref()
.and_then(|o| o.namespaces.clone())
.filter(|ns| !ns.is_empty())
.ok_or_else(|| {
anyhow::anyhow!(
"at least one namespace is required — pass --orb-namespace <NS> or add \
`namespaces = [\"<ns>\"]` to the [orb] section of gen-circleci-orb.toml"
)
})
}
pub(crate) fn resolve_orb_dir(cli: Option<&str>, config: &crate::orb_config::OrbConfig) -> String {
cli.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| config.orb.as_ref().and_then(|o| o.orb_dir.clone()))
.unwrap_or_else(|| "orb".to_string())
}
pub(crate) fn resolve_git_push_subcommands(
cli: &[String],
config: &crate::orb_config::OrbConfig,
) -> Vec<String> {
if !cli.is_empty() {
return cli.to_vec();
}
config
.orb
.as_ref()
.and_then(|o| o.git_push_subcommands.clone())
.unwrap_or_default()
}
pub(crate) fn resolve_install_method(
cli: Option<&InstallMethod>,
config: &crate::orb_config::OrbConfig,
) -> InstallMethod {
if let Some(m) = cli {
return m.clone();
}
config
.orb
.as_ref()
.and_then(|o| o.install_method.as_deref())
.and_then(|s| match s {
"apt" => Some(InstallMethod::Apt),
"binstall" => Some(InstallMethod::Binstall),
"local" => Some(InstallMethod::Local),
_ => None,
})
.unwrap_or(InstallMethod::Binstall)
}
pub(crate) fn resolve_base_image(
cli: Option<&str>,
config: &crate::orb_config::OrbConfig,
) -> String {
cli.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| config.orb.as_ref().and_then(|o| o.base_image.clone()))
.unwrap_or_else(|| {
if mcp_enabled(config) {
MCP_DEFAULT_BASE_IMAGE.to_string()
} else {
DEFAULT_BASE_IMAGE.to_string()
}
})
}
pub(crate) fn resolve_apt_packages(
cli: &[String],
config: &crate::orb_config::OrbConfig,
) -> Vec<String> {
let mut pkgs = if !cli.is_empty() {
cli.to_vec()
} else {
config
.orb
.as_ref()
.and_then(|o| o.apt_packages.clone())
.unwrap_or_default()
};
if mcp_enabled(config) {
for pkg in MCP_APT_PACKAGES {
if !pkgs.iter().any(|p| p == pkg) {
pkgs.push((*pkg).to_string());
}
}
}
pkgs
}
pub(crate) fn resolve_config_path(explicit: Option<&PathBuf>, output: &Path) -> PathBuf {
explicit
.cloned()
.unwrap_or_else(|| output.join("gen-circleci-orb.toml"))
}
impl Generate {
pub fn run(&self) -> Result<()> {
let config_path = resolve_config_path(self.config.as_ref(), &self.output);
let orb_config = orb_config::load_config(&config_path)?;
let binary = resolve_binary(self.binary.as_deref(), &orb_config)?;
let namespaces = resolve_namespaces(&self.namespaces, &orb_config)?;
let orb_dir = resolve_orb_dir(self.orb_dir.as_deref(), &orb_config);
let orb_root = self.output.join(&orb_dir);
check_orb_dir(&orb_root)?;
tracing::info!("Parsing {} --help", binary);
let cli_def = help_parser::parse_binary(&binary)?;
tracing::info!("Discovered {} subcommand(s)", cli_def.subcommands.len());
let config_url = orb_config.orb.as_ref();
let detected_url = self.source_url.is_none().then(detect_source_url).flatten();
let source_url = self
.source_url
.clone()
.or_else(|| config_url.and_then(|o| o.source_url.clone()))
.or_else(|| detected_url.clone());
let home_url = self
.home_url
.clone()
.or_else(|| config_url.and_then(|o| o.home_url.clone()))
.or_else(|| detected_url.clone());
let opts = orb_generator::GenerateOpts {
namespaces,
install_method: resolve_install_method(self.install_method.as_ref(), &orb_config),
base_image: resolve_base_image(self.base_image.as_deref(), &orb_config),
home_url,
source_url,
binary_name: cli_def.binary_name.clone(),
git_push_subcommands: resolve_git_push_subcommands(
&self.git_push_subcommands,
&orb_config,
),
circleci_cli_version: self.circleci_cli_version.clone(),
apt_packages: resolve_apt_packages(&self.apt_packages, &orb_config),
};
let files = orb_generator::generate(&cli_def, &opts, Some(&orb_config));
tracing::info!("Generated {} file(s)", files.len());
let report = output_writer::write_tree(&orb_root, &files, self.dry_run)?;
println!(
"Done: {} created, {} updated, {} unchanged",
report.created, report.updated, report.unchanged
);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn normalize_ssh_remote_to_https() {
assert_eq!(
normalize_git_remote_url("git@github.com:jerus-org/gen-circleci-orb.git"),
"https://github.com/jerus-org/gen-circleci-orb"
);
}
#[test]
fn normalize_https_with_git_suffix() {
assert_eq!(
normalize_git_remote_url("https://github.com/jerus-org/gen-circleci-orb.git"),
"https://github.com/jerus-org/gen-circleci-orb"
);
}
#[test]
fn normalize_https_without_git_suffix_unchanged() {
assert_eq!(
normalize_git_remote_url("https://github.com/jerus-org/gen-circleci-orb"),
"https://github.com/jerus-org/gen-circleci-orb"
);
}
#[test]
fn normalize_ssh_non_github_host() {
assert_eq!(
normalize_git_remote_url("git@gitlab.com:myorg/myrepo.git"),
"https://gitlab.com/myorg/myrepo"
);
}
#[test]
fn check_orb_dir_absent_is_ok() {
let tmp = TempDir::new().unwrap();
let orb_root = tmp.path().join("orb");
assert!(check_orb_dir(&orb_root).is_ok());
}
#[test]
fn check_orb_dir_with_orb_yml_is_ok() {
let tmp = TempDir::new().unwrap();
let orb_root = tmp.path().join("orb");
fs::create_dir_all(orb_root.join("src")).unwrap();
fs::write(orb_root.join("src/@orb.yml"), "version: 2.1").unwrap();
assert!(check_orb_dir(&orb_root).is_ok());
}
#[test]
fn check_orb_dir_empty_is_ok() {
let tmp = TempDir::new().unwrap();
let orb_root = tmp.path().join("orb");
fs::create_dir_all(&orb_root).unwrap();
assert!(check_orb_dir(&orb_root).is_ok());
}
#[test]
fn check_orb_dir_with_unrelated_content_errors() {
let tmp = TempDir::new().unwrap();
let orb_root = tmp.path().join("src");
fs::create_dir_all(&orb_root).unwrap();
fs::write(orb_root.join("main.rs"), "fn main() {}").unwrap();
let result = check_orb_dir(&orb_root);
assert!(
result.is_err(),
"should error when unrelated content present"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("does not appear to contain a CircleCI orb"),
"unexpected error message: {msg}"
);
}
#[test]
fn config_path_defaults_to_output_dir() {
let tmp = TempDir::new().unwrap();
let output = tmp.path();
let resolved = resolve_config_path(None, output);
assert_eq!(resolved, output.join("gen-circleci-orb.toml"));
}
#[test]
fn config_path_uses_explicit_when_provided() {
let tmp = TempDir::new().unwrap();
let output = tmp.path();
let explicit = PathBuf::from("/custom/path/config.toml");
let resolved = resolve_config_path(Some(&explicit), output);
assert_eq!(resolved, explicit);
}
#[test]
fn resolve_binary_uses_cli_when_provided() {
use crate::orb_config::OrbConfig;
let config = OrbConfig::default();
let binary = resolve_binary(Some("mytool"), &config).unwrap();
assert_eq!(binary, "mytool");
}
#[test]
fn resolve_binary_falls_back_to_config() {
use crate::orb_config::{OrbConfig, OrbSection};
let config = OrbConfig {
orb: Some(OrbSection {
binary: Some("config-tool".to_string()),
..OrbSection::default()
}),
..OrbConfig::default()
};
let binary = resolve_binary(None, &config).unwrap();
assert_eq!(binary, "config-tool");
}
#[test]
fn resolve_binary_errors_when_neither_provided() {
use crate::orb_config::OrbConfig;
let result = resolve_binary(None, &OrbConfig::default());
assert!(
result.is_err(),
"must error when no binary in CLI or config"
);
assert!(result.unwrap_err().to_string().contains("binary"));
}
#[test]
fn resolve_namespaces_uses_cli_when_provided() {
use crate::orb_config::OrbConfig;
let ns = resolve_namespaces(&["my-org".to_string()], &OrbConfig::default()).unwrap();
assert_eq!(ns, vec!["my-org".to_string()]);
}
#[test]
fn resolve_namespaces_falls_back_to_config() {
use crate::orb_config::{OrbConfig, OrbSection};
let config = OrbConfig {
orb: Some(OrbSection {
namespaces: Some(vec!["cfg-org".to_string()]),
..OrbSection::default()
}),
..OrbConfig::default()
};
let ns = resolve_namespaces(&[], &config).unwrap();
assert_eq!(ns, vec!["cfg-org".to_string()]);
}
#[test]
fn resolve_namespaces_errors_when_neither_provided() {
use crate::orb_config::OrbConfig;
let result = resolve_namespaces(&[], &OrbConfig::default());
assert!(
result.is_err(),
"must error when no namespaces in CLI or config"
);
assert!(result.unwrap_err().to_string().contains("namespace"));
}
#[test]
fn resolve_orb_dir_uses_cli_when_provided() {
use crate::orb_config::OrbConfig;
let dir = resolve_orb_dir(Some("custom-orb"), &OrbConfig::default());
assert_eq!(dir, "custom-orb");
}
#[test]
fn resolve_orb_dir_falls_back_to_config() {
use crate::orb_config::{OrbConfig, OrbSection};
let config = OrbConfig {
orb: Some(OrbSection {
orb_dir: Some("src/orb".to_string()),
..OrbSection::default()
}),
..OrbConfig::default()
};
let dir = resolve_orb_dir(None, &config);
assert_eq!(dir, "src/orb");
}
#[test]
fn resolve_orb_dir_defaults_to_orb_when_nothing_provided() {
use crate::orb_config::OrbConfig;
let dir = resolve_orb_dir(None, &OrbConfig::default());
assert_eq!(dir, "orb");
}
#[test]
fn resolve_install_method_uses_cli_when_provided() {
use crate::orb_config::OrbConfig;
let result = resolve_install_method(Some(&InstallMethod::Apt), &OrbConfig::default());
assert!(matches!(result, InstallMethod::Apt));
}
#[test]
fn resolve_install_method_falls_back_to_config() {
use crate::orb_config::{OrbConfig, OrbSection};
let config = OrbConfig {
orb: Some(OrbSection {
install_method: Some("apt".to_string()),
..OrbSection::default()
}),
..OrbConfig::default()
};
let result = resolve_install_method(None, &config);
assert!(
matches!(result, InstallMethod::Apt),
"expected Apt from config, got {result:?}"
);
}
#[test]
fn resolve_install_method_cli_overrides_config() {
use crate::orb_config::{OrbConfig, OrbSection};
let config = OrbConfig {
orb: Some(OrbSection {
install_method: Some("apt".to_string()),
..OrbSection::default()
}),
..OrbConfig::default()
};
let result = resolve_install_method(Some(&InstallMethod::Binstall), &config);
assert!(matches!(result, InstallMethod::Binstall));
}
#[test]
fn resolve_install_method_local_from_config() {
use crate::orb_config::{OrbConfig, OrbSection};
let config = OrbConfig {
orb: Some(OrbSection {
install_method: Some("local".to_string()),
..OrbSection::default()
}),
..OrbConfig::default()
};
let result = resolve_install_method(None, &config);
assert!(matches!(result, InstallMethod::Local));
}
#[test]
fn resolve_install_method_defaults_to_binstall() {
use crate::orb_config::OrbConfig;
let result = resolve_install_method(None, &OrbConfig::default());
assert!(matches!(result, InstallMethod::Binstall));
}
#[test]
fn resolve_base_image_uses_cli_when_provided() {
use crate::orb_config::OrbConfig;
let result = resolve_base_image(Some("ubuntu:22.04"), &OrbConfig::default());
assert_eq!(result, "ubuntu:22.04");
}
#[test]
fn resolve_base_image_falls_back_to_config() {
use crate::orb_config::{OrbConfig, OrbSection};
let config = OrbConfig {
orb: Some(OrbSection {
base_image: Some("ubuntu:22.04".to_string()),
..OrbSection::default()
}),
..OrbConfig::default()
};
let result = resolve_base_image(None, &config);
assert_eq!(result, "ubuntu:22.04");
}
#[test]
fn resolve_base_image_cli_overrides_config() {
use crate::orb_config::{OrbConfig, OrbSection};
let config = OrbConfig {
orb: Some(OrbSection {
base_image: Some("ubuntu:22.04".to_string()),
..OrbSection::default()
}),
..OrbConfig::default()
};
let result = resolve_base_image(Some("alpine:3.19"), &config);
assert_eq!(result, "alpine:3.19");
}
#[test]
fn resolve_base_image_defaults_to_debian() {
use crate::orb_config::OrbConfig;
let result = resolve_base_image(None, &OrbConfig::default());
assert_eq!(result, DEFAULT_BASE_IMAGE);
}
#[test]
fn resolve_apt_packages_uses_cli_when_provided() {
use crate::orb_config::OrbConfig;
let cli = vec!["libssl-dev".to_string(), "pkg-config".to_string()];
let result = resolve_apt_packages(&cli, &OrbConfig::default());
assert_eq!(result, vec!["libssl-dev", "pkg-config"]);
}
#[test]
fn resolve_apt_packages_falls_back_to_config() {
use crate::orb_config::{OrbConfig, OrbSection};
let config = OrbConfig {
orb: Some(OrbSection {
apt_packages: Some(vec!["libssl-dev".to_string(), "pkg-config".to_string()]),
..OrbSection::default()
}),
..OrbConfig::default()
};
let result = resolve_apt_packages(&[], &config);
assert_eq!(result, vec!["libssl-dev", "pkg-config"]);
}
#[test]
fn resolve_apt_packages_cli_overrides_config() {
use crate::orb_config::{OrbConfig, OrbSection};
let config = OrbConfig {
orb: Some(OrbSection {
apt_packages: Some(vec!["libssl-dev".to_string()]),
..OrbSection::default()
}),
..OrbConfig::default()
};
let result = resolve_apt_packages(&["cmake".to_string()], &config);
assert_eq!(result, vec!["cmake"]);
}
#[test]
fn resolve_apt_packages_defaults_to_empty() {
use crate::orb_config::OrbConfig;
let result = resolve_apt_packages(&[], &OrbConfig::default());
assert!(result.is_empty());
}
fn mcp_config() -> crate::orb_config::OrbConfig {
use crate::orb_config::{CiSection, OrbConfig};
OrbConfig {
ci: Some(CiSection {
mcp: Some(true),
..CiSection::default()
}),
..OrbConfig::default()
}
}
#[test]
fn mcp_enabled_reads_ci_section() {
use crate::orb_config::OrbConfig;
assert!(mcp_enabled(&mcp_config()));
assert!(!mcp_enabled(&OrbConfig::default()));
}
#[test]
fn resolve_base_image_defaults_to_rust_when_mcp_enabled() {
let result = resolve_base_image(None, &mcp_config());
assert_eq!(result, MCP_DEFAULT_BASE_IMAGE);
}
#[test]
fn resolve_base_image_explicit_config_overrides_mcp_default() {
use crate::orb_config::{CiSection, OrbConfig, OrbSection};
let config = OrbConfig {
orb: Some(OrbSection {
base_image: Some("rust:1.85-slim".to_string()),
..OrbSection::default()
}),
ci: Some(CiSection {
mcp: Some(true),
..CiSection::default()
}),
..OrbConfig::default()
};
assert_eq!(resolve_base_image(None, &config), "rust:1.85-slim");
}
#[test]
fn resolve_apt_packages_injects_mcp_deps_when_enabled() {
let result = resolve_apt_packages(&[], &mcp_config());
for pkg in MCP_APT_PACKAGES {
assert!(
result.iter().any(|p| p == pkg),
"mcp must inject {pkg}; got {result:?}"
);
}
}
#[test]
fn resolve_apt_packages_mcp_deps_union_with_user_packages() {
use crate::orb_config::{CiSection, OrbConfig, OrbSection};
let config = OrbConfig {
orb: Some(OrbSection {
apt_packages: Some(vec!["cmake".to_string()]),
..OrbSection::default()
}),
ci: Some(CiSection {
mcp: Some(true),
..CiSection::default()
}),
..OrbConfig::default()
};
let result = resolve_apt_packages(&[], &config);
assert!(
result.iter().any(|p| p == "cmake"),
"keep user pkg: {result:?}"
);
assert!(
result.iter().any(|p| p == "gnupg"),
"inject gnupg: {result:?}"
);
}
#[test]
fn resolve_apt_packages_no_mcp_deps_when_disabled() {
use crate::orb_config::OrbConfig;
let result = resolve_apt_packages(&[], &OrbConfig::default());
assert!(
!result.iter().any(|p| p == "gnupg"),
"no mcp deps when mcp disabled: {result:?}"
);
}
#[test]
fn resolve_apt_packages_mcp_deps_not_duplicated() {
use crate::orb_config::{CiSection, OrbConfig, OrbSection};
let config = OrbConfig {
orb: Some(OrbSection {
apt_packages: Some(vec!["gnupg".to_string()]),
..OrbSection::default()
}),
ci: Some(CiSection {
mcp: Some(true),
..CiSection::default()
}),
..OrbConfig::default()
};
let result = resolve_apt_packages(&[], &config);
assert_eq!(
result.iter().filter(|p| *p == "gnupg").count(),
1,
"gnupg must not be duplicated: {result:?}"
);
}
#[test]
fn git_push_subcommands_falls_back_to_config_when_cli_empty() {
use crate::orb_config::{OrbConfig, OrbSection};
let config = OrbConfig {
orb: Some(OrbSection {
git_push_subcommands: Some(vec!["save".to_string()]),
..OrbSection::default()
}),
..OrbConfig::default()
};
let resolved = resolve_git_push_subcommands(&[], &config);
assert_eq!(resolved, vec!["save".to_string()]);
}
#[test]
fn git_push_subcommands_cli_takes_precedence_over_config() {
use crate::orb_config::{OrbConfig, OrbSection};
let config = OrbConfig {
orb: Some(OrbSection {
git_push_subcommands: Some(vec!["save".to_string()]),
..OrbSection::default()
}),
..OrbConfig::default()
};
let resolved = resolve_git_push_subcommands(&["commit".to_string()], &config);
assert_eq!(resolved, vec!["commit".to_string()]);
}
}