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:12-slim";
#[derive(Debug, Clone, ValueEnum)]
pub enum InstallMethod {
Binstall,
Apt,
}
#[derive(Debug, clap::Args)]
pub struct Generate {
#[arg(long)]
pub binary: String,
#[arg(long = "orb-namespace", required = true)]
pub namespaces: Vec<String>,
#[arg(long, default_value = ".")]
pub output: PathBuf,
#[arg(long, value_enum, default_value = "binstall")]
pub install_method: InstallMethod,
#[arg(long, default_value = DEFAULT_BASE_IMAGE)]
pub base_image: String,
#[arg(long)]
pub home_url: Option<String>,
#[arg(long)]
pub source_url: Option<String>,
#[arg(long, default_value = "orb")]
pub orb_dir: 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_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_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 orb_root = self.output.join(&self.orb_dir);
check_orb_dir(&orb_root)?;
let config_path = resolve_config_path(self.config.as_ref(), &self.output);
let orb_config = orb_config::load_config(&config_path)?;
tracing::info!("Parsing {} --help", self.binary);
let cli_def = help_parser::parse_binary(&self.binary)?;
tracing::info!("Discovered {} subcommand(s)", cli_def.subcommands.len());
let detected_url = self.source_url.is_none().then(detect_source_url).flatten();
let source_url = self.source_url.clone().or_else(|| detected_url.clone());
let home_url = self.home_url.clone().or_else(|| detected_url.clone());
let opts = orb_generator::GenerateOpts {
namespaces: self.namespaces.clone(),
install_method: self.install_method.clone(),
base_image: self.base_image.clone(),
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: self.apt_packages.clone(),
};
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 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()]);
}
}