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 = None)]
pub struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
Generate {
#[arg(short = 'p', long)]
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(short = 'V', long)]
version: Option<String>,
#[arg(long)]
force: bool,
#[arg(long)]
migrations: Option<std::path::PathBuf>,
#[arg(long)]
prior_versions: Option<std::path::PathBuf>,
},
Validate {
#[arg(short = 'p', long)]
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)]
dry_run: bool,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum OutputFormat {
Binary,
Source,
}
const DEFAULT_VERSION: &str = "0.1.0";
struct GenerateExtras<'a> {
migrations: &'a Option<std::path::PathBuf>,
prior_versions_dir: &'a Option<std::path::PathBuf>,
}
impl Cli {
pub fn run(&self) -> Result<()> {
match &self.command {
Commands::Generate {
orb_path,
output,
format,
name,
version,
force,
migrations,
prior_versions,
} => run_generate(
orb_path,
output,
format,
name,
version,
*force,
GenerateExtras {
migrations,
prior_versions_dir: prior_versions,
},
),
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,
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,
*ephemeral,
*dry_run,
),
}
}
}
fn run_generate(
orb_path: &std::path::PathBuf,
output: &std::path::PathBuf,
format: &OutputFormat,
name: &Option<String>,
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 resolved_version = resolve_version(output, version.as_deref(), force)?;
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,
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 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,
};
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(())
}
fn find_git_root(start: &std::path::Path) -> Result<std::path::PathBuf> {
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 resolve_version(output: &std::path::Path, version: Option<&str>, force: bool) -> Result<String> {
let cargo_toml = output.join("Cargo.toml");
let output_exists = cargo_toml.exists();
match (version, output_exists) {
(Some(v), false) => {
tracing::debug!("Using provided version for fresh generation");
Ok(v.to_string())
}
(Some(v), true) => {
if !force {
anyhow::bail!(
"Output directory '{}' already exists. Use --force to overwrite.",
output.display()
);
}
tracing::debug!("Using provided version, overwriting existing output");
Ok(v.to_string())
}
(None, false) => {
tracing::debug!("Fresh generation with default version");
Ok(DEFAULT_VERSION.to_string())
}
(None, true) => {
anyhow::bail!(
"Output directory '{}' already exists.\n\
To regenerate, you must specify the version explicitly:\n\n\
\x20 gen-orb-mcp generate --orb-path <PATH> --output {} --version <VERSION> --force\n\n\
For CI release workflows, use the orb release version (e.g., --version 1.6.0).",
output.display(),
output.display()
);
}
}
}
#[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_with_version() {
let cli = Cli::try_parse_from([
"gen-orb-mcp",
"generate",
"--orb-path",
"test.yml",
"--output",
"./out",
"--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",
"--version",
"1.2.3",
"--force",
]);
assert!(cli.is_ok());
}
#[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);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "2.0.0");
}
#[test]
fn test_resolve_version_fresh_with_default() {
let temp_dir = TempDir::new().unwrap();
let result = resolve_version(temp_dir.path(), None, false);
assert!(result.is_ok());
assert_eq!(result.unwrap(), DEFAULT_VERSION);
}
#[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);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("already exists"));
assert!(err.contains("--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);
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);
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,
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!(!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_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");
}
}
}