use anyhow::Result;
use indexmap::IndexMap;
use std::path::PathBuf;
use crate::{
ci_patcher,
commands::generate::Generate,
help_parser::types::CliDefinition,
orb_config::{CiSection, OrbConfig, OrbSection, RecordConfig, SubcommandConfig},
};
pub const DEFAULT_DOCKER_ORB_VERSION: &str = "3.0.1";
pub const DEFAULT_DOCKER_CONTEXT: &str = "docker-credentials";
pub const DEFAULT_ORB_CONTEXT: &str = "orb-publishing";
pub const DEFAULT_MCP_CONTEXT: &str = "pcu-app";
pub const DEFAULT_MCP_EARLIEST_VERSION: &str = "0.0.1";
pub const DEFAULT_GEN_ORB_MCP_ORB_VERSION: &str = "0.1.48";
pub(crate) struct GatheredExtras {
pub home_url: Option<String>,
pub source_url: Option<String>,
pub git_push_subcommands: Vec<String>,
pub docker_context: String,
pub orb_context: String,
pub mcp_context: Vec<String>,
pub mcp_earliest_version: String,
pub record: Option<RecordConfig>,
}
fn is_non_interactive(dry_run: bool) -> bool {
dry_run || std::env::var("CI").is_ok() || !console::Term::stderr().is_term()
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_record_config(
enabled: bool,
gpg_key_env: Option<&str>,
gpg_trust_env: Option<&str>,
user_name_env: Option<&str>,
user_email_env: Option<&str>,
signing_key_env: Option<&str>,
push_ssh_fingerprint: Option<&str>,
contexts: &[String],
) -> Result<Option<RecordConfig>> {
if !enabled {
return Ok(None);
}
let req = |v: Option<&str>, flag: &str| -> Result<String> {
v.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.ok_or_else(|| {
anyhow::anyhow!(
"auto-record is enabled but {flag} was not provided \
(no default — supply the env-var name)"
)
})
};
let contexts: Vec<String> = contexts
.iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if contexts.is_empty() {
anyhow::bail!(
"auto-record is enabled but no --record-context was provided \
(the record job needs the CircleCI context(s) that supply the GPG \
signing material)"
);
}
Ok(Some(RecordConfig {
enabled: true,
gpg_key_env: req(gpg_key_env, "--record-gpg-key-env")?,
gpg_trust_env: req(gpg_trust_env, "--record-gpg-trust-env")?,
user_name_env: req(user_name_env, "--record-user-name-env")?,
user_email_env: req(user_email_env, "--record-user-email-env")?,
signing_key_env: req(signing_key_env, "--record-signing-key-env")?,
push_ssh_fingerprint: push_ssh_fingerprint
.map(str::trim)
.unwrap_or("")
.to_string(),
contexts,
}))
}
pub(crate) fn detect_orb_path_subcommands(cli: &CliDefinition) -> Vec<String> {
cli.subcommands
.iter()
.filter(|sub| {
sub.is_leaf
&& sub
.parameters
.iter()
.any(|p| p.long_name == "orb_path" && p.required)
})
.map(|sub| sub.name.clone())
.collect()
}
pub(crate) fn populate_orb_path_defaults(
config: &mut crate::orb_config::OrbConfig,
subcommands: &[String],
) {
use crate::orb_config::ParamOverride;
if subcommands.is_empty() {
return;
}
let sc_map = config
.subcommand
.get_or_insert_with(indexmap::IndexMap::new);
for name in subcommands {
let sc = sc_map.entry(name.clone()).or_default();
let params = sc.param.get_or_insert_with(indexmap::IndexMap::new);
params
.entry("orb_path".to_string())
.or_insert(ParamOverride {
default: Some("src/@orb.yml".to_string()),
});
}
}
pub(crate) fn detect_git_push_subcommands(cli: &CliDefinition) -> Vec<String> {
cli.subcommands
.iter()
.filter(|sub| {
sub.is_leaf
&& sub
.parameters
.iter()
.any(|p| matches!(p.long_name.as_str(), "push" | "no_push" | "sign"))
})
.map(|sub| sub.name.clone())
.collect()
}
#[derive(Debug, clap::Args)]
pub struct Init {
#[arg(long)]
pub binary: String,
#[arg(long = "public-orb-namespace")]
pub public_orb_namespaces: Vec<String>,
#[arg(long = "private-orb-namespace")]
pub private_orb_namespaces: Vec<String>,
#[arg(long)]
pub build_workflow: String,
#[arg(long)]
pub release_workflow: String,
#[arg(long)]
pub requires_job: Option<String>,
#[arg(long)]
pub crate_tag_prefix: String,
#[arg(long)]
pub release_after_job: String,
#[arg(long, default_value = "orb")]
pub orb_dir: String,
#[arg(long, default_value = ".circleci")]
pub ci_dir: PathBuf,
#[arg(long, default_value = "12.3.3")]
pub orb_tools_version: String,
#[arg(long, default_value = DEFAULT_DOCKER_ORB_VERSION)]
pub docker_orb_version: String,
#[arg(long)]
pub docker_namespace: String,
#[arg(long)]
pub docker_context: Option<String>,
#[arg(long)]
pub orb_context: Option<String>,
#[arg(long, default_value = env!("CARGO_PKG_VERSION"))]
pub gen_circleci_orb_version: String,
#[arg(long)]
pub mcp: bool,
#[arg(long)]
pub mcp_earliest_version: Option<String>,
#[arg(long = "mcp-context", value_delimiter = ',')]
pub mcp_context: Vec<String>,
#[arg(long, value_delimiter = ',')]
pub git_push_subcommands: Vec<String>,
#[arg(long)]
pub home_url: Option<String>,
#[arg(long)]
pub source_url: Option<String>,
#[arg(long)]
pub record: bool,
#[arg(long)]
pub record_gpg_key_env: Option<String>,
#[arg(long)]
pub record_gpg_trust_env: Option<String>,
#[arg(long)]
pub record_user_name_env: Option<String>,
#[arg(long)]
pub record_user_email_env: Option<String>,
#[arg(long)]
pub record_signing_key_env: Option<String>,
#[arg(long)]
pub record_push_ssh_fingerprint: Option<String>,
#[arg(long = "record-context", value_delimiter = ',')]
pub record_contexts: Vec<String>,
#[arg(long)]
pub dry_run: bool,
}
pub(crate) fn present_default_interactive(cli: &CliDefinition) -> Vec<String> {
cli.subcommands
.iter()
.filter(|s| crate::orb_generator::render::DEFAULT_INTERACTIVE.contains(&s.name.as_str()))
.map(|s| s.name.clone())
.collect()
}
pub(crate) fn build_bootstrap_config(
binary: &str,
namespaces: &[String],
orb_dir: &str,
home_url: Option<&str>,
source_url: Option<&str>,
git_push_subcommands: &[String],
interactive: &[(String, bool)],
) -> OrbConfig {
let mut subcommands = IndexMap::new();
for (name, is_interactive) in interactive {
subcommands.insert(
name.clone(),
SubcommandConfig {
interactive: Some(*is_interactive),
..SubcommandConfig::default()
},
);
}
let subcommand = if subcommands.is_empty() {
None
} else {
Some(subcommands)
};
OrbConfig {
orb: Some(OrbSection {
binary: Some(binary.to_string()),
namespaces: Some(namespaces.to_vec()),
orb_dir: Some(orb_dir.to_string()),
base_image: None,
builder_image: None,
install_method: None,
apt_packages: None,
home_url: home_url.map(str::to_string),
source_url: source_url.map(str::to_string),
git_push_subcommands: if git_push_subcommands.is_empty() {
None
} else {
Some(git_push_subcommands.to_vec())
},
custom_files: None,
}),
ci: None, orbs: None,
subcommand,
job_group: None,
extra_job: None,
record: None, }
}
impl Init {
fn gather_record(&self, existing: &OrbConfig) -> Result<Option<RecordConfig>> {
let ex = existing.record.as_ref();
let resolve = |cli: Option<&String>, prev: Option<&str>| -> Option<String> {
cli.filter(|s| !s.is_empty())
.cloned()
.or_else(|| prev.map(str::to_string))
};
let gpg_key = resolve(
self.record_gpg_key_env.as_ref(),
ex.map(|r| r.gpg_key_env.as_str()),
);
let gpg_trust = resolve(
self.record_gpg_trust_env.as_ref(),
ex.map(|r| r.gpg_trust_env.as_str()),
);
let user_name = resolve(
self.record_user_name_env.as_ref(),
ex.map(|r| r.user_name_env.as_str()),
);
let user_email = resolve(
self.record_user_email_env.as_ref(),
ex.map(|r| r.user_email_env.as_str()),
);
let sign_key = resolve(
self.record_signing_key_env.as_ref(),
ex.map(|r| r.signing_key_env.as_str()),
);
let push_fingerprint = resolve(
self.record_push_ssh_fingerprint.as_ref(),
ex.map(|r| r.push_ssh_fingerprint.as_str()),
);
let contexts: Vec<String> = if !self.record_contexts.is_empty() {
self.record_contexts.clone()
} else {
ex.map(|r| r.contexts.clone()).unwrap_or_default()
};
if is_non_interactive(self.dry_run) {
let enabled = self.record || ex.map(|r| r.enabled).unwrap_or(false);
return build_record_config(
enabled,
gpg_key.as_deref(),
gpg_trust.as_deref(),
user_name.as_deref(),
user_email.as_deref(),
sign_key.as_deref(),
push_fingerprint.as_deref(),
&contexts,
);
}
let enabled = if self.record {
true
} else {
dialoguer::Confirm::new()
.with_prompt("Enable auto-record (CI signs + pushes the regenerated orb)?")
.default(ex.map(|r| r.enabled).unwrap_or(false))
.interact()?
};
if !enabled {
return Ok(None);
}
use dialoguer::Input;
let prompt_name = |label: &str, current: Option<String>| -> Result<String> {
let mut input = Input::<String>::new().with_prompt(label);
if let Some(c) = current.filter(|s| !s.is_empty()) {
input = input.default(c);
}
Ok(input.interact_text()?)
};
let gpg_key = prompt_name("Env var name — base64 GPG private key", gpg_key)?;
let gpg_trust = prompt_name("Env var name — GPG ownertrust export", gpg_trust)?;
let user_name = prompt_name("Env var name — committer name", user_name)?;
let user_email = prompt_name("Env var name — committer email", user_email)?;
let sign_key = prompt_name("Env var name — GPG signing key id", sign_key)?;
let push_fingerprint = prompt_name(
"SSH key fingerprint for the push job (optional; empty = ambient credentials)",
push_fingerprint,
)?;
let contexts_default = if contexts.is_empty() {
None
} else {
Some(contexts.join(","))
};
let contexts_raw = prompt_name(
"CircleCI context(s) supplying the GPG signing material, comma-separated",
contexts_default,
)?;
let contexts: Vec<String> = contexts_raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
build_record_config(
true,
Some(&gpg_key),
Some(&gpg_trust),
Some(&user_name),
Some(&user_email),
Some(&sign_key),
Some(&push_fingerprint),
&contexts,
)
}
pub(crate) fn gather_extras(
&self,
detected: &[String],
existing: &OrbConfig,
) -> Result<GatheredExtras> {
let existing_ci = existing.ci.as_ref();
let existing_orb = existing.orb.as_ref();
let record = self.gather_record(existing)?;
let effective_push = if !self.git_push_subcommands.is_empty() {
self.git_push_subcommands.clone()
} else {
existing_orb
.and_then(|o| o.git_push_subcommands.clone())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| detected.to_vec())
};
if is_non_interactive(self.dry_run) {
return Ok(GatheredExtras {
home_url: self
.home_url
.clone()
.or_else(|| existing_orb.and_then(|o| o.home_url.clone())),
source_url: self
.source_url
.clone()
.or_else(|| existing_orb.and_then(|o| o.source_url.clone())),
git_push_subcommands: effective_push,
docker_context: self
.docker_context
.clone()
.or_else(|| existing_ci.and_then(|ci| ci.docker_context.clone()))
.unwrap_or_else(|| DEFAULT_DOCKER_CONTEXT.to_string()),
orb_context: self
.orb_context
.clone()
.or_else(|| existing_ci.and_then(|ci| ci.orb_context.clone()))
.unwrap_or_else(|| DEFAULT_ORB_CONTEXT.to_string()),
mcp_context: if !self.mcp_context.is_empty() {
self.mcp_context.clone()
} else {
existing_ci
.and_then(|ci| ci.mcp_context.clone())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| vec![DEFAULT_MCP_CONTEXT.to_string()])
},
mcp_earliest_version: self
.mcp_earliest_version
.clone()
.or_else(|| existing_ci.and_then(|ci| ci.mcp_earliest_version.clone()))
.unwrap_or_else(|| DEFAULT_MCP_EARLIEST_VERSION.to_string()),
record,
});
}
use dialoguer::Input;
let home_url = if let Some(v) = self.home_url.clone() {
Some(v).filter(|s| !s.is_empty())
} else {
let default = existing_orb
.and_then(|o| o.home_url.clone())
.unwrap_or_default();
let val = Input::<String>::new()
.with_prompt("Home URL for orb registry (Enter to skip)")
.default(default)
.allow_empty(true)
.interact_text()?;
if val.is_empty() {
None
} else {
Some(val)
}
};
let source_url = if let Some(v) = self.source_url.clone() {
Some(v).filter(|s| !s.is_empty())
} else {
let default = existing_orb
.and_then(|o| o.source_url.clone())
.unwrap_or_default();
let val = Input::<String>::new()
.with_prompt("Source URL for orb registry (Enter to skip)")
.default(default)
.allow_empty(true)
.interact_text()?;
if val.is_empty() {
None
} else {
Some(val)
}
};
let git_push_subcommands = if !self.git_push_subcommands.is_empty() {
effective_push
} else {
let cfg_push = existing_orb
.and_then(|o| o.git_push_subcommands.clone())
.unwrap_or_default();
let current = if !cfg_push.is_empty() {
cfg_push.join(",")
} else {
detected.join(",")
};
let prompt = if !detected.is_empty() && cfg_push.is_empty() {
format!(
"Push-capable subcommands detected: {} — confirm or override (comma-separated)",
detected.join(", ")
)
} else {
"Subcommands that push to git, comma-separated (e.g. save)".to_string()
};
let val = Input::<String>::new()
.with_prompt(prompt)
.default(current)
.allow_empty(true)
.interact_text()?;
val.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
};
let docker_context = if let Some(v) = self.docker_context.clone() {
v
} else {
let default = existing_ci
.and_then(|ci| ci.docker_context.clone())
.unwrap_or_else(|| DEFAULT_DOCKER_CONTEXT.to_string());
Input::<String>::new()
.with_prompt("Docker context name (needs: DOCKER_LOGIN, DOCKER_PASSWORD)")
.default(default)
.interact_text()?
};
let orb_context = if let Some(v) = self.orb_context.clone() {
v
} else {
let default = existing_ci
.and_then(|ci| ci.orb_context.clone())
.unwrap_or_else(|| DEFAULT_ORB_CONTEXT.to_string());
Input::<String>::new()
.with_prompt("Orb publishing context name (needs: CIRCLECI_CLI_TOKEN)")
.default(default)
.interact_text()?
};
let mcp_context = if self.mcp {
if !self.mcp_context.is_empty() {
self.mcp_context.clone()
} else {
let default = existing_ci
.and_then(|ci| ci.mcp_context.as_ref())
.filter(|v| !v.is_empty())
.map(|v| v.join(","))
.unwrap_or_else(|| DEFAULT_MCP_CONTEXT.to_string());
let val = Input::<String>::new()
.with_prompt(
"MCP context names, comma-separated (needs: GITHUB_TOKEN with contents:write + bypass branch protection, BOT_GPG_KEY, BOT_TRUST, BOT_USER_NAME, BOT_USER_EMAIL, BOT_SIGN_KEY)",
)
.default(default)
.interact_text()?;
val.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
}
} else if !self.mcp_context.is_empty() {
self.mcp_context.clone()
} else {
existing_ci
.and_then(|ci| ci.mcp_context.clone())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| vec![DEFAULT_MCP_CONTEXT.to_string()])
};
let mcp_earliest_version = if self.mcp {
if let Some(v) = self.mcp_earliest_version.clone() {
v
} else {
let default = existing_ci
.and_then(|ci| ci.mcp_earliest_version.clone())
.unwrap_or_else(|| DEFAULT_MCP_EARLIEST_VERSION.to_string());
Input::<String>::new()
.with_prompt("Earliest orb version to include in MCP snapshots")
.default(default)
.interact_text()?
}
} else {
self.mcp_earliest_version
.clone()
.or_else(|| existing_ci.and_then(|ci| ci.mcp_earliest_version.clone()))
.unwrap_or_else(|| DEFAULT_MCP_EARLIEST_VERSION.to_string())
};
Ok(GatheredExtras {
home_url,
source_url,
git_push_subcommands,
docker_context,
orb_context,
mcp_context,
mcp_earliest_version,
record,
})
}
fn resolve_interactive(&self, present: &[String]) -> Result<Vec<(String, bool)>> {
if is_non_interactive(self.dry_run) {
return Ok(present.iter().map(|n| (n.clone(), true)).collect());
}
present
.iter()
.map(|name| -> Result<(String, bool)> {
let reserve = dialoguer::Confirm::new()
.with_prompt(format!(
"Reserve `{name}` as interactive-only (CLI setup — excluded from the CI orb)?"
))
.default(true)
.interact()?;
Ok((name.clone(), reserve))
})
.collect()
}
pub fn run(&self) -> Result<()> {
let (detected_push, detected_orb_path, present_interactive) =
match crate::help_parser::parse_binary(&self.binary) {
Ok(cli) => (
detect_git_push_subcommands(&cli),
detect_orb_path_subcommands(&cli),
present_default_interactive(&cli),
),
Err(_) => (vec![], vec![], vec![]),
};
let config_path = std::path::Path::new("gen-circleci-orb.toml");
let existing_config = crate::orb_config::load_config(config_path)?;
let extras = self.gather_extras(&detected_push, &existing_config)?;
let namespaces: Vec<String> = self
.public_orb_namespaces
.iter()
.chain(self.private_orb_namespaces.iter())
.cloned()
.collect();
tracing::info!("Generating orb source into ./{}", self.orb_dir);
let gen = Generate {
binary: Some(self.binary.clone()),
namespaces: namespaces.clone(),
output: PathBuf::from("."),
orb_dir: Some(self.orb_dir.clone()),
install_method: None,
base_image: None,
home_url: extras.home_url.clone(),
source_url: extras.source_url.clone(),
git_push_subcommands: extras.git_push_subcommands.clone(),
circleci_cli_version: None,
apt_packages: vec![],
dry_run: self.dry_run,
config: None,
no_record: true,
check: false,
};
gen.run()?;
let opts = ci_patcher::PatchOpts {
binary: self.binary.clone(),
namespaces,
docker_namespace: self.docker_namespace.clone(),
orb_dir: self.orb_dir.clone(),
build_workflow: self.build_workflow.clone(),
release_workflow: self.release_workflow.clone(),
requires_job: self.requires_job.clone(),
crate_tag_prefix: self.crate_tag_prefix.clone(),
release_after_job: self.release_after_job.clone(),
orb_tools_version: self.orb_tools_version.clone(),
docker_orb_version: self.docker_orb_version.clone(),
docker_context: extras.docker_context.clone(),
orb_context: extras.orb_context.clone(),
private_namespaces: self.private_orb_namespaces.clone(),
gen_circleci_orb_version: self.gen_circleci_orb_version.clone(),
mcp: self.mcp,
mcp_earliest_version: extras.mcp_earliest_version.clone(),
mcp_context: extras.mcp_context.clone(),
gen_orb_mcp_orb_version: DEFAULT_GEN_ORB_MCP_ORB_VERSION.to_string(),
record_contexts: extras
.record
.as_ref()
.map(|r| r.contexts.clone())
.unwrap_or_default(),
record_push_ssh_fingerprint: extras
.record
.as_ref()
.map(|r| r.push_ssh_fingerprint.clone())
.unwrap_or_default(),
};
let summary = ci_patcher::apply_patches(&self.ci_dir, &opts, self.dry_run)?;
for line in &summary {
println!("{line}");
}
let config_path = std::path::Path::new("gen-circleci-orb.toml");
let interactive = self.resolve_interactive(&present_interactive)?;
let mut bootstrap = build_bootstrap_config(
&self.binary,
opts.namespaces.as_slice(),
&self.orb_dir,
extras.home_url.as_deref(),
extras.source_url.as_deref(),
&extras.git_push_subcommands,
&interactive,
);
populate_orb_path_defaults(&mut bootstrap, &detected_orb_path);
bootstrap.ci = Some(CiSection {
build_workflow: Some(self.build_workflow.clone()),
release_workflow: Some(self.release_workflow.clone()),
requires_job: self.requires_job.clone(),
release_after_job: Some(self.release_after_job.clone()),
crate_tag_prefix: Some(self.crate_tag_prefix.clone()),
docker_namespace: Some(self.docker_namespace.clone()),
docker_context: Some(extras.docker_context.clone()),
orb_context: Some(extras.orb_context.clone()),
mcp: Some(self.mcp),
mcp_context: Some(extras.mcp_context.clone()),
mcp_earliest_version: Some(extras.mcp_earliest_version.clone()),
gen_orb_mcp_orb_version: None,
});
bootstrap.record = extras.record.clone();
if self.dry_run {
let content = toml::to_string_pretty(&bootstrap)?;
println!("(dry-run) Would write {}", config_path.display());
println!("{content}");
println!("(dry-run: no files written)");
} else {
crate::orb_config::save_config(config_path, &bootstrap)?;
println!("Wrote {}", config_path.display());
println!("Done.");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_docker_orb_version_matches_registry() {
assert_eq!(
DEFAULT_DOCKER_ORB_VERSION, "3.0.1",
"DEFAULT_DOCKER_ORB_VERSION must be the registry-available version"
);
}
#[test]
fn bootstrap_config_has_orb_section_with_binary() {
let config = build_bootstrap_config(
"mytool",
&["my-org".to_string()],
"orb",
None,
None,
&[],
&[],
);
assert!(
config.orb.is_some(),
"bootstrap config must have [orb] section"
);
assert_eq!(
config.orb.as_ref().unwrap().binary.as_deref(),
Some("mytool")
);
}
#[test]
fn bootstrap_config_has_namespaces() {
let config = build_bootstrap_config(
"mytool",
&["ns1".to_string(), "ns2".to_string()],
"orb",
None,
None,
&[],
&[],
);
assert_eq!(
config.orb.as_ref().unwrap().namespaces.as_deref(),
Some(&["ns1".to_string(), "ns2".to_string()][..])
);
}
#[test]
fn bootstrap_config_scaffolds_interactive_decisions() {
let config = build_bootstrap_config(
"mytool",
&["my-org".to_string()],
"orb",
None,
None,
&[],
&[("init".to_string(), true), ("config".to_string(), false)],
);
let subcommands = config
.subcommand
.as_ref()
.expect("subcommand section missing");
assert_eq!(subcommands.get("init").unwrap().interactive, Some(true));
assert_eq!(subcommands.get("config").unwrap().interactive, Some(false));
assert!(
!subcommands.contains_key("help"),
"help is parser-reserved, not scaffolded into the toml"
);
}
#[test]
fn bootstrap_config_has_no_subcommand_section_without_interactive() {
let config = build_bootstrap_config(
"mytool",
&["my-org".to_string()],
"orb",
None,
None,
&[],
&[],
);
assert!(
config.subcommand.is_none(),
"no interactive decisions → no [subcommand] section"
);
}
#[test]
fn present_default_interactive_returns_only_present_defaults() {
use crate::help_parser::types::{CliDefinition, SubCommand};
let sub = |name: &str| SubCommand {
name: name.to_string(),
description: String::new(),
is_leaf: true,
parameters: vec![],
subcommands: vec![],
};
let cli = CliDefinition {
binary_name: "mytool".to_string(),
description: String::new(),
subcommands: vec![sub("init"), sub("run")],
};
assert_eq!(present_default_interactive(&cli), vec!["init".to_string()]);
}
#[test]
fn init_has_git_push_subcommands_field() {
let init = Init {
binary: "mytool".to_string(),
public_orb_namespaces: vec!["my-org".to_string()],
private_orb_namespaces: vec![],
build_workflow: "validation".to_string(),
release_workflow: "orb-release".to_string(),
requires_job: None,
crate_tag_prefix: "mytool-v".to_string(),
release_after_job: "publish-orb".to_string(),
orb_dir: "orb".to_string(),
ci_dir: std::path::PathBuf::from(".circleci"),
orb_tools_version: "12.3.3".to_string(),
docker_orb_version: "3.0.1".to_string(),
docker_namespace: "my-docker-ns".to_string(),
docker_context: None,
orb_context: None,
gen_circleci_orb_version: "0.0.1".to_string(),
mcp: false,
mcp_earliest_version: None,
mcp_context: vec![],
dry_run: false,
git_push_subcommands: vec!["save".to_string()],
home_url: None,
source_url: None,
record: false,
record_gpg_key_env: None,
record_gpg_trust_env: None,
record_user_name_env: None,
record_user_email_env: None,
record_signing_key_env: None,
record_push_ssh_fingerprint: None,
record_contexts: vec![],
};
assert_eq!(
init.git_push_subcommands,
vec!["save".to_string()],
"Init must hold git_push_subcommands and pass it through to Generate"
);
}
#[test]
fn bootstrap_config_includes_git_push_subcommands() {
let config = build_bootstrap_config(
"mytool",
&["my-org".to_string()],
"orb",
None,
None,
&["save".to_string()],
&[],
);
assert_eq!(
config.orb.as_ref().unwrap().git_push_subcommands.as_deref(),
Some(&["save".to_string()][..])
);
}
#[test]
fn bootstrap_config_git_push_subcommands_none_when_empty() {
let config = build_bootstrap_config(
"mytool",
&["my-org".to_string()],
"orb",
None,
None,
&[],
&[],
);
assert_eq!(
config.orb.as_ref().unwrap().git_push_subcommands,
None,
"empty slice must produce None (not an empty list) to keep the TOML clean"
);
}
#[test]
fn init_run_writes_ci_section_to_config() {
let init = make_init(true);
let extras = init.gather_extras(&[], &OrbConfig::default()).unwrap();
let ci = CiSection {
build_workflow: Some(init.build_workflow.clone()),
release_workflow: Some(init.release_workflow.clone()),
requires_job: init.requires_job.clone(),
release_after_job: Some(init.release_after_job.clone()),
crate_tag_prefix: Some(init.crate_tag_prefix.clone()),
docker_namespace: Some(init.docker_namespace.clone()),
docker_context: Some(extras.docker_context.clone()),
orb_context: Some(extras.orb_context.clone()),
mcp: Some(init.mcp),
mcp_context: Some(extras.mcp_context.clone()),
mcp_earliest_version: Some(extras.mcp_earliest_version.clone()),
gen_orb_mcp_orb_version: None,
};
assert_eq!(ci.build_workflow.as_deref(), Some("validation"));
assert_eq!(ci.docker_context.as_deref(), Some(DEFAULT_DOCKER_CONTEXT));
assert_eq!(ci.mcp, Some(false));
}
#[test]
fn bootstrap_config_includes_home_and_source_url() {
let config = build_bootstrap_config(
"mytool",
&["my-org".to_string()],
"orb",
Some("https://example.com/home"),
Some("https://example.com/source"),
&[],
&[],
);
assert_eq!(
config.orb.as_ref().unwrap().home_url.as_deref(),
Some("https://example.com/home")
);
assert_eq!(
config.orb.as_ref().unwrap().source_url.as_deref(),
Some("https://example.com/source")
);
}
fn make_cli_with_orb_path(
sub_name: &str,
required: bool,
) -> crate::help_parser::types::CliDefinition {
use crate::help_parser::types::{CliDefinition, ParamType, Parameter, SubCommand};
let p = Parameter {
long_name: "orb_path".to_string(),
short: Some('p'),
param_type: ParamType::String,
default: None,
required,
description: "Path to orb YAML".to_string(),
};
let sub = SubCommand {
name: sub_name.to_string(),
description: String::new(),
is_leaf: true,
parameters: vec![p],
subcommands: vec![],
};
CliDefinition {
binary_name: "mytool".to_string(),
description: "My tool".to_string(),
subcommands: vec![sub],
}
}
#[test]
fn detect_required_orb_path_subcommand() {
let cli = make_cli_with_orb_path("generate", true);
let detected = detect_orb_path_subcommands(&cli);
assert_eq!(detected, vec!["generate".to_string()]);
}
#[test]
fn optional_orb_path_not_detected() {
let cli = make_cli_with_orb_path("generate", false);
let detected = detect_orb_path_subcommands(&cli);
assert!(
detected.is_empty(),
"optional orb_path must not trigger default injection"
);
}
#[test]
fn populate_orb_path_defaults_adds_subcommand_entries() {
let mut config = build_bootstrap_config(
"mytool",
&["my-org".to_string()],
"orb",
None,
None,
&[],
&[],
);
populate_orb_path_defaults(
&mut config,
&["generate".to_string(), "validate".to_string()],
);
let subcommands = config.subcommand.as_ref().unwrap();
let gen_params = subcommands.get("generate").unwrap().param.as_ref().unwrap();
assert_eq!(
gen_params.get("orb_path").unwrap().default.as_deref(),
Some("src/@orb.yml")
);
let val_params = subcommands.get("validate").unwrap().param.as_ref().unwrap();
assert_eq!(
val_params.get("orb_path").unwrap().default.as_deref(),
Some("src/@orb.yml")
);
}
#[test]
fn populate_orb_path_defaults_preserves_existing_subcommand_entries() {
let mut config = build_bootstrap_config(
"mytool",
&["my-org".to_string()],
"orb",
None,
None,
&[],
&[("init".to_string(), true)],
);
populate_orb_path_defaults(&mut config, &["generate".to_string()]);
let subcommands = config.subcommand.as_ref().unwrap();
assert_eq!(subcommands.get("init").unwrap().interactive, Some(true));
}
#[test]
fn populate_orb_path_defaults_noop_when_empty() {
let mut config = build_bootstrap_config(
"mytool",
&["my-org".to_string()],
"orb",
None,
None,
&[],
&[],
);
let before = config.subcommand.clone();
populate_orb_path_defaults(&mut config, &[]);
assert_eq!(
config.subcommand, before,
"no change when no subcommands detected"
);
}
#[test]
fn detect_push_subcommand_with_push_param() {
use crate::help_parser::types::{CliDefinition, ParamType, Parameter, SubCommand};
let push_param = Parameter {
long_name: "push".to_string(),
short: None,
param_type: ParamType::Enum(vec!["true".to_string(), "false".to_string()]),
default: Some("true".to_string()),
required: false,
description: "Push after committing".to_string(),
};
let sub = SubCommand {
name: "save".to_string(),
description: "Save artifacts".to_string(),
is_leaf: true,
parameters: vec![push_param],
subcommands: vec![],
};
let cli = CliDefinition {
binary_name: "mytool".to_string(),
description: "My tool".to_string(),
subcommands: vec![sub],
};
let detected = detect_git_push_subcommands(&cli);
assert_eq!(detected, vec!["save".to_string()]);
}
#[test]
fn detect_push_subcommand_with_sign_param() {
use crate::help_parser::types::{CliDefinition, ParamType, Parameter, SubCommand};
let sign_param = Parameter {
long_name: "sign".to_string(),
short: None,
param_type: ParamType::Boolean,
default: None,
required: false,
description: "GPG sign".to_string(),
};
let sub = SubCommand {
name: "commit".to_string(),
description: "Commit".to_string(),
is_leaf: true,
parameters: vec![sign_param],
subcommands: vec![],
};
let cli = CliDefinition {
binary_name: "mytool".to_string(),
description: "My tool".to_string(),
subcommands: vec![sub],
};
let detected = detect_git_push_subcommands(&cli);
assert_eq!(detected, vec!["commit".to_string()]);
}
#[test]
fn non_push_subcommand_not_detected() {
use crate::help_parser::types::{CliDefinition, ParamType, Parameter, SubCommand};
let other_param = Parameter {
long_name: "output".to_string(),
short: None,
param_type: ParamType::String,
default: Some("./dist".to_string()),
required: false,
description: "Output dir".to_string(),
};
let sub = SubCommand {
name: "generate".to_string(),
description: "Generate".to_string(),
is_leaf: true,
parameters: vec![other_param],
subcommands: vec![],
};
let cli = CliDefinition {
binary_name: "mytool".to_string(),
description: "My tool".to_string(),
subcommands: vec![sub],
};
let detected = detect_git_push_subcommands(&cli);
assert!(detected.is_empty());
}
#[test]
fn gather_extras_uses_detected_when_cli_empty() {
let init = make_init(true); let extras = init
.gather_extras(&["save".to_string()], &OrbConfig::default())
.unwrap();
assert_eq!(
extras.git_push_subcommands,
vec!["save".to_string()],
"detected candidates must be used when --git-push-subcommands not set"
);
}
#[test]
fn gather_extras_cli_overrides_detected() {
let init = Init {
git_push_subcommands: vec!["custom".to_string()],
dry_run: true,
..make_init(true)
};
let extras = init
.gather_extras(&["save".to_string()], &OrbConfig::default())
.unwrap();
assert_eq!(
extras.git_push_subcommands,
vec!["custom".to_string()],
"explicit CLI value must override detected candidates"
);
}
fn make_init(dry_run: bool) -> Init {
Init {
binary: "mytool".to_string(),
public_orb_namespaces: vec!["my-org".to_string()],
private_orb_namespaces: vec![],
build_workflow: "validation".to_string(),
release_workflow: "orb-release".to_string(),
requires_job: None,
crate_tag_prefix: "mytool-v".to_string(),
release_after_job: "publish-orb".to_string(),
orb_dir: "orb".to_string(),
ci_dir: std::path::PathBuf::from(".circleci"),
orb_tools_version: "12.3.3".to_string(),
docker_orb_version: "3.0.1".to_string(),
docker_namespace: "my-docker-ns".to_string(),
docker_context: None,
orb_context: None,
gen_circleci_orb_version: "0.0.1".to_string(),
mcp: false,
mcp_earliest_version: None,
mcp_context: vec![],
dry_run,
git_push_subcommands: vec![],
home_url: None,
source_url: None,
record: false,
record_gpg_key_env: None,
record_gpg_trust_env: None,
record_user_name_env: None,
record_user_email_env: None,
record_signing_key_env: None,
record_push_ssh_fingerprint: None,
record_contexts: vec![],
}
}
#[test]
fn build_record_config_disabled_returns_none() {
let rec = build_record_config(false, None, None, None, None, None, None, &[])
.expect("disabled is ok");
assert!(rec.is_none(), "disabled must yield no [record] section");
}
#[test]
fn build_record_config_collects_names_and_contexts() {
let rec = build_record_config(
true,
Some("G_KEY"),
Some("G_TRUST"),
Some("G_NAME"),
Some("G_EMAIL"),
Some("G_SIGN"),
None,
&["release".to_string()],
)
.expect("all values present")
.expect("enabled yields Some");
assert!(rec.enabled);
assert_eq!(rec.gpg_key_env, "G_KEY");
assert_eq!(rec.signing_key_env, "G_SIGN");
assert_eq!(rec.contexts, vec!["release"]);
}
#[test]
fn build_record_config_errors_when_enabled_without_name() {
let err = build_record_config(
true,
None, Some("G_TRUST"),
Some("G_NAME"),
Some("G_EMAIL"),
Some("G_SIGN"),
None,
&["release".to_string()],
)
.unwrap_err()
.to_string();
assert!(err.contains("--record-gpg-key-env"), "unexpected: {err}");
}
#[test]
fn build_record_config_errors_when_enabled_without_context() {
let err = build_record_config(
true,
Some("G_KEY"),
Some("G_TRUST"),
Some("G_NAME"),
Some("G_EMAIL"),
Some("G_SIGN"),
None,
&[], )
.unwrap_err()
.to_string();
assert!(err.contains("--record-context"), "unexpected: {err}");
}
#[test]
fn gather_extras_non_interactive_uses_hardcoded_defaults() {
let init = make_init(true); let extras = init.gather_extras(&[], &OrbConfig::default()).unwrap();
assert_eq!(extras.docker_context, DEFAULT_DOCKER_CONTEXT);
assert_eq!(extras.orb_context, DEFAULT_ORB_CONTEXT);
assert_eq!(extras.mcp_context, vec![DEFAULT_MCP_CONTEXT.to_string()]);
assert_eq!(extras.mcp_earliest_version, DEFAULT_MCP_EARLIEST_VERSION);
assert_eq!(extras.home_url, None);
assert_eq!(extras.source_url, None);
assert!(extras.git_push_subcommands.is_empty());
}
#[test]
fn gather_extras_cli_values_take_precedence_over_defaults() {
let init = Init {
docker_context: Some("my-docker".to_string()),
orb_context: Some("my-orb-ctx".to_string()),
mcp_context: vec!["my-mcp-ctx".to_string()],
mcp_earliest_version: Some("1.2.3".to_string()),
home_url: Some("https://example.com".to_string()),
source_url: Some("https://src.example.com".to_string()),
git_push_subcommands: vec!["save".to_string()],
dry_run: true,
..make_init(true)
};
let extras = init.gather_extras(&[], &OrbConfig::default()).unwrap();
assert_eq!(extras.docker_context, "my-docker");
assert_eq!(extras.orb_context, "my-orb-ctx");
assert_eq!(extras.mcp_context, vec!["my-mcp-ctx".to_string()]);
assert_eq!(extras.mcp_earliest_version, "1.2.3");
assert_eq!(extras.home_url.as_deref(), Some("https://example.com"));
assert_eq!(
extras.source_url.as_deref(),
Some("https://src.example.com")
);
assert_eq!(extras.git_push_subcommands, vec!["save"]);
}
#[test]
fn gather_extras_ci_env_var_is_non_interactive() {
std::env::set_var("CI", "true");
let init = make_init(false);
let extras = init.gather_extras(&[], &OrbConfig::default()).unwrap();
std::env::remove_var("CI");
assert_eq!(extras.docker_context, DEFAULT_DOCKER_CONTEXT);
}
#[test]
fn gather_extras_skips_docker_context_prompt_when_set() {
let init = Init {
docker_context: Some("explicit-docker".to_string()),
dry_run: true,
..make_init(true)
};
let extras = init.gather_extras(&[], &OrbConfig::default()).unwrap();
assert_eq!(extras.docker_context, "explicit-docker");
}
#[test]
fn gather_extras_skips_orb_context_prompt_when_set() {
let init = Init {
orb_context: Some("explicit-orb".to_string()),
dry_run: true,
..make_init(true)
};
let extras = init.gather_extras(&[], &OrbConfig::default()).unwrap();
assert_eq!(extras.orb_context, "explicit-orb");
}
#[test]
fn gather_extras_skips_mcp_context_prompt_when_set() {
let init = Init {
mcp: true,
mcp_context: vec!["ctx-a".to_string(), "ctx-b".to_string()],
dry_run: true,
..make_init(true)
};
let extras = init.gather_extras(&[], &OrbConfig::default()).unwrap();
assert_eq!(extras.mcp_context, vec!["ctx-a", "ctx-b"]);
}
#[test]
fn gather_extras_skips_mcp_earliest_version_prompt_when_set() {
let init = Init {
mcp: true,
mcp_earliest_version: Some("3.0.0".to_string()),
dry_run: true,
..make_init(true)
};
let extras = init.gather_extras(&[], &OrbConfig::default()).unwrap();
assert_eq!(extras.mcp_earliest_version, "3.0.0");
}
#[test]
fn gather_extras_skips_git_push_subcommands_prompt_when_set() {
let init = Init {
git_push_subcommands: vec!["deploy".to_string()],
dry_run: true,
..make_init(true)
};
let extras = init
.gather_extras(&["save".to_string()], &OrbConfig::default())
.unwrap();
assert_eq!(extras.git_push_subcommands, vec!["deploy"]);
}
#[test]
fn gather_extras_skips_home_url_prompt_when_set() {
let init = Init {
home_url: Some("https://example.com/home".to_string()),
dry_run: true,
..make_init(true)
};
let extras = init.gather_extras(&[], &OrbConfig::default()).unwrap();
assert_eq!(extras.home_url.as_deref(), Some("https://example.com/home"));
}
#[test]
fn gather_extras_skips_source_url_prompt_when_set() {
let init = Init {
source_url: Some("https://example.com/src".to_string()),
dry_run: true,
..make_init(true)
};
let extras = init.gather_extras(&[], &OrbConfig::default()).unwrap();
assert_eq!(
extras.source_url.as_deref(),
Some("https://example.com/src")
);
}
fn make_existing_config() -> OrbConfig {
use crate::orb_config::CiSection;
OrbConfig {
orb: Some(OrbSection {
home_url: Some("https://existing-home.example.com".to_string()),
source_url: Some("https://existing-src.example.com".to_string()),
git_push_subcommands: Some(vec!["existing-push".to_string()]),
..OrbSection::default()
}),
ci: Some(CiSection {
docker_context: Some("existing-docker".to_string()),
orb_context: Some("existing-orb".to_string()),
mcp_context: Some(vec!["existing-mcp".to_string()]),
mcp_earliest_version: Some("9.9.9".to_string()),
..CiSection::default()
}),
..OrbConfig::default()
}
}
#[test]
fn gather_extras_falls_back_to_existing_docker_context() {
let init = make_init(true); let extras = init.gather_extras(&[], &make_existing_config()).unwrap();
assert_eq!(
extras.docker_context, "existing-docker",
"should use [ci].docker_context from existing config when CLI flag not set"
);
}
#[test]
fn gather_extras_falls_back_to_existing_orb_context() {
let init = make_init(true);
let extras = init.gather_extras(&[], &make_existing_config()).unwrap();
assert_eq!(extras.orb_context, "existing-orb");
}
#[test]
fn gather_extras_falls_back_to_existing_mcp_context() {
let init = Init {
mcp: true,
dry_run: true,
..make_init(true)
};
let extras = init.gather_extras(&[], &make_existing_config()).unwrap();
assert_eq!(extras.mcp_context, vec!["existing-mcp"]);
}
#[test]
fn gather_extras_falls_back_to_existing_mcp_earliest_version() {
let init = make_init(true);
let extras = init.gather_extras(&[], &make_existing_config()).unwrap();
assert_eq!(extras.mcp_earliest_version, "9.9.9");
}
#[test]
fn gather_extras_falls_back_to_existing_home_url() {
let init = make_init(true);
let extras = init.gather_extras(&[], &make_existing_config()).unwrap();
assert_eq!(
extras.home_url.as_deref(),
Some("https://existing-home.example.com")
);
}
#[test]
fn gather_extras_falls_back_to_existing_source_url() {
let init = make_init(true);
let extras = init.gather_extras(&[], &make_existing_config()).unwrap();
assert_eq!(
extras.source_url.as_deref(),
Some("https://existing-src.example.com")
);
}
#[test]
fn gather_extras_falls_back_to_existing_git_push_subcommands() {
let init = make_init(true);
let extras = init.gather_extras(&[], &make_existing_config()).unwrap();
assert_eq!(extras.git_push_subcommands, vec!["existing-push"]);
}
#[test]
fn gather_extras_cli_takes_precedence_over_existing_config() {
let init = Init {
docker_context: Some("cli-docker".to_string()),
orb_context: Some("cli-orb".to_string()),
dry_run: true,
..make_init(true)
};
let extras = init.gather_extras(&[], &make_existing_config()).unwrap();
assert_eq!(extras.docker_context, "cli-docker");
assert_eq!(extras.orb_context, "cli-orb");
}
#[test]
fn gather_extras_detected_used_when_neither_cli_nor_config_has_push_subcommands() {
let init = make_init(true);
let existing = OrbConfig::default(); let extras = init
.gather_extras(&["detected-push".to_string()], &existing)
.unwrap();
assert_eq!(extras.git_push_subcommands, vec!["detected-push"]);
}
#[test]
fn is_non_interactive_reflects_tty_state() {
let ci_was = std::env::var("CI").ok();
std::env::remove_var("CI");
let is_tty = console::Term::stderr().is_term();
let result = is_non_interactive(false);
if let Some(val) = ci_was {
std::env::set_var("CI", val);
}
if is_tty {
assert!(
!result,
"is_non_interactive must be false when stderr IS a terminal \
(and neither dry_run nor $CI is set)"
);
} else {
assert!(
result,
"is_non_interactive must be true when stderr is NOT a terminal"
);
}
}
#[test]
fn init_docker_context_field_is_option() {
let init = make_init(true);
let _: Option<String> = init.docker_context;
}
#[test]
fn bootstrap_config_has_orb_dir() {
let config = build_bootstrap_config(
"mytool",
&["my-org".to_string()],
"custom-orb",
None,
None,
&[],
&[],
);
assert_eq!(
config.orb.as_ref().unwrap().orb_dir.as_deref(),
Some("custom-orb")
);
}
}