gen_circleci_orb/commands/
generate.rs1use 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#[derive(Debug, clap::Args)]
17pub struct Generate {
18 #[arg(long)]
20 pub binary: String,
21
22 #[arg(long = "orb-namespace", required = true)]
24 pub namespaces: Vec<String>,
25
26 #[arg(long, default_value = ".")]
28 pub output: PathBuf,
29
30 #[arg(long, value_enum, default_value = "binstall")]
32 pub install_method: InstallMethod,
33
34 #[arg(long, default_value = DEFAULT_BASE_IMAGE)]
36 pub base_image: String,
37
38 #[arg(long)]
40 pub home_url: Option<String>,
41
42 #[arg(long)]
44 pub source_url: Option<String>,
45
46 #[arg(long, default_value = "orb")]
48 pub orb_dir: String,
49
50 #[arg(long)]
52 pub dry_run: bool,
53}
54
55pub(crate) fn normalize_git_remote_url(url: &str) -> String {
62 let url = if let Some(rest) = url.strip_prefix("git@") {
63 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
72pub(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
90pub(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 #[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}