Skip to main content

gen_orb_mcp/
lib.rs

1//! # gen-orb-mcp
2//!
3//! Generate MCP (Model Context Protocol) servers from CircleCI orb definitions.
4//!
5//! This tool enables AI coding assistants to understand and work with private
6//! CircleCI orbs by generating MCP servers that expose orb commands, jobs,
7//! and executors as resources.
8//!
9//! ## Usage
10//!
11//! ```bash
12//! gen-orb-mcp generate --orb-path ./src/@orb.yml --output ./dist/
13//! ```
14
15pub 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/// Generate MCP servers from CircleCI orb definitions.
29#[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 an MCP server from an orb definition
49    Generate {
50        /// Path to the orb YAML file (e.g., src/@orb.yml)
51        #[arg(short = 'p', long, default_value = "src/@orb.yml")]
52        orb_path: std::path::PathBuf,
53
54        /// Output directory for generated server
55        #[arg(short = 'o', long, default_value = "./dist")]
56        output: std::path::PathBuf,
57
58        /// Output format
59        #[arg(short, long, value_enum, default_value = "source")]
60        format: OutputFormat,
61
62        /// Name for the generated orb server (defaults to filename)
63        #[arg(short, long)]
64        name: Option<String>,
65
66        /// Version for the generated MCP server crate (e.g., "1.0.0")
67        ///
68        /// Required when regenerating an existing output directory.
69        /// For CI workflows, this should match the orb release version.
70        #[arg(long = "crate-version")]
71        crate_version: Option<String>,
72
73        /// Overwrite existing files without confirmation
74        ///
75        /// Required for non-interactive CI environments when output exists.
76        #[arg(long)]
77        force: bool,
78
79        /// Directory containing conformance rule JSON files to embed in the
80        /// server
81        ///
82        /// All *.json files in this directory are merged and embedded as
83        /// migration tooling in the generated server. When provided,
84        /// the server gains plan_migration and apply_migration MCP
85        /// Tools in addition to Resources.
86        #[arg(long)]
87        migrations: Option<std::path::PathBuf>,
88
89        /// Directory of prior orb version YAML snapshots to embed in the server
90        ///
91        /// Each file should be named `<version>.yml` (e.g., `4.7.1.yml`). The
92        /// generated server will expose version-specific resources for each
93        /// prior version alongside the current version.
94        #[arg(long)]
95        prior_versions: Option<std::path::PathBuf>,
96
97        /// Tag prefix used to discover the orb version from git tags
98        ///
99        /// The git repository is derived automatically from --orb-path.
100        /// Defaults to "v" (matches tags like v6.0.0).
101        #[arg(long, default_value = "v")]
102        tag_prefix: String,
103    },
104    /// Validate an orb definition without generating
105    Validate {
106        /// Path to the orb YAML file
107        #[arg(short = 'p', long, default_value = "src/@orb.yml")]
108        orb_path: std::path::PathBuf,
109    },
110    /// Compute conformance rules by diffing two orb versions
111    ///
112    /// Compares the current orb against a previous version (read from a file)
113    /// and emits a JSON array of ConformanceRule values. These rules can be
114    /// passed to `generate --migrations` to embed migration tooling in the
115    /// generated MCP server.
116    Diff {
117        /// Path to the current orb YAML (the new version)
118        #[arg(long)]
119        current: std::path::PathBuf,
120
121        /// Path to the previous orb YAML (the old version to diff against)
122        #[arg(long)]
123        previous: std::path::PathBuf,
124
125        /// The version string to embed in emitted rules (e.g. "5.0.0")
126        #[arg(long)]
127        since_version: String,
128
129        /// Optional output file for the JSON rules (default: stdout)
130        #[arg(long)]
131        output: Option<std::path::PathBuf>,
132    },
133    /// Apply conformance-based migration to a consumer's .circleci/ directory
134    ///
135    /// Reads conformance rules from a JSON file (produced by `diff`) and
136    /// applies them to the consumer's CI config. Reports planned changes
137    /// before applying.
138    Migrate {
139        /// Path to the consumer's .circleci/ directory
140        #[arg(long, default_value = ".circleci")]
141        ci_dir: std::path::PathBuf,
142
143        /// The orb alias as used in the consumer's orbs: section (e.g.
144        /// "toolkit")
145        #[arg(long)]
146        orb: String,
147
148        /// Path to the conformance rules JSON file (produced by `diff`)
149        #[arg(long)]
150        rules: std::path::PathBuf,
151
152        /// Show planned changes without modifying files
153        #[arg(long)]
154        dry_run: bool,
155    },
156    /// Populate prior-versions/ and migrations/ from git history
157    ///
158    /// Discovers version tags in a sliding window (default: last 6 months),
159    /// checks out each version, saves a snapshot to
160    /// `prior-versions/<version>.yml`, and computes conformance-rule diffs
161    /// to `migrations/<version>.json`. Removes files for versions outside
162    /// the window to keep binary size bounded. Idempotent.
163    Prime {
164        /// Path to the orb YAML entry point
165        #[arg(short = 'p', long, default_value = "src/@orb.yml")]
166        orb_path: std::path::PathBuf,
167
168        /// Path to the git repository root (default: walk up from orb-path to
169        /// .git)
170        #[arg(long)]
171        git_repo: Option<std::path::PathBuf>,
172
173        /// Git tag prefix (e.g. "v" matches tags like "v4.1.0")
174        #[arg(long, default_value = "v")]
175        tag_prefix: String,
176
177        /// Fixed earliest version anchor (e.g. "4.1.0"); conflicts with --since
178        #[arg(long, conflicts_with = "since")]
179        earliest_version: Option<String>,
180
181        /// Rolling window duration (e.g. "6 months", "1 year"); default: "6
182        /// months"
183        #[arg(long)]
184        since: Option<String>,
185
186        /// Directory to write prior-version snapshots
187        #[arg(long, default_value = "prior-versions")]
188        prior_versions_dir: std::path::PathBuf,
189
190        /// Directory to write migration rule JSON files
191        #[arg(long, default_value = "migrations")]
192        migrations_dir: std::path::PathBuf,
193
194        /// Write to `/tmp/gen-orb-mcp-prime-<pid>/` and print
195        /// PRIME_PV_DIR/PRIME_MIG_DIR to stdout
196        #[arg(long)]
197        ephemeral: bool,
198
199        /// Override git rename detection for a specific job (repeatable).
200        /// Format: `OLD=NEW`, e.g. `--rename-map common_tests_rolling=common_tests`.
201        /// Manual entries take precedence over git-detected hints for matching
202        /// old names.  Use this when commits cannot be restructured to follow
203        /// the two-commit rename rule.
204        #[arg(long, value_name = "OLD=NEW")]
205        rename_map: Vec<String>,
206
207        /// Describe actions without writing any files
208        #[arg(long)]
209        dry_run: bool,
210    },
211    /// Stage, commit, and push generated artifacts back to the repository
212    ///
213    /// Idempotent: if the working tree is clean after staging the specified
214    /// paths, exits successfully without creating an empty commit.
215    /// The default commit message includes [skip ci] to prevent CI re-triggering.
216    Save {
217        /// Paths to stage and commit (relative to repository root)
218        #[arg(long, required = true)]
219        paths: Vec<std::path::PathBuf>,
220
221        /// Commit message
222        #[arg(
223            short = 'm',
224            long,
225            default_value = "chore: update generated MCP server artifacts [skip ci]"
226        )]
227        message: String,
228
229        /// Push after committing (default: true)
230        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
231        push: bool,
232
233        /// Stage and commit only, do not push
234        #[arg(long, conflicts_with = "push")]
235        no_push: bool,
236
237        /// Show what would be committed without writing anything
238        #[arg(long)]
239        dry_run: bool,
240
241        /// Use GPG-signed commit and GitHub App token push.
242        ///
243        /// Reads: BOT_GPG_KEY (base64 GPG key), BOT_TRUST (ownertrust),
244        /// BOT_USER_NAME, BOT_USER_EMAIL, BOT_SIGN_KEY (key ID),
245        /// GITHUB_TOKEN (GitHub App installation token),
246        /// CIRCLE_PROJECT_USERNAME, CIRCLE_PROJECT_REPONAME, CIRCLE_BRANCH.
247        #[arg(long)]
248        sign: bool,
249    },
250    /// Upload a compiled binary to an existing GitHub release as a release asset
251    ///
252    /// The GitHub release must already exist before this command is run.
253    /// Set GITHUB_TOKEN, CIRCLE_PROJECT_USERNAME, CIRCLE_PROJECT_REPONAME,
254    /// and CIRCLE_TAG (or use --tag) in the environment.
255    Publish {
256        /// Path to the binary file to upload
257        #[arg(short = 'b', long)]
258        binary: std::path::PathBuf,
259
260        /// Name for the release asset (e.g. my-orb-mcp-linux-x86_64)
261        #[arg(short = 'a', long)]
262        asset_name: String,
263
264        /// Release tag to publish to (default: $CIRCLE_TAG)
265        #[arg(long)]
266        tag: Option<String>,
267
268        /// Describe the upload without performing it
269        #[arg(long)]
270        dry_run: bool,
271    },
272    /// Compile generated MCP server source to a native binary
273    Build {
274        /// Directory containing generated MCP server source
275        #[arg(short = 'i', long)]
276        input: std::path::PathBuf,
277
278        /// Override the binary name (default: derived from Cargo.toml)
279        #[arg(short = 'n', long)]
280        name: Option<String>,
281
282        /// Rust target triple (default: host)
283        #[arg(long)]
284        target: Option<String>,
285
286        /// Print the cargo command without running it
287        #[arg(long)]
288        dry_run: bool,
289    },
290}
291
292/// Output format for generated MCP server
293#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
294pub enum OutputFormat {
295    /// Compile to native binary (Linux x86_64)
296    Binary,
297    /// Generate Rust source code
298    Source,
299}
300
301/// Optional embedding inputs for `run_generate`.
302struct 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    /// Execute the CLI command
310    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    // Auto-discover version from the git repo containing orb_path
417    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
598/// Loads prior orb version snapshots from a directory of `<version>.yml` files.
599fn 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
626/// Loads and merges conformance rules from all `*.json` files in a directory.
627fn 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    // Resolve git repo path: either provided, or walk up from orb_path
667    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    // Relative orb path from repo root
674    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    // Resolve output dirs
686    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    // Discover and filter tags
698    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        // Need dates for each tag
710        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    // Parse --rename-map OLD=NEW entries into (from, to) pairs.
729    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    // PCU_APP_ID and PCU_PRIVATE_KEY (if present via pcu-app context) are
806    // picked up automatically by the PCU_ prefix source and used for GitHub
807    // App auth, which carries branch-protection bypass authority.
808    // GITHUB_TOKEN is registered as a PAT fallback for environments without
809    // App credentials.
810    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    // Open a fresh repo handle after staging so the index reflects the
865    // changes written to disk by client.stage_paths().
866    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
1115/// Extract the `name` field from the `[package]` section of a Cargo.toml string.
1116fn 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
1133/// Parse a `name = "value"` assignment line, returning the unquoted value.
1134fn 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
1141/// Walk up from `start` looking for a `.git` directory.
1142fn find_git_root(start: &std::path::Path) -> Result<std::path::PathBuf> {
1143    // Canonicalise first: a relative path like "src/@orb.yml" would otherwise
1144    // produce Path("") when walking up past "src", and "" cannot be
1145    // canonicalised.  That propagates as an absolute orb_path_relative which
1146    // makes worktree.join() ignore the worktree entirely.
1147    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
1169/// Derive orb name from the orb path.
1170///
1171/// For unpacked orbs (`@orb.yml`), uses the project directory name.
1172/// Handles the common `project/src/@orb.yml` structure by skipping the `src`
1173/// directory. For packed orbs, uses the file stem (filename without extension).
1174fn 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        // Get parent directory
1179        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 is "src", go up one more level to get project name
1183        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        // Use filename without extension
1195        path.file_stem()
1196            .and_then(|s| s.to_str())
1197            .unwrap_or("orb")
1198            .to_string()
1199    }
1200}
1201
1202/// Discover the latest version tag in a git repository with the given prefix.
1203///
1204/// Returns `None` when no matching tags exist. On error (e.g. not a git repo),
1205/// returns `Ok(None)` rather than propagating so callers fall through to the
1206/// next resolution strategy.
1207fn 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    // discover_tags returns versions sorted ascending; highest is last
1211    Ok(tags.into_iter().last())
1212}
1213
1214/// Resolve the version to use for the generated MCP server.
1215///
1216/// # Version Resolution Rules (priority order)
1217///
1218/// 1. Explicit `--version` — always wins
1219/// 2. `git_hint` — version auto-discovered from git tags via `--git-repo`
1220/// 3. Fresh generation with no hints — `DEFAULT_VERSION`
1221/// 4. Existing output with no version — error (must specify `--version`)
1222///
1223/// The `--force` flag is required when overwriting existing output.
1224fn 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    // Explicit version always wins (with force check if output exists)
1234    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    // Git-discovered version
1246    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    // No version available — refuse to generate with an unknown version
1258    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        // Standard orb structure: project/src/@orb.yml -> "project"
1404        let path = Path::new("/path/to/my-toolkit/src/@orb.yml");
1405        assert_eq!(derive_orb_name(path), "my-toolkit");
1406
1407        // Non-standard structure without src: my-orb/@orb.yml -> "my-orb"
1408        let path = Path::new("my-orb/@orb.yml");
1409        assert_eq!(derive_orb_name(path), "my-orb");
1410
1411        // Edge case: src/@orb.yml at root -> "orb" (no grandparent, falls back to
1412        // default)
1413        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        // Create a Cargo.toml to simulate existing output
1446        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    // Tests 11-15: prime command CLI parsing
1504
1505    #[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        // --earliest-version and --since are mutually exclusive
1565        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    // Serialises tests that mutate the global CWD.
1611    static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1612
1613    /// Regression test: `find_git_root` with a *relative* orb path must return
1614    /// an **absolute** path.
1615    ///
1616    /// When the user runs `gen-orb-mcp prime --orb-path src/@orb.yml` (the
1617    /// default), `orb_path` is relative.  `find_git_root` walks up from
1618    /// `src/@orb.yml` → `src` → `""` (Rust `Path::parent` of `"src"` is `""`).
1619    /// If the function returns `""`, `repo_abs` cannot be canonicalised, so
1620    /// `strip_prefix("")` on the absolute `orb_abs` returns the full absolute
1621    /// path.  `worktree.join(absolute_path)` then ignores the worktree and reads
1622    /// the current working copy — producing snapshots with current-version
1623    /// content for every historical tag.
1624    ///
1625    /// The fix: canonicalise `start` at the top of `find_git_root` so the
1626    /// walk-up always operates on absolute paths and returns an absolute result.
1627    #[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        // Change to the fake repo root so that "src/@orb.yml" is a valid
1642        // relative path.
1643        std::env::set_current_dir(tmp.path()).unwrap();
1644
1645        let result = find_git_root(std::path::Path::new("src/@orb.yml"));
1646
1647        // Always restore CWD before asserting so a failure doesn't leave the
1648        // process in the tmp directory.
1649        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    // --- Tests for discover_latest_version ---
1664
1665    #[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    // --- save subcommand tests ---
1783
1784    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        // Initial commit so HEAD exists
1801        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        // Stage the path we already committed — tree is clean after staging
1822        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        // Verify a commit was created beyond the initial one
1857        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        // Create a directory with files inside — mirrors the prior-versions/ and migrations/ case
1874        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        // Only the initial commit should exist
1923        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    // --- publish subcommand tests ---
2011
2012    #[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        // dry_run must succeed without credentials — no API call is made
2035        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        // no --tag and no CIRCLE_TAG — should fail with a clear message
2051        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    // --- build subcommand tests ---
2123
2124    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        // Not a valid Rust project — cargo would fail if invoked.
2149        // With dry_run=true the function must succeed without running cargo.
2150        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}