pub mod conformance_rule;
pub mod consumer_parser;
pub mod differ;
pub mod generator;
pub mod migrator;
pub mod parser;
pub mod primer;
use anyhow::Result;
use clap::{Parser, Subcommand};
use generator::CodeGenerator;
use parser::OrbParser;
#[derive(Debug, Parser)]
#[command(name = "gen-orb-mcp")]
#[command(
author,
version,
about,
long_about = "Generate MCP servers from CircleCI orb definitions, \
exposing commands, jobs, and executors as AI-accessible resources. \
Supports migration tooling, prior-version snapshots, and diff-based \
conformance rules to help consumers keep their CI config in sync with \
orb updates."
)]
pub struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
Generate {
#[arg(short = 'p', long, default_value = "src/@orb.yml")]
orb_path: std::path::PathBuf,
#[arg(short = 'o', long, default_value = "./dist")]
output: std::path::PathBuf,
#[arg(short, long, value_enum, default_value = "source")]
format: OutputFormat,
#[arg(short, long)]
name: Option<String>,
#[arg(long = "crate-version")]
crate_version: Option<String>,
#[arg(long)]
force: bool,
#[arg(long)]
migrations: Option<std::path::PathBuf>,
#[arg(long)]
prior_versions: Option<std::path::PathBuf>,
#[arg(long, default_value = "v")]
tag_prefix: String,
},
Validate {
#[arg(short = 'p', long, default_value = "src/@orb.yml")]
orb_path: std::path::PathBuf,
},
Diff {
#[arg(long)]
current: std::path::PathBuf,
#[arg(long)]
previous: std::path::PathBuf,
#[arg(long)]
since_version: String,
#[arg(long)]
output: Option<std::path::PathBuf>,
},
Migrate {
#[arg(long, default_value = ".circleci")]
ci_dir: std::path::PathBuf,
#[arg(long)]
orb: String,
#[arg(long)]
rules: std::path::PathBuf,
#[arg(long)]
dry_run: bool,
},
Prime {
#[arg(short = 'p', long, default_value = "src/@orb.yml")]
orb_path: std::path::PathBuf,
#[arg(long)]
git_repo: Option<std::path::PathBuf>,
#[arg(long, default_value = "v")]
tag_prefix: String,
#[arg(long, conflicts_with = "since")]
earliest_version: Option<String>,
#[arg(long)]
since: Option<String>,
#[arg(long, default_value = "prior-versions")]
prior_versions_dir: std::path::PathBuf,
#[arg(long, default_value = "migrations")]
migrations_dir: std::path::PathBuf,
#[arg(long)]
ephemeral: bool,
#[arg(long, value_name = "OLD=NEW")]
rename_map: Vec<String>,
#[arg(long)]
dry_run: bool,
},
Save {
#[arg(long, required = true)]
paths: Vec<std::path::PathBuf>,
#[arg(
short = 'm',
long,
default_value = "chore: update generated MCP server artifacts [skip ci]"
)]
message: String,
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
push: bool,
#[arg(long, conflicts_with = "push")]
no_push: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
sign: bool,
},
Publish {
#[arg(short = 'b', long)]
binary: std::path::PathBuf,
#[arg(short = 'a', long)]
asset_name: String,
#[arg(long)]
tag: Option<String>,
#[arg(long)]
dry_run: bool,
},
Build {
#[arg(short = 'i', long)]
input: std::path::PathBuf,
#[arg(short = 'n', long)]
name: Option<String>,
#[arg(long)]
target: Option<String>,
#[arg(long)]
dry_run: bool,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum OutputFormat {
Binary,
Source,
}
struct GenerateExtras<'a> {
migrations: &'a Option<std::path::PathBuf>,
prior_versions_dir: &'a Option<std::path::PathBuf>,
tag_prefix: &'a str,
}
impl Cli {
pub fn run(&self) -> Result<()> {
match &self.command {
Commands::Generate {
orb_path,
output,
format,
name,
crate_version,
force,
migrations,
prior_versions,
tag_prefix,
} => run_generate(
orb_path,
output,
format,
name,
crate_version,
*force,
GenerateExtras {
migrations,
prior_versions_dir: prior_versions,
tag_prefix,
},
),
Commands::Validate { orb_path } => run_validate(orb_path),
Commands::Diff {
current,
previous,
since_version,
output,
} => run_diff(current, previous, since_version, output),
Commands::Migrate {
ci_dir,
orb,
rules: rules_path,
dry_run,
} => run_migrate(ci_dir, orb, rules_path, *dry_run),
Commands::Prime {
orb_path,
git_repo,
tag_prefix,
earliest_version,
since,
prior_versions_dir,
migrations_dir,
rename_map,
ephemeral,
dry_run,
} => run_prime(
orb_path,
git_repo.as_deref(),
tag_prefix,
earliest_version.as_deref(),
since.as_deref(),
prior_versions_dir,
migrations_dir,
rename_map,
*ephemeral,
*dry_run,
),
Commands::Save {
paths,
message,
push,
no_push,
dry_run,
sign,
} => run_save(paths, message, *push && !*no_push, *dry_run, *sign),
Commands::Publish {
binary,
asset_name,
tag,
dry_run,
} => run_publish(binary, asset_name, tag.as_deref(), *dry_run),
Commands::Build {
input,
name,
target,
dry_run,
} => run_build(input, name.as_deref(), target.as_deref(), *dry_run),
}
}
}
fn run_generate(
orb_path: &std::path::PathBuf,
output: &std::path::PathBuf,
format: &OutputFormat,
name: &Option<String>,
crate_version: &Option<String>,
force: bool,
extras: GenerateExtras<'_>,
) -> Result<()> {
tracing::info!(?orb_path, ?output, ?format, "Generating MCP server");
let orb = OrbParser::parse(orb_path).map_err(|e| anyhow::anyhow!("{}", e))?;
tracing::info!(
commands = orb.commands.len(),
jobs = orb.jobs.len(),
executors = orb.executors.len(),
"Parsed orb definition"
);
let orb_name = name.clone().unwrap_or_else(|| derive_orb_name(orb_path));
let git_hint: Option<String> = match find_git_root(orb_path) {
Ok(repo) => discover_latest_version(&repo, extras.tag_prefix)?,
Err(_) => None,
};
let resolved_version =
resolve_version(output, crate_version.as_deref(), force, git_hint.as_deref())?;
tracing::info!(version = %resolved_version, "Using version");
let conformance_rules = if let Some(migrations_dir) = extras.migrations {
load_conformance_rules(migrations_dir)?
} else {
vec![]
};
if !conformance_rules.is_empty() {
tracing::info!(rules = conformance_rules.len(), "Loaded conformance rules");
}
let prior_versions_data = if let Some(dir) = extras.prior_versions_dir {
load_prior_versions(dir)?
} else {
vec![]
};
if !prior_versions_data.is_empty() {
tracing::info!(
versions = prior_versions_data.len(),
"Loaded prior versions"
);
}
let conformance_rules_json = if !conformance_rules.is_empty() {
Some(serde_json::to_string(&conformance_rules)?)
} else {
None
};
let generator = CodeGenerator::new()
.map_err(|e| anyhow::anyhow!("{}", e))?
.with_prior_versions(prior_versions_data)
.with_conformance_rules_json_opt(conformance_rules_json);
let server = generator
.generate(&orb, &orb_name, &resolved_version)
.map_err(|e| anyhow::anyhow!("{}", e))?;
match format {
OutputFormat::Source => {
server
.write_to(output)
.map_err(|e| anyhow::anyhow!("{}", e))?;
println!("Generated MCP server source code:");
println!(" Output: {}", output.display());
println!(" Crate: {}", server.crate_name);
println!(" Version: {}", resolved_version);
println!(" Commands: {}", orb.commands.len());
println!(" Jobs: {}", orb.jobs.len());
println!(" Executors: {}", orb.executors.len());
println!();
println!("To build: cd {} && cargo build --release", output.display());
}
OutputFormat::Binary => {
server
.write_to(output)
.map_err(|e| anyhow::anyhow!("{}", e))?;
println!("Compiling MCP server...");
let status = std::process::Command::new("cargo")
.args(["build", "--release"])
.current_dir(output)
.status();
match status {
Ok(s) if s.success() => {
let binary_path = output.join("target/release").join(&server.crate_name);
println!("Successfully compiled MCP server:");
println!(" Binary: {}", binary_path.display());
println!(" Version: {}", resolved_version);
}
Ok(_) => {
anyhow::bail!(
"Compilation failed. Source code is available at: {}",
output.display()
);
}
Err(e) => {
anyhow::bail!(
"Failed to run cargo: {}. Source code is available at: {}",
e,
output.display()
);
}
}
}
}
Ok(())
}
fn run_validate(orb_path: &std::path::PathBuf) -> Result<()> {
tracing::info!(?orb_path, "Validating orb definition");
let orb = OrbParser::parse(orb_path).map_err(|e| anyhow::anyhow!("{}", e))?;
println!("Orb validation successful!");
println!(" Version: {}", orb.version);
if let Some(desc) = &orb.description {
println!(" Description: {}", desc);
}
println!(" Commands: {}", orb.commands.len());
for name in orb.commands.keys() {
println!(" - {}", name);
}
println!(" Jobs: {}", orb.jobs.len());
for name in orb.jobs.keys() {
println!(" - {}", name);
}
println!(" Executors: {}", orb.executors.len());
for name in orb.executors.keys() {
println!(" - {}", name);
}
Ok(())
}
fn run_diff(
current: &std::path::PathBuf,
previous: &std::path::PathBuf,
since_version: &str,
output: &Option<std::path::PathBuf>,
) -> Result<()> {
tracing::info!(?current, ?previous, "Diffing orb versions");
let new_orb = OrbParser::parse(current).map_err(|e| anyhow::anyhow!("{}", e))?;
let old_orb = OrbParser::parse(previous).map_err(|e| anyhow::anyhow!("{}", e))?;
let rules = differ::diff(&old_orb, &new_orb, since_version);
println!("Computed {} conformance rule(s):", rules.len());
for rule in &rules {
println!(" • {}", rule.description());
}
let json = serde_json::to_string_pretty(&rules)?;
if let Some(out_path) = output {
std::fs::write(out_path, &json)?;
println!("\nRules written to: {}", out_path.display());
} else {
println!("\n{}", json);
}
Ok(())
}
fn run_migrate(
ci_dir: &std::path::PathBuf,
orb: &str,
rules_path: &std::path::PathBuf,
dry_run: bool,
) -> Result<()> {
tracing::info!(?ci_dir, orb, "Migrating consumer config");
let rules_json = std::fs::read_to_string(rules_path)
.map_err(|e| anyhow::anyhow!("Failed to read rules file: {}", e))?;
let rules: Vec<conformance_rule::ConformanceRule> = serde_json::from_str(&rules_json)
.map_err(|e| anyhow::anyhow!("Failed to parse rules JSON: {}", e))?;
let config = consumer_parser::ConsumerParser::parse_directory(ci_dir)
.map_err(|e| anyhow::anyhow!("Failed to parse CI config: {}", e))?;
let plan = migrator::Migrator::plan(&rules, &config, orb, "");
println!("{}", plan.format_summary());
if plan.changes.is_empty() {
return Ok(());
}
if dry_run {
println!("\n(Dry run — no files modified)");
return Ok(());
}
let applied = migrator::Migrator::apply(&plan, false)?;
println!("\n{}", applied.format_summary());
Ok(())
}
fn load_prior_versions(dir: &std::path::Path) -> Result<Vec<(String, parser::OrbDefinition)>> {
if !dir.is_dir() {
anyhow::bail!("Prior versions directory does not exist: {}", dir.display());
}
let mut versions = Vec::new();
let entries = std::fs::read_dir(dir)?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("yml") {
continue;
}
let version = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if version.is_empty() {
continue;
}
let orb_def = OrbParser::parse(&path)
.map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", path.display(), e))?;
tracing::debug!(path = %path.display(), version = %version, "Loaded prior version");
versions.push((version, orb_def));
}
Ok(versions)
}
fn load_conformance_rules(dir: &std::path::Path) -> Result<Vec<conformance_rule::ConformanceRule>> {
if !dir.is_dir() {
anyhow::bail!("Migrations directory does not exist: {}", dir.display());
}
let mut all_rules = Vec::new();
let entries = std::fs::read_dir(dir)?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let json = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("Failed to read {}: {}", path.display(), e))?;
let rules: Vec<conformance_rule::ConformanceRule> = serde_json::from_str(&json)
.map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", path.display(), e))?;
tracing::debug!(path = %path.display(), count = rules.len(), "Loaded rules file");
all_rules.extend(rules);
}
Ok(all_rules)
}
#[allow(clippy::too_many_arguments)]
fn run_prime(
orb_path: &std::path::Path,
git_repo: Option<&std::path::Path>,
tag_prefix: &str,
earliest_version: Option<&str>,
since: Option<&str>,
prior_versions_dir: &std::path::Path,
migrations_dir: &std::path::Path,
rename_map: &[String],
ephemeral: bool,
dry_run: bool,
) -> Result<()> {
use chrono::Local;
use primer::{
discover_tags, filter_by_date, filter_by_version, since_cutoff, tag_date, PrimeConfig,
};
let repo_path = if let Some(r) = git_repo {
r.to_path_buf()
} else {
find_git_root(orb_path)?
};
let orb_abs = orb_path
.canonicalize()
.unwrap_or_else(|_| orb_path.to_path_buf());
let repo_abs = repo_path
.canonicalize()
.unwrap_or_else(|_| repo_path.to_path_buf());
let orb_rel = orb_abs
.strip_prefix(&repo_abs)
.unwrap_or(orb_path)
.to_path_buf();
let (pv_dir, mig_dir) = if ephemeral {
let base =
std::path::PathBuf::from(format!("/tmp/gen-orb-mcp-prime-{}", std::process::id()));
(base.join("prior-versions"), base.join("migrations"))
} else {
(
prior_versions_dir.to_path_buf(),
migrations_dir.to_path_buf(),
)
};
let all_tags = discover_tags(&repo_path, tag_prefix)?;
tracing::info!(count = all_tags.len(), "Discovered version tags");
let window_versions: Vec<String> = if let Some(ver_str) = earliest_version {
let earliest = semver::Version::parse(ver_str)
.map_err(|e| anyhow::anyhow!("Invalid version '{}': {}", ver_str, e))?;
filter_by_version(&all_tags, &earliest)
} else {
let since_str = since.unwrap_or("6 months");
let today = Local::now().date_naive();
let cutoff = since_cutoff(since_str, today)?;
let tags_with_dates: Vec<primer::TagWithDate> = all_tags
.iter()
.filter_map(|v| match tag_date(&repo_path, tag_prefix, v) {
Ok(d) => Some(primer::TagWithDate {
version: v.clone(),
date: d,
}),
Err(e) => {
tracing::warn!(version = %v, error = %e, "Could not get tag date, skipping");
None
}
})
.collect();
filter_by_date(&tags_with_dates, cutoff)
};
tracing::info!(count = window_versions.len(), "Versions in window");
let extra_rename_hints: Vec<(String, String)> = rename_map
.iter()
.filter_map(|entry| {
let mut parts = entry.splitn(2, '=');
let from = parts.next()?.trim().to_string();
let to = parts.next()?.trim().to_string();
if from.is_empty() || to.is_empty() {
tracing::warn!(entry, "--rename-map entry is malformed; skipping");
return None;
}
Some((from, to))
})
.collect();
let config = PrimeConfig {
git_repo: repo_path,
tag_prefix: tag_prefix.to_string(),
orb_path_relative: orb_rel,
prior_versions_dir: pv_dir.clone(),
migrations_dir: mig_dir.clone(),
dry_run,
extra_rename_hints,
};
let result = primer::prime(&config, &window_versions)?;
if ephemeral {
println!("PRIME_PV_DIR={}", pv_dir.display());
println!("PRIME_MIG_DIR={}", mig_dir.display());
}
println!(
"prime: +{} snapshots, -{} snapshots, +{} migrations, -{} migrations",
result.snapshots_added,
result.snapshots_removed,
result.migrations_added,
result.migrations_removed,
);
Ok(())
}
#[derive(Debug)]
struct SignEnv {
gpg_key_b64: String,
gpg_trust: String,
user_name: String,
user_email: String,
sign_key: String,
}
fn read_sign_env() -> Result<SignEnv> {
Ok(SignEnv {
gpg_key_b64: std::env::var("BOT_GPG_KEY")
.map_err(|_| anyhow::anyhow!("BOT_GPG_KEY env var not set (required with --sign)"))?,
gpg_trust: std::env::var("BOT_TRUST")
.map_err(|_| anyhow::anyhow!("BOT_TRUST env var not set (required with --sign)"))?,
user_name: std::env::var("BOT_USER_NAME")
.map_err(|_| anyhow::anyhow!("BOT_USER_NAME env var not set (required with --sign)"))?,
user_email: std::env::var("BOT_USER_EMAIL").map_err(|_| {
anyhow::anyhow!("BOT_USER_EMAIL env var not set (required with --sign)")
})?,
sign_key: std::env::var("BOT_SIGN_KEY")
.map_err(|_| anyhow::anyhow!("BOT_SIGN_KEY env var not set (required with --sign)"))?,
})
}
fn setup_git_identity(repo: &git2::Repository, sign_env: &SignEnv) -> Result<()> {
let mut config = repo.config()?;
config.set_str("user.name", &sign_env.user_name)?;
config.set_str("user.email", &sign_env.user_email)?;
config.set_str("user.signingkey", &sign_env.sign_key)?;
Ok(())
}
fn build_pcu_config() -> Result<config::Config> {
let mut builder = config::Config::builder()
.set_default("prlog", "PRLOG.md")?
.set_default("branch", "CIRCLE_BRANCH")?
.set_default("default_branch", "main")?
.set_default("username", "CIRCLE_PROJECT_USERNAME")?
.set_default("reponame", "CIRCLE_PROJECT_REPONAME")?
.set_override("command", "push")?
.add_source(config::Environment::with_prefix("PCU"));
if let Ok(token) = std::env::var("GITHUB_TOKEN") {
builder = builder.set_default("pat", token)?;
}
Ok(builder.build()?)
}
fn run_save(
paths: &[std::path::PathBuf],
message: &str,
push: bool,
dry_run: bool,
sign: bool,
) -> Result<()> {
if sign {
let sign_env = read_sign_env()?;
pcu::import_gpg_key(&sign_env.gpg_key_b64, &sign_env.gpg_trust)
.map_err(|e| anyhow::anyhow!("GPG import failed: {e}"))?;
let repo = git2::Repository::discover(".")
.map_err(|e| anyhow::anyhow!("Not inside a git repository: {}", e))?;
setup_git_identity(&repo, &sign_env)?;
run_save_signed(paths, message, push, dry_run)
} else {
run_save_unsigned(paths, message, push, dry_run)
}
}
fn run_save_signed(
paths: &[std::path::PathBuf],
message: &str,
push: bool,
dry_run: bool,
) -> Result<()> {
let pcu_config = build_pcu_config()?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let client = rt
.block_on(pcu::Client::new_with(&pcu_config))
.map_err(|e| anyhow::anyhow!("Failed to create pcu client: {}", e))?;
use pcu::GitOps;
let path_refs: Vec<&std::path::Path> = paths.iter().map(|p| p.as_path()).collect();
client
.stage_paths(&path_refs)
.map_err(|e| anyhow::anyhow!("Failed to stage paths: {e}"))?;
let repo = git2::Repository::discover(".")
.map_err(|e| anyhow::anyhow!("Not inside a git repository: {}", e))?;
let mut index = repo.index()?;
let head_commit = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
let diff = save_compute_diff(&repo, &mut index, head_commit.as_ref())?;
if diff.deltas().count() == 0 {
println!("Nothing to commit — working tree clean after staging.");
return Ok(());
}
if dry_run {
save_print_dry_run(&diff, message, push);
return Ok(());
}
let sign_config = pcu::SignConfig::new(pcu::Sign::Gpg);
client
.commit_staged(sign_config, message, "", None)
.map_err(|e| anyhow::anyhow!("Failed to sign and commit: {}", e))?;
println!("Created signed commit: {message}");
if push {
let bot_name = std::env::var("BOT_USER_NAME").unwrap_or_else(|_| "bot".to_string());
client
.push_commit("", None, false, &bot_name)
.map_err(|e| anyhow::anyhow!("Failed to push: {}", e))?;
println!("Pushed to remote.");
}
Ok(())
}
fn run_save_unsigned(
paths: &[std::path::PathBuf],
message: &str,
push: bool,
dry_run: bool,
) -> Result<()> {
let repo = git2::Repository::discover(".")
.map_err(|e| anyhow::anyhow!("Not inside a git repository: {}", e))?;
let mut index = repo.index()?;
let path_strs: Vec<&str> = paths.iter().filter_map(|p| p.to_str()).collect();
index
.add_all(path_strs.iter(), git2::IndexAddOption::DEFAULT, None)
.map_err(|e| anyhow::anyhow!("Failed to stage paths: {e}"))?;
index.write()?;
let head_commit = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
let diff = save_compute_diff(&repo, &mut index, head_commit.as_ref())?;
if diff.deltas().count() == 0 {
println!("Nothing to commit — working tree clean after staging.");
return Ok(());
}
if dry_run {
save_print_dry_run(&diff, message, push);
return Ok(());
}
let oid = save_create_commit(&repo, &mut index, message, head_commit.as_ref())?;
tracing::info!(commit = %oid, "Created commit");
println!("Created commit {oid}: {message}");
if push {
save_git_push(&repo)?;
}
Ok(())
}
fn save_compute_diff<'repo>(
repo: &'repo git2::Repository,
index: &mut git2::Index,
head_commit: Option<&git2::Commit<'_>>,
) -> Result<git2::Diff<'repo>> {
let new_tree_oid = index.write_tree()?;
let new_tree = repo.find_tree(new_tree_oid)?;
let head_tree = head_commit.map(|c| c.tree()).transpose()?;
Ok(repo.diff_tree_to_tree(head_tree.as_ref(), Some(&new_tree), None)?)
}
fn save_print_dry_run(diff: &git2::Diff<'_>, message: &str, push: bool) {
println!("Would commit the following changes:");
for delta in diff.deltas() {
let path = delta
.new_file()
.path()
.and_then(|p| p.to_str())
.unwrap_or("(unknown)");
println!(" {path}");
}
println!("Commit message: {message}");
if push {
println!("Would push after committing.");
}
}
fn save_create_commit(
repo: &git2::Repository,
index: &mut git2::Index,
message: &str,
head_commit: Option<&git2::Commit<'_>>,
) -> Result<git2::Oid> {
let sig = repo.signature()?;
let new_tree_oid = index.write_tree()?;
let new_tree = repo.find_tree(new_tree_oid)?;
let parents: Vec<&git2::Commit> = head_commit.into_iter().collect();
Ok(repo.commit(Some("HEAD"), &sig, &sig, message, &new_tree, &parents)?)
}
fn save_git_push(repo: &git2::Repository) -> Result<()> {
let remote_name = repo
.remotes()?
.iter()
.flatten()
.next()
.map(|s| s.to_string())
.unwrap_or_else(|| "origin".to_string());
let mut callbacks = git2::RemoteCallbacks::new();
let git_config = repo.config()?;
let mut cred_handler = git2_credentials::CredentialHandler::new(git_config);
callbacks.credentials(move |url, username, allowed| {
cred_handler.try_next_credential(url, username, allowed)
});
let mut push_opts = git2::PushOptions::new();
push_opts.remote_callbacks(callbacks);
let head_ref = repo.head()?;
let branch_name = head_ref
.shorthand()
.ok_or_else(|| anyhow::anyhow!("HEAD has no branch name"))?;
let refspec = format!("refs/heads/{branch_name}:refs/heads/{branch_name}");
let mut remote = repo.find_remote(&remote_name)?;
remote
.push(&[refspec.as_str()], Some(&mut push_opts))
.map_err(|e| anyhow::anyhow!("Push failed: {}", e))?;
println!("Pushed to {remote_name}/{branch_name}");
Ok(())
}
fn run_publish(
binary: &std::path::Path,
asset_name: &str,
tag: Option<&str>,
dry_run: bool,
) -> Result<()> {
if !binary.exists() {
anyhow::bail!("Binary not found: {}", binary.display());
}
let resolved_tag = match tag {
Some(t) => t.to_string(),
None => std::env::var("CIRCLE_TAG").map_err(|_| {
anyhow::anyhow!("No release tag provided. Set CIRCLE_TAG or use --tag <TAG>")
})?,
};
if dry_run {
let owner = std::env::var("CIRCLE_PROJECT_USERNAME").unwrap_or_default();
let repo_name = std::env::var("CIRCLE_PROJECT_REPONAME").unwrap_or_default();
println!("Would upload release asset (dry run):");
println!(" Binary: {}", binary.display());
println!(" Asset name: {asset_name}");
println!(" Tag: {resolved_tag}");
if !owner.is_empty() && !repo_name.is_empty() {
println!(" Repo: {owner}/{repo_name}");
}
return Ok(());
}
let pcu_config = build_pcu_config()?;
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(async {
let client = pcu::Client::new_with(&pcu_config)
.await
.map_err(|e| anyhow::anyhow!("Failed to create pcu client: {e}"))?;
client
.upload_release_asset(&resolved_tag, binary, asset_name)
.await
.map_err(|e| anyhow::anyhow!("Failed to upload release asset: {e}"))
})
}
fn run_build(
input: &std::path::Path,
name: Option<&str>,
target: Option<&str>,
dry_run: bool,
) -> Result<()> {
let cargo_toml = input.join("Cargo.toml");
if !cargo_toml.exists() {
anyhow::bail!(
"No Cargo.toml found in input directory: {}",
input.display()
);
}
let binary_name = match name {
Some(n) => n.to_string(),
None => read_crate_name(input)?,
};
let mut cargo_args = vec!["build", "--release"];
if let Some(t) = target {
cargo_args.extend(["--target", t]);
}
let binary_dir = match target {
Some(t) => input.join("target").join(t).join("release"),
None => input.join("target").join("release"),
};
let binary_path = binary_dir.join(&binary_name);
if dry_run {
println!("Would run: cargo {}", cargo_args.join(" "));
println!(" Input: {}", input.display());
println!(" Binary: {}", binary_path.display());
return Ok(());
}
tracing::info!(input = %input.display(), binary = %binary_path.display(), "Compiling MCP server");
println!("Compiling MCP server...");
let status = std::process::Command::new("cargo")
.args(&cargo_args)
.current_dir(input)
.status()
.map_err(|e| anyhow::anyhow!("Failed to run cargo: {}", e))?;
if !status.success() {
anyhow::bail!(
"cargo build failed. Source code is available at: {}",
input.display()
);
}
println!("Successfully compiled MCP server:");
println!(" Binary: {}", binary_path.display());
Ok(())
}
fn read_crate_name(input: &std::path::Path) -> Result<String> {
let content = std::fs::read_to_string(input.join("Cargo.toml"))
.map_err(|e| anyhow::anyhow!("Failed to read Cargo.toml: {}", e))?;
parse_package_name(&content)
.ok_or_else(|| anyhow::anyhow!("Could not find [package] name in Cargo.toml"))
}
fn parse_package_name(toml: &str) -> Option<String> {
let mut in_package = false;
for line in toml.lines() {
let trimmed = line.trim();
if trimmed == "[package]" {
in_package = true;
} else if trimmed.starts_with('[') {
in_package = false;
} else if in_package {
if let Some(name) = parse_name_assignment(trimmed) {
return Some(name);
}
}
}
None
}
fn parse_name_assignment(line: &str) -> Option<String> {
let rest = line.strip_prefix("name")?;
let rest = rest.trim().strip_prefix('=')?;
let name = rest.trim().trim_matches('"').trim_matches('\'').to_string();
(!name.is_empty()).then_some(name)
}
fn find_git_root(start: &std::path::Path) -> Result<std::path::PathBuf> {
let start = start
.canonicalize()
.map_err(|e| anyhow::anyhow!("Cannot access orb path '{}': {}", start.display(), e))?;
let mut dir = if start.is_file() {
start.parent().unwrap_or(&start).to_path_buf()
} else {
start.to_path_buf()
};
loop {
if dir.join(".git").exists() {
return Ok(dir);
}
match dir.parent() {
Some(p) => dir = p.to_path_buf(),
None => anyhow::bail!(
"Could not find git repository root starting from '{}'",
start.display()
),
}
}
}
fn derive_orb_name(path: &std::path::Path) -> String {
let filename = path.file_name().and_then(|s| s.to_str()).unwrap_or("orb");
if filename == "@orb.yml" {
let parent = path.parent();
let parent_name = parent.and_then(|p| p.file_name()).and_then(|s| s.to_str());
if parent_name == Some("src") {
parent
.and_then(|p| p.parent())
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
.unwrap_or("orb")
.to_string()
} else {
parent_name.unwrap_or("orb").to_string()
}
} else {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("orb")
.to_string()
}
}
fn discover_latest_version(repo: &std::path::Path, tag_prefix: &str) -> Result<Option<String>> {
use primer::discover_tags;
let tags = discover_tags(repo, tag_prefix).unwrap_or_default();
Ok(tags.into_iter().last())
}
fn resolve_version(
output: &std::path::Path,
version: Option<&str>,
force: bool,
git_hint: Option<&str>,
) -> Result<String> {
let cargo_toml = output.join("Cargo.toml");
let output_exists = cargo_toml.exists();
if let Some(v) = version {
if output_exists && !force {
anyhow::bail!(
"Output directory '{}' already exists. Use --force to overwrite.",
output.display()
);
}
tracing::debug!("Using provided version");
return Ok(v.to_string());
}
if let Some(v) = git_hint {
if output_exists && !force {
anyhow::bail!(
"Output directory '{}' already exists. Use --force to overwrite.",
output.display()
);
}
tracing::debug!(version = %v, "Using git-discovered version");
return Ok(v.to_string());
}
let msg = if output_exists {
format!(
"Output directory '{}' already exists and no version could be determined.\n\
Provide the version explicitly:\n\n\
\x20 gen-orb-mcp generate --orb-path <PATH> --output {} --crate-version <VERSION> --force\n\n\
Or ensure --orb-path is inside a git repository with version tags (e.g. v6.0.0).\n\
Use --tag-prefix if your tags use a non-standard prefix.",
output.display(),
output.display()
)
} else {
format!(
"No version could be determined for the generated MCP server.\n\
Provide the version explicitly:\n\n\
\x20 gen-orb-mcp generate --orb-path <PATH> --output {} --crate-version <VERSION>\n\n\
Or ensure --orb-path is inside a git repository with version tags (e.g. v6.0.0).\n\
Use --tag-prefix if your tags use a non-standard prefix.",
output.display()
)
};
anyhow::bail!(msg)
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::*;
#[test]
fn test_cli_parse_generate() {
let cli = Cli::try_parse_from([
"gen-orb-mcp",
"generate",
"--orb-path",
"test.yml",
"--output",
"./out",
]);
assert!(cli.is_ok());
}
#[test]
fn test_cli_parse_generate_default_orb_path() {
let cli = Cli::try_parse_from(["gen-orb-mcp", "generate"]);
assert!(
cli.is_ok(),
"generate should work without --orb-path (default: src/@orb.yml)"
);
if let Ok(Cli {
command: Commands::Generate { orb_path, .. },
}) = cli
{
assert_eq!(orb_path, std::path::PathBuf::from("src/@orb.yml"));
}
}
#[test]
fn test_cli_parse_validate_default_orb_path() {
let cli = Cli::try_parse_from(["gen-orb-mcp", "validate"]);
assert!(
cli.is_ok(),
"validate should work without --orb-path (default: src/@orb.yml)"
);
if let Ok(Cli {
command: Commands::Validate { orb_path },
}) = cli
{
assert_eq!(orb_path, std::path::PathBuf::from("src/@orb.yml"));
}
}
#[test]
fn test_cli_parse_generate_with_crate_version_legacy() {
let cli = Cli::try_parse_from([
"gen-orb-mcp",
"generate",
"--orb-path",
"test.yml",
"--output",
"./out",
"--crate-version",
"1.2.3",
]);
assert!(cli.is_ok());
}
#[test]
fn test_cli_parse_generate_with_force() {
let cli = Cli::try_parse_from([
"gen-orb-mcp",
"generate",
"--orb-path",
"test.yml",
"--output",
"./out",
"--crate-version",
"1.2.3",
"--force",
]);
assert!(cli.is_ok());
}
#[test]
fn test_cli_parse_generate_with_crate_version() {
let cli = Cli::try_parse_from([
"gen-orb-mcp",
"generate",
"--orb-path",
"test.yml",
"--output",
"./out",
"--crate-version",
"1.2.3",
]);
assert!(cli.is_ok(), "--crate-version should be accepted");
}
#[test]
fn test_cli_parse_generate_version_flag_rejected() {
let cli = Cli::try_parse_from([
"gen-orb-mcp",
"generate",
"--orb-path",
"test.yml",
"--output",
"./out",
"--version",
"1.2.3",
]);
assert!(
cli.is_err(),
"--version should be rejected (conflicts with clap built-in)"
);
}
#[test]
fn test_cli_parse_validate() {
let cli = Cli::try_parse_from(["gen-orb-mcp", "validate", "--orb-path", "test.yml"]);
assert!(cli.is_ok());
}
#[test]
fn test_derive_orb_name_from_orb_yml() {
use std::path::Path;
let path = Path::new("/path/to/my-toolkit/src/@orb.yml");
assert_eq!(derive_orb_name(path), "my-toolkit");
let path = Path::new("my-orb/@orb.yml");
assert_eq!(derive_orb_name(path), "my-orb");
let path = Path::new("src/@orb.yml");
assert_eq!(derive_orb_name(path), "orb");
}
#[test]
fn test_derive_orb_name_from_packed() {
use std::path::Path;
let path = Path::new("/path/to/my-toolkit.yml");
assert_eq!(derive_orb_name(path), "my-toolkit");
let path = Path::new("orb.yml");
assert_eq!(derive_orb_name(path), "orb");
}
#[test]
fn test_resolve_version_fresh_with_explicit() {
let temp_dir = TempDir::new().unwrap();
let result = resolve_version(temp_dir.path(), Some("2.0.0"), false, None);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "2.0.0");
}
#[test]
fn test_resolve_version_fresh_no_version_errors() {
let temp_dir = TempDir::new().unwrap();
let result = resolve_version(temp_dir.path(), None, false, None);
assert!(result.is_err());
}
#[test]
fn test_resolve_version_existing_without_version_fails() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(
temp_dir.path().join("Cargo.toml"),
"[package]\nname = \"test\"",
)
.unwrap();
let result = resolve_version(temp_dir.path(), None, false, None);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("already exists"));
assert!(err.contains("--crate-version"));
}
#[test]
fn test_resolve_version_existing_with_version_no_force_fails() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(
temp_dir.path().join("Cargo.toml"),
"[package]\nname = \"test\"",
)
.unwrap();
let result = resolve_version(temp_dir.path(), Some("1.5.0"), false, None);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("--force"));
}
#[test]
fn test_resolve_version_existing_with_version_and_force_succeeds() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(
temp_dir.path().join("Cargo.toml"),
"[package]\nname = \"test\"",
)
.unwrap();
let result = resolve_version(temp_dir.path(), Some("1.5.0"), true, None);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "1.5.0");
}
#[test]
fn test_cli_parse_generate_with_prior_versions() {
let cli = Cli::try_parse_from([
"gen-orb-mcp",
"generate",
"--orb-path",
"test.yml",
"--output",
"./out",
"--prior-versions",
"./prior",
]);
assert!(cli.is_ok(), "expected --prior-versions flag to be accepted");
}
#[test]
fn test_cli_parse_prime_defaults() {
let cli = Cli::try_parse_from(["gen-orb-mcp", "prime"]);
assert!(cli.is_ok(), "prime with all defaults should parse");
if let Commands::Prime {
orb_path,
tag_prefix,
earliest_version,
since,
prior_versions_dir,
migrations_dir,
rename_map,
ephemeral,
dry_run,
git_repo,
} = cli.unwrap().command
{
assert_eq!(orb_path.to_str().unwrap(), "src/@orb.yml");
assert_eq!(tag_prefix, "v");
assert!(earliest_version.is_none());
assert!(since.is_none());
assert_eq!(prior_versions_dir.to_str().unwrap(), "prior-versions");
assert_eq!(migrations_dir.to_str().unwrap(), "migrations");
assert!(rename_map.is_empty());
assert!(!ephemeral);
assert!(!dry_run);
assert!(git_repo.is_none());
} else {
panic!("expected Prime variant");
}
}
#[test]
fn test_cli_parse_prime_earliest_version() {
let cli = Cli::try_parse_from(["gen-orb-mcp", "prime", "--earliest-version", "4.1.0"]);
assert!(cli.is_ok(), "prime --earliest-version should parse");
if let Commands::Prime {
earliest_version, ..
} = cli.unwrap().command
{
assert_eq!(earliest_version.as_deref(), Some("4.1.0"));
} else {
panic!("expected Prime variant");
}
}
#[test]
fn test_cli_parse_prime_since() {
let cli = Cli::try_parse_from(["gen-orb-mcp", "prime", "--since", "3 months"]);
assert!(cli.is_ok(), "prime --since should parse");
if let Commands::Prime { since, .. } = cli.unwrap().command {
assert_eq!(since.as_deref(), Some("3 months"));
} else {
panic!("expected Prime variant");
}
}
#[test]
fn test_cli_parse_prime_exclusive_flags() {
let cli = Cli::try_parse_from([
"gen-orb-mcp",
"prime",
"--earliest-version",
"4.1.0",
"--since",
"6 months",
]);
assert!(
cli.is_err(),
"prime with both --earliest-version and --since should be rejected"
);
}
#[test]
fn test_cli_parse_prime_rename_map() {
let cli = Cli::try_parse_from([
"gen-orb-mcp",
"prime",
"--rename-map",
"common_tests_rolling=common_tests",
"--rename-map",
"required_builds_rolling=required_builds",
]);
assert!(cli.is_ok(), "prime --rename-map should parse");
if let Commands::Prime { rename_map, .. } = cli.unwrap().command {
assert_eq!(rename_map.len(), 2);
assert!(rename_map.contains(&"common_tests_rolling=common_tests".to_string()));
assert!(rename_map.contains(&"required_builds_rolling=required_builds".to_string()));
} else {
panic!("expected Prime variant");
}
}
#[test]
fn test_cli_parse_prime_ephemeral() {
let cli = Cli::try_parse_from(["gen-orb-mcp", "prime", "--ephemeral"]);
assert!(cli.is_ok(), "prime --ephemeral should parse");
if let Commands::Prime { ephemeral, .. } = cli.unwrap().command {
assert!(ephemeral);
} else {
panic!("expected Prime variant");
}
}
static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn test_find_git_root_returns_absolute_path_for_relative_input() {
let _cwd_guard = CWD_LOCK.lock().unwrap();
let original = std::env::current_dir().unwrap();
let tmp = TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::write(
tmp.path().join("src").join("@orb.yml"),
"version: 2.1\ndescription: test",
)
.unwrap();
std::env::set_current_dir(tmp.path()).unwrap();
let result = find_git_root(std::path::Path::new("src/@orb.yml"));
std::env::set_current_dir(&original).unwrap();
let result = result.expect("find_git_root should succeed");
assert!(
result.is_absolute(),
"find_git_root must return an absolute path, got: {:?}",
result
);
assert_eq!(
result.canonicalize().unwrap(),
tmp.path().canonicalize().unwrap(),
);
}
#[test]
fn test_discover_latest_version_returns_none_for_no_tags() {
let tmp = TempDir::new().unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(tmp.path())
.output()
.unwrap();
let result = discover_latest_version(tmp.path(), "v");
assert!(result.is_ok());
assert_eq!(result.unwrap(), None);
}
#[test]
fn test_discover_latest_version_returns_highest_semver_tag() {
let tmp = TempDir::new().unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(tmp.path())
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(tmp.path())
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(tmp.path())
.output()
.unwrap();
std::fs::write(tmp.path().join("README.md"), "test").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(tmp.path())
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(tmp.path())
.output()
.unwrap();
for tag in ["v1.0.0", "v2.0.0", "v1.5.0"] {
std::process::Command::new("git")
.args(["tag", tag])
.current_dir(tmp.path())
.output()
.unwrap();
}
let result = discover_latest_version(tmp.path(), "v");
assert!(result.is_ok());
assert_eq!(result.unwrap(), Some("2.0.0".to_string()));
}
#[test]
fn test_resolve_version_uses_git_hint_when_no_explicit_version() {
let temp_dir = TempDir::new().unwrap();
let result = resolve_version(temp_dir.path(), None, false, Some("3.1.0"));
assert!(result.is_ok());
assert_eq!(result.unwrap(), "3.1.0");
}
#[test]
fn test_resolve_version_explicit_overrides_git_hint() {
let temp_dir = TempDir::new().unwrap();
let result = resolve_version(temp_dir.path(), Some("5.0.0"), false, Some("3.1.0"));
assert!(result.is_ok());
assert_eq!(result.unwrap(), "5.0.0");
}
#[test]
fn test_resolve_version_errors_without_version_or_hint() {
let temp_dir = TempDir::new().unwrap();
let result = resolve_version(temp_dir.path(), None, false, None);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("No version could be determined"), "got: {msg}");
}
#[test]
fn test_cli_parse_generate_with_tag_prefix() {
let cli = Cli::try_parse_from([
"gen-orb-mcp",
"generate",
"--orb-path",
"test.yml",
"--output",
"./out",
"--tag-prefix",
"orb-v",
]);
assert!(cli.is_ok(), "generate --tag-prefix should parse");
if let Commands::Generate { tag_prefix, .. } = cli.unwrap().command {
assert_eq!(tag_prefix, "orb-v");
} else {
panic!("expected Generate variant");
}
}
#[test]
fn test_cli_parse_generate_tag_prefix_defaults_to_v() {
let cli = Cli::try_parse_from([
"gen-orb-mcp",
"generate",
"--orb-path",
"test.yml",
"--output",
"./out",
]);
assert!(cli.is_ok());
if let Commands::Generate { tag_prefix, .. } = cli.unwrap().command {
assert_eq!(tag_prefix, "v");
} else {
panic!("expected Generate variant");
}
}
fn init_git_repo(dir: &std::path::Path) {
std::process::Command::new("git")
.args(["init"])
.current_dir(dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(dir)
.output()
.unwrap();
std::fs::write(dir.join("README.md"), "test").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(dir)
.output()
.unwrap();
}
#[test]
fn test_save_clean_tree_exits_without_commit() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
let _cwd_guard = CWD_LOCK.lock().unwrap();
let original = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let result = run_save(
&[std::path::PathBuf::from("README.md")],
"chore: test",
false,
false,
false,
);
std::env::set_current_dir(&original).unwrap();
assert!(
result.is_ok(),
"clean tree should exit 0 without creating a commit: {result:?}"
);
}
#[test]
fn test_save_changed_path_creates_commit() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
std::fs::write(dir.path().join("new-file.txt"), "hello").unwrap();
let _cwd_guard = CWD_LOCK.lock().unwrap();
let original = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let result = run_save(
&[std::path::PathBuf::from("new-file.txt")],
"chore: add generated file",
false,
false,
false,
);
std::env::set_current_dir(&original).unwrap();
assert!(
result.is_ok(),
"changed path should commit successfully: {result:?}"
);
let log = std::process::Command::new("git")
.args(["log", "--oneline"])
.current_dir(dir.path())
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(
log_str.lines().count() >= 2,
"expected at least 2 commits, got: {log_str}"
);
}
#[test]
fn test_save_directory_path_stages_contents() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
let subdir = dir.path().join("generated");
std::fs::create_dir(&subdir).unwrap();
std::fs::write(subdir.join("a.json"), r#"{"v": 1}"#).unwrap();
std::fs::write(subdir.join("b.json"), r#"{"v": 2}"#).unwrap();
let _cwd_guard = CWD_LOCK.lock().unwrap();
let original = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let result = run_save(
&[std::path::PathBuf::from("generated")],
"chore: add generated dir",
false,
false,
false,
);
std::env::set_current_dir(&original).unwrap();
assert!(
result.is_ok(),
"directory path should stage all contents and commit: {result:?}"
);
let log = std::process::Command::new("git")
.args(["log", "--oneline"])
.current_dir(dir.path())
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(
log_str.lines().count() >= 2,
"expected at least 2 commits after staging directory, got: {log_str}"
);
}
#[test]
fn test_save_dry_run_does_not_commit() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
std::fs::write(dir.path().join("artifact.txt"), "generated").unwrap();
let _cwd_guard = CWD_LOCK.lock().unwrap();
let original = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let result = run_save(
&[std::path::PathBuf::from("artifact.txt")],
"chore: generated",
false,
true,
false,
);
std::env::set_current_dir(&original).unwrap();
assert!(result.is_ok(), "dry_run should succeed: {result:?}");
let log = std::process::Command::new("git")
.args(["log", "--oneline"])
.current_dir(dir.path())
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert_eq!(
log_str.lines().count(),
1,
"dry_run must not create a commit, got: {log_str}"
);
}
#[test]
fn test_cli_parse_save_required_paths() {
let cli = Cli::try_parse_from([
"gen-orb-mcp",
"save",
"--paths",
"prior-versions",
"--paths",
"migrations",
]);
assert!(cli.is_ok(), "save with --paths should parse");
}
#[test]
fn test_cli_parse_save_sign_flag() {
let cli =
Cli::try_parse_from(["gen-orb-mcp", "save", "--paths", "prior-versions", "--sign"]);
assert!(
cli.is_ok(),
"--sign flag should be accepted on save command"
);
if let Commands::Save { sign, .. } = cli.unwrap().command {
assert!(sign, "--sign should be true when flag is passed");
} else {
panic!("expected Save variant");
}
}
#[test]
fn test_read_sign_env_missing_bot_gpg_key() {
let prev = std::env::var("BOT_GPG_KEY").ok();
std::env::remove_var("BOT_GPG_KEY");
let result = read_sign_env();
if let Some(v) = prev {
std::env::set_var("BOT_GPG_KEY", v);
}
assert!(result.is_err(), "should fail when BOT_GPG_KEY is absent");
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("BOT_GPG_KEY"),
"error should mention BOT_GPG_KEY, got: {msg}"
);
}
#[test]
fn test_cli_parse_save_all_flags() {
let cli = Cli::try_parse_from([
"gen-orb-mcp",
"save",
"--paths",
"prior-versions",
"--message",
"custom message",
"--no-push",
"--dry-run",
]);
assert!(cli.is_ok(), "save with all flags should parse");
if let Commands::Save {
paths,
message,
no_push,
dry_run,
..
} = cli.unwrap().command
{
assert_eq!(paths, vec![std::path::PathBuf::from("prior-versions")]);
assert_eq!(message, "custom message");
assert!(no_push);
assert!(dry_run);
} else {
panic!("expected Save variant");
}
}
#[test]
fn test_publish_missing_binary_returns_error() {
let dir = TempDir::new().unwrap();
let result = run_publish(
&dir.path().join("missing-binary"),
"asset.tar.gz",
None,
false,
);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("Binary not found"),
"error should mention missing binary, got: {msg}"
);
}
#[test]
fn test_publish_dry_run_succeeds_without_token() {
let dir = TempDir::new().unwrap();
let binary = dir.path().join("my-binary");
std::fs::write(&binary, b"fake binary").unwrap();
std::env::remove_var("GITHUB_TOKEN");
let result = run_publish(&binary, "my-asset", Some("v1.0.0"), true);
assert!(
result.is_ok(),
"dry_run should not require credentials: {result:?}"
);
}
#[test]
fn test_publish_dry_run_missing_tag_returns_error() {
let dir = TempDir::new().unwrap();
let binary = dir.path().join("my-binary");
std::fs::write(&binary, b"fake binary").unwrap();
std::env::set_var("GITHUB_TOKEN", "fake-token");
std::env::remove_var("CIRCLE_TAG");
let result = run_publish(&binary, "my-asset", None, true);
std::env::remove_var("GITHUB_TOKEN");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("tag") || msg.contains("CIRCLE_TAG"),
"error should mention tag or CIRCLE_TAG, got: {msg}"
);
}
#[test]
fn test_publish_dry_run_prints_parameters() {
let dir = TempDir::new().unwrap();
let binary = dir.path().join("my-binary");
std::fs::write(&binary, b"fake binary").unwrap();
std::env::set_var("GITHUB_TOKEN", "fake-token");
std::env::set_var("CIRCLE_PROJECT_USERNAME", "jerus-org");
std::env::set_var("CIRCLE_PROJECT_REPONAME", "my-orb");
let result = run_publish(&binary, "my-asset-linux-x86_64", Some("v1.0.0"), true);
std::env::remove_var("GITHUB_TOKEN");
std::env::remove_var("CIRCLE_PROJECT_USERNAME");
std::env::remove_var("CIRCLE_PROJECT_REPONAME");
assert!(
result.is_ok(),
"dry_run with all params should succeed: {result:?}"
);
}
#[test]
fn test_cli_parse_publish_required_args() {
let cli = Cli::try_parse_from([
"gen-orb-mcp",
"publish",
"--binary",
"/tmp/my-binary",
"--asset-name",
"my-binary-linux-x86_64",
]);
assert!(cli.is_ok(), "publish with required args should parse");
}
#[test]
fn test_cli_parse_publish_all_flags() {
let cli = Cli::try_parse_from([
"gen-orb-mcp",
"publish",
"--binary",
"/tmp/my-binary",
"--asset-name",
"my-binary-linux-x86_64",
"--tag",
"v2.0.0",
"--dry-run",
]);
assert!(cli.is_ok(), "publish with all flags should parse");
if let Commands::Publish {
binary,
asset_name,
tag,
dry_run,
} = cli.unwrap().command
{
assert_eq!(binary.to_str().unwrap(), "/tmp/my-binary");
assert_eq!(asset_name, "my-binary-linux-x86_64");
assert_eq!(tag.as_deref(), Some("v2.0.0"));
assert!(dry_run);
} else {
panic!("expected Publish variant");
}
}
fn write_cargo_toml(dir: &std::path::Path, name: &str) {
std::fs::write(
dir.join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
)
.unwrap();
}
#[test]
fn test_build_missing_cargo_toml_returns_error() {
let dir = TempDir::new().unwrap();
let result = run_build(dir.path(), None, None, false);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("Cargo.toml"),
"error should mention Cargo.toml, got: {msg}"
);
}
#[test]
fn test_build_dry_run_does_not_invoke_cargo() {
let dir = TempDir::new().unwrap();
write_cargo_toml(dir.path(), "my-server");
let result = run_build(dir.path(), None, None, true);
assert!(
result.is_ok(),
"dry_run should succeed without invoking cargo: {result:?}"
);
}
#[test]
fn test_build_name_override_accepted_in_dry_run() {
let dir = TempDir::new().unwrap();
write_cargo_toml(dir.path(), "my-server");
let result = run_build(dir.path(), Some("custom-name"), None, true);
assert!(
result.is_ok(),
"name override + dry_run should succeed: {result:?}"
);
}
#[test]
fn test_build_target_triple_accepted_in_dry_run() {
let dir = TempDir::new().unwrap();
write_cargo_toml(dir.path(), "my-server");
let result = run_build(dir.path(), None, Some("x86_64-unknown-linux-musl"), true);
assert!(
result.is_ok(),
"target + dry_run should succeed: {result:?}"
);
}
#[test]
fn test_parse_package_name_extracts_name() {
let toml = "[package]\nname = \"my-orb-mcp\"\nversion = \"0.1.0\"\n";
assert_eq!(
parse_package_name(toml),
Some("my-orb-mcp".to_string()),
"should extract package name"
);
}
#[test]
fn test_parse_package_name_stops_at_next_section() {
let toml = "[package]\nname = \"my-orb-mcp\"\n[dependencies]\nname = \"ignored\"\n";
assert_eq!(parse_package_name(toml), Some("my-orb-mcp".to_string()));
}
#[test]
fn test_parse_package_name_returns_none_when_absent() {
let toml = "[dependencies]\nanyhow = \"1\"\n";
assert_eq!(parse_package_name(toml), None);
}
#[test]
fn test_read_crate_name_from_file() {
let dir = TempDir::new().unwrap();
write_cargo_toml(dir.path(), "test-crate");
let result = read_crate_name(dir.path());
assert!(result.is_ok(), "read_crate_name should succeed: {result:?}");
assert_eq!(result.unwrap(), "test-crate");
}
#[test]
fn test_cli_parse_build_required_input() {
let cli = Cli::try_parse_from(["gen-orb-mcp", "build", "--input", "/tmp/my-server"]);
assert!(cli.is_ok(), "build --input should parse");
}
#[test]
fn test_cli_parse_build_all_flags() {
let cli = Cli::try_parse_from([
"gen-orb-mcp",
"build",
"--input",
"/tmp/my-server",
"--name",
"my_server",
"--target",
"x86_64-unknown-linux-musl",
"--dry-run",
]);
assert!(cli.is_ok(), "build with all flags should parse");
if let Commands::Build {
input,
name,
target,
dry_run,
} = cli.unwrap().command
{
assert_eq!(input.to_str().unwrap(), "/tmp/my-server");
assert_eq!(name.as_deref(), Some("my_server"));
assert_eq!(target.as_deref(), Some("x86_64-unknown-linux-musl"));
assert!(dry_run);
} else {
panic!("expected Build variant");
}
}
}