1pub mod conformance_rule;
16pub mod consumer_parser;
17pub mod differ;
18pub mod generator;
19pub mod migrator;
20pub mod parser;
21pub mod primer;
22
23use anyhow::Result;
24use clap::{Parser, Subcommand};
25use generator::CodeGenerator;
26use parser::OrbParser;
27
28#[derive(Debug, Parser)]
30#[command(name = "gen-orb-mcp")]
31#[command(
32 author,
33 version,
34 about,
35 long_about = "Generate MCP servers from CircleCI orb definitions, \
36 exposing commands, jobs, and executors as AI-accessible resources. \
37 Supports migration tooling, prior-version snapshots, and diff-based \
38 conformance rules to help consumers keep their CI config in sync with \
39 orb updates."
40)]
41pub struct Cli {
42 #[command(subcommand)]
43 command: Commands,
44}
45
46#[derive(Debug, Subcommand)]
47enum Commands {
48 Generate {
50 #[arg(short = 'p', long, default_value = "src/@orb.yml")]
52 orb_path: std::path::PathBuf,
53
54 #[arg(short = 'o', long, default_value = "./dist")]
56 output: std::path::PathBuf,
57
58 #[arg(short, long, value_enum, default_value = "source")]
60 format: OutputFormat,
61
62 #[arg(short, long)]
64 name: Option<String>,
65
66 #[arg(long = "crate-version")]
71 crate_version: Option<String>,
72
73 #[arg(long)]
77 force: bool,
78
79 #[arg(long)]
87 migrations: Option<std::path::PathBuf>,
88
89 #[arg(long)]
95 prior_versions: Option<std::path::PathBuf>,
96
97 #[arg(long, default_value = "v")]
102 tag_prefix: String,
103 },
104 Validate {
106 #[arg(short = 'p', long, default_value = "src/@orb.yml")]
108 orb_path: std::path::PathBuf,
109 },
110 Diff {
117 #[arg(long)]
119 current: std::path::PathBuf,
120
121 #[arg(long)]
123 previous: std::path::PathBuf,
124
125 #[arg(long)]
127 since_version: String,
128
129 #[arg(long)]
131 output: Option<std::path::PathBuf>,
132 },
133 Migrate {
139 #[arg(long, default_value = ".circleci")]
141 ci_dir: std::path::PathBuf,
142
143 #[arg(long)]
146 orb: String,
147
148 #[arg(long)]
150 rules: std::path::PathBuf,
151
152 #[arg(long)]
154 dry_run: bool,
155 },
156 Prime {
164 #[arg(short = 'p', long, default_value = "src/@orb.yml")]
166 orb_path: std::path::PathBuf,
167
168 #[arg(long)]
171 git_repo: Option<std::path::PathBuf>,
172
173 #[arg(long, default_value = "v")]
175 tag_prefix: String,
176
177 #[arg(long, conflicts_with = "since")]
179 earliest_version: Option<String>,
180
181 #[arg(long)]
184 since: Option<String>,
185
186 #[arg(long, default_value = "prior-versions")]
188 prior_versions_dir: std::path::PathBuf,
189
190 #[arg(long, default_value = "migrations")]
192 migrations_dir: std::path::PathBuf,
193
194 #[arg(long)]
197 ephemeral: bool,
198
199 #[arg(long, value_name = "OLD=NEW")]
205 rename_map: Vec<String>,
206
207 #[arg(long)]
209 dry_run: bool,
210 },
211 Save {
217 #[arg(long, required = true)]
219 paths: Vec<std::path::PathBuf>,
220
221 #[arg(
223 short = 'm',
224 long,
225 default_value = "chore: update generated MCP server artifacts [skip ci]"
226 )]
227 message: String,
228
229 #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
231 push: bool,
232
233 #[arg(long, conflicts_with = "push")]
235 no_push: bool,
236
237 #[arg(long)]
239 dry_run: bool,
240
241 #[arg(long)]
248 sign: bool,
249 },
250 Publish {
256 #[arg(short = 'b', long)]
258 binary: std::path::PathBuf,
259
260 #[arg(short = 'a', long)]
262 asset_name: String,
263
264 #[arg(long)]
266 tag: Option<String>,
267
268 #[arg(long)]
270 dry_run: bool,
271 },
272 Build {
274 #[arg(short = 'i', long)]
276 input: std::path::PathBuf,
277
278 #[arg(short = 'n', long)]
280 name: Option<String>,
281
282 #[arg(long)]
284 target: Option<String>,
285
286 #[arg(long)]
288 dry_run: bool,
289 },
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
294pub enum OutputFormat {
295 Binary,
297 Source,
299}
300
301struct GenerateExtras<'a> {
303 migrations: &'a Option<std::path::PathBuf>,
304 prior_versions_dir: &'a Option<std::path::PathBuf>,
305 tag_prefix: &'a str,
306}
307
308impl Cli {
309 pub fn run(&self) -> Result<()> {
311 match &self.command {
312 Commands::Generate {
313 orb_path,
314 output,
315 format,
316 name,
317 crate_version,
318 force,
319 migrations,
320 prior_versions,
321 tag_prefix,
322 } => run_generate(
323 orb_path,
324 output,
325 format,
326 name,
327 crate_version,
328 *force,
329 GenerateExtras {
330 migrations,
331 prior_versions_dir: prior_versions,
332 tag_prefix,
333 },
334 ),
335 Commands::Validate { orb_path } => run_validate(orb_path),
336 Commands::Diff {
337 current,
338 previous,
339 since_version,
340 output,
341 } => run_diff(current, previous, since_version, output),
342 Commands::Migrate {
343 ci_dir,
344 orb,
345 rules: rules_path,
346 dry_run,
347 } => run_migrate(ci_dir, orb, rules_path, *dry_run),
348 Commands::Prime {
349 orb_path,
350 git_repo,
351 tag_prefix,
352 earliest_version,
353 since,
354 prior_versions_dir,
355 migrations_dir,
356 rename_map,
357 ephemeral,
358 dry_run,
359 } => run_prime(
360 orb_path,
361 git_repo.as_deref(),
362 tag_prefix,
363 earliest_version.as_deref(),
364 since.as_deref(),
365 prior_versions_dir,
366 migrations_dir,
367 rename_map,
368 *ephemeral,
369 *dry_run,
370 ),
371 Commands::Save {
372 paths,
373 message,
374 push,
375 no_push,
376 dry_run,
377 sign,
378 } => run_save(paths, message, *push && !*no_push, *dry_run, *sign),
379 Commands::Publish {
380 binary,
381 asset_name,
382 tag,
383 dry_run,
384 } => run_publish(binary, asset_name, tag.as_deref(), *dry_run),
385 Commands::Build {
386 input,
387 name,
388 target,
389 dry_run,
390 } => run_build(input, name.as_deref(), target.as_deref(), *dry_run),
391 }
392 }
393}
394
395fn run_generate(
396 orb_path: &std::path::PathBuf,
397 output: &std::path::PathBuf,
398 format: &OutputFormat,
399 name: &Option<String>,
400 crate_version: &Option<String>,
401 force: bool,
402 extras: GenerateExtras<'_>,
403) -> Result<()> {
404 tracing::info!(?orb_path, ?output, ?format, "Generating MCP server");
405
406 let orb = OrbParser::parse(orb_path).map_err(|e| anyhow::anyhow!("{}", e))?;
407 tracing::info!(
408 commands = orb.commands.len(),
409 jobs = orb.jobs.len(),
410 executors = orb.executors.len(),
411 "Parsed orb definition"
412 );
413
414 let orb_name = name.clone().unwrap_or_else(|| derive_orb_name(orb_path));
415
416 let git_hint: Option<String> = match find_git_root(orb_path) {
418 Ok(repo) => discover_latest_version(&repo, extras.tag_prefix)?,
419 Err(_) => None,
420 };
421 let resolved_version =
422 resolve_version(output, crate_version.as_deref(), force, git_hint.as_deref())?;
423 tracing::info!(version = %resolved_version, "Using version");
424
425 let conformance_rules = if let Some(migrations_dir) = extras.migrations {
426 load_conformance_rules(migrations_dir)?
427 } else {
428 vec![]
429 };
430 if !conformance_rules.is_empty() {
431 tracing::info!(rules = conformance_rules.len(), "Loaded conformance rules");
432 }
433
434 let prior_versions_data = if let Some(dir) = extras.prior_versions_dir {
435 load_prior_versions(dir)?
436 } else {
437 vec![]
438 };
439 if !prior_versions_data.is_empty() {
440 tracing::info!(
441 versions = prior_versions_data.len(),
442 "Loaded prior versions"
443 );
444 }
445
446 let conformance_rules_json = if !conformance_rules.is_empty() {
447 Some(serde_json::to_string(&conformance_rules)?)
448 } else {
449 None
450 };
451
452 let generator = CodeGenerator::new()
453 .map_err(|e| anyhow::anyhow!("{}", e))?
454 .with_prior_versions(prior_versions_data)
455 .with_conformance_rules_json_opt(conformance_rules_json);
456 let server = generator
457 .generate(&orb, &orb_name, &resolved_version)
458 .map_err(|e| anyhow::anyhow!("{}", e))?;
459
460 match format {
461 OutputFormat::Source => {
462 server
463 .write_to(output)
464 .map_err(|e| anyhow::anyhow!("{}", e))?;
465 println!("Generated MCP server source code:");
466 println!(" Output: {}", output.display());
467 println!(" Crate: {}", server.crate_name);
468 println!(" Version: {}", resolved_version);
469 println!(" Commands: {}", orb.commands.len());
470 println!(" Jobs: {}", orb.jobs.len());
471 println!(" Executors: {}", orb.executors.len());
472 println!();
473 println!("To build: cd {} && cargo build --release", output.display());
474 }
475 OutputFormat::Binary => {
476 server
477 .write_to(output)
478 .map_err(|e| anyhow::anyhow!("{}", e))?;
479 println!("Compiling MCP server...");
480 let status = std::process::Command::new("cargo")
481 .args(["build", "--release"])
482 .current_dir(output)
483 .status();
484 match status {
485 Ok(s) if s.success() => {
486 let binary_path = output.join("target/release").join(&server.crate_name);
487 println!("Successfully compiled MCP server:");
488 println!(" Binary: {}", binary_path.display());
489 println!(" Version: {}", resolved_version);
490 }
491 Ok(_) => {
492 anyhow::bail!(
493 "Compilation failed. Source code is available at: {}",
494 output.display()
495 );
496 }
497 Err(e) => {
498 anyhow::bail!(
499 "Failed to run cargo: {}. Source code is available at: {}",
500 e,
501 output.display()
502 );
503 }
504 }
505 }
506 }
507
508 Ok(())
509}
510
511fn run_validate(orb_path: &std::path::PathBuf) -> Result<()> {
512 tracing::info!(?orb_path, "Validating orb definition");
513 let orb = OrbParser::parse(orb_path).map_err(|e| anyhow::anyhow!("{}", e))?;
514
515 println!("Orb validation successful!");
516 println!(" Version: {}", orb.version);
517 if let Some(desc) = &orb.description {
518 println!(" Description: {}", desc);
519 }
520 println!(" Commands: {}", orb.commands.len());
521 for name in orb.commands.keys() {
522 println!(" - {}", name);
523 }
524 println!(" Jobs: {}", orb.jobs.len());
525 for name in orb.jobs.keys() {
526 println!(" - {}", name);
527 }
528 println!(" Executors: {}", orb.executors.len());
529 for name in orb.executors.keys() {
530 println!(" - {}", name);
531 }
532 Ok(())
533}
534
535fn run_diff(
536 current: &std::path::PathBuf,
537 previous: &std::path::PathBuf,
538 since_version: &str,
539 output: &Option<std::path::PathBuf>,
540) -> Result<()> {
541 tracing::info!(?current, ?previous, "Diffing orb versions");
542
543 let new_orb = OrbParser::parse(current).map_err(|e| anyhow::anyhow!("{}", e))?;
544 let old_orb = OrbParser::parse(previous).map_err(|e| anyhow::anyhow!("{}", e))?;
545
546 let rules = differ::diff(&old_orb, &new_orb, since_version);
547 println!("Computed {} conformance rule(s):", rules.len());
548 for rule in &rules {
549 println!(" • {}", rule.description());
550 }
551
552 let json = serde_json::to_string_pretty(&rules)?;
553
554 if let Some(out_path) = output {
555 std::fs::write(out_path, &json)?;
556 println!("\nRules written to: {}", out_path.display());
557 } else {
558 println!("\n{}", json);
559 }
560
561 Ok(())
562}
563
564fn run_migrate(
565 ci_dir: &std::path::PathBuf,
566 orb: &str,
567 rules_path: &std::path::PathBuf,
568 dry_run: bool,
569) -> Result<()> {
570 tracing::info!(?ci_dir, orb, "Migrating consumer config");
571
572 let rules_json = std::fs::read_to_string(rules_path)
573 .map_err(|e| anyhow::anyhow!("Failed to read rules file: {}", e))?;
574 let rules: Vec<conformance_rule::ConformanceRule> = serde_json::from_str(&rules_json)
575 .map_err(|e| anyhow::anyhow!("Failed to parse rules JSON: {}", e))?;
576
577 let config = consumer_parser::ConsumerParser::parse_directory(ci_dir)
578 .map_err(|e| anyhow::anyhow!("Failed to parse CI config: {}", e))?;
579
580 let plan = migrator::Migrator::plan(&rules, &config, orb, "");
581 println!("{}", plan.format_summary());
582
583 if plan.changes.is_empty() {
584 return Ok(());
585 }
586
587 if dry_run {
588 println!("\n(Dry run — no files modified)");
589 return Ok(());
590 }
591
592 let applied = migrator::Migrator::apply(&plan, false)?;
593 println!("\n{}", applied.format_summary());
594
595 Ok(())
596}
597
598fn load_prior_versions(dir: &std::path::Path) -> Result<Vec<(String, parser::OrbDefinition)>> {
600 if !dir.is_dir() {
601 anyhow::bail!("Prior versions directory does not exist: {}", dir.display());
602 }
603 let mut versions = Vec::new();
604 let entries = std::fs::read_dir(dir)?;
605 for entry in entries.flatten() {
606 let path = entry.path();
607 if path.extension().and_then(|e| e.to_str()) != Some("yml") {
608 continue;
609 }
610 let version = path
611 .file_stem()
612 .and_then(|s| s.to_str())
613 .unwrap_or("")
614 .to_string();
615 if version.is_empty() {
616 continue;
617 }
618 let orb_def = OrbParser::parse(&path)
619 .map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", path.display(), e))?;
620 tracing::debug!(path = %path.display(), version = %version, "Loaded prior version");
621 versions.push((version, orb_def));
622 }
623 Ok(versions)
624}
625
626fn load_conformance_rules(dir: &std::path::Path) -> Result<Vec<conformance_rule::ConformanceRule>> {
628 if !dir.is_dir() {
629 anyhow::bail!("Migrations directory does not exist: {}", dir.display());
630 }
631 let mut all_rules = Vec::new();
632 let entries = std::fs::read_dir(dir)?;
633 for entry in entries.flatten() {
634 let path = entry.path();
635 if path.extension().and_then(|e| e.to_str()) != Some("json") {
636 continue;
637 }
638 let json = std::fs::read_to_string(&path)
639 .map_err(|e| anyhow::anyhow!("Failed to read {}: {}", path.display(), e))?;
640 let rules: Vec<conformance_rule::ConformanceRule> = serde_json::from_str(&json)
641 .map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", path.display(), e))?;
642 tracing::debug!(path = %path.display(), count = rules.len(), "Loaded rules file");
643 all_rules.extend(rules);
644 }
645 Ok(all_rules)
646}
647
648#[allow(clippy::too_many_arguments)]
649fn run_prime(
650 orb_path: &std::path::Path,
651 git_repo: Option<&std::path::Path>,
652 tag_prefix: &str,
653 earliest_version: Option<&str>,
654 since: Option<&str>,
655 prior_versions_dir: &std::path::Path,
656 migrations_dir: &std::path::Path,
657 rename_map: &[String],
658 ephemeral: bool,
659 dry_run: bool,
660) -> Result<()> {
661 use chrono::Local;
662 use primer::{
663 discover_tags, filter_by_date, filter_by_version, since_cutoff, tag_date, PrimeConfig,
664 };
665
666 let repo_path = if let Some(r) = git_repo {
668 r.to_path_buf()
669 } else {
670 find_git_root(orb_path)?
671 };
672
673 let orb_abs = orb_path
675 .canonicalize()
676 .unwrap_or_else(|_| orb_path.to_path_buf());
677 let repo_abs = repo_path
678 .canonicalize()
679 .unwrap_or_else(|_| repo_path.to_path_buf());
680 let orb_rel = orb_abs
681 .strip_prefix(&repo_abs)
682 .unwrap_or(orb_path)
683 .to_path_buf();
684
685 let (pv_dir, mig_dir) = if ephemeral {
687 let base =
688 std::path::PathBuf::from(format!("/tmp/gen-orb-mcp-prime-{}", std::process::id()));
689 (base.join("prior-versions"), base.join("migrations"))
690 } else {
691 (
692 prior_versions_dir.to_path_buf(),
693 migrations_dir.to_path_buf(),
694 )
695 };
696
697 let all_tags = discover_tags(&repo_path, tag_prefix)?;
699 tracing::info!(count = all_tags.len(), "Discovered version tags");
700
701 let window_versions: Vec<String> = if let Some(ver_str) = earliest_version {
702 let earliest = semver::Version::parse(ver_str)
703 .map_err(|e| anyhow::anyhow!("Invalid version '{}': {}", ver_str, e))?;
704 filter_by_version(&all_tags, &earliest)
705 } else {
706 let since_str = since.unwrap_or("6 months");
707 let today = Local::now().date_naive();
708 let cutoff = since_cutoff(since_str, today)?;
709 let tags_with_dates: Vec<primer::TagWithDate> = all_tags
711 .iter()
712 .filter_map(|v| match tag_date(&repo_path, tag_prefix, v) {
713 Ok(d) => Some(primer::TagWithDate {
714 version: v.clone(),
715 date: d,
716 }),
717 Err(e) => {
718 tracing::warn!(version = %v, error = %e, "Could not get tag date, skipping");
719 None
720 }
721 })
722 .collect();
723 filter_by_date(&tags_with_dates, cutoff)
724 };
725
726 tracing::info!(count = window_versions.len(), "Versions in window");
727
728 let extra_rename_hints: Vec<(String, String)> = rename_map
730 .iter()
731 .filter_map(|entry| {
732 let mut parts = entry.splitn(2, '=');
733 let from = parts.next()?.trim().to_string();
734 let to = parts.next()?.trim().to_string();
735 if from.is_empty() || to.is_empty() {
736 tracing::warn!(entry, "--rename-map entry is malformed; skipping");
737 return None;
738 }
739 Some((from, to))
740 })
741 .collect();
742
743 let config = PrimeConfig {
744 git_repo: repo_path,
745 tag_prefix: tag_prefix.to_string(),
746 orb_path_relative: orb_rel,
747 prior_versions_dir: pv_dir.clone(),
748 migrations_dir: mig_dir.clone(),
749 dry_run,
750 extra_rename_hints,
751 };
752
753 let result = primer::prime(&config, &window_versions)?;
754
755 if ephemeral {
756 println!("PRIME_PV_DIR={}", pv_dir.display());
757 println!("PRIME_MIG_DIR={}", mig_dir.display());
758 }
759
760 println!(
761 "prime: +{} snapshots, -{} snapshots, +{} migrations, -{} migrations",
762 result.snapshots_added,
763 result.snapshots_removed,
764 result.migrations_added,
765 result.migrations_removed,
766 );
767
768 Ok(())
769}
770
771#[derive(Debug)]
772struct SignEnv {
773 gpg_key_b64: String,
774 gpg_trust: String,
775 user_name: String,
776 user_email: String,
777 sign_key: String,
778}
779
780fn read_sign_env() -> Result<SignEnv> {
781 Ok(SignEnv {
782 gpg_key_b64: std::env::var("BOT_GPG_KEY")
783 .map_err(|_| anyhow::anyhow!("BOT_GPG_KEY env var not set (required with --sign)"))?,
784 gpg_trust: std::env::var("BOT_TRUST")
785 .map_err(|_| anyhow::anyhow!("BOT_TRUST env var not set (required with --sign)"))?,
786 user_name: std::env::var("BOT_USER_NAME")
787 .map_err(|_| anyhow::anyhow!("BOT_USER_NAME env var not set (required with --sign)"))?,
788 user_email: std::env::var("BOT_USER_EMAIL").map_err(|_| {
789 anyhow::anyhow!("BOT_USER_EMAIL env var not set (required with --sign)")
790 })?,
791 sign_key: std::env::var("BOT_SIGN_KEY")
792 .map_err(|_| anyhow::anyhow!("BOT_SIGN_KEY env var not set (required with --sign)"))?,
793 })
794}
795
796fn setup_git_identity(repo: &git2::Repository, sign_env: &SignEnv) -> Result<()> {
797 let mut config = repo.config()?;
798 config.set_str("user.name", &sign_env.user_name)?;
799 config.set_str("user.email", &sign_env.user_email)?;
800 config.set_str("user.signingkey", &sign_env.sign_key)?;
801 Ok(())
802}
803
804fn build_pcu_config() -> Result<config::Config> {
805 let mut builder = config::Config::builder()
811 .set_default("prlog", "PRLOG.md")?
812 .set_default("branch", "CIRCLE_BRANCH")?
813 .set_default("default_branch", "main")?
814 .set_default("username", "CIRCLE_PROJECT_USERNAME")?
815 .set_default("reponame", "CIRCLE_PROJECT_REPONAME")?
816 .set_override("command", "push")?
817 .add_source(config::Environment::with_prefix("PCU"));
818 if let Ok(token) = std::env::var("GITHUB_TOKEN") {
819 builder = builder.set_default("pat", token)?;
820 }
821 Ok(builder.build()?)
822}
823
824fn run_save(
825 paths: &[std::path::PathBuf],
826 message: &str,
827 push: bool,
828 dry_run: bool,
829 sign: bool,
830) -> Result<()> {
831 if sign {
832 let sign_env = read_sign_env()?;
833 pcu::import_gpg_key(&sign_env.gpg_key_b64, &sign_env.gpg_trust)
834 .map_err(|e| anyhow::anyhow!("GPG import failed: {e}"))?;
835 let repo = git2::Repository::discover(".")
836 .map_err(|e| anyhow::anyhow!("Not inside a git repository: {}", e))?;
837 setup_git_identity(&repo, &sign_env)?;
838 run_save_signed(paths, message, push, dry_run)
839 } else {
840 run_save_unsigned(paths, message, push, dry_run)
841 }
842}
843
844fn run_save_signed(
845 paths: &[std::path::PathBuf],
846 message: &str,
847 push: bool,
848 dry_run: bool,
849) -> Result<()> {
850 let pcu_config = build_pcu_config()?;
851 let rt = tokio::runtime::Builder::new_current_thread()
852 .enable_all()
853 .build()?;
854 let client = rt
855 .block_on(pcu::Client::new_with(&pcu_config))
856 .map_err(|e| anyhow::anyhow!("Failed to create pcu client: {}", e))?;
857
858 use pcu::GitOps;
859 let path_refs: Vec<&std::path::Path> = paths.iter().map(|p| p.as_path()).collect();
860 client
861 .stage_paths(&path_refs)
862 .map_err(|e| anyhow::anyhow!("Failed to stage paths: {e}"))?;
863
864 let repo = git2::Repository::discover(".")
867 .map_err(|e| anyhow::anyhow!("Not inside a git repository: {}", e))?;
868 let mut index = repo.index()?;
869 let head_commit = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
870 let diff = save_compute_diff(&repo, &mut index, head_commit.as_ref())?;
871
872 if diff.deltas().count() == 0 {
873 println!("Nothing to commit — working tree clean after staging.");
874 return Ok(());
875 }
876 if dry_run {
877 save_print_dry_run(&diff, message, push);
878 return Ok(());
879 }
880
881 let sign_config = pcu::SignConfig::new(pcu::Sign::Gpg);
882 client
883 .commit_staged(sign_config, message, "", None)
884 .map_err(|e| anyhow::anyhow!("Failed to sign and commit: {}", e))?;
885 println!("Created signed commit: {message}");
886 if push {
887 let bot_name = std::env::var("BOT_USER_NAME").unwrap_or_else(|_| "bot".to_string());
888 client
889 .push_commit("", None, false, &bot_name)
890 .map_err(|e| anyhow::anyhow!("Failed to push: {}", e))?;
891 println!("Pushed to remote.");
892 }
893 Ok(())
894}
895
896fn run_save_unsigned(
897 paths: &[std::path::PathBuf],
898 message: &str,
899 push: bool,
900 dry_run: bool,
901) -> Result<()> {
902 let repo = git2::Repository::discover(".")
903 .map_err(|e| anyhow::anyhow!("Not inside a git repository: {}", e))?;
904 let mut index = repo.index()?;
905 let path_strs: Vec<&str> = paths.iter().filter_map(|p| p.to_str()).collect();
906 index
907 .add_all(path_strs.iter(), git2::IndexAddOption::DEFAULT, None)
908 .map_err(|e| anyhow::anyhow!("Failed to stage paths: {e}"))?;
909 index.write()?;
910 let head_commit = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
911 let diff = save_compute_diff(&repo, &mut index, head_commit.as_ref())?;
912
913 if diff.deltas().count() == 0 {
914 println!("Nothing to commit — working tree clean after staging.");
915 return Ok(());
916 }
917 if dry_run {
918 save_print_dry_run(&diff, message, push);
919 return Ok(());
920 }
921
922 let oid = save_create_commit(&repo, &mut index, message, head_commit.as_ref())?;
923 tracing::info!(commit = %oid, "Created commit");
924 println!("Created commit {oid}: {message}");
925 if push {
926 save_git_push(&repo)?;
927 }
928 Ok(())
929}
930
931fn save_compute_diff<'repo>(
932 repo: &'repo git2::Repository,
933 index: &mut git2::Index,
934 head_commit: Option<&git2::Commit<'_>>,
935) -> Result<git2::Diff<'repo>> {
936 let new_tree_oid = index.write_tree()?;
937 let new_tree = repo.find_tree(new_tree_oid)?;
938 let head_tree = head_commit.map(|c| c.tree()).transpose()?;
939 Ok(repo.diff_tree_to_tree(head_tree.as_ref(), Some(&new_tree), None)?)
940}
941
942fn save_print_dry_run(diff: &git2::Diff<'_>, message: &str, push: bool) {
943 println!("Would commit the following changes:");
944 for delta in diff.deltas() {
945 let path = delta
946 .new_file()
947 .path()
948 .and_then(|p| p.to_str())
949 .unwrap_or("(unknown)");
950 println!(" {path}");
951 }
952 println!("Commit message: {message}");
953 if push {
954 println!("Would push after committing.");
955 }
956}
957
958fn save_create_commit(
959 repo: &git2::Repository,
960 index: &mut git2::Index,
961 message: &str,
962 head_commit: Option<&git2::Commit<'_>>,
963) -> Result<git2::Oid> {
964 let sig = repo.signature()?;
965 let new_tree_oid = index.write_tree()?;
966 let new_tree = repo.find_tree(new_tree_oid)?;
967 let parents: Vec<&git2::Commit> = head_commit.into_iter().collect();
968 Ok(repo.commit(Some("HEAD"), &sig, &sig, message, &new_tree, &parents)?)
969}
970
971fn save_git_push(repo: &git2::Repository) -> Result<()> {
972 let remote_name = repo
973 .remotes()?
974 .iter()
975 .flatten()
976 .next()
977 .map(|s| s.to_string())
978 .unwrap_or_else(|| "origin".to_string());
979
980 let mut callbacks = git2::RemoteCallbacks::new();
981 let git_config = repo.config()?;
982 let mut cred_handler = git2_credentials::CredentialHandler::new(git_config);
983 callbacks.credentials(move |url, username, allowed| {
984 cred_handler.try_next_credential(url, username, allowed)
985 });
986
987 let mut push_opts = git2::PushOptions::new();
988 push_opts.remote_callbacks(callbacks);
989
990 let head_ref = repo.head()?;
991 let branch_name = head_ref
992 .shorthand()
993 .ok_or_else(|| anyhow::anyhow!("HEAD has no branch name"))?;
994 let refspec = format!("refs/heads/{branch_name}:refs/heads/{branch_name}");
995
996 let mut remote = repo.find_remote(&remote_name)?;
997 remote
998 .push(&[refspec.as_str()], Some(&mut push_opts))
999 .map_err(|e| anyhow::anyhow!("Push failed: {}", e))?;
1000
1001 println!("Pushed to {remote_name}/{branch_name}");
1002 Ok(())
1003}
1004
1005fn run_publish(
1006 binary: &std::path::Path,
1007 asset_name: &str,
1008 tag: Option<&str>,
1009 dry_run: bool,
1010) -> Result<()> {
1011 if !binary.exists() {
1012 anyhow::bail!("Binary not found: {}", binary.display());
1013 }
1014
1015 let resolved_tag = match tag {
1016 Some(t) => t.to_string(),
1017 None => std::env::var("CIRCLE_TAG").map_err(|_| {
1018 anyhow::anyhow!("No release tag provided. Set CIRCLE_TAG or use --tag <TAG>")
1019 })?,
1020 };
1021
1022 if dry_run {
1023 let owner = std::env::var("CIRCLE_PROJECT_USERNAME").unwrap_or_default();
1024 let repo_name = std::env::var("CIRCLE_PROJECT_REPONAME").unwrap_or_default();
1025 println!("Would upload release asset (dry run):");
1026 println!(" Binary: {}", binary.display());
1027 println!(" Asset name: {asset_name}");
1028 println!(" Tag: {resolved_tag}");
1029 if !owner.is_empty() && !repo_name.is_empty() {
1030 println!(" Repo: {owner}/{repo_name}");
1031 }
1032 return Ok(());
1033 }
1034
1035 let pcu_config = build_pcu_config()?;
1036 tokio::runtime::Builder::new_current_thread()
1037 .enable_all()
1038 .build()?
1039 .block_on(async {
1040 let client = pcu::Client::new_with(&pcu_config)
1041 .await
1042 .map_err(|e| anyhow::anyhow!("Failed to create pcu client: {e}"))?;
1043 client
1044 .upload_release_asset(&resolved_tag, binary, asset_name)
1045 .await
1046 .map_err(|e| anyhow::anyhow!("Failed to upload release asset: {e}"))
1047 })
1048}
1049
1050fn run_build(
1051 input: &std::path::Path,
1052 name: Option<&str>,
1053 target: Option<&str>,
1054 dry_run: bool,
1055) -> Result<()> {
1056 let cargo_toml = input.join("Cargo.toml");
1057 if !cargo_toml.exists() {
1058 anyhow::bail!(
1059 "No Cargo.toml found in input directory: {}",
1060 input.display()
1061 );
1062 }
1063
1064 let binary_name = match name {
1065 Some(n) => n.to_string(),
1066 None => read_crate_name(input)?,
1067 };
1068
1069 let mut cargo_args = vec!["build", "--release"];
1070 if let Some(t) = target {
1071 cargo_args.extend(["--target", t]);
1072 }
1073
1074 let binary_dir = match target {
1075 Some(t) => input.join("target").join(t).join("release"),
1076 None => input.join("target").join("release"),
1077 };
1078 let binary_path = binary_dir.join(&binary_name);
1079
1080 if dry_run {
1081 println!("Would run: cargo {}", cargo_args.join(" "));
1082 println!(" Input: {}", input.display());
1083 println!(" Binary: {}", binary_path.display());
1084 return Ok(());
1085 }
1086
1087 tracing::info!(input = %input.display(), binary = %binary_path.display(), "Compiling MCP server");
1088 println!("Compiling MCP server...");
1089 let status = std::process::Command::new("cargo")
1090 .args(&cargo_args)
1091 .current_dir(input)
1092 .status()
1093 .map_err(|e| anyhow::anyhow!("Failed to run cargo: {}", e))?;
1094
1095 if !status.success() {
1096 anyhow::bail!(
1097 "cargo build failed. Source code is available at: {}",
1098 input.display()
1099 );
1100 }
1101
1102 println!("Successfully compiled MCP server:");
1103 println!(" Binary: {}", binary_path.display());
1104
1105 Ok(())
1106}
1107
1108fn read_crate_name(input: &std::path::Path) -> Result<String> {
1109 let content = std::fs::read_to_string(input.join("Cargo.toml"))
1110 .map_err(|e| anyhow::anyhow!("Failed to read Cargo.toml: {}", e))?;
1111 parse_package_name(&content)
1112 .ok_or_else(|| anyhow::anyhow!("Could not find [package] name in Cargo.toml"))
1113}
1114
1115fn parse_package_name(toml: &str) -> Option<String> {
1117 let mut in_package = false;
1118 for line in toml.lines() {
1119 let trimmed = line.trim();
1120 if trimmed == "[package]" {
1121 in_package = true;
1122 } else if trimmed.starts_with('[') {
1123 in_package = false;
1124 } else if in_package {
1125 if let Some(name) = parse_name_assignment(trimmed) {
1126 return Some(name);
1127 }
1128 }
1129 }
1130 None
1131}
1132
1133fn parse_name_assignment(line: &str) -> Option<String> {
1135 let rest = line.strip_prefix("name")?;
1136 let rest = rest.trim().strip_prefix('=')?;
1137 let name = rest.trim().trim_matches('"').trim_matches('\'').to_string();
1138 (!name.is_empty()).then_some(name)
1139}
1140
1141fn find_git_root(start: &std::path::Path) -> Result<std::path::PathBuf> {
1143 let start = start
1148 .canonicalize()
1149 .map_err(|e| anyhow::anyhow!("Cannot access orb path '{}': {}", start.display(), e))?;
1150 let mut dir = if start.is_file() {
1151 start.parent().unwrap_or(&start).to_path_buf()
1152 } else {
1153 start.to_path_buf()
1154 };
1155 loop {
1156 if dir.join(".git").exists() {
1157 return Ok(dir);
1158 }
1159 match dir.parent() {
1160 Some(p) => dir = p.to_path_buf(),
1161 None => anyhow::bail!(
1162 "Could not find git repository root starting from '{}'",
1163 start.display()
1164 ),
1165 }
1166 }
1167}
1168
1169fn derive_orb_name(path: &std::path::Path) -> String {
1175 let filename = path.file_name().and_then(|s| s.to_str()).unwrap_or("orb");
1176
1177 if filename == "@orb.yml" {
1178 let parent = path.parent();
1180 let parent_name = parent.and_then(|p| p.file_name()).and_then(|s| s.to_str());
1181
1182 if parent_name == Some("src") {
1184 parent
1185 .and_then(|p| p.parent())
1186 .and_then(|p| p.file_name())
1187 .and_then(|s| s.to_str())
1188 .unwrap_or("orb")
1189 .to_string()
1190 } else {
1191 parent_name.unwrap_or("orb").to_string()
1192 }
1193 } else {
1194 path.file_stem()
1196 .and_then(|s| s.to_str())
1197 .unwrap_or("orb")
1198 .to_string()
1199 }
1200}
1201
1202fn discover_latest_version(repo: &std::path::Path, tag_prefix: &str) -> Result<Option<String>> {
1208 use primer::discover_tags;
1209 let tags = discover_tags(repo, tag_prefix).unwrap_or_default();
1210 Ok(tags.into_iter().last())
1212}
1213
1214fn resolve_version(
1225 output: &std::path::Path,
1226 version: Option<&str>,
1227 force: bool,
1228 git_hint: Option<&str>,
1229) -> Result<String> {
1230 let cargo_toml = output.join("Cargo.toml");
1231 let output_exists = cargo_toml.exists();
1232
1233 if let Some(v) = version {
1235 if output_exists && !force {
1236 anyhow::bail!(
1237 "Output directory '{}' already exists. Use --force to overwrite.",
1238 output.display()
1239 );
1240 }
1241 tracing::debug!("Using provided version");
1242 return Ok(v.to_string());
1243 }
1244
1245 if let Some(v) = git_hint {
1247 if output_exists && !force {
1248 anyhow::bail!(
1249 "Output directory '{}' already exists. Use --force to overwrite.",
1250 output.display()
1251 );
1252 }
1253 tracing::debug!(version = %v, "Using git-discovered version");
1254 return Ok(v.to_string());
1255 }
1256
1257 let msg = if output_exists {
1259 format!(
1260 "Output directory '{}' already exists and no version could be determined.\n\
1261 Provide the version explicitly:\n\n\
1262 \x20 gen-orb-mcp generate --orb-path <PATH> --output {} --crate-version <VERSION> --force\n\n\
1263 Or ensure --orb-path is inside a git repository with version tags (e.g. v6.0.0).\n\
1264 Use --tag-prefix if your tags use a non-standard prefix.",
1265 output.display(),
1266 output.display()
1267 )
1268 } else {
1269 format!(
1270 "No version could be determined for the generated MCP server.\n\
1271 Provide the version explicitly:\n\n\
1272 \x20 gen-orb-mcp generate --orb-path <PATH> --output {} --crate-version <VERSION>\n\n\
1273 Or ensure --orb-path is inside a git repository with version tags (e.g. v6.0.0).\n\
1274 Use --tag-prefix if your tags use a non-standard prefix.",
1275 output.display()
1276 )
1277 };
1278 anyhow::bail!(msg)
1279}
1280
1281#[cfg(test)]
1282mod tests {
1283 use tempfile::TempDir;
1284
1285 use super::*;
1286
1287 #[test]
1288 fn test_cli_parse_generate() {
1289 let cli = Cli::try_parse_from([
1290 "gen-orb-mcp",
1291 "generate",
1292 "--orb-path",
1293 "test.yml",
1294 "--output",
1295 "./out",
1296 ]);
1297 assert!(cli.is_ok());
1298 }
1299
1300 #[test]
1301 fn test_cli_parse_generate_default_orb_path() {
1302 let cli = Cli::try_parse_from(["gen-orb-mcp", "generate"]);
1303 assert!(
1304 cli.is_ok(),
1305 "generate should work without --orb-path (default: src/@orb.yml)"
1306 );
1307 if let Ok(Cli {
1308 command: Commands::Generate { orb_path, .. },
1309 }) = cli
1310 {
1311 assert_eq!(orb_path, std::path::PathBuf::from("src/@orb.yml"));
1312 }
1313 }
1314
1315 #[test]
1316 fn test_cli_parse_validate_default_orb_path() {
1317 let cli = Cli::try_parse_from(["gen-orb-mcp", "validate"]);
1318 assert!(
1319 cli.is_ok(),
1320 "validate should work without --orb-path (default: src/@orb.yml)"
1321 );
1322 if let Ok(Cli {
1323 command: Commands::Validate { orb_path },
1324 }) = cli
1325 {
1326 assert_eq!(orb_path, std::path::PathBuf::from("src/@orb.yml"));
1327 }
1328 }
1329
1330 #[test]
1331 fn test_cli_parse_generate_with_crate_version_legacy() {
1332 let cli = Cli::try_parse_from([
1333 "gen-orb-mcp",
1334 "generate",
1335 "--orb-path",
1336 "test.yml",
1337 "--output",
1338 "./out",
1339 "--crate-version",
1340 "1.2.3",
1341 ]);
1342 assert!(cli.is_ok());
1343 }
1344
1345 #[test]
1346 fn test_cli_parse_generate_with_force() {
1347 let cli = Cli::try_parse_from([
1348 "gen-orb-mcp",
1349 "generate",
1350 "--orb-path",
1351 "test.yml",
1352 "--output",
1353 "./out",
1354 "--crate-version",
1355 "1.2.3",
1356 "--force",
1357 ]);
1358 assert!(cli.is_ok());
1359 }
1360
1361 #[test]
1362 fn test_cli_parse_generate_with_crate_version() {
1363 let cli = Cli::try_parse_from([
1364 "gen-orb-mcp",
1365 "generate",
1366 "--orb-path",
1367 "test.yml",
1368 "--output",
1369 "./out",
1370 "--crate-version",
1371 "1.2.3",
1372 ]);
1373 assert!(cli.is_ok(), "--crate-version should be accepted");
1374 }
1375
1376 #[test]
1377 fn test_cli_parse_generate_version_flag_rejected() {
1378 let cli = Cli::try_parse_from([
1379 "gen-orb-mcp",
1380 "generate",
1381 "--orb-path",
1382 "test.yml",
1383 "--output",
1384 "./out",
1385 "--version",
1386 "1.2.3",
1387 ]);
1388 assert!(
1389 cli.is_err(),
1390 "--version should be rejected (conflicts with clap built-in)"
1391 );
1392 }
1393
1394 #[test]
1395 fn test_cli_parse_validate() {
1396 let cli = Cli::try_parse_from(["gen-orb-mcp", "validate", "--orb-path", "test.yml"]);
1397 assert!(cli.is_ok());
1398 }
1399
1400 #[test]
1401 fn test_derive_orb_name_from_orb_yml() {
1402 use std::path::Path;
1403 let path = Path::new("/path/to/my-toolkit/src/@orb.yml");
1405 assert_eq!(derive_orb_name(path), "my-toolkit");
1406
1407 let path = Path::new("my-orb/@orb.yml");
1409 assert_eq!(derive_orb_name(path), "my-orb");
1410
1411 let path = Path::new("src/@orb.yml");
1414 assert_eq!(derive_orb_name(path), "orb");
1415 }
1416
1417 #[test]
1418 fn test_derive_orb_name_from_packed() {
1419 use std::path::Path;
1420 let path = Path::new("/path/to/my-toolkit.yml");
1421 assert_eq!(derive_orb_name(path), "my-toolkit");
1422
1423 let path = Path::new("orb.yml");
1424 assert_eq!(derive_orb_name(path), "orb");
1425 }
1426
1427 #[test]
1428 fn test_resolve_version_fresh_with_explicit() {
1429 let temp_dir = TempDir::new().unwrap();
1430 let result = resolve_version(temp_dir.path(), Some("2.0.0"), false, None);
1431 assert!(result.is_ok());
1432 assert_eq!(result.unwrap(), "2.0.0");
1433 }
1434
1435 #[test]
1436 fn test_resolve_version_fresh_no_version_errors() {
1437 let temp_dir = TempDir::new().unwrap();
1438 let result = resolve_version(temp_dir.path(), None, false, None);
1439 assert!(result.is_err());
1440 }
1441
1442 #[test]
1443 fn test_resolve_version_existing_without_version_fails() {
1444 let temp_dir = TempDir::new().unwrap();
1445 std::fs::write(
1447 temp_dir.path().join("Cargo.toml"),
1448 "[package]\nname = \"test\"",
1449 )
1450 .unwrap();
1451
1452 let result = resolve_version(temp_dir.path(), None, false, None);
1453 assert!(result.is_err());
1454 let err = result.unwrap_err().to_string();
1455 assert!(err.contains("already exists"));
1456 assert!(err.contains("--crate-version"));
1457 }
1458
1459 #[test]
1460 fn test_resolve_version_existing_with_version_no_force_fails() {
1461 let temp_dir = TempDir::new().unwrap();
1462 std::fs::write(
1463 temp_dir.path().join("Cargo.toml"),
1464 "[package]\nname = \"test\"",
1465 )
1466 .unwrap();
1467
1468 let result = resolve_version(temp_dir.path(), Some("1.5.0"), false, None);
1469 assert!(result.is_err());
1470 let err = result.unwrap_err().to_string();
1471 assert!(err.contains("--force"));
1472 }
1473
1474 #[test]
1475 fn test_resolve_version_existing_with_version_and_force_succeeds() {
1476 let temp_dir = TempDir::new().unwrap();
1477 std::fs::write(
1478 temp_dir.path().join("Cargo.toml"),
1479 "[package]\nname = \"test\"",
1480 )
1481 .unwrap();
1482
1483 let result = resolve_version(temp_dir.path(), Some("1.5.0"), true, None);
1484 assert!(result.is_ok());
1485 assert_eq!(result.unwrap(), "1.5.0");
1486 }
1487
1488 #[test]
1489 fn test_cli_parse_generate_with_prior_versions() {
1490 let cli = Cli::try_parse_from([
1491 "gen-orb-mcp",
1492 "generate",
1493 "--orb-path",
1494 "test.yml",
1495 "--output",
1496 "./out",
1497 "--prior-versions",
1498 "./prior",
1499 ]);
1500 assert!(cli.is_ok(), "expected --prior-versions flag to be accepted");
1501 }
1502
1503 #[test]
1506 fn test_cli_parse_prime_defaults() {
1507 let cli = Cli::try_parse_from(["gen-orb-mcp", "prime"]);
1508 assert!(cli.is_ok(), "prime with all defaults should parse");
1509 if let Commands::Prime {
1510 orb_path,
1511 tag_prefix,
1512 earliest_version,
1513 since,
1514 prior_versions_dir,
1515 migrations_dir,
1516 rename_map,
1517 ephemeral,
1518 dry_run,
1519 git_repo,
1520 } = cli.unwrap().command
1521 {
1522 assert_eq!(orb_path.to_str().unwrap(), "src/@orb.yml");
1523 assert_eq!(tag_prefix, "v");
1524 assert!(earliest_version.is_none());
1525 assert!(since.is_none());
1526 assert_eq!(prior_versions_dir.to_str().unwrap(), "prior-versions");
1527 assert_eq!(migrations_dir.to_str().unwrap(), "migrations");
1528 assert!(rename_map.is_empty());
1529 assert!(!ephemeral);
1530 assert!(!dry_run);
1531 assert!(git_repo.is_none());
1532 } else {
1533 panic!("expected Prime variant");
1534 }
1535 }
1536
1537 #[test]
1538 fn test_cli_parse_prime_earliest_version() {
1539 let cli = Cli::try_parse_from(["gen-orb-mcp", "prime", "--earliest-version", "4.1.0"]);
1540 assert!(cli.is_ok(), "prime --earliest-version should parse");
1541 if let Commands::Prime {
1542 earliest_version, ..
1543 } = cli.unwrap().command
1544 {
1545 assert_eq!(earliest_version.as_deref(), Some("4.1.0"));
1546 } else {
1547 panic!("expected Prime variant");
1548 }
1549 }
1550
1551 #[test]
1552 fn test_cli_parse_prime_since() {
1553 let cli = Cli::try_parse_from(["gen-orb-mcp", "prime", "--since", "3 months"]);
1554 assert!(cli.is_ok(), "prime --since should parse");
1555 if let Commands::Prime { since, .. } = cli.unwrap().command {
1556 assert_eq!(since.as_deref(), Some("3 months"));
1557 } else {
1558 panic!("expected Prime variant");
1559 }
1560 }
1561
1562 #[test]
1563 fn test_cli_parse_prime_exclusive_flags() {
1564 let cli = Cli::try_parse_from([
1566 "gen-orb-mcp",
1567 "prime",
1568 "--earliest-version",
1569 "4.1.0",
1570 "--since",
1571 "6 months",
1572 ]);
1573 assert!(
1574 cli.is_err(),
1575 "prime with both --earliest-version and --since should be rejected"
1576 );
1577 }
1578
1579 #[test]
1580 fn test_cli_parse_prime_rename_map() {
1581 let cli = Cli::try_parse_from([
1582 "gen-orb-mcp",
1583 "prime",
1584 "--rename-map",
1585 "common_tests_rolling=common_tests",
1586 "--rename-map",
1587 "required_builds_rolling=required_builds",
1588 ]);
1589 assert!(cli.is_ok(), "prime --rename-map should parse");
1590 if let Commands::Prime { rename_map, .. } = cli.unwrap().command {
1591 assert_eq!(rename_map.len(), 2);
1592 assert!(rename_map.contains(&"common_tests_rolling=common_tests".to_string()));
1593 assert!(rename_map.contains(&"required_builds_rolling=required_builds".to_string()));
1594 } else {
1595 panic!("expected Prime variant");
1596 }
1597 }
1598
1599 #[test]
1600 fn test_cli_parse_prime_ephemeral() {
1601 let cli = Cli::try_parse_from(["gen-orb-mcp", "prime", "--ephemeral"]);
1602 assert!(cli.is_ok(), "prime --ephemeral should parse");
1603 if let Commands::Prime { ephemeral, .. } = cli.unwrap().command {
1604 assert!(ephemeral);
1605 } else {
1606 panic!("expected Prime variant");
1607 }
1608 }
1609
1610 static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1612
1613 #[test]
1628 fn test_find_git_root_returns_absolute_path_for_relative_input() {
1629 let _cwd_guard = CWD_LOCK.lock().unwrap();
1630 let original = std::env::current_dir().unwrap();
1631
1632 let tmp = TempDir::new().unwrap();
1633 std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
1634 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
1635 std::fs::write(
1636 tmp.path().join("src").join("@orb.yml"),
1637 "version: 2.1\ndescription: test",
1638 )
1639 .unwrap();
1640
1641 std::env::set_current_dir(tmp.path()).unwrap();
1644
1645 let result = find_git_root(std::path::Path::new("src/@orb.yml"));
1646
1647 std::env::set_current_dir(&original).unwrap();
1650
1651 let result = result.expect("find_git_root should succeed");
1652 assert!(
1653 result.is_absolute(),
1654 "find_git_root must return an absolute path, got: {:?}",
1655 result
1656 );
1657 assert_eq!(
1658 result.canonicalize().unwrap(),
1659 tmp.path().canonicalize().unwrap(),
1660 );
1661 }
1662
1663 #[test]
1666 fn test_discover_latest_version_returns_none_for_no_tags() {
1667 let tmp = TempDir::new().unwrap();
1668 std::process::Command::new("git")
1669 .args(["init"])
1670 .current_dir(tmp.path())
1671 .output()
1672 .unwrap();
1673 let result = discover_latest_version(tmp.path(), "v");
1674 assert!(result.is_ok());
1675 assert_eq!(result.unwrap(), None);
1676 }
1677
1678 #[test]
1679 fn test_discover_latest_version_returns_highest_semver_tag() {
1680 let tmp = TempDir::new().unwrap();
1681 std::process::Command::new("git")
1682 .args(["init"])
1683 .current_dir(tmp.path())
1684 .output()
1685 .unwrap();
1686 std::process::Command::new("git")
1687 .args(["config", "user.email", "test@test.com"])
1688 .current_dir(tmp.path())
1689 .output()
1690 .unwrap();
1691 std::process::Command::new("git")
1692 .args(["config", "user.name", "Test"])
1693 .current_dir(tmp.path())
1694 .output()
1695 .unwrap();
1696 std::fs::write(tmp.path().join("README.md"), "test").unwrap();
1697 std::process::Command::new("git")
1698 .args(["add", "."])
1699 .current_dir(tmp.path())
1700 .output()
1701 .unwrap();
1702 std::process::Command::new("git")
1703 .args(["commit", "-m", "init"])
1704 .current_dir(tmp.path())
1705 .output()
1706 .unwrap();
1707 for tag in ["v1.0.0", "v2.0.0", "v1.5.0"] {
1708 std::process::Command::new("git")
1709 .args(["tag", tag])
1710 .current_dir(tmp.path())
1711 .output()
1712 .unwrap();
1713 }
1714 let result = discover_latest_version(tmp.path(), "v");
1715 assert!(result.is_ok());
1716 assert_eq!(result.unwrap(), Some("2.0.0".to_string()));
1717 }
1718
1719 #[test]
1720 fn test_resolve_version_uses_git_hint_when_no_explicit_version() {
1721 let temp_dir = TempDir::new().unwrap();
1722 let result = resolve_version(temp_dir.path(), None, false, Some("3.1.0"));
1723 assert!(result.is_ok());
1724 assert_eq!(result.unwrap(), "3.1.0");
1725 }
1726
1727 #[test]
1728 fn test_resolve_version_explicit_overrides_git_hint() {
1729 let temp_dir = TempDir::new().unwrap();
1730 let result = resolve_version(temp_dir.path(), Some("5.0.0"), false, Some("3.1.0"));
1731 assert!(result.is_ok());
1732 assert_eq!(result.unwrap(), "5.0.0");
1733 }
1734
1735 #[test]
1736 fn test_resolve_version_errors_without_version_or_hint() {
1737 let temp_dir = TempDir::new().unwrap();
1738 let result = resolve_version(temp_dir.path(), None, false, None);
1739 assert!(result.is_err());
1740 let msg = result.unwrap_err().to_string();
1741 assert!(msg.contains("No version could be determined"), "got: {msg}");
1742 }
1743
1744 #[test]
1745 fn test_cli_parse_generate_with_tag_prefix() {
1746 let cli = Cli::try_parse_from([
1747 "gen-orb-mcp",
1748 "generate",
1749 "--orb-path",
1750 "test.yml",
1751 "--output",
1752 "./out",
1753 "--tag-prefix",
1754 "orb-v",
1755 ]);
1756 assert!(cli.is_ok(), "generate --tag-prefix should parse");
1757 if let Commands::Generate { tag_prefix, .. } = cli.unwrap().command {
1758 assert_eq!(tag_prefix, "orb-v");
1759 } else {
1760 panic!("expected Generate variant");
1761 }
1762 }
1763
1764 #[test]
1765 fn test_cli_parse_generate_tag_prefix_defaults_to_v() {
1766 let cli = Cli::try_parse_from([
1767 "gen-orb-mcp",
1768 "generate",
1769 "--orb-path",
1770 "test.yml",
1771 "--output",
1772 "./out",
1773 ]);
1774 assert!(cli.is_ok());
1775 if let Commands::Generate { tag_prefix, .. } = cli.unwrap().command {
1776 assert_eq!(tag_prefix, "v");
1777 } else {
1778 panic!("expected Generate variant");
1779 }
1780 }
1781
1782 fn init_git_repo(dir: &std::path::Path) {
1785 std::process::Command::new("git")
1786 .args(["init"])
1787 .current_dir(dir)
1788 .output()
1789 .unwrap();
1790 std::process::Command::new("git")
1791 .args(["config", "user.email", "test@test.com"])
1792 .current_dir(dir)
1793 .output()
1794 .unwrap();
1795 std::process::Command::new("git")
1796 .args(["config", "user.name", "Test"])
1797 .current_dir(dir)
1798 .output()
1799 .unwrap();
1800 std::fs::write(dir.join("README.md"), "test").unwrap();
1802 std::process::Command::new("git")
1803 .args(["add", "."])
1804 .current_dir(dir)
1805 .output()
1806 .unwrap();
1807 std::process::Command::new("git")
1808 .args(["commit", "-m", "init"])
1809 .current_dir(dir)
1810 .output()
1811 .unwrap();
1812 }
1813
1814 #[test]
1815 fn test_save_clean_tree_exits_without_commit() {
1816 let dir = TempDir::new().unwrap();
1817 init_git_repo(dir.path());
1818 let _cwd_guard = CWD_LOCK.lock().unwrap();
1819 let original = std::env::current_dir().unwrap();
1820 std::env::set_current_dir(dir.path()).unwrap();
1821 let result = run_save(
1823 &[std::path::PathBuf::from("README.md")],
1824 "chore: test",
1825 false,
1826 false,
1827 false,
1828 );
1829 std::env::set_current_dir(&original).unwrap();
1830 assert!(
1831 result.is_ok(),
1832 "clean tree should exit 0 without creating a commit: {result:?}"
1833 );
1834 }
1835
1836 #[test]
1837 fn test_save_changed_path_creates_commit() {
1838 let dir = TempDir::new().unwrap();
1839 init_git_repo(dir.path());
1840 std::fs::write(dir.path().join("new-file.txt"), "hello").unwrap();
1841 let _cwd_guard = CWD_LOCK.lock().unwrap();
1842 let original = std::env::current_dir().unwrap();
1843 std::env::set_current_dir(dir.path()).unwrap();
1844 let result = run_save(
1845 &[std::path::PathBuf::from("new-file.txt")],
1846 "chore: add generated file",
1847 false,
1848 false,
1849 false,
1850 );
1851 std::env::set_current_dir(&original).unwrap();
1852 assert!(
1853 result.is_ok(),
1854 "changed path should commit successfully: {result:?}"
1855 );
1856 let log = std::process::Command::new("git")
1858 .args(["log", "--oneline"])
1859 .current_dir(dir.path())
1860 .output()
1861 .unwrap();
1862 let log_str = String::from_utf8_lossy(&log.stdout);
1863 assert!(
1864 log_str.lines().count() >= 2,
1865 "expected at least 2 commits, got: {log_str}"
1866 );
1867 }
1868
1869 #[test]
1870 fn test_save_directory_path_stages_contents() {
1871 let dir = TempDir::new().unwrap();
1872 init_git_repo(dir.path());
1873 let subdir = dir.path().join("generated");
1875 std::fs::create_dir(&subdir).unwrap();
1876 std::fs::write(subdir.join("a.json"), r#"{"v": 1}"#).unwrap();
1877 std::fs::write(subdir.join("b.json"), r#"{"v": 2}"#).unwrap();
1878 let _cwd_guard = CWD_LOCK.lock().unwrap();
1879 let original = std::env::current_dir().unwrap();
1880 std::env::set_current_dir(dir.path()).unwrap();
1881 let result = run_save(
1882 &[std::path::PathBuf::from("generated")],
1883 "chore: add generated dir",
1884 false,
1885 false,
1886 false,
1887 );
1888 std::env::set_current_dir(&original).unwrap();
1889 assert!(
1890 result.is_ok(),
1891 "directory path should stage all contents and commit: {result:?}"
1892 );
1893 let log = std::process::Command::new("git")
1894 .args(["log", "--oneline"])
1895 .current_dir(dir.path())
1896 .output()
1897 .unwrap();
1898 let log_str = String::from_utf8_lossy(&log.stdout);
1899 assert!(
1900 log_str.lines().count() >= 2,
1901 "expected at least 2 commits after staging directory, got: {log_str}"
1902 );
1903 }
1904
1905 #[test]
1906 fn test_save_dry_run_does_not_commit() {
1907 let dir = TempDir::new().unwrap();
1908 init_git_repo(dir.path());
1909 std::fs::write(dir.path().join("artifact.txt"), "generated").unwrap();
1910 let _cwd_guard = CWD_LOCK.lock().unwrap();
1911 let original = std::env::current_dir().unwrap();
1912 std::env::set_current_dir(dir.path()).unwrap();
1913 let result = run_save(
1914 &[std::path::PathBuf::from("artifact.txt")],
1915 "chore: generated",
1916 false,
1917 true,
1918 false,
1919 );
1920 std::env::set_current_dir(&original).unwrap();
1921 assert!(result.is_ok(), "dry_run should succeed: {result:?}");
1922 let log = std::process::Command::new("git")
1924 .args(["log", "--oneline"])
1925 .current_dir(dir.path())
1926 .output()
1927 .unwrap();
1928 let log_str = String::from_utf8_lossy(&log.stdout);
1929 assert_eq!(
1930 log_str.lines().count(),
1931 1,
1932 "dry_run must not create a commit, got: {log_str}"
1933 );
1934 }
1935
1936 #[test]
1937 fn test_cli_parse_save_required_paths() {
1938 let cli = Cli::try_parse_from([
1939 "gen-orb-mcp",
1940 "save",
1941 "--paths",
1942 "prior-versions",
1943 "--paths",
1944 "migrations",
1945 ]);
1946 assert!(cli.is_ok(), "save with --paths should parse");
1947 }
1948
1949 #[test]
1950 fn test_cli_parse_save_sign_flag() {
1951 let cli =
1952 Cli::try_parse_from(["gen-orb-mcp", "save", "--paths", "prior-versions", "--sign"]);
1953 assert!(
1954 cli.is_ok(),
1955 "--sign flag should be accepted on save command"
1956 );
1957 if let Commands::Save { sign, .. } = cli.unwrap().command {
1958 assert!(sign, "--sign should be true when flag is passed");
1959 } else {
1960 panic!("expected Save variant");
1961 }
1962 }
1963
1964 #[test]
1965 fn test_read_sign_env_missing_bot_gpg_key() {
1966 let prev = std::env::var("BOT_GPG_KEY").ok();
1967 std::env::remove_var("BOT_GPG_KEY");
1968 let result = read_sign_env();
1969 if let Some(v) = prev {
1970 std::env::set_var("BOT_GPG_KEY", v);
1971 }
1972 assert!(result.is_err(), "should fail when BOT_GPG_KEY is absent");
1973 let msg = result.unwrap_err().to_string();
1974 assert!(
1975 msg.contains("BOT_GPG_KEY"),
1976 "error should mention BOT_GPG_KEY, got: {msg}"
1977 );
1978 }
1979
1980 #[test]
1981 fn test_cli_parse_save_all_flags() {
1982 let cli = Cli::try_parse_from([
1983 "gen-orb-mcp",
1984 "save",
1985 "--paths",
1986 "prior-versions",
1987 "--message",
1988 "custom message",
1989 "--no-push",
1990 "--dry-run",
1991 ]);
1992 assert!(cli.is_ok(), "save with all flags should parse");
1993 if let Commands::Save {
1994 paths,
1995 message,
1996 no_push,
1997 dry_run,
1998 ..
1999 } = cli.unwrap().command
2000 {
2001 assert_eq!(paths, vec![std::path::PathBuf::from("prior-versions")]);
2002 assert_eq!(message, "custom message");
2003 assert!(no_push);
2004 assert!(dry_run);
2005 } else {
2006 panic!("expected Save variant");
2007 }
2008 }
2009
2010 #[test]
2013 fn test_publish_missing_binary_returns_error() {
2014 let dir = TempDir::new().unwrap();
2015 let result = run_publish(
2016 &dir.path().join("missing-binary"),
2017 "asset.tar.gz",
2018 None,
2019 false,
2020 );
2021 assert!(result.is_err());
2022 let msg = result.unwrap_err().to_string();
2023 assert!(
2024 msg.contains("Binary not found"),
2025 "error should mention missing binary, got: {msg}"
2026 );
2027 }
2028
2029 #[test]
2030 fn test_publish_dry_run_succeeds_without_token() {
2031 let dir = TempDir::new().unwrap();
2032 let binary = dir.path().join("my-binary");
2033 std::fs::write(&binary, b"fake binary").unwrap();
2034 std::env::remove_var("GITHUB_TOKEN");
2036 let result = run_publish(&binary, "my-asset", Some("v1.0.0"), true);
2037 assert!(
2038 result.is_ok(),
2039 "dry_run should not require credentials: {result:?}"
2040 );
2041 }
2042
2043 #[test]
2044 fn test_publish_dry_run_missing_tag_returns_error() {
2045 let dir = TempDir::new().unwrap();
2046 let binary = dir.path().join("my-binary");
2047 std::fs::write(&binary, b"fake binary").unwrap();
2048 std::env::set_var("GITHUB_TOKEN", "fake-token");
2049 std::env::remove_var("CIRCLE_TAG");
2050 let result = run_publish(&binary, "my-asset", None, true);
2052 std::env::remove_var("GITHUB_TOKEN");
2053 assert!(result.is_err());
2054 let msg = result.unwrap_err().to_string();
2055 assert!(
2056 msg.contains("tag") || msg.contains("CIRCLE_TAG"),
2057 "error should mention tag or CIRCLE_TAG, got: {msg}"
2058 );
2059 }
2060
2061 #[test]
2062 fn test_publish_dry_run_prints_parameters() {
2063 let dir = TempDir::new().unwrap();
2064 let binary = dir.path().join("my-binary");
2065 std::fs::write(&binary, b"fake binary").unwrap();
2066 std::env::set_var("GITHUB_TOKEN", "fake-token");
2067 std::env::set_var("CIRCLE_PROJECT_USERNAME", "jerus-org");
2068 std::env::set_var("CIRCLE_PROJECT_REPONAME", "my-orb");
2069 let result = run_publish(&binary, "my-asset-linux-x86_64", Some("v1.0.0"), true);
2070 std::env::remove_var("GITHUB_TOKEN");
2071 std::env::remove_var("CIRCLE_PROJECT_USERNAME");
2072 std::env::remove_var("CIRCLE_PROJECT_REPONAME");
2073 assert!(
2074 result.is_ok(),
2075 "dry_run with all params should succeed: {result:?}"
2076 );
2077 }
2078
2079 #[test]
2080 fn test_cli_parse_publish_required_args() {
2081 let cli = Cli::try_parse_from([
2082 "gen-orb-mcp",
2083 "publish",
2084 "--binary",
2085 "/tmp/my-binary",
2086 "--asset-name",
2087 "my-binary-linux-x86_64",
2088 ]);
2089 assert!(cli.is_ok(), "publish with required args should parse");
2090 }
2091
2092 #[test]
2093 fn test_cli_parse_publish_all_flags() {
2094 let cli = Cli::try_parse_from([
2095 "gen-orb-mcp",
2096 "publish",
2097 "--binary",
2098 "/tmp/my-binary",
2099 "--asset-name",
2100 "my-binary-linux-x86_64",
2101 "--tag",
2102 "v2.0.0",
2103 "--dry-run",
2104 ]);
2105 assert!(cli.is_ok(), "publish with all flags should parse");
2106 if let Commands::Publish {
2107 binary,
2108 asset_name,
2109 tag,
2110 dry_run,
2111 } = cli.unwrap().command
2112 {
2113 assert_eq!(binary.to_str().unwrap(), "/tmp/my-binary");
2114 assert_eq!(asset_name, "my-binary-linux-x86_64");
2115 assert_eq!(tag.as_deref(), Some("v2.0.0"));
2116 assert!(dry_run);
2117 } else {
2118 panic!("expected Publish variant");
2119 }
2120 }
2121
2122 fn write_cargo_toml(dir: &std::path::Path, name: &str) {
2125 std::fs::write(
2126 dir.join("Cargo.toml"),
2127 format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
2128 )
2129 .unwrap();
2130 }
2131
2132 #[test]
2133 fn test_build_missing_cargo_toml_returns_error() {
2134 let dir = TempDir::new().unwrap();
2135 let result = run_build(dir.path(), None, None, false);
2136 assert!(result.is_err());
2137 let msg = result.unwrap_err().to_string();
2138 assert!(
2139 msg.contains("Cargo.toml"),
2140 "error should mention Cargo.toml, got: {msg}"
2141 );
2142 }
2143
2144 #[test]
2145 fn test_build_dry_run_does_not_invoke_cargo() {
2146 let dir = TempDir::new().unwrap();
2147 write_cargo_toml(dir.path(), "my-server");
2148 let result = run_build(dir.path(), None, None, true);
2151 assert!(
2152 result.is_ok(),
2153 "dry_run should succeed without invoking cargo: {result:?}"
2154 );
2155 }
2156
2157 #[test]
2158 fn test_build_name_override_accepted_in_dry_run() {
2159 let dir = TempDir::new().unwrap();
2160 write_cargo_toml(dir.path(), "my-server");
2161 let result = run_build(dir.path(), Some("custom-name"), None, true);
2162 assert!(
2163 result.is_ok(),
2164 "name override + dry_run should succeed: {result:?}"
2165 );
2166 }
2167
2168 #[test]
2169 fn test_build_target_triple_accepted_in_dry_run() {
2170 let dir = TempDir::new().unwrap();
2171 write_cargo_toml(dir.path(), "my-server");
2172 let result = run_build(dir.path(), None, Some("x86_64-unknown-linux-musl"), true);
2173 assert!(
2174 result.is_ok(),
2175 "target + dry_run should succeed: {result:?}"
2176 );
2177 }
2178
2179 #[test]
2180 fn test_parse_package_name_extracts_name() {
2181 let toml = "[package]\nname = \"my-orb-mcp\"\nversion = \"0.1.0\"\n";
2182 assert_eq!(
2183 parse_package_name(toml),
2184 Some("my-orb-mcp".to_string()),
2185 "should extract package name"
2186 );
2187 }
2188
2189 #[test]
2190 fn test_parse_package_name_stops_at_next_section() {
2191 let toml = "[package]\nname = \"my-orb-mcp\"\n[dependencies]\nname = \"ignored\"\n";
2192 assert_eq!(parse_package_name(toml), Some("my-orb-mcp".to_string()));
2193 }
2194
2195 #[test]
2196 fn test_parse_package_name_returns_none_when_absent() {
2197 let toml = "[dependencies]\nanyhow = \"1\"\n";
2198 assert_eq!(parse_package_name(toml), None);
2199 }
2200
2201 #[test]
2202 fn test_read_crate_name_from_file() {
2203 let dir = TempDir::new().unwrap();
2204 write_cargo_toml(dir.path(), "test-crate");
2205 let result = read_crate_name(dir.path());
2206 assert!(result.is_ok(), "read_crate_name should succeed: {result:?}");
2207 assert_eq!(result.unwrap(), "test-crate");
2208 }
2209
2210 #[test]
2211 fn test_cli_parse_build_required_input() {
2212 let cli = Cli::try_parse_from(["gen-orb-mcp", "build", "--input", "/tmp/my-server"]);
2213 assert!(cli.is_ok(), "build --input should parse");
2214 }
2215
2216 #[test]
2217 fn test_cli_parse_build_all_flags() {
2218 let cli = Cli::try_parse_from([
2219 "gen-orb-mcp",
2220 "build",
2221 "--input",
2222 "/tmp/my-server",
2223 "--name",
2224 "my_server",
2225 "--target",
2226 "x86_64-unknown-linux-musl",
2227 "--dry-run",
2228 ]);
2229 assert!(cli.is_ok(), "build with all flags should parse");
2230 if let Commands::Build {
2231 input,
2232 name,
2233 target,
2234 dry_run,
2235 } = cli.unwrap().command
2236 {
2237 assert_eq!(input.to_str().unwrap(), "/tmp/my-server");
2238 assert_eq!(name.as_deref(), Some("my_server"));
2239 assert_eq!(target.as_deref(), Some("x86_64-unknown-linux-musl"));
2240 assert!(dry_run);
2241 } else {
2242 panic!("expected Build variant");
2243 }
2244 }
2245}