Skip to main content

gen_circleci_orb/orb_generator/
render.rs

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    /// Binary name included in generated run-step commands.
15    pub binary_name: String,
16}
17
18/// Generate all orb artifact strings keyed by their relative output path.
19pub fn generate(cli: &CliDefinition, opts: &GenerateOpts) -> HashMap<PathBuf, String> {
20    let mut files = HashMap::new();
21
22    // @orb.yml — metadata only; hand-formatted so `version: 2.1` stays unquoted
23    files.insert(PathBuf::from("src/@orb.yml"), render_orb_root(cli, opts));
24
25    // executors/default.yml
26    files.insert(
27        PathBuf::from("src/executors/default.yml"),
28        render_executor(&cli.binary_name),
29    );
30
31    // commands/<name>.yml and jobs/<name>.yml for each leaf subcommand
32    for sub in &cli.subcommands {
33        render_subcommand(sub, &cli.binary_name, &mut files);
34    }
35
36    // Dockerfile
37    files.insert(
38        PathBuf::from("Dockerfile"),
39        render_dockerfile(&cli.binary_name, &opts.install_method, &opts.base_image),
40    );
41
42    // examples/example.yml (RC003)
43    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    // version must be the YAML float 2.1, not a quoted string
53    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
86/// CircleCI parameter names that are restricted in command definitions.
87/// orb pack rejects these with "Restricted parameter: '<name>'".
88/// Rather than dropping them, the generator renames them to `{subcommand}_{param}`
89/// so the functionality is preserved under a descriptive, unambiguous name.
90const RESTRICTED_COMMAND_PARAMS: &[&str] = &["name"];
91
92/// Returns the orb parameter name to use for a CLI parameter in a command.
93/// Restricted names are prefixed with the subcommand name
94/// (e.g. `generate` + `name` → `generate_name`).
95fn resolve_command_param_name(subcommand: &str, param: &str) -> String {
96    if RESTRICTED_COMMAND_PARAMS.contains(&param) {
97        format!("{subcommand}_{param}")
98    } else {
99        param.to_string()
100    }
101}
102
103/// CircleCI job parameter names that are reserved by the platform and cannot be
104/// used as user-defined parameters in job definitions.
105const 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
127/// Build orb parameters for a command, renaming any restricted names with a
128/// subcommand prefix so they remain usable (e.g. `name` → `generate_name`).
129fn 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
165/// Build the shell script body for a command.
166/// Parameters are received as uppercased env vars (set via the YAML environment: block).
167fn 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    // Use the first leaf subcommand name for the example job, or the binary name if none.
260    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                // Clap booleans default to false but never emit [default: false] in help
286                // text. Always supply default: false so orb consumers can omit the param.
287                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                // Optional CLI flag with no default: use empty string so consumers can
292                // omit the param. The run step uses a mustache conditional, so "" means
293                // the flag is not forwarded to the binary.
294                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
314/// Build the `run:` step for a command, referencing the script file (RC009 compliance).
315/// Adds an `environment:` block so the script can read params as uppercased env vars.
316fn 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
351/// Build the command invocation step for a job.
352fn 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    // ── @orb.yml ────────────────────────────────────────────────────────────
408
409    #[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        // version must be the YAML float 2.1, not a quoted string
434        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    // ── executor ────────────────────────────────────────────────────────────
442
443    #[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    // ── Dockerfile ──────────────────────────────────────────────────────────
463
464    #[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        // Builder stage uses Rust on Bookworm so binary links against same GLIBC as runtime
470        assert!(
471            content.contains("FROM rust:1-slim-bookworm AS builder"),
472            "should use rust:1-slim-bookworm builder stage:\n{content}"
473        );
474        // Runtime stage is the slim Debian image
475        assert!(
476            content.contains("FROM debian:12-slim"),
477            "should use debian:12-slim runtime stage:\n{content}"
478        );
479        // Binary compiled from source in builder stage — no curl|bash
480        assert!(
481            content.contains("cargo install mytool"),
482            "should install via cargo install in builder stage:\n{content}"
483        );
484        // Binary copied from builder to runtime
485        assert!(
486            content.contains("COPY --from=builder"),
487            "should copy binary from builder stage:\n{content}"
488        );
489        // No pipe-to-bash pattern
490        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        // git must appear as an apt package install, not just in cargo paths
517        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        // USER circleci must appear after the binary is copied — not root at final layer
548        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    // ── command files / scripts ─────────────────────────────────────────────
603
604    #[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    // ── examples ────────────────────────────────────────────────────────────
732
733    #[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        // Required params are always appended — no guard needed.
756        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    // ── orb parameter defaults ─────────────────────────────────────────────
847
848    #[test]
849    fn boolean_orb_parameter_has_false_default() {
850        // Clap boolean flags never emit [default: false] in help text, so p.default is None.
851        // The orb must supply default: false so consumers can omit the parameter.
852        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        // Optional CLI flag (inside [OPTIONS], no [default:]) must get default: "" so
872        // the orb consumer does not have to supply it.  The mustache conditional ensures
873        // the flag is not forwarded to the binary when the value is empty.
874        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        // serde_yaml serialises an empty string as ''
886        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        // Truly required params (listed outside [OPTIONS] on the Usage line) must NOT
895        // have a default: key — CircleCI will then enforce that the consumer supplies them.
896        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    // ── job files ───────────────────────────────────────────────────────────
914
915    #[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        // CircleCI reserves "name" (and others) as job-level parameters.
942        // The generator must omit reserved names from job files so orb pack
943        // does not reject the output with "Reserved job parameter name: 'name'".
944        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        // Job must NOT contain `name:` as a parameter key (2-space indent = parameter level).
967        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        // Non-reserved param must still appear in the job
974        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        // CircleCI restricts "name" as a command parameter.
983        // Rather than silently dropping it, the generator must rename it to
984        // "{subcommand}_{param}" so the functionality is preserved under a
985        // descriptive, unambiguous name — e.g. "generate" + "name" → "generate_name".
986        // The CLI flag emitted in the script stays --name (the original flag).
987        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        // Must NOT appear as the bare restricted name
1011        assert!(
1012            !cmd.contains("\n  name:\n"),
1013            "command must not use bare restricted parameter 'name':\n{cmd}"
1014        );
1015        // MUST appear under the prefixed name
1016        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        // Script uses the uppercased env var for the renamed orb parameter …
1023        assert!(
1024            script.contains("GENERATE_NAME"),
1025            "script must reference 'GENERATE_NAME' env var:\n{script}"
1026        );
1027        // … but still emits the original CLI flag to the binary
1028        assert!(
1029            script.contains("--name"),
1030            "script must still emit '--name' flag to the binary:\n{script}"
1031        );
1032
1033        // Non-restricted param must still appear unchanged
1034        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        // SonarQube S7018: package lists must be sorted alphanumerically.
1043        let dockerfile = render_dockerfile("mytool", &InstallMethod::Binstall, "debian:12-slim");
1044        // builder stage: ca-certificates libssl-dev pkg-config (alphabetical)
1045        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}