gen-circleci-orb 0.0.34

Generate a CircleCI orb to provide the facilities offered by a CLI program
Documentation
use anyhow::Result;
use indexmap::IndexMap;
use std::path::PathBuf;

use crate::{
    ci_patcher,
    commands::generate::Generate,
    orb_config::{OrbConfig, OrbSection, SubcommandConfig},
};

pub const DEFAULT_DOCKER_ORB_VERSION: &str = "3.0.1";

/// Wire orb generation into an existing repo's CI configuration.
#[derive(Debug, clap::Args)]
pub struct Init {
    /// Name of the binary to introspect (must be on PATH).
    #[arg(long)]
    pub binary: String,

    /// CircleCI namespace(s) to publish the orb under as a public orb (repeatable).
    /// Must be set correctly on first init — visibility cannot be changed after the orb is created.
    #[arg(long = "public-orb-namespace")]
    pub public_orb_namespaces: Vec<String>,

    /// CircleCI namespace(s) to publish the orb under as a private orb (repeatable).
    /// Each listed namespace gets `--private` in its `circleci orb create` command.
    /// Must be set correctly on first init — visibility cannot be changed after the orb is created.
    #[arg(long = "private-orb-namespace")]
    pub private_orb_namespaces: Vec<String>,

    /// Name of the build/validation workflow to patch.
    #[arg(long)]
    pub build_workflow: String,

    /// Name of the release workflow to patch.
    #[arg(long)]
    pub release_workflow: String,

    /// Job in the build workflow that regenerate-orb should require.
    #[arg(long)]
    pub requires_job: Option<String>,

    /// Tag prefix used by `toolkit/release_crate` for the crate (e.g. `gen-orb-mcp-v`).
    /// Used to filter the `orb-release:` workflow trigger in config.yml and to normalise
    /// `CIRCLE_TAG` for `orb-tools/publish`.
    #[arg(long)]
    pub crate_tag_prefix: String,

    /// Job in the release workflow after which the generated release jobs
    /// (build-binary-release, pack-orb-release, build-container, ensure-orb-registered)
    /// should be gated. This is the sole mechanism for specifying where the generated
    /// jobs plug into the existing pipeline topology.
    #[arg(long)]
    pub release_after_job: String,

    /// Output directory for the generated orb source (relative to repo root).
    #[arg(long, default_value = "orb")]
    pub orb_dir: String,

    /// Path to the .circleci/ directory.
    #[arg(long, default_value = ".circleci")]
    pub ci_dir: PathBuf,

    /// circleci/orb-tools version to pin in generated CI.
    #[arg(long, default_value = "12.3.3")]
    pub orb_tools_version: String,

    /// circleci/docker orb version to pin in generated CI.
    #[arg(long, default_value = DEFAULT_DOCKER_ORB_VERSION)]
    pub docker_orb_version: String,

    /// Docker Hub (or registry) namespace for the built container image.
    #[arg(long)]
    pub docker_namespace: String,

    /// CircleCI context name holding Docker Hub credentials.
    #[arg(long, default_value = "docker-credentials")]
    pub docker_context: String,

    /// CircleCI context name holding orb publishing credentials.
    #[arg(long, default_value = "orb-publishing")]
    pub orb_context: String,

    /// Version of the jerus-org/gen-circleci-orb orb to pin in generated CI.
    /// Defaults to the version of this binary (orb and crate are released together).
    #[arg(long, default_value = env!("CARGO_PKG_VERSION"))]
    pub gen_circleci_orb_version: String,

    /// Wire in gen-orb-mcp MCP server generation + publish after orb publish.
    #[arg(long)]
    pub mcp: bool,

    /// Earliest orb version to include when priming prior-version snapshots.
    /// Passed to gen-circleci-orb/build_mcp_server as `earliest_version`.
    /// Only used when --mcp is enabled.
    #[arg(long, default_value = "0.0.1")]
    pub mcp_earliest_version: String,

    /// CircleCI context providing push authority for MCP server build + publish + save steps.
    /// Only used when --mcp is enabled.
    #[arg(long, default_value = "pcu-app")]
    pub mcp_context: String,

    /// Show planned changes without modifying any files.
    #[arg(long)]
    pub dry_run: bool,
}

pub(crate) fn build_bootstrap_config(
    binary: &str,
    namespaces: &[String],
    orb_dir: &str,
) -> OrbConfig {
    let mut subcommands = IndexMap::new();
    subcommands.insert(
        "help".to_string(),
        SubcommandConfig {
            generate_job: Some(false),
            param: None,
        },
    );
    OrbConfig {
        orb: Some(OrbSection {
            binary: Some(binary.to_string()),
            namespaces: Some(namespaces.to_vec()),
            orb_dir: Some(orb_dir.to_string()),
            base_image: None,
            install_method: None,
            home_url: None,
            source_url: None,
        }),
        orbs: None,
        subcommand: Some(subcommands),
        job_group: None,
        extra_job: None,
    }
}

impl Init {
    pub fn run(&self) -> Result<()> {
        let namespaces: Vec<String> = self
            .public_orb_namespaces
            .iter()
            .chain(self.private_orb_namespaces.iter())
            .cloned()
            .collect();

        // Step 1: generate orb source files
        tracing::info!("Generating orb source into ./{}", self.orb_dir);
        let gen = Generate {
            binary: self.binary.clone(),
            namespaces: namespaces.clone(),
            output: PathBuf::from("."),
            orb_dir: self.orb_dir.clone(),
            install_method: crate::commands::generate::InstallMethod::Binstall,
            base_image: crate::commands::generate::DEFAULT_BASE_IMAGE.to_string(),
            home_url: None,
            source_url: None,
            git_push_subcommands: vec![],
            circleci_cli_version: None,
            apt_packages: vec![],
            dry_run: self.dry_run,
            config: None,
        };
        gen.run()?;

        // Step 2: patch CI configs
        let opts = ci_patcher::PatchOpts {
            binary: self.binary.clone(),
            namespaces,
            docker_namespace: self.docker_namespace.clone(),
            orb_dir: self.orb_dir.clone(),
            build_workflow: self.build_workflow.clone(),
            release_workflow: self.release_workflow.clone(),
            requires_job: self.requires_job.clone(),
            crate_tag_prefix: self.crate_tag_prefix.clone(),
            release_after_job: self.release_after_job.clone(),
            orb_tools_version: self.orb_tools_version.clone(),
            docker_orb_version: self.docker_orb_version.clone(),
            docker_context: self.docker_context.clone(),
            orb_context: self.orb_context.clone(),
            private_namespaces: self.private_orb_namespaces.clone(),
            gen_circleci_orb_version: self.gen_circleci_orb_version.clone(),
            mcp: self.mcp,
            mcp_earliest_version: self.mcp_earliest_version.clone(),
            mcp_context: self.mcp_context.clone(),
        };

        let summary = ci_patcher::apply_patches(&self.ci_dir, &opts, self.dry_run)?;
        for line in &summary {
            println!("{line}");
        }

        // Step 3: write bootstrap gen-circleci-orb.toml
        let config_path = std::path::Path::new("gen-circleci-orb.toml");
        let bootstrap =
            build_bootstrap_config(&self.binary, opts.namespaces.as_slice(), &self.orb_dir);
        if self.dry_run {
            let content = toml::to_string_pretty(&bootstrap)?;
            println!("(dry-run) Would write {}", config_path.display());
            println!("{content}");
            println!("(dry-run: no files written)");
        } else {
            crate::orb_config::save_config(config_path, &bootstrap)?;
            println!("Wrote {}", config_path.display());
            println!("Done.");
        }

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_docker_orb_version_matches_registry() {
        // The CircleCI registry has circleci/docker@3.0.1 as latest.
        // 3.2.0 does not exist and causes "Cannot find circleci/docker@3.2.0" errors.
        assert_eq!(
            DEFAULT_DOCKER_ORB_VERSION, "3.0.1",
            "DEFAULT_DOCKER_ORB_VERSION must be the registry-available version"
        );
    }

    // ── Phase 6: bootstrap config written by init ───────────────────────────

    #[test]
    fn bootstrap_config_has_orb_section_with_binary() {
        let config = build_bootstrap_config("mytool", &["my-org".to_string()], "orb");
        assert!(
            config.orb.is_some(),
            "bootstrap config must have [orb] section"
        );
        assert_eq!(
            config.orb.as_ref().unwrap().binary.as_deref(),
            Some("mytool")
        );
    }

    #[test]
    fn bootstrap_config_has_namespaces() {
        let config =
            build_bootstrap_config("mytool", &["ns1".to_string(), "ns2".to_string()], "orb");
        assert_eq!(
            config.orb.as_ref().unwrap().namespaces.as_deref(),
            Some(&["ns1".to_string(), "ns2".to_string()][..])
        );
    }

    #[test]
    fn bootstrap_config_suppresses_help_subcommand() {
        let config = build_bootstrap_config("mytool", &["my-org".to_string()], "orb");
        let subcommands = config
            .subcommand
            .as_ref()
            .expect("subcommand section missing");
        let help = subcommands.get("help").expect("help entry missing");
        assert_eq!(
            help.generate_job,
            Some(false),
            "help subcommand must be suppressed in bootstrap config"
        );
    }

    #[test]
    fn bootstrap_config_has_orb_dir() {
        let config = build_bootstrap_config("mytool", &["my-org".to_string()], "custom-orb");
        assert_eq!(
            config.orb.as_ref().unwrap().orb_dir.as_deref(),
            Some("custom-orb")
        );
    }
}