1use super::types::{DockerImage, OrbCommand, OrbExecutor, OrbJob, OrbParameter};
2use crate::commands::generate::InstallMethod;
3use crate::help_parser::types::{CliDefinition, ParamType, SubCommand};
4use indexmap::IndexMap;
5use std::collections::HashMap;
6use std::path::PathBuf;
7
8pub struct GenerateOpts {
9 pub namespaces: Vec<String>,
10 pub install_method: InstallMethod,
11 pub base_image: String,
12 pub home_url: Option<String>,
13 pub source_url: Option<String>,
14 pub binary_name: String,
16}
17
18pub fn generate(cli: &CliDefinition, opts: &GenerateOpts) -> HashMap<PathBuf, String> {
20 let mut files = HashMap::new();
21
22 files.insert(PathBuf::from("src/@orb.yml"), render_orb_root(cli, opts));
24
25 files.insert(
27 PathBuf::from("src/executors/default.yml"),
28 render_executor(&cli.binary_name),
29 );
30
31 for sub in &cli.subcommands {
33 render_subcommand(sub, &cli.binary_name, &mut files);
34 }
35
36 files.insert(
38 PathBuf::from("Dockerfile"),
39 render_dockerfile(&cli.binary_name, &opts.install_method, &opts.base_image),
40 );
41
42 files.insert(
44 PathBuf::from("src/examples/example.yml"),
45 render_example(cli, opts),
46 );
47
48 files
49}
50
51fn render_orb_root(cli: &CliDefinition, opts: &GenerateOpts) -> String {
52 let mut out = format!("version: 2.1\ndescription: >\n {}\n", cli.description);
54 if opts.home_url.is_some() || opts.source_url.is_some() {
55 out.push_str("display:\n");
56 if let Some(url) = &opts.home_url {
57 out.push_str(&format!(" home_url: \"{url}\"\n"));
58 }
59 if let Some(url) = &opts.source_url {
60 out.push_str(&format!(" source_url: \"{url}\"\n"));
61 }
62 }
63 out
64}
65
66fn render_subcommand(sub: &SubCommand, binary: &str, files: &mut HashMap<PathBuf, String>) {
67 if sub.is_leaf {
68 files.insert(
69 PathBuf::from(format!("src/commands/{}.yml", sub.name)),
70 render_command(sub),
71 );
72 files.insert(
73 PathBuf::from(format!("src/jobs/{}.yml", sub.name)),
74 render_job(sub),
75 );
76 files.insert(
77 PathBuf::from(format!("src/scripts/{}.sh", sub.name)),
78 render_command_script_content(sub, binary),
79 );
80 }
81 for child in &sub.subcommands {
82 render_subcommand(child, binary, files);
83 }
84}
85
86const RESTRICTED_COMMAND_PARAMS: &[&str] = &["name"];
91
92fn resolve_command_param_name(subcommand: &str, param: &str) -> String {
96 if RESTRICTED_COMMAND_PARAMS.contains(¶m) {
97 format!("{subcommand}_{param}")
98 } else {
99 param.to_string()
100 }
101}
102
103const RESERVED_JOB_PARAMS: &[&str] = &[
106 "name",
107 "type",
108 "filters",
109 "matrix",
110 "requires",
111 "context",
112 "pre_steps",
113 "post_steps",
114];
115
116fn render_command(sub: &SubCommand) -> String {
117 let parameters = build_command_orb_parameters(sub);
118 let step = build_run_step(sub);
119 let cmd = OrbCommand {
120 description: sub.description.clone(),
121 parameters,
122 steps: vec![step],
123 };
124 serde_yaml::to_string(&cmd).unwrap()
125}
126
127fn build_command_orb_parameters(sub: &SubCommand) -> IndexMap<String, OrbParameter> {
130 let mut params = IndexMap::new();
131 for p in &sub.parameters {
132 let orb_name = resolve_command_param_name(&sub.name, &p.long_name);
133 let (type_str, enum_vals) = match &p.param_type {
134 ParamType::String => ("string".to_string(), None),
135 ParamType::Boolean => ("boolean".to_string(), None),
136 ParamType::Integer => ("integer".to_string(), None),
137 ParamType::Enum(vals) => ("enum".to_string(), Some(vals.clone())),
138 };
139 let default = match &p.param_type {
140 ParamType::Boolean => {
141 let val = p.default.as_ref().map(|d| d == "true").unwrap_or(false);
142 Some(serde_yaml::Value::Bool(val))
143 }
144 _ if !p.required && p.default.is_none() => {
145 Some(serde_yaml::Value::String(String::new()))
146 }
147 _ => p
148 .default
149 .as_ref()
150 .map(|d| serde_yaml::Value::String(d.clone())),
151 };
152 params.insert(
153 orb_name,
154 OrbParameter {
155 param_type: type_str,
156 description: p.description.clone(),
157 default,
158 enum_values: enum_vals,
159 },
160 );
161 }
162 params
163}
164
165fn render_command_script_content(sub: &SubCommand, binary: &str) -> String {
168 let mut lines: Vec<String> = vec![format!("set -- {} {}", binary, sub.name.replace('_', "-"))];
169
170 for p in &sub.parameters {
171 let orb_name = resolve_command_param_name(&sub.name, &p.long_name);
172 let env_var = orb_name.to_uppercase();
173 let flag = format!("--{}", p.long_name.replace('_', "-"));
174 let line = match &p.param_type {
175 ParamType::Boolean => {
176 format!(r#"[ "${{{env_var}:-false}}" = "true" ] && set -- "$@" {flag}"#)
177 }
178 _ => {
179 if p.required {
180 format!(r#"set -- "$@" {flag} "${{{env_var}}}""#)
181 } else {
182 format!(r#"[ -n "${{{env_var}:-}}" ] && set -- "$@" {flag} "${{{env_var}}}""#)
183 }
184 }
185 };
186 lines.push(line);
187 }
188
189 lines.push(r#""$@""#.to_string());
190 lines.join("\n")
191}
192
193fn render_job(sub: &SubCommand) -> String {
194 let parameters = build_orb_parameters(sub, RESERVED_JOB_PARAMS);
195 let checkout_step: serde_yaml::Value = serde_yaml::Value::String("checkout".to_string());
196 let invoke_step = build_invoke_step(sub, RESERVED_JOB_PARAMS);
197 let job = OrbJob {
198 description: format!("Run {} {} in a dedicated job.", sub.name, "command"),
199 executor: "default".to_string(),
200 parameters,
201 steps: vec![checkout_step, invoke_step],
202 };
203 serde_yaml::to_string(&job).unwrap()
204}
205
206fn render_executor(binary_name: &str) -> String {
207 let mut params = IndexMap::new();
208 params.insert(
209 "tag".to_string(),
210 OrbParameter {
211 param_type: "string".to_string(),
212 description: "Docker image tag.".to_string(),
213 default: Some(serde_yaml::Value::String("latest".to_string())),
214 enum_values: None,
215 },
216 );
217 let executor = OrbExecutor {
218 description: format!("Execution environment with {binary_name} pre-installed."),
219 docker: vec![DockerImage {
220 image: format!("jerusdp/{binary_name}:<< parameters.tag >>"),
221 }],
222 parameters: params,
223 };
224 serde_yaml::to_string(&executor).unwrap()
225}
226
227fn render_dockerfile(binary: &str, method: &InstallMethod, base_image: &str) -> String {
228 match method {
229 InstallMethod::Binstall => format!(
230 r#"FROM rust:1-slim-bookworm AS builder
231RUN apt-get update \
232 && apt-get install -y --no-install-recommends ca-certificates libssl-dev pkg-config \
233 && rm -rf /var/lib/apt/lists/* \
234 && cargo install {binary}
235
236FROM {base_image}
237RUN apt-get update \
238 && apt-get install -y --no-install-recommends ca-certificates git \
239 && rm -rf /var/lib/apt/lists/* \
240 && useradd -ms /bin/bash circleci
241COPY --from=builder /usr/local/cargo/bin/{binary} /usr/local/bin/{binary}
242USER circleci
243WORKDIR /home/circleci/project
244"#
245 ),
246 InstallMethod::Apt => format!(
247 "FROM {base_image}\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends git {binary} \\\n && rm -rf /var/lib/apt/lists/*\n"
248 ),
249 }
250}
251
252fn render_example(cli: &CliDefinition, opts: &GenerateOpts) -> String {
253 let namespace = opts
254 .namespaces
255 .first()
256 .map(String::as_str)
257 .unwrap_or("my-org");
258 let binary = &cli.binary_name;
259 let job_name = cli
261 .subcommands
262 .iter()
263 .find(|s| s.is_leaf)
264 .map(|s| s.name.as_str())
265 .unwrap_or(binary);
266 format!(
267 "description: >\n Example usage of the {binary} orb.\nusage:\n version: 2.1\n orbs:\n {binary}: {namespace}/{binary}@1.0\n workflows:\n use-my-orb:\n jobs:\n - {binary}/{job_name}\n"
268 )
269}
270
271fn build_orb_parameters(sub: &SubCommand, skip: &[&str]) -> IndexMap<String, OrbParameter> {
272 let mut params = IndexMap::new();
273 for p in &sub.parameters {
274 if skip.contains(&p.long_name.as_str()) {
275 continue;
276 }
277 let (type_str, enum_vals) = match &p.param_type {
278 ParamType::String => ("string".to_string(), None),
279 ParamType::Boolean => ("boolean".to_string(), None),
280 ParamType::Integer => ("integer".to_string(), None),
281 ParamType::Enum(vals) => ("enum".to_string(), Some(vals.clone())),
282 };
283 let default = match &p.param_type {
284 ParamType::Boolean => {
285 let val = p.default.as_ref().map(|d| d == "true").unwrap_or(false);
288 Some(serde_yaml::Value::Bool(val))
289 }
290 _ if !p.required && p.default.is_none() => {
291 Some(serde_yaml::Value::String(String::new()))
295 }
296 _ => p
297 .default
298 .as_ref()
299 .map(|d| serde_yaml::Value::String(d.clone())),
300 };
301 params.insert(
302 p.long_name.clone(),
303 OrbParameter {
304 param_type: type_str,
305 description: p.description.clone(),
306 default,
307 enum_values: enum_vals,
308 },
309 );
310 }
311 params
312}
313
314fn build_run_step(sub: &SubCommand) -> serde_yaml::Value {
317 serde_yaml::Value::Mapping({
318 let mut m = serde_yaml::Mapping::new();
319 let mut run_map = serde_yaml::Mapping::new();
320 run_map.insert(
321 serde_yaml::Value::String("name".to_string()),
322 serde_yaml::Value::String(sub.name.clone()),
323 );
324 run_map.insert(
325 serde_yaml::Value::String("command".to_string()),
326 serde_yaml::Value::String(format!("<<include(scripts/{}.sh)>>", sub.name)),
327 );
328 if !sub.parameters.is_empty() {
329 let mut env_map = serde_yaml::Mapping::new();
330 for p in &sub.parameters {
331 let orb_name = resolve_command_param_name(&sub.name, &p.long_name);
332 let env_var = orb_name.to_uppercase();
333 env_map.insert(
334 serde_yaml::Value::String(env_var),
335 serde_yaml::Value::String(format!("<< parameters.{orb_name} >>")),
336 );
337 }
338 run_map.insert(
339 serde_yaml::Value::String("environment".to_string()),
340 serde_yaml::Value::Mapping(env_map),
341 );
342 }
343 m.insert(
344 serde_yaml::Value::String("run".to_string()),
345 serde_yaml::Value::Mapping(run_map),
346 );
347 m
348 })
349}
350
351fn build_invoke_step(sub: &SubCommand, skip: &[&str]) -> serde_yaml::Value {
353 let mut invoke_map = serde_yaml::Mapping::new();
354 for p in &sub.parameters {
355 if skip.contains(&p.long_name.as_str()) {
356 continue;
357 }
358 invoke_map.insert(
359 serde_yaml::Value::String(p.long_name.clone()),
360 serde_yaml::Value::String(format!("<< parameters.{} >>", p.long_name)),
361 );
362 }
363 serde_yaml::Value::Mapping({
364 let mut m = serde_yaml::Mapping::new();
365 m.insert(
366 serde_yaml::Value::String(sub.name.clone()),
367 serde_yaml::Value::Mapping(invoke_map),
368 );
369 m
370 })
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use crate::help_parser::types::{ParamType, Parameter, SubCommand};
377
378 fn make_leaf(name: &str, params: Vec<Parameter>) -> SubCommand {
379 SubCommand {
380 name: name.to_string(),
381 description: format!("Does {name} things."),
382 is_leaf: true,
383 parameters: params,
384 subcommands: vec![],
385 }
386 }
387
388 fn make_cli(binary: &str, subs: Vec<SubCommand>) -> CliDefinition {
389 CliDefinition {
390 binary_name: binary.to_string(),
391 description: format!("The {binary} tool."),
392 subcommands: subs,
393 }
394 }
395
396 fn default_opts() -> GenerateOpts {
397 GenerateOpts {
398 namespaces: vec!["my-org".to_string()],
399 install_method: InstallMethod::Binstall,
400 base_image: "debian:12-slim".to_string(),
401 home_url: None,
402 source_url: None,
403 binary_name: "mytool".to_string(),
404 }
405 }
406
407 #[test]
410 fn orb_yml_has_no_commands_jobs_executors_keys() {
411 let cli = make_cli("mytool", vec![]);
412 let files = generate(&cli, &default_opts());
413 let content = files[&PathBuf::from("src/@orb.yml")].clone();
414 assert!(
415 !content.contains("commands:"),
416 "@orb.yml must not list commands:\n{content}"
417 );
418 assert!(
419 !content.contains("jobs:"),
420 "@orb.yml must not list jobs:\n{content}"
421 );
422 assert!(
423 !content.contains("executors:"),
424 "@orb.yml must not list executors:\n{content}"
425 );
426 }
427
428 #[test]
429 fn orb_yml_contains_version_and_description() {
430 let cli = make_cli("mytool", vec![]);
431 let files = generate(&cli, &default_opts());
432 let content = &files[&PathBuf::from("src/@orb.yml")];
433 assert!(
435 content.contains("version: 2.1"),
436 "version must be unquoted:\n{content}"
437 );
438 assert!(content.contains("The mytool tool."));
439 }
440
441 #[test]
444 fn executor_has_docker_image_with_tag_param() {
445 let cli = make_cli("mytool", vec![]);
446 let files = generate(&cli, &default_opts());
447 let content = &files[&PathBuf::from("src/executors/default.yml")];
448 assert!(
449 content.contains("jerusdp/mytool:<< parameters.tag >>"),
450 "executor image wrong:\n{content}"
451 );
452 assert!(
453 content.contains("tag:"),
454 "executor missing tag param:\n{content}"
455 );
456 assert!(
457 content.contains("default: latest"),
458 "tag default missing:\n{content}"
459 );
460 }
461
462 #[test]
465 fn dockerfile_binstall_uses_multistage_build() {
466 let cli = make_cli("mytool", vec![]);
467 let files = generate(&cli, &default_opts());
468 let content = &files[&PathBuf::from("Dockerfile")];
469 assert!(
471 content.contains("FROM rust:1-slim-bookworm AS builder"),
472 "should use rust:1-slim-bookworm builder stage:\n{content}"
473 );
474 assert!(
476 content.contains("FROM debian:12-slim"),
477 "should use debian:12-slim runtime stage:\n{content}"
478 );
479 assert!(
481 content.contains("cargo install mytool"),
482 "should install via cargo install in builder stage:\n{content}"
483 );
484 assert!(
486 content.contains("COPY --from=builder"),
487 "should copy binary from builder stage:\n{content}"
488 );
489 assert!(
491 !content.contains("| bash"),
492 "must not use curl|bash pattern:\n{content}"
493 );
494 }
495
496 #[test]
497 fn dockerfile_binstall_runtime_has_ca_certs_and_git() {
498 let cli = make_cli("mytool", vec![]);
499 let files = generate(&cli, &default_opts());
500 let content = &files[&PathBuf::from("Dockerfile")];
501 assert!(
502 content.contains("ca-certificates"),
503 "runtime stage should install ca-certificates:\n{content}"
504 );
505 assert!(
506 content.contains("apt-get install") && content.contains(" git"),
507 "runtime stage must install git for CircleCI checkout step:\n{content}"
508 );
509 }
510
511 #[test]
512 fn dockerfile_binstall_includes_git() {
513 let cli = make_cli("mytool", vec![]);
514 let files = generate(&cli, &default_opts());
515 let content = &files[&PathBuf::from("Dockerfile")];
516 assert!(
518 content.contains("apt-get install") && content.contains(" git"),
519 "Dockerfile must install git via apt for CircleCI checkout step:\n{content}"
520 );
521 }
522
523 #[test]
524 fn dockerfile_binstall_has_circleci_user_and_workdir() {
525 let cli = make_cli("mytool", vec![]);
526 let files = generate(&cli, &default_opts());
527 let content = &files[&PathBuf::from("Dockerfile")];
528 assert!(
529 content.contains("useradd") && content.contains("circleci"),
530 "runtime stage must create circleci user:\n{content}"
531 );
532 assert!(
533 content.contains("USER circleci"),
534 "runtime stage must set USER circleci:\n{content}"
535 );
536 assert!(
537 content.contains("WORKDIR /home/circleci/project"),
538 "runtime stage must set WORKDIR /home/circleci/project:\n{content}"
539 );
540 }
541
542 #[test]
543 fn dockerfile_binstall_does_not_run_as_root() {
544 let cli = make_cli("mytool", vec![]);
545 let files = generate(&cli, &default_opts());
546 let content = &files[&PathBuf::from("Dockerfile")];
547 let user_pos = content
549 .rfind("USER circleci")
550 .expect("USER circleci not found");
551 let copy_pos = content
552 .rfind("COPY --from=builder")
553 .expect("COPY --from=builder not found");
554 assert!(
555 user_pos > copy_pos,
556 "USER circleci must appear after COPY --from=builder:\n{content}"
557 );
558 }
559
560 #[test]
561 fn dockerfile_apt_includes_git() {
562 let cli = make_cli("mytool", vec![]);
563 let opts = GenerateOpts {
564 install_method: InstallMethod::Apt,
565 ..default_opts()
566 };
567 let files = generate(&cli, &opts);
568 let content = &files[&PathBuf::from("Dockerfile")];
569 assert!(
570 content.contains("apt-get install") && content.contains(" git"),
571 "Dockerfile (apt) must install git via apt for CircleCI checkout step:\n{content}"
572 );
573 }
574
575 #[test]
576 fn dockerfile_apt() {
577 let cli = make_cli("mytool", vec![]);
578 let opts = GenerateOpts {
579 install_method: InstallMethod::Apt,
580 ..default_opts()
581 };
582 let files = generate(&cli, &opts);
583 let content = &files[&PathBuf::from("Dockerfile")];
584 assert!(
585 content.contains("apt-get install -y"),
586 "missing apt-get install:\n{content}"
587 );
588 assert!(
589 content.contains("mytool"),
590 "missing binary name:\n{content}"
591 );
592 assert!(
593 content.contains("--no-install-recommends"),
594 "apt should use --no-install-recommends:\n{content}"
595 );
596 assert!(
597 content.contains("rm -rf /var/lib/apt/lists"),
598 "apt should clean lists:\n{content}"
599 );
600 }
601
602 #[test]
605 fn command_step_uses_script_include() {
606 let sub = make_leaf("generate", vec![]);
607 let cli = make_cli("mytool", vec![sub]);
608 let files = generate(&cli, &default_opts());
609 let content = &files[&PathBuf::from("src/commands/generate.yml")];
610 assert!(
611 content.contains("<<include(scripts/generate.sh)>>"),
612 "command step must use script include for RC009 compliance:\n{content}"
613 );
614 }
615
616 #[test]
617 fn script_file_generated_for_each_subcommand() {
618 let subs = vec![make_leaf("generate", vec![]), make_leaf("validate", vec![])];
619 let cli = make_cli("mytool", subs);
620 let files = generate(&cli, &default_opts());
621 for name in &["generate", "validate"] {
622 assert!(
623 files.contains_key(&PathBuf::from(format!("src/scripts/{name}.sh"))),
624 "missing scripts/{name}.sh"
625 );
626 }
627 }
628
629 #[test]
630 fn script_file_contains_required_param_flag() {
631 let params = vec![Parameter {
632 long_name: "orb_path".to_string(),
633 short: Some('p'),
634 param_type: ParamType::String,
635 default: None,
636 required: true,
637 description: "Path to orb.".to_string(),
638 }];
639 let sub = make_leaf("generate", params);
640 let cli = make_cli("mytool", vec![sub]);
641 let files = generate(&cli, &default_opts());
642 let script = &files[&PathBuf::from("src/scripts/generate.sh")];
643 assert!(
644 script.contains("set -- \"$@\" --orb-path \"${ORB_PATH}\""),
645 "script must append required param via env var:\n{script}"
646 );
647 assert!(
648 !script.contains("<<"),
649 "script must not contain unsubstituted << parameters >> literals:\n{script}"
650 );
651 }
652
653 #[test]
654 fn script_file_contains_optional_param_conditional() {
655 let params = vec![Parameter {
656 long_name: "output".to_string(),
657 short: None,
658 param_type: ParamType::String,
659 default: Some("./dist".to_string()),
660 required: false,
661 description: "Output dir.".to_string(),
662 }];
663 let sub = make_leaf("generate", params);
664 let cli = make_cli("mytool", vec![sub]);
665 let files = generate(&cli, &default_opts());
666 let script = &files[&PathBuf::from("src/scripts/generate.sh")];
667 assert!(
668 script.contains("[ -n \"${OUTPUT:-}\" ]") && script.contains("--output \"${OUTPUT}\""),
669 "optional param in script must use shell conditional on env var:\n{script}"
670 );
671 }
672
673 #[test]
674 fn script_file_contains_boolean_flag() {
675 let params = vec![Parameter {
676 long_name: "force".to_string(),
677 short: None,
678 param_type: ParamType::Boolean,
679 default: None,
680 required: false,
681 description: "Force overwrite.".to_string(),
682 }];
683 let sub = make_leaf("generate", params);
684 let cli = make_cli("mytool", vec![sub]);
685 let files = generate(&cli, &default_opts());
686 let script = &files[&PathBuf::from("src/scripts/generate.sh")];
687 assert!(
688 script.contains("[ \"${FORCE:-false}\" = \"true\" ]") && script.contains("--force"),
689 "boolean flag in script must use shell conditional on env var:\n{script}"
690 );
691 }
692
693 #[test]
694 fn command_run_step_has_environment_block() {
695 let params = vec![
696 Parameter {
697 long_name: "orb_path".to_string(),
698 short: None,
699 param_type: ParamType::String,
700 default: None,
701 required: true,
702 description: "Path to orb.".to_string(),
703 },
704 Parameter {
705 long_name: "force".to_string(),
706 short: None,
707 param_type: ParamType::Boolean,
708 default: None,
709 required: false,
710 description: "Force.".to_string(),
711 },
712 ];
713 let sub = make_leaf("generate", params);
714 let cli = make_cli("mytool", vec![sub]);
715 let files = generate(&cli, &default_opts());
716 let content = &files[&PathBuf::from("src/commands/generate.yml")];
717 assert!(
718 content.contains("environment:"),
719 "command run step must have environment block:\n{content}"
720 );
721 assert!(
722 content.contains("ORB_PATH: << parameters.orb_path >>"),
723 "environment must map ORB_PATH:\n{content}"
724 );
725 assert!(
726 content.contains("FORCE: << parameters.force >>"),
727 "environment must map FORCE:\n{content}"
728 );
729 }
730
731 #[test]
734 fn example_file_generated() {
735 let sub = make_leaf("generate", vec![]);
736 let cli = make_cli("mytool", vec![sub]);
737 let files = generate(&cli, &default_opts());
738 assert!(
739 files.contains_key(&PathBuf::from("src/examples/example.yml")),
740 "src/examples/example.yml must be generated for RC003 compliance"
741 );
742 let example = &files[&PathBuf::from("src/examples/example.yml")];
743 assert!(
744 example.contains("usage:"),
745 "example must have a usage block:\n{example}"
746 );
747 assert!(
748 example.contains("my-org/mytool"),
749 "example must reference the orb:\n{example}"
750 );
751 }
752
753 #[test]
754 fn required_param_renders_without_conditional() {
755 let params = vec![Parameter {
757 long_name: "orb_path".to_string(),
758 short: Some('p'),
759 param_type: ParamType::String,
760 default: None,
761 required: true,
762 description: "Path to orb.".to_string(),
763 }];
764 let sub = make_leaf("generate", params);
765 let cli = make_cli("mytool", vec![sub]);
766 let files = generate(&cli, &default_opts());
767 let script = &files[&PathBuf::from("src/scripts/generate.sh")];
768 assert!(
769 script.contains("set -- \"$@\" --orb-path \"${ORB_PATH}\""),
770 "required param must unconditionally append via env var:\n{script}"
771 );
772 assert!(
773 !script.contains("[ -n") || !script.contains("ORB_PATH"),
774 "required param must not use conditional guard:\n{script}"
775 );
776 }
777
778 #[test]
779 fn optional_string_param_renders_with_env_var_conditional() {
780 let params = vec![Parameter {
781 long_name: "output".to_string(),
782 short: Some('o'),
783 param_type: ParamType::String,
784 default: Some("./dist".to_string()),
785 required: false,
786 description: "Output dir.".to_string(),
787 }];
788 let sub = make_leaf("generate", params);
789 let cli = make_cli("mytool", vec![sub]);
790 let files = generate(&cli, &default_opts());
791 let script = &files[&PathBuf::from("src/scripts/generate.sh")];
792 assert!(
793 script.contains("[ -n \"${OUTPUT:-}\" ]"),
794 "optional param should use shell conditional on env var:\n{script}"
795 );
796 }
797
798 #[test]
799 fn boolean_flag_renders_with_env_var_conditional() {
800 let params = vec![Parameter {
801 long_name: "force".to_string(),
802 short: None,
803 param_type: ParamType::Boolean,
804 default: None,
805 required: false,
806 description: "Force overwrite.".to_string(),
807 }];
808 let sub = make_leaf("generate", params);
809 let cli = make_cli("mytool", vec![sub]);
810 let files = generate(&cli, &default_opts());
811 let script = &files[&PathBuf::from("src/scripts/generate.sh")];
812 assert!(
813 script.contains("[ \"${FORCE:-false}\" = \"true\" ]"),
814 "boolean flag must use shell conditional on env var:\n{script}"
815 );
816 }
817
818 #[test]
819 fn enum_parameter_has_enum_key() {
820 let params = vec![Parameter {
821 long_name: "format".to_string(),
822 short: Some('f'),
823 param_type: ParamType::Enum(vec!["binary".to_string(), "source".to_string()]),
824 default: Some("source".to_string()),
825 required: false,
826 description: "Output format.".to_string(),
827 }];
828 let sub = make_leaf("generate", params);
829 let cli = make_cli("mytool", vec![sub]);
830 let files = generate(&cli, &default_opts());
831 let content = &files[&PathBuf::from("src/commands/generate.yml")];
832 assert!(
833 content.contains("enum:"),
834 "enum param missing enum key:\n{content}"
835 );
836 assert!(
837 content.contains("binary"),
838 "enum missing value 'binary':\n{content}"
839 );
840 assert!(
841 content.contains("source"),
842 "enum missing value 'source':\n{content}"
843 );
844 }
845
846 #[test]
849 fn boolean_orb_parameter_has_false_default() {
850 let params = vec![Parameter {
853 long_name: "force".to_string(),
854 short: None,
855 param_type: ParamType::Boolean,
856 default: None,
857 required: false,
858 description: "Force overwrite.".to_string(),
859 }];
860 let sub = make_leaf("cmd", params);
861 let files = generate(&make_cli("mytool", vec![sub]), &default_opts());
862 let content = &files[&PathBuf::from("src/commands/cmd.yml")];
863 assert!(
864 content.contains("default: false"),
865 "boolean param must have default: false so it is optional for orb consumers:\n{content}"
866 );
867 }
868
869 #[test]
870 fn optional_string_no_default_has_empty_string_default() {
871 let params = vec![Parameter {
875 long_name: "output".to_string(),
876 short: None,
877 param_type: ParamType::String,
878 default: None,
879 required: false,
880 description: "Output path.".to_string(),
881 }];
882 let sub = make_leaf("cmd", params);
883 let files = generate(&make_cli("mytool", vec![sub]), &default_opts());
884 let content = &files[&PathBuf::from("src/commands/cmd.yml")];
885 assert!(
887 content.contains("default: ''"),
888 "optional no-default string param must have default: '' so consumers can omit it:\n{content}"
889 );
890 }
891
892 #[test]
893 fn required_string_no_default_has_no_default_key() {
894 let params = vec![Parameter {
897 long_name: "orb_path".to_string(),
898 short: None,
899 param_type: ParamType::String,
900 default: None,
901 required: true,
902 description: "Path to orb.".to_string(),
903 }];
904 let sub = make_leaf("cmd", params);
905 let files = generate(&make_cli("mytool", vec![sub]), &default_opts());
906 let content = &files[&PathBuf::from("src/commands/cmd.yml")];
907 assert!(
908 !content.contains("default:"),
909 "required param must not have a default key:\n{content}"
910 );
911 }
912
913 #[test]
916 fn job_references_executor_default() {
917 let sub = make_leaf("validate", vec![]);
918 let cli = make_cli("mytool", vec![sub]);
919 let files = generate(&cli, &default_opts());
920 let content = &files[&PathBuf::from("src/jobs/validate.yml")];
921 assert!(
922 content.contains("executor: default"),
923 "job must reference default executor:\n{content}"
924 );
925 }
926
927 #[test]
928 fn job_has_checkout_step() {
929 let sub = make_leaf("validate", vec![]);
930 let cli = make_cli("mytool", vec![sub]);
931 let files = generate(&cli, &default_opts());
932 let content = &files[&PathBuf::from("src/jobs/validate.yml")];
933 assert!(
934 content.contains("checkout"),
935 "job missing checkout step:\n{content}"
936 );
937 }
938
939 #[test]
940 fn job_excludes_reserved_circleci_parameter_names() {
941 let params = vec![
945 Parameter {
946 long_name: "name".to_string(),
947 short: Some('n'),
948 param_type: ParamType::String,
949 default: None,
950 required: false,
951 description: "Name for the output.".to_string(),
952 },
953 Parameter {
954 long_name: "output".to_string(),
955 short: Some('o'),
956 param_type: ParamType::String,
957 default: Some("./dist".to_string()),
958 required: false,
959 description: "Output dir.".to_string(),
960 },
961 ];
962 let sub = make_leaf("generate", params);
963 let cli = make_cli("mytool", vec![sub]);
964 let files = generate(&cli, &default_opts());
965
966 let job = &files[&PathBuf::from("src/jobs/generate.yml")];
968 assert!(
969 !job.contains("\n name:\n"),
970 "job must not contain reserved parameter 'name':\n{job}"
971 );
972
973 assert!(
975 job.contains("output:"),
976 "job must still contain non-reserved parameter 'output':\n{job}"
977 );
978 }
979
980 #[test]
981 fn command_renames_restricted_parameter_with_subcommand_prefix() {
982 let params = vec![
988 Parameter {
989 long_name: "name".to_string(),
990 short: Some('n'),
991 param_type: ParamType::String,
992 default: Some(String::new()),
993 required: false,
994 description: "Name for the output.".to_string(),
995 },
996 Parameter {
997 long_name: "output".to_string(),
998 short: Some('o'),
999 param_type: ParamType::String,
1000 default: Some("./dist".to_string()),
1001 required: false,
1002 description: "Output dir.".to_string(),
1003 },
1004 ];
1005 let sub = make_leaf("generate", params);
1006 let cli = make_cli("mytool", vec![sub]);
1007 let files = generate(&cli, &default_opts());
1008
1009 let cmd = &files[&PathBuf::from("src/commands/generate.yml")];
1010 assert!(
1012 !cmd.contains("\n name:\n"),
1013 "command must not use bare restricted parameter 'name':\n{cmd}"
1014 );
1015 assert!(
1017 cmd.contains("generate_name:"),
1018 "command must expose 'name' as 'generate_name':\n{cmd}"
1019 );
1020
1021 let script = &files[&PathBuf::from("src/scripts/generate.sh")];
1022 assert!(
1024 script.contains("GENERATE_NAME"),
1025 "script must reference 'GENERATE_NAME' env var:\n{script}"
1026 );
1027 assert!(
1029 script.contains("--name"),
1030 "script must still emit '--name' flag to the binary:\n{script}"
1031 );
1032
1033 assert!(
1035 cmd.contains("output:"),
1036 "command must still contain non-restricted parameter 'output':\n{cmd}"
1037 );
1038 }
1039
1040 #[test]
1041 fn dockerfile_binstall_builder_packages_sorted() {
1042 let dockerfile = render_dockerfile("mytool", &InstallMethod::Binstall, "debian:12-slim");
1044 assert!(
1046 dockerfile.contains("ca-certificates libssl-dev pkg-config"),
1047 "builder packages must be sorted: ca-certificates libssl-dev pkg-config\n{dockerfile}"
1048 );
1049 }
1050
1051 #[test]
1052 fn command_and_job_files_created_for_each_leaf() {
1053 let subs = vec![
1054 make_leaf("generate", vec![]),
1055 make_leaf("validate", vec![]),
1056 make_leaf("diff", vec![]),
1057 ];
1058 let cli = make_cli("mytool", subs);
1059 let files = generate(&cli, &default_opts());
1060 for name in &["generate", "validate", "diff"] {
1061 assert!(
1062 files.contains_key(&PathBuf::from(format!("src/commands/{name}.yml"))),
1063 "missing commands/{name}.yml"
1064 );
1065 assert!(
1066 files.contains_key(&PathBuf::from(format!("src/jobs/{name}.yml"))),
1067 "missing jobs/{name}.yml"
1068 );
1069 assert!(
1070 files.contains_key(&PathBuf::from(format!("src/scripts/{name}.sh"))),
1071 "missing scripts/{name}.sh"
1072 );
1073 }
1074 }
1075}