Skip to main content

gen_circleci_orb/commands/
generate.rs

1use anyhow::Result;
2use clap::ValueEnum;
3use std::path::{Path, PathBuf};
4
5use crate::{help_parser, orb_generator, output_writer};
6
7pub const DEFAULT_BASE_IMAGE: &str = "debian:12-slim";
8
9#[derive(Debug, Clone, ValueEnum)]
10pub enum InstallMethod {
11    Binstall,
12    Apt,
13}
14
15/// Generate orb source files from a CLI binary's --help output.
16#[derive(Debug, clap::Args)]
17pub struct Generate {
18    /// Name of the binary to introspect (must be on PATH).
19    #[arg(long)]
20    pub binary: String,
21
22    /// CircleCI orb namespace(s) to publish the orb under (repeatable).
23    #[arg(long = "orb-namespace", required = true)]
24    pub namespaces: Vec<String>,
25
26    /// Project root directory (orb source is written to <output>/<orb-dir>/).
27    #[arg(long, default_value = ".")]
28    pub output: PathBuf,
29
30    /// How the binary is installed in the generated Docker image.
31    #[arg(long, value_enum, default_value = "binstall")]
32    pub install_method: InstallMethod,
33
34    /// Base Docker image for the generated executor.
35    #[arg(long, default_value = DEFAULT_BASE_IMAGE)]
36    pub base_image: String,
37
38    /// Home URL for the orb registry display section.
39    #[arg(long)]
40    pub home_url: Option<String>,
41
42    /// Source URL for the orb registry display section.
43    #[arg(long)]
44    pub source_url: Option<String>,
45
46    /// Subdirectory within --output where orb source is written (default: orb).
47    #[arg(long, default_value = "orb")]
48    pub orb_dir: String,
49
50    /// Show planned output without writing any files.
51    #[arg(long)]
52    pub dry_run: bool,
53}
54
55/// Convert any git remote URL to a plain HTTPS URL, stripping the `.git` suffix.
56///
57/// Handles:
58/// - `git@github.com:org/repo.git` → `https://github.com/org/repo`
59/// - `https://github.com/org/repo.git` → `https://github.com/org/repo`
60/// - `https://github.com/org/repo` → unchanged
61pub(crate) fn normalize_git_remote_url(url: &str) -> String {
62    let url = if let Some(rest) = url.strip_prefix("git@") {
63        // git@host:org/repo.git → https://host/org/repo
64        let normalized = rest.replacen(':', "/", 1);
65        format!("https://{normalized}")
66    } else {
67        url.to_string()
68    };
69    url.strip_suffix(".git").unwrap_or(&url).to_string()
70}
71
72/// Attempt to detect the repository source URL from the git remote named `origin`.
73/// Returns `None` if git is unavailable or no `origin` remote is configured.
74pub(crate) fn detect_source_url() -> Option<String> {
75    let output = std::process::Command::new("git")
76        .args(["remote", "get-url", "origin"])
77        .output()
78        .ok()?;
79    if !output.status.success() {
80        return None;
81    }
82    let raw = String::from_utf8(output.stdout).ok()?;
83    let trimmed = raw.trim();
84    if trimmed.is_empty() {
85        return None;
86    }
87    Some(normalize_git_remote_url(trimmed))
88}
89
90/// Guard: refuse to write into a directory that exists but looks like unrelated source code.
91///
92/// A directory is considered safe if it is absent, empty, or already contains `src/@orb.yml`.
93pub(crate) fn check_orb_dir(orb_root: &Path) -> Result<()> {
94    if !orb_root.exists() {
95        return Ok(());
96    }
97    if orb_root.join("src/@orb.yml").exists() {
98        return Ok(());
99    }
100    let has_content = std::fs::read_dir(orb_root)?.next().is_some();
101    if has_content {
102        anyhow::bail!(
103            "Directory '{}' already exists but does not appear to contain a CircleCI orb \
104             (no src/@orb.yml found). Refusing to write into it to avoid mixing orb source \
105             with unrelated code. Use --orb-dir to specify a different subdirectory.",
106            orb_root.display()
107        );
108    }
109    Ok(())
110}
111
112impl Generate {
113    pub fn run(&self) -> Result<()> {
114        let orb_root = self.output.join(&self.orb_dir);
115
116        check_orb_dir(&orb_root)?;
117
118        tracing::info!("Parsing {} --help", self.binary);
119        let cli_def = help_parser::parse_binary(&self.binary)?;
120
121        tracing::info!("Discovered {} subcommand(s)", cli_def.subcommands.len());
122
123        let detected_url = self.source_url.is_none().then(detect_source_url).flatten();
124        let source_url = self.source_url.clone().or_else(|| detected_url.clone());
125        let home_url = self.home_url.clone().or_else(|| detected_url.clone());
126
127        let opts = orb_generator::GenerateOpts {
128            namespaces: self.namespaces.clone(),
129            install_method: self.install_method.clone(),
130            base_image: self.base_image.clone(),
131            home_url,
132            source_url,
133            binary_name: cli_def.binary_name.clone(),
134        };
135
136        let files = orb_generator::generate(&cli_def, &opts);
137
138        tracing::info!("Generated {} file(s)", files.len());
139
140        let report = output_writer::write_tree(&orb_root, &files, self.dry_run)?;
141
142        println!(
143            "Done: {} created, {} updated, {} unchanged",
144            report.created, report.updated, report.unchanged
145        );
146        Ok(())
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use std::fs;
154    use tempfile::TempDir;
155
156    // ── normalize_git_remote_url ────────────────────────────────────────────
157
158    #[test]
159    fn normalize_ssh_remote_to_https() {
160        assert_eq!(
161            normalize_git_remote_url("git@github.com:jerus-org/gen-circleci-orb.git"),
162            "https://github.com/jerus-org/gen-circleci-orb"
163        );
164    }
165
166    #[test]
167    fn normalize_https_with_git_suffix() {
168        assert_eq!(
169            normalize_git_remote_url("https://github.com/jerus-org/gen-circleci-orb.git"),
170            "https://github.com/jerus-org/gen-circleci-orb"
171        );
172    }
173
174    #[test]
175    fn normalize_https_without_git_suffix_unchanged() {
176        assert_eq!(
177            normalize_git_remote_url("https://github.com/jerus-org/gen-circleci-orb"),
178            "https://github.com/jerus-org/gen-circleci-orb"
179        );
180    }
181
182    #[test]
183    fn normalize_ssh_non_github_host() {
184        assert_eq!(
185            normalize_git_remote_url("git@gitlab.com:myorg/myrepo.git"),
186            "https://gitlab.com/myorg/myrepo"
187        );
188    }
189
190    #[test]
191    fn check_orb_dir_absent_is_ok() {
192        let tmp = TempDir::new().unwrap();
193        let orb_root = tmp.path().join("orb");
194        assert!(check_orb_dir(&orb_root).is_ok());
195    }
196
197    #[test]
198    fn check_orb_dir_with_orb_yml_is_ok() {
199        let tmp = TempDir::new().unwrap();
200        let orb_root = tmp.path().join("orb");
201        fs::create_dir_all(orb_root.join("src")).unwrap();
202        fs::write(orb_root.join("src/@orb.yml"), "version: 2.1").unwrap();
203        assert!(check_orb_dir(&orb_root).is_ok());
204    }
205
206    #[test]
207    fn check_orb_dir_empty_is_ok() {
208        let tmp = TempDir::new().unwrap();
209        let orb_root = tmp.path().join("orb");
210        fs::create_dir_all(&orb_root).unwrap();
211        assert!(check_orb_dir(&orb_root).is_ok());
212    }
213
214    #[test]
215    fn check_orb_dir_with_unrelated_content_errors() {
216        let tmp = TempDir::new().unwrap();
217        let orb_root = tmp.path().join("src");
218        fs::create_dir_all(&orb_root).unwrap();
219        fs::write(orb_root.join("main.rs"), "fn main() {}").unwrap();
220        let result = check_orb_dir(&orb_root);
221        assert!(
222            result.is_err(),
223            "should error when unrelated content present"
224        );
225        let msg = result.unwrap_err().to_string();
226        assert!(
227            msg.contains("does not appear to contain a CircleCI orb"),
228            "unexpected error message: {msg}"
229        );
230    }
231}