Skip to main content

gen_circleci_orb/ci_patcher/
mod.rs

1use anyhow::Result;
2
3pub struct PatchOpts {
4    pub binary: String,
5    /// One or more CircleCI namespaces to publish the orb under.
6    /// Each namespace gets its own `ensure-orb-registered-<ns>` job and
7    /// `orb-tools/publish: name: publish-orb-<ns>` workflow step.
8    pub namespaces: Vec<String>,
9    pub docker_namespace: String,
10    pub orb_dir: String,
11    pub build_workflow: String,
12    pub release_workflow: String,
13    pub requires_job: Option<String>,
14    pub release_after_job: String,
15    pub orb_tools_version: String,
16    pub docker_orb_version: String,
17    pub docker_context: String,
18    pub orb_context: String,
19    /// Namespaces that should be registered as private orbs.
20    /// A namespace listed here gets `--private` in its `circleci orb create` command.
21    /// Namespaces not listed are registered as public.
22    /// Visibility is set at orb creation time and cannot be changed afterwards.
23    pub private_namespaces: Vec<String>,
24    pub mcp: bool,
25}
26
27pub struct PatchReport {
28    pub insertions: Vec<String>,
29    pub skipped: Vec<String>,
30}
31
32/// Patch a build/validation CircleCI config string.
33/// Returns the modified content and a report of what was changed or skipped.
34pub fn patch_build(content: &str, opts: &PatchOpts) -> (String, PatchReport) {
35    let mut report = PatchReport {
36        insertions: vec![],
37        skipped: vec![],
38    };
39    let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
40
41    // 1. Add orb-tools to orbs section
42    let orb_entry = format!("  orb-tools: circleci/orb-tools@{}", opts.orb_tools_version);
43    if content.contains("orb-tools:") {
44        report.skipped.push("orb-tools orb".to_string());
45    } else if let Some(pos) = find_section_end(&lines, "orbs:") {
46        lines.insert(pos, orb_entry);
47        report.insertions.push("orb-tools orb".to_string());
48    }
49
50    // 2. Add jobs section if missing, then add build-binary + regenerate-orb jobs
51    let jobs_present = content.contains("build-binary:") && content.contains("regenerate-orb:");
52    if jobs_present {
53        report
54            .skipped
55            .push("build-binary and regenerate-orb jobs".to_string());
56    } else {
57        let build_block = build_binary_job(opts);
58        let regen_block = regenerate_orb_job(opts);
59        if let Some(pos) = find_section_end(&lines, "jobs:") {
60            for (i, l) in build_block.iter().enumerate() {
61                lines.insert(pos + i, l.clone());
62            }
63            let after_build = pos + build_block.len();
64            for (i, l) in regen_block.iter().enumerate() {
65                lines.insert(after_build + i, l.clone());
66            }
67        } else {
68            // No jobs section — insert before workflows:
69            if let Some(wf_pos) = find_top_level(&lines, "workflows:") {
70                lines.insert(wf_pos, String::new());
71                lines.insert(wf_pos, String::new());
72                // Insert regen block first (it goes last in jobs), then build block before it
73                let regen_len = regen_block.len();
74                for (i, _) in regen_block.iter().rev().enumerate() {
75                    lines.insert(wf_pos, regen_block[regen_len - 1 - i].clone());
76                }
77                let build_len = build_block.len();
78                for (i, _) in build_block.iter().rev().enumerate() {
79                    lines.insert(wf_pos, build_block[build_len - 1 - i].clone());
80                }
81                lines.insert(wf_pos, "jobs:".to_string());
82            }
83        }
84        report
85            .insertions
86            .push("build-binary and regenerate-orb jobs".to_string());
87    }
88
89    // 3. Add workflow steps (regenerate-orb, orb-tools/pack, orb-tools/validate)
90    if content.contains("orb-tools/pack:") {
91        report.skipped.push("workflow steps".to_string());
92    } else {
93        let step_block = pack_validate_steps(opts);
94        if let Some(pos) = find_workflow_jobs_end(&lines, &opts.build_workflow) {
95            for (i, l) in step_block.iter().enumerate() {
96                lines.insert(pos + i, l.clone());
97            }
98        }
99        report.insertions.push("workflow steps".to_string());
100    }
101
102    let mut output = lines.join("\n");
103    if content.ends_with('\n') {
104        output.push('\n');
105    }
106    (output, report)
107}
108
109/// Patch a release CircleCI config string.
110/// Returns the modified content and a report of what was changed or skipped.
111pub fn patch_release(content: &str, opts: &PatchOpts) -> (String, PatchReport) {
112    let mut report = PatchReport {
113        insertions: vec![],
114        skipped: vec![],
115    };
116    let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
117
118    // 1. Add docker + orb-tools orbs
119    // Check specifically for circleci orb entries, not random "docker:" keys in job defs
120    let docker_entry = format!("  docker: circleci/docker@{}", opts.docker_orb_version);
121    let orb_entry = format!("  orb-tools: circleci/orb-tools@{}", opts.orb_tools_version);
122    let has_docker_orb = content.contains("  docker: circleci/");
123    let has_orb_tools = content.contains("  orb-tools: circleci/");
124
125    if has_docker_orb && has_orb_tools {
126        report.skipped.push("docker and orb-tools orbs".to_string());
127    } else {
128        if let Some(pos) = find_section_end(&lines, "orbs:") {
129            let mut insert_pos = pos;
130            if !has_orb_tools {
131                lines.insert(insert_pos, orb_entry);
132                insert_pos += 1;
133            }
134            if !has_docker_orb {
135                lines.insert(insert_pos, docker_entry);
136            }
137            report
138                .insertions
139                .push("docker and orb-tools orbs".to_string());
140        }
141    }
142
143    // 2. Add build-binary-release, ensure-orb-registered-<ns> (per namespace), and build-container jobs
144    let has_binary_release = content.contains("build-binary-release:");
145    let missing_ensure_ns: Vec<&String> = opts
146        .namespaces
147        .iter()
148        .filter(|ns| !content.contains(&format!("  ensure-orb-registered-{ns}:")))
149        .collect();
150    let has_container = content.contains("build-container:");
151    if has_binary_release && missing_ensure_ns.is_empty() && has_container {
152        report.skipped.push(
153            "release jobs (build-binary-release, ensure-orb-registered, build-container)"
154                .to_string(),
155        );
156    } else {
157        if let Some(pos) = find_section_end(&lines, "jobs:") {
158            let mut insert_pos = pos;
159            if !has_binary_release {
160                let binary_block = build_binary_release_job(opts);
161                let binary_len = binary_block.len();
162                for (i, l) in binary_block.into_iter().enumerate() {
163                    lines.insert(insert_pos + i, l);
164                }
165                insert_pos += binary_len;
166            }
167            for ns in &missing_ensure_ns {
168                let ensure_block = ensure_orb_registered_job_for(
169                    ns,
170                    &opts.binary,
171                    opts.private_namespaces.contains(ns),
172                );
173                let ensure_len = ensure_block.len();
174                for (i, l) in ensure_block.into_iter().enumerate() {
175                    lines.insert(insert_pos + i, l);
176                }
177                insert_pos += ensure_len;
178            }
179            if !has_container {
180                let container_block = build_container_job(opts);
181                for (i, l) in container_block.into_iter().enumerate() {
182                    lines.insert(insert_pos + i, l);
183                }
184            }
185        } else if let Some(wf_pos) = find_top_level(&lines, "workflows:") {
186            // No top-level jobs section — create one before workflows:
187            lines.insert(wf_pos, String::new());
188            lines.insert(wf_pos, String::new());
189            let container_block = build_container_job(opts);
190            let container_len = container_block.len();
191            for i in 0..container_len {
192                lines.insert(wf_pos, container_block[container_len - 1 - i].clone());
193            }
194            // Insert ensure jobs in reverse order to preserve namespace ordering
195            for ns in opts.namespaces.iter().rev() {
196                if missing_ensure_ns.contains(&ns) {
197                    let ensure_block = ensure_orb_registered_job_for(
198                        ns,
199                        &opts.binary,
200                        opts.private_namespaces.contains(ns),
201                    );
202                    let ensure_len = ensure_block.len();
203                    for i in 0..ensure_len {
204                        lines.insert(wf_pos, ensure_block[ensure_len - 1 - i].clone());
205                    }
206                }
207            }
208            let binary_block = build_binary_release_job(opts);
209            let binary_len = binary_block.len();
210            for i in 0..binary_len {
211                lines.insert(wf_pos, binary_block[binary_len - 1 - i].clone());
212            }
213            lines.insert(wf_pos, "jobs:".to_string());
214        }
215        report.insertions.push(
216            "release jobs (build-binary-release, ensure-orb-registered, build-container)"
217                .to_string(),
218        );
219    }
220
221    // 3. Add release workflow steps
222    let all_ns_steps_present = opts.namespaces.iter().all(|ns| {
223        content.contains(&format!("      - ensure-orb-registered-{ns}:"))
224            && content.contains(&format!("name: publish-orb-{ns}"))
225    });
226    if content.contains("pack-orb-release")
227        && content.contains("      - build-binary-release:")
228        && content.contains("      - build-container:")
229        && all_ns_steps_present
230    {
231        report.skipped.push("release workflow steps".to_string());
232    } else {
233        let step_block = release_workflow_steps(opts);
234        if let Some(pos) = find_workflow_jobs_end(&lines, &opts.release_workflow) {
235            for (i, l) in step_block.iter().enumerate() {
236                lines.insert(pos + i, l.clone());
237            }
238        }
239        report.insertions.push("release workflow steps".to_string());
240    }
241
242    let mut output = lines.join("\n");
243    if content.ends_with('\n') {
244        output.push('\n');
245    }
246    (output, report)
247}
248
249/// Apply patches to CI config files on disk (or dry-run).
250pub fn apply_patches(
251    ci_dir: &std::path::Path,
252    opts: &PatchOpts,
253    dry_run: bool,
254) -> Result<Vec<String>> {
255    let mut summary = vec![];
256
257    for (filename, patch_fn) in &[
258        (
259            "config.yml",
260            patch_build as fn(&str, &PatchOpts) -> (String, PatchReport),
261        ),
262        (
263            "release.yml",
264            patch_release as fn(&str, &PatchOpts) -> (String, PatchReport),
265        ),
266    ] {
267        let path = ci_dir.join(filename);
268        if !path.exists() {
269            summary.push(format!("{filename}: not found, skipped"));
270            continue;
271        }
272        let content = std::fs::read_to_string(&path)?;
273        let (patched, report) = patch_fn(&content, opts);
274
275        for ins in &report.insertions {
276            summary.push(format!("{filename}: inserted {ins}"));
277        }
278        for sk in &report.skipped {
279            summary.push(format!("{filename}: skipped {sk} (already present)"));
280        }
281
282        if !dry_run && patched != content {
283            std::fs::write(&path, &patched)?;
284        }
285    }
286
287    Ok(summary)
288}
289
290// ── helpers ──────────────────────────────────────────────────────────────────
291
292fn find_top_level(lines: &[String], header: &str) -> Option<usize> {
293    lines.iter().position(|l| l.trim_end() == header)
294}
295
296/// Find the line index where new entries should be inserted inside a top-level section.
297/// Returns the index of the first top-level line *after* the section header.
298fn find_section_end(lines: &[String], header: &str) -> Option<usize> {
299    let start = find_top_level(lines, header)?;
300    for (i, l) in lines.iter().enumerate().skip(start + 1) {
301        if !l.is_empty() && !l.starts_with(' ') && !l.starts_with('\t') && !l.starts_with('#') {
302            return Some(i);
303        }
304    }
305    Some(lines.len())
306}
307
308/// Find the insertion point at the end of a named workflow's `jobs:` list.
309fn find_workflow_jobs_end(lines: &[String], workflow: &str) -> Option<usize> {
310    let wf_line = format!("  {workflow}:");
311    let wf_idx = lines
312        .iter()
313        .position(|l| l.trim_end() == wf_line.trim_end())?;
314
315    // Find `    jobs:` within this workflow
316    let mut jobs_idx = None;
317    for (i, line) in lines.iter().enumerate().skip(wf_idx + 1) {
318        let l = line.trim_end();
319        if !line.starts_with("  ") {
320            break;
321        }
322        if l == "    jobs:" || l == "  jobs:" {
323            jobs_idx = Some(i);
324            break;
325        }
326    }
327    let jobs_start = jobs_idx?;
328
329    // Scan forward to find where the jobs list ends
330    for (i, l) in lines.iter().enumerate().skip(jobs_start + 1) {
331        if l.trim_end().is_empty() {
332            continue;
333        }
334        // Jobs entries are indented 6+ spaces; anything less ends the block
335        let indent = l.len() - l.trim_start().len();
336        if indent <= 2 {
337            return Some(i);
338        }
339    }
340    Some(lines.len())
341}
342
343fn build_binary_job(opts: &PatchOpts) -> Vec<String> {
344    let binary = &opts.binary;
345    vec![
346        "  build-binary:".to_string(),
347        "    docker:".to_string(),
348        "      - image: rust:latest".to_string(),
349        "    steps:".to_string(),
350        "      - checkout".to_string(),
351        "      - run:".to_string(),
352        "          name: Build binary".to_string(),
353        "          command: cargo build --release".to_string(),
354        "      - persist_to_workspace:".to_string(),
355        "          root: target/release".to_string(),
356        format!("          paths: [{binary}]"),
357    ]
358}
359
360fn regenerate_orb_job(opts: &PatchOpts) -> Vec<String> {
361    let binary = &opts.binary;
362    let orb_dir = &opts.orb_dir;
363    // gen-circleci-orb is pre-installed in its own Docker image (jerusdp/gen-circleci-orb).
364    // The target binary is attached from the build-binary workspace — no runtime installs needed.
365    let mut lines = vec![
366        "  regenerate-orb:".to_string(),
367        "    docker:".to_string(),
368        "      - image: jerusdp/gen-circleci-orb:latest".to_string(),
369        "    steps:".to_string(),
370        "      - checkout".to_string(),
371        "      - attach_workspace:".to_string(),
372        "          at: /tmp/bin".to_string(),
373        "      - run:".to_string(),
374        "          name: Regenerate orb source".to_string(),
375        "          command: |".to_string(),
376        "            export PATH=\"/tmp/bin:$PATH\"".to_string(),
377        "            gen-circleci-orb generate \\".to_string(),
378        format!("              --binary {binary} \\"),
379    ];
380    // Each namespace gets its own --orb-namespace flag (repeatable CLI arg).
381    for ns in &opts.namespaces {
382        lines.push(format!("              --orb-namespace {ns} \\"));
383    }
384    lines.push(format!("              --orb-dir {orb_dir}"));
385    lines
386}
387
388fn pack_validate_steps(opts: &PatchOpts) -> Vec<String> {
389    let orb_dir = &opts.orb_dir;
390    let mut steps = vec![];
391
392    // build-binary workflow step — compiles the binary and persists to workspace
393    steps.push("      - build-binary:".to_string());
394    if let Some(req) = &opts.requires_job {
395        steps.push(format!("          requires: [{req}]"));
396    }
397
398    // regenerate-orb workflow step — attaches workspace, installs gen-circleci-orb, runs generate
399    steps.push("      - regenerate-orb:".to_string());
400    steps.push("          requires: [build-binary]".to_string());
401
402    // orb-tools/pack (source_dir + workspace persistence; validates on pack)
403    steps.push("      - orb-tools/pack:".to_string());
404    steps.push("          name: pack-orb".to_string());
405    steps.push(format!("          source_dir: {orb_dir}/src"));
406    steps.push("          requires: [regenerate-orb]".to_string());
407
408    // orb-tools/review (best-practice review of packed orb)
409    steps.push("      - orb-tools/review:".to_string());
410    steps.push("          name: review-orb".to_string());
411    steps.push(format!("          source_dir: {orb_dir}/src"));
412    steps.push("          requires: [pack-orb]".to_string());
413
414    steps
415}
416
417fn build_binary_release_job(opts: &PatchOpts) -> Vec<String> {
418    let binary = &opts.binary;
419    vec![
420        "  build-binary-release:".to_string(),
421        "    docker:".to_string(),
422        "      - image: rust:latest".to_string(),
423        "    steps:".to_string(),
424        "      - checkout".to_string(),
425        "      - run:".to_string(),
426        "          name: Build release binary".to_string(),
427        format!("          command: cargo build --release -p {binary}"),
428        "      - persist_to_workspace:".to_string(),
429        "          root: target/release".to_string(),
430        format!("          paths: [{binary}]"),
431    ]
432}
433
434/// Build the job definition lines for one `ensure-orb-registered-<ns>` job.
435/// Uses orb-tools/default executor (same circleci/circleci-cli image as orb-tools/publish,
436/// so the CircleCI CLI is pre-installed). The orb-publishing context injects CIRCLE_TOKEN.
437/// circleci setup must be called first — without it the CLI has no session config and
438/// every orb command fails with "please set a token with 'circleci setup'".
439fn ensure_orb_registered_job_for(ns: &str, binary: &str, private: bool) -> Vec<String> {
440    // --private must be passed at creation time; visibility cannot be changed afterwards.
441    let create_flags = if private {
442        "--private --no-prompt"
443    } else {
444        "--no-prompt"
445    };
446    vec![
447        format!("  ensure-orb-registered-{ns}:"),
448        "    executor: orb-tools/default".to_string(),
449        "    steps:".to_string(),
450        "      - run:".to_string(),
451        "          name: Ensure orb is registered".to_string(),
452        "          command: |".to_string(),
453        "            circleci setup --token \"${CIRCLE_TOKEN}\" --host https://circleci.com --no-prompt".to_string(),
454        format!("            if ! circleci orb info {ns}/{binary} > /dev/null 2>&1; then"),
455        format!("              create_output=$(circleci orb create {ns}/{binary} {create_flags} 2>&1)"),
456        "              create_exit=$?".to_string(),
457        "              echo \"${create_output}\"".to_string(),
458        "              if [ \"${create_exit}\" -ne 0 ] && ! echo \"${create_output}\" | grep -q \"already exists\"; then".to_string(),
459        "                exit \"${create_exit}\"".to_string(),
460        "              fi".to_string(),
461        "            fi".to_string(),
462        "            echo \"Orb is registered.\"".to_string(),
463    ]
464}
465
466fn build_container_job(opts: &PatchOpts) -> Vec<String> {
467    let binary = &opts.binary;
468    let docker_ns = &opts.docker_namespace;
469    let orb_dir = &opts.orb_dir;
470    // Version is read from versions.env written by toolkit/calculate_versions.
471    // The release pipeline is approval-triggered (not tag-triggered), so CIRCLE_TAG is empty.
472    // The binary is attached from the build-binary-release workspace and copied into the
473    // Docker build context so the image contains the freshly-compiled binary.
474    let version_var = format!("CRATE_VERSION_{}", binary.to_uppercase().replace('-', "_"));
475    vec![
476        "  build-container:".to_string(),
477        "    docker:".to_string(),
478        "      - image: cimg/base:stable".to_string(),
479        "    steps:".to_string(),
480        "      - checkout".to_string(),
481        "      - setup_remote_docker".to_string(),
482        "      - attach_workspace:".to_string(),
483        "          at: /tmp/release-versions".to_string(),
484        "      - attach_workspace:".to_string(),
485        "          at: /tmp/bin".to_string(),
486        "      - run:".to_string(),
487        "          name: Build and push Docker image".to_string(),
488        "          command: |".to_string(),
489        "            source /tmp/release-versions/versions.env".to_string(),
490        format!("            VERSION=${{{version_var}}}"),
491        format!("            cp /tmp/bin/{binary} {orb_dir}/{binary}"),
492        format!("            docker build -t {docker_ns}/{binary}:${{VERSION}} -t {docker_ns}/{binary}:latest {orb_dir}"),
493        "            echo \"${DOCKERHUB_PASSWORD}\" | docker login -u \"${DOCKERHUB_USERNAME}\" --password-stdin".to_string(),
494        format!("            docker push {docker_ns}/{binary}:${{VERSION}}"),
495        format!("            docker push {docker_ns}/{binary}:latest"),
496    ]
497}
498
499fn release_workflow_steps(opts: &PatchOpts) -> Vec<String> {
500    let binary = &opts.binary;
501    let docker_ctx = &opts.docker_context;
502    let orb_ctx = &opts.orb_context;
503    let orb_dir = &opts.orb_dir;
504    // release_after_job is the approval gate; build-binary-release, pack-orb-release, and
505    // all ensure-orb-registered-<ns> jobs require it so they run in parallel immediately
506    // after the gate clears.
507    let requires = format!("[{}]", opts.release_after_job);
508    // Variable name: CRATE_VERSION_ + binary uppercased with hyphens replaced by underscores.
509    // This matches the format written by toolkit/calculate_versions into versions.env.
510    let version_var = format!("CRATE_VERSION_{}", binary.to_uppercase().replace('-', "_"));
511    let mut steps = vec![];
512
513    // build-binary-release: compile the release binary, persist to workspace
514    steps.push("      - build-binary-release:".to_string());
515    steps.push(format!("          requires: {requires}"));
516
517    // Pack orb source (parallel with build-binary-release; both require the approval gate)
518    steps.push("      - orb-tools/pack:".to_string());
519    steps.push("          name: pack-orb-release".to_string());
520    steps.push(format!("          source_dir: {orb_dir}/src"));
521    steps.push(format!("          requires: {requires}"));
522
523    // build-container: requires binary from workspace → sequential after build-binary-release
524    steps.push("      - build-container:".to_string());
525    steps.push("          requires: [build-binary-release]".to_string());
526    steps.push(format!("          context: [{docker_ctx}]"));
527
528    // Per-namespace: ensure-orb-registered-<ns> + orb-tools/publish: name: publish-orb-<ns>
529    // Each namespace gets its own registration check and its own publish job.
530    // Ensure jobs run in parallel with build-container (both require the approval gate).
531    // Each publish job fans in from build-container, pack-orb-release, and its own ensure job.
532    for ns in &opts.namespaces {
533        // ensure-orb-registered-<ns>: dedicated job (not a pre-step — see job definition comment)
534        steps.push(format!("      - ensure-orb-registered-{ns}:"));
535        steps.push(format!("          requires: {requires}"));
536        steps.push(format!("          context: [{orb_ctx}]"));
537
538        // orb-tools/publish: inject CIRCLE_TAG via pre-steps (pipeline is approval-triggered,
539        // not tag-triggered, so CIRCLE_TAG is empty; inject it from versions.env).
540        steps.push("      - orb-tools/publish:".to_string());
541        steps.push(format!("          name: publish-orb-{ns}"));
542        steps.push("          pre-steps:".to_string());
543        steps.push("            - attach_workspace:".to_string());
544        steps.push("                at: /tmp/release-versions".to_string());
545        steps.push("            - run:".to_string());
546        steps.push("                name: Export orb version as CIRCLE_TAG".to_string());
547        steps.push("                command: |".to_string());
548        steps.push("                  source /tmp/release-versions/versions.env".to_string());
549        // orb-tools/publish requires CIRCLE_TAG to match ^v[0-9]+\.[0-9]+\.[0-9]+$
550        steps.push(format!(
551            "                  echo \"export CIRCLE_TAG=v${{{version_var}}}\" >> \"$BASH_ENV\""
552        ));
553        steps.push(format!("          orb_name: {ns}/{binary}"));
554        steps.push("          pub_type: production".to_string());
555        steps.push("          vcs_type: github".to_string());
556        steps.push(format!(
557            "          requires: [build-container, pack-orb-release, ensure-orb-registered-{ns}]"
558        ));
559        steps.push(format!("          context: [{orb_ctx}]"));
560    }
561
562    steps
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    fn make_opts() -> PatchOpts {
570        PatchOpts {
571            binary: "mytool".to_string(),
572            namespaces: vec!["my-org".to_string()],
573            docker_namespace: "my-docker-org".to_string(),
574            orb_dir: "orb".to_string(),
575            build_workflow: "validation".to_string(),
576            release_workflow: "release".to_string(),
577            requires_job: Some("common-tests".to_string()),
578            // The approval gate job that binary build and orb pack run after.
579            // Docker/orb publish run before crates.io to establish the correct release order.
580            release_after_job: "approve-release".to_string(),
581            orb_tools_version: "12.3.3".to_string(),
582            docker_orb_version: "3.0.1".to_string(),
583            docker_context: "docker".to_string(),
584            orb_context: "orb-publishing".to_string(),
585            private_namespaces: vec![],
586            mcp: false,
587        }
588    }
589
590    fn make_opts_two_ns() -> PatchOpts {
591        PatchOpts {
592            namespaces: vec!["my-org".to_string(), "other-org".to_string()],
593            ..make_opts()
594        }
595    }
596
597    const BUILD_FIXTURE: &str = "\
598version: 2.1
599
600orbs:
601  toolkit: jerus-org/circleci-toolkit@6.0.0
602
603jobs:
604  some-job:
605    docker:
606      - image: cimg/base:stable
607    steps:
608      - run: echo hello
609
610workflows:
611  validation:
612    jobs:
613      - some-job
614";
615
616    // Typical toolkit 6.0 config: no top-level jobs section, only orbs + workflows.
617    const BUILD_FIXTURE_NO_JOBS: &str = "\
618version: 2.1
619
620parameters:
621  min_rust_version:
622    type: string
623    default: \"1.87\"
624
625orbs:
626  toolkit: jerus-org/circleci-toolkit@6.0.0
627
628workflows:
629  validation:
630    jobs:
631      - toolkit/required_builds:
632          min_rust_version: << pipeline.parameters.min_rust_version >>
633
634      - toolkit/common_tests:
635          min_rust_version: << pipeline.parameters.min_rust_version >>
636
637      - toolkit/idiomatic_rust:
638          filters:
639            branches:
640              ignore: main
641";
642
643    const RELEASE_FIXTURE: &str = "\
644version: 2.1
645
646orbs:
647  toolkit: jerus-org/circleci-toolkit@6.0.0
648
649jobs:
650  release-mytool:
651    docker:
652      - image: cimg/rust:stable
653    steps:
654      - checkout
655
656workflows:
657  release:
658    jobs:
659      - release-mytool
660";
661
662    // Toolkit-style release.yml: no top-level jobs section, only orbs + workflows.
663    // This is the common case for projects using only toolkit jobs.
664    const RELEASE_FIXTURE_NO_JOBS: &str = "\
665version: 2.1
666
667orbs:
668  toolkit: jerus-org/circleci-toolkit@6.0.0
669
670workflows:
671  release:
672    jobs:
673      - toolkit/release_crate:
674          name: release-mytool
675          context: [release]
676      - toolkit/release_prlog:
677          requires: [release-mytool]
678          context: [release]
679";
680
681    // Toolkit-style release.yml with toolkit/release_crate requiring an approval gate.
682    // This is the pattern init must re-wire to require publish-orb-<namespace> instead.
683    const RELEASE_FIXTURE_WITH_RELEASE_CRATE: &str = "\
684version: 2.1
685
686orbs:
687  toolkit: jerus-org/circleci-toolkit@6.0.0
688
689workflows:
690  release:
691    jobs:
692      - approve-release:
693          type: approval
694      - toolkit/release_crate:
695          name: release-mytool
696          requires: [approve-release]
697          context: [release]
698      - toolkit/release_prlog:
699          requires: [release-mytool]
700          context: [release]
701";
702
703    // ── patch_build (no pre-existing jobs section) ───────────────────────────
704
705    #[test]
706    fn patch_build_wires_workflow_steps_when_no_jobs_section() {
707        let (output, report) = patch_build(BUILD_FIXTURE_NO_JOBS, &make_opts());
708        assert!(
709            output.contains("regenerate-orb:"),
710            "missing job def:\n{output}"
711        );
712        assert!(
713            output.contains("orb-tools/pack:"),
714            "pack step not wired into workflow:\n{output}"
715        );
716        assert!(
717            output.contains("orb-tools/review:"),
718            "review step not wired into workflow:\n{output}"
719        );
720        // Both the job and the workflow steps should be in the report
721        assert!(
722            report
723                .insertions
724                .iter()
725                .any(|s| s.contains("regenerate-orb")),
726            "report missing regenerate-orb"
727        );
728        assert!(
729            report.insertions.iter().any(|s| s.contains("workflow")),
730            "report missing workflow steps"
731        );
732    }
733
734    // ── patch_build ───────────────────────────────────────────────────────────
735
736    #[test]
737    fn patch_build_adds_orb_tools_to_orbs_section() {
738        let (output, report) = patch_build(BUILD_FIXTURE, &make_opts());
739        assert!(
740            output.contains("orb-tools: circleci/orb-tools@12.3.3"),
741            "missing orb-tools entry:\n{output}"
742        );
743        assert!(
744            report.insertions.iter().any(|s| s.contains("orb-tools")),
745            "report missing orb-tools insertion"
746        );
747    }
748
749    #[test]
750    fn patch_build_adds_build_binary_job() {
751        let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
752        assert!(
753            output.contains("  build-binary:"),
754            "missing build-binary job:\n{output}"
755        );
756    }
757
758    /// Collect lines belonging to a top-level job block (2-space indented header).
759    /// Returns the content lines (not the header itself) as a single string.
760    fn job_block(output: &str, job_name: &str) -> String {
761        let header = format!("  {job_name}:");
762        let mut in_block = false;
763        let mut result = String::new();
764        for line in output.lines() {
765            if line.trim_end() == header.trim_end() {
766                in_block = true;
767                continue;
768            }
769            if in_block {
770                // A non-empty line starting with ≤2 spaces that isn't the header means a new
771                // top-level section or job; the block is done.
772                if !line.is_empty() && !line.starts_with("   ") && !line.starts_with('\t') {
773                    break;
774                }
775                result.push_str(line);
776                result.push('\n');
777            }
778        }
779        result
780    }
781
782    #[test]
783    fn build_binary_uses_public_rust_image() {
784        let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
785        let block = job_block(&output, "build-binary");
786        assert!(
787            block.contains("rust:latest"),
788            "build-binary must use the public rust:latest image, not a private CI image:\n{block}"
789        );
790    }
791
792    #[test]
793    fn build_binary_runs_cargo_build_release() {
794        let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
795        let block = job_block(&output, "build-binary");
796        assert!(
797            block.contains("cargo build --release"),
798            "build-binary must run cargo build --release:\n{block}"
799        );
800    }
801
802    #[test]
803    fn build_binary_persists_to_workspace() {
804        let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
805        let block = job_block(&output, "build-binary");
806        assert!(
807            block.contains("persist_to_workspace"),
808            "build-binary must persist binary to workspace:\n{block}"
809        );
810    }
811
812    #[test]
813    fn patch_build_adds_regenerate_orb_job() {
814        let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
815        assert!(output.contains("regenerate-orb:"), "missing job:\n{output}");
816        // gen-circleci-orb is pre-installed in its own image; no install step needed
817        assert!(
818            !output.contains("install-from-binstall-release.sh"),
819            "regenerate-orb must not bootstrap cargo-binstall — use the gen-circleci-orb image:\n{output}"
820        );
821        assert!(
822            !output.contains("cargo-binstall --no-confirm gen-circleci-orb"),
823            "regenerate-orb must not install gen-circleci-orb at runtime:\n{output}"
824        );
825        assert!(
826            output.contains("gen-circleci-orb generate"),
827            "missing generate step:\n{output}"
828        );
829    }
830
831    #[test]
832    fn regenerate_orb_uses_gen_circleci_orb_image_and_attaches_workspace() {
833        let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
834        let block = job_block(&output, "regenerate-orb");
835        assert!(
836            block.contains("jerusdp/gen-circleci-orb:latest"),
837            "regenerate-orb must use the gen-circleci-orb Docker image (gen-circleci-orb is pre-installed):\n{block}"
838        );
839        assert!(
840            block.contains("attach_workspace"),
841            "regenerate-orb must attach workspace to get the target binary:\n{block}"
842        );
843        assert!(
844            !block.contains("cargo-binstall"),
845            "regenerate-orb must not install anything — gen-circleci-orb is in the image:\n{block}"
846        );
847    }
848
849    #[test]
850    fn workflow_build_binary_precedes_regenerate_orb() {
851        let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
852        let bb_pos = output
853            .find("      - build-binary:")
854            .expect("no build-binary workflow step");
855        let regen_pos = output
856            .find("      - regenerate-orb:")
857            .expect("no regenerate-orb workflow step");
858        assert!(
859            bb_pos < regen_pos,
860            "build-binary must appear before regenerate-orb in the workflow"
861        );
862    }
863
864    #[test]
865    fn build_binary_workflow_step_requires_configured_job() {
866        let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
867        // The build-binary workflow step must require the user-configured prerequisite
868        let after_bb = output
869            .split("      - build-binary:")
870            .nth(1)
871            .expect("no build-binary workflow step");
872        let step_block = after_bb.split("      - ").next().unwrap_or(after_bb);
873        assert!(
874            step_block.contains("requires: [common-tests]"),
875            "build-binary workflow step must require the configured job:\n{step_block}"
876        );
877    }
878
879    #[test]
880    fn regenerate_orb_workflow_step_requires_build_binary() {
881        let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
882        let after_regen = output
883            .split("      - regenerate-orb:")
884            .nth(1)
885            .expect("no regenerate-orb workflow step");
886        let step_block = after_regen.split("      - ").next().unwrap_or(after_regen);
887        assert!(
888            step_block.contains("requires: [build-binary]"),
889            "regenerate-orb workflow step must require build-binary:\n{step_block}"
890        );
891    }
892
893    #[test]
894    fn patch_build_adds_workflow_steps() {
895        let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
896        assert!(
897            output.contains("orb-tools/pack:"),
898            "missing pack step:\n{output}"
899        );
900        assert!(
901            output.contains("orb-tools/review:"),
902            "missing review step:\n{output}"
903        );
904        // Parameters must use snake_case (orb-tools@12 API)
905        assert!(
906            output.contains("source_dir: orb/src"),
907            "pack/review must use source_dir (not source-dir):\n{output}"
908        );
909        assert!(
910            !output.contains("destination-orb-path"),
911            "must not use deprecated destination-orb-path:\n{output}"
912        );
913    }
914
915    #[test]
916    fn patch_build_is_idempotent() {
917        let (first, _) = patch_build(BUILD_FIXTURE, &make_opts());
918        let (second, second_report) = patch_build(&first, &make_opts());
919        assert_eq!(
920            first, second,
921            "second patch changed content — not idempotent"
922        );
923        assert!(
924            second_report
925                .skipped
926                .iter()
927                .any(|s| s.contains("orb-tools")),
928            "expected orb-tools skipped on second run"
929        );
930        assert!(
931            second_report
932                .skipped
933                .iter()
934                .any(|s| s.contains("build-binary")),
935            "expected build-binary and regenerate-orb skipped on second run"
936        );
937    }
938
939    #[test]
940    fn patch_build_job_in_jobs_section_not_workflows() {
941        let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
942        // The regenerate-orb: job definition must appear in the jobs section
943        let jobs_pos = output.find("\njobs:").expect("no jobs: section");
944        let workflows_pos = output.find("\nworkflows:").expect("no workflows: section");
945        let job_def_pos = output.find("  regenerate-orb:").expect("no job def");
946        assert!(
947            job_def_pos > jobs_pos && job_def_pos < workflows_pos,
948            "job definition not in jobs section"
949        );
950    }
951
952    // ── patch_release ─────────────────────────────────────────────────────────
953
954    #[test]
955    fn patch_release_adds_docker_and_orb_tools_orbs() {
956        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
957        assert!(
958            output.contains("docker: circleci/docker@3.0.1"),
959            "missing docker orb:\n{output}"
960        );
961        assert!(
962            output.contains("orb-tools: circleci/orb-tools@12.3.3"),
963            "missing orb-tools orb:\n{output}"
964        );
965    }
966
967    #[test]
968    fn patch_release_adds_build_container_job() {
969        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
970        assert!(
971            output.contains("build-container:"),
972            "missing job:\n{output}"
973        );
974        assert!(
975            output.contains("docker build"),
976            "missing docker build step:\n{output}"
977        );
978        assert!(
979            output.contains("docker push"),
980            "missing docker push step:\n{output}"
981        );
982        // Version comes from versions.env written by calculate_versions, not from git tags.
983        // The release pipeline is approval-triggered (not tag-triggered), so CIRCLE_TAG is empty.
984        assert!(
985            !output.contains("${CIRCLE_TAG}"),
986            "must not use CIRCLE_TAG (empty in approval-triggered pipelines):\n{output}"
987        );
988        assert!(
989            output.contains("versions.env"),
990            "must source versions.env to get the release version:\n{output}"
991        );
992        assert!(
993            output.contains("attach_workspace"),
994            "must attach workspace to get versions.env and the binary:\n{output}"
995        );
996        assert!(
997            output.contains("docker login -u \"${DOCKERHUB_USERNAME}\""),
998            "must log in to Docker Hub with DOCKERHUB_USERNAME before pushing:\n{output}"
999        );
1000        assert!(
1001            output.contains("--password-stdin"),
1002            "must use --password-stdin (not -p flag) for Docker login:\n{output}"
1003        );
1004        assert!(
1005            output.contains(":latest"),
1006            "must also push a :latest tag:\n{output}"
1007        );
1008    }
1009
1010    #[test]
1011    fn patch_release_uses_docker_namespace_not_orb_namespace() {
1012        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1013        // Docker build/push must use docker_namespace, not the orb namespace
1014        assert!(
1015            output.contains("docker build -t my-docker-org/mytool"),
1016            "docker build must use docker_namespace:\n{output}"
1017        );
1018        assert!(
1019            output.contains("docker push my-docker-org/mytool"),
1020            "docker push must use docker_namespace:\n{output}"
1021        );
1022        assert!(
1023            !output.contains("docker build -t my-org/"),
1024            "docker build must NOT use orb namespace:\n{output}"
1025        );
1026    }
1027
1028    #[test]
1029    fn patch_release_adds_workflow_steps() {
1030        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1031        // Pack step must appear in release workflow to provide workspace for publish
1032        assert!(
1033            output.contains("orb-tools/pack:"),
1034            "missing pack step in release workflow:\n{output}"
1035        );
1036        assert!(
1037            output.contains("orb-tools/publish:"),
1038            "missing publish step:\n{output}"
1039        );
1040        assert!(
1041            output.contains("publish-orb-my-org"),
1042            "missing publish job name:\n{output}"
1043        );
1044        assert!(
1045            output.contains("pub_type: production"),
1046            "publish must set pub_type production:\n{output}"
1047        );
1048        assert!(
1049            output.contains("vcs_type: github"),
1050            "publish must set vcs_type (required by orb-tools/publish):\n{output}"
1051        );
1052    }
1053
1054    #[test]
1055    fn patch_release_is_idempotent() {
1056        let (first, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1057        let (second, second_report) = patch_release(&first, &make_opts());
1058        assert_eq!(
1059            first, second,
1060            "second release patch changed content — not idempotent"
1061        );
1062        assert!(
1063            !second_report.skipped.is_empty(),
1064            "expected skipped entries on second run"
1065        );
1066    }
1067
1068    #[test]
1069    fn patch_release_includes_namespace_and_binary_in_publish() {
1070        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1071        // orb-tools@12 uses snake_case orb_name (not hyphenated orb-name)
1072        assert!(
1073            output.contains("orb_name: my-org/mytool"),
1074            "missing orb_name (underscore):\n{output}"
1075        );
1076        assert!(
1077            !output.contains("orb-path:"),
1078            "must not use deprecated orb-path:\n{output}"
1079        );
1080    }
1081
1082    #[test]
1083    fn patch_release_adds_build_container_job_when_no_jobs_section() {
1084        // release.yml with only toolkit jobs (no top-level jobs: section) is the
1085        // common case. patch_release must create the jobs: section and insert
1086        // both build-binary-release and build-container, not silently skip them.
1087        let (output, _) = patch_release(RELEASE_FIXTURE_NO_JOBS, &make_opts());
1088        assert!(
1089            output.contains("build-binary-release:"),
1090            "build-binary-release job definition missing when no pre-existing jobs section:\n{output}"
1091        );
1092        assert!(
1093            output.contains("build-container:"),
1094            "build-container job definition missing when no pre-existing jobs section:\n{output}"
1095        );
1096        let jobs_pos = output.find("\njobs:").expect("jobs: section not created");
1097        let workflows_pos = output.find("\nworkflows:").expect("no workflows: section");
1098        let binary_release_pos = output
1099            .find("  build-binary-release:")
1100            .expect("no build-binary-release job definition");
1101        let container_pos = output
1102            .find("  build-container:")
1103            .expect("no build-container job definition");
1104        assert!(
1105            binary_release_pos > jobs_pos && binary_release_pos < workflows_pos,
1106            "build-binary-release definition must be in jobs: section, not workflows:\n{output}"
1107        );
1108        assert!(
1109            container_pos > jobs_pos && container_pos < workflows_pos,
1110            "build-container definition must be in jobs: section, not workflows:\n{output}"
1111        );
1112    }
1113
1114    // ── new: build-binary-release job in release.yml ──────────────────────────
1115
1116    #[test]
1117    fn patch_release_adds_build_binary_release_job() {
1118        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1119        assert!(
1120            output.contains("  build-binary-release:"),
1121            "missing build-binary-release job definition:\n{output}"
1122        );
1123        let jobs_pos = output.find("\njobs:").expect("no jobs: section");
1124        let workflows_pos = output.find("\nworkflows:").expect("no workflows: section");
1125        let pos = output
1126            .find("  build-binary-release:")
1127            .expect("no build-binary-release job");
1128        assert!(
1129            pos > jobs_pos && pos < workflows_pos,
1130            "build-binary-release must be in the jobs section:\n{output}"
1131        );
1132    }
1133
1134    #[test]
1135    fn build_binary_release_job_uses_rust_latest() {
1136        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1137        let block = job_block(&output, "build-binary-release");
1138        assert!(
1139            block.contains("rust:latest"),
1140            "build-binary-release must use the public rust:latest image:\n{block}"
1141        );
1142    }
1143
1144    #[test]
1145    fn build_binary_release_job_has_package_flag() {
1146        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1147        let block = job_block(&output, "build-binary-release");
1148        assert!(
1149            block.contains("cargo build --release -p mytool"),
1150            "build-binary-release must compile with -p <binary> flag:\n{block}"
1151        );
1152    }
1153
1154    #[test]
1155    fn build_binary_release_job_persists_binary_to_workspace() {
1156        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1157        let block = job_block(&output, "build-binary-release");
1158        assert!(
1159            block.contains("persist_to_workspace"),
1160            "build-binary-release must persist binary to workspace:\n{block}"
1161        );
1162        assert!(
1163            block.contains("paths: [mytool]"),
1164            "build-binary-release must persist the binary by name:\n{block}"
1165        );
1166    }
1167
1168    // ── new: build-container uses versions.env + workspace binary ─────────────
1169
1170    #[test]
1171    fn build_container_sources_versions_env() {
1172        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1173        let block = job_block(&output, "build-container");
1174        assert!(
1175            block.contains("source /tmp/release-versions/versions.env"),
1176            "build-container must source versions.env to get the release version:\n{block}"
1177        );
1178    }
1179
1180    #[test]
1181    fn build_container_attaches_release_versions_workspace() {
1182        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1183        let block = job_block(&output, "build-container");
1184        assert!(
1185            block.contains("at: /tmp/release-versions"),
1186            "build-container must attach the release-versions workspace:\n{block}"
1187        );
1188    }
1189
1190    #[test]
1191    fn build_container_attaches_bin_workspace() {
1192        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1193        let block = job_block(&output, "build-container");
1194        assert!(
1195            block.contains("at: /tmp/bin"),
1196            "build-container must attach the bin workspace to get the compiled binary:\n{block}"
1197        );
1198    }
1199
1200    #[test]
1201    fn build_container_copies_workspace_binary() {
1202        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1203        let block = job_block(&output, "build-container");
1204        assert!(
1205            block.contains("cp /tmp/bin/mytool orb/mytool"),
1206            "build-container must copy binary from workspace into the Docker build context:\n{block}"
1207        );
1208    }
1209
1210    #[test]
1211    fn build_container_uses_crate_version_var() {
1212        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1213        let block = job_block(&output, "build-container");
1214        // Variable name formula: CRATE_VERSION_ + binary.to_uppercase().replace('-', '_')
1215        // mytool → CRATE_VERSION_MYTOOL
1216        assert!(
1217            block.contains("CRATE_VERSION_MYTOOL"),
1218            "build-container must derive version from CRATE_VERSION_<BINARY> env var:\n{block}"
1219        );
1220    }
1221
1222    // ── new: release workflow step ordering ──────────────────────────────────
1223
1224    #[test]
1225    fn release_workflow_has_build_binary_release_step() {
1226        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1227        assert!(
1228            output.contains("      - build-binary-release:"),
1229            "missing build-binary-release workflow step:\n{output}"
1230        );
1231    }
1232
1233    #[test]
1234    fn release_workflow_binary_and_pack_both_require_approval_gate() {
1235        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1236        // build-binary-release step block
1237        let after_binary = output
1238            .split("      - build-binary-release:")
1239            .nth(1)
1240            .expect("no build-binary-release workflow step");
1241        let binary_step = after_binary
1242            .split("      - ")
1243            .next()
1244            .unwrap_or(after_binary);
1245        assert!(
1246            binary_step.contains("requires: [approve-release]"),
1247            "build-binary-release workflow step must require the approval gate:\n{binary_step}"
1248        );
1249        // pack-orb-release step block
1250        let after_pack = output
1251            .split("name: pack-orb-release")
1252            .nth(1)
1253            .expect("no pack-orb-release step");
1254        let pack_step = after_pack.split("      - ").next().unwrap_or(after_pack);
1255        assert!(
1256            pack_step.contains("requires: [approve-release]"),
1257            "pack-orb-release step must require the approval gate (runs in parallel with build-binary-release):\n{pack_step}"
1258        );
1259    }
1260
1261    #[test]
1262    fn release_workflow_container_requires_build_binary_release() {
1263        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1264        let after_container = output
1265            .split("      - build-container:")
1266            .nth(1)
1267            .expect("no build-container workflow step");
1268        let step_block = after_container
1269            .split("      - ")
1270            .next()
1271            .unwrap_or(after_container);
1272        assert!(
1273            step_block.contains("requires: [build-binary-release]"),
1274            "build-container must require build-binary-release (not the approval gate directly):\n{step_block}"
1275        );
1276    }
1277
1278    #[test]
1279    fn release_workflow_publish_has_pre_steps_circle_tag_injection() {
1280        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1281        // orb-tools/publish requires CIRCLE_TAG for pub_type: production.
1282        // Since the pipeline is approval-triggered (not tag-triggered), CIRCLE_TAG must be
1283        // injected via pre-steps from versions.env.
1284        assert!(
1285            output.contains("pre-steps:"),
1286            "orb-tools/publish must have pre-steps to inject CIRCLE_TAG:\n{output}"
1287        );
1288        assert!(
1289            output.contains("Export orb version as CIRCLE_TAG"),
1290            "pre-steps must contain a named step that exports CIRCLE_TAG:\n{output}"
1291        );
1292        assert!(
1293            output.contains("CIRCLE_TAG"),
1294            "pre-steps must set CIRCLE_TAG in BASH_ENV:\n{output}"
1295        );
1296        assert!(
1297            output.contains("BASH_ENV"),
1298            "pre-steps must export CIRCLE_TAG via BASH_ENV:\n{output}"
1299        );
1300    }
1301
1302    #[test]
1303    fn release_workflow_publish_pre_steps_use_versions_env() {
1304        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1305        // The pre-steps must source versions.env from the workspace to get the version.
1306        // The workspace is attached first, then the env file sourced.
1307        assert!(
1308            output.contains("/tmp/release-versions"),
1309            "publish pre-steps must attach the release-versions workspace:\n{output}"
1310        );
1311        assert!(
1312            output.contains("source /tmp/release-versions/versions.env"),
1313            "publish pre-steps must source versions.env to read CRATE_VERSION_*:\n{output}"
1314        );
1315    }
1316
1317    #[test]
1318    fn release_workflow_publish_pre_steps_inject_correct_variable() {
1319        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1320        // Variable name: CRATE_VERSION_ + binary.to_uppercase().replace('-', '_')
1321        // mytool → CRATE_VERSION_MYTOOL
1322        // orb-tools/publish pub_type:production requires CIRCLE_TAG matching ^v[0-9]+\.[0-9]+\.[0-9]+$
1323        // so the v prefix is mandatory.
1324        assert!(
1325            output.contains("CIRCLE_TAG=v${CRATE_VERSION_MYTOOL}"),
1326            "CIRCLE_TAG must have a v prefix — orb-tools/publish requires tag pattern ^v\\d+\\.\\d+\\.\\d+$:\n{output}"
1327        );
1328    }
1329
1330    // ── ensure-orb-registered: separate job (not a pre-step) ────────────────
1331    // pre-steps run before the orb-tools job configures CLI auth, so the CLI has
1332    // no token. The fix is a dedicated inline job using `executor: orb-tools/default`
1333    // (same image as orb-tools/publish — CLI pre-installed) with the orb-publishing
1334    // context, which injects CIRCLECI_CLI_TOKEN directly (the CLI reads it automatically).
1335
1336    #[test]
1337    fn patch_release_adds_ensure_orb_registered_job() {
1338        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1339        // Job name always includes namespace suffix for unambiguous multi-namespace support.
1340        assert!(
1341            output.contains("  ensure-orb-registered-my-org:"),
1342            "missing ensure-orb-registered-my-org job definition:\n{output}"
1343        );
1344        let jobs_pos = output.find("\njobs:").expect("no jobs: section");
1345        let workflows_pos = output.find("\nworkflows:").expect("no workflows: section");
1346        let pos = output
1347            .find("  ensure-orb-registered-my-org:")
1348            .expect("no ensure-orb-registered-my-org job");
1349        assert!(
1350            pos > jobs_pos && pos < workflows_pos,
1351            "ensure-orb-registered-my-org must be defined in the jobs section:\n{output}"
1352        );
1353    }
1354
1355    #[test]
1356    fn ensure_orb_registered_job_uses_orb_tools_executor() {
1357        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1358        let block = job_block(&output, "ensure-orb-registered-my-org");
1359        // orb-tools/default uses the circleci/circleci-cli Docker image — CLI pre-installed.
1360        // No need to add a circleci/circleci-cli orb dependency.
1361        assert!(
1362            block.contains("executor: orb-tools/default"),
1363            "ensure-orb-registered-my-org must use orb-tools/default executor (has CLI pre-installed):\n{block}"
1364        );
1365    }
1366
1367    #[test]
1368    fn ensure_orb_registered_job_runs_circleci_setup() {
1369        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1370        let block = job_block(&output, "ensure-orb-registered-my-org");
1371        // The CLI has no session config in a fresh executor. circleci setup must be called
1372        // with CIRCLE_TOKEN (the orb-publishing context variable) before any orb commands.
1373        assert!(
1374            block.contains("circleci setup") && block.contains("CIRCLE_TOKEN"),
1375            "ensure-orb-registered-my-org must run circleci setup --token ${{CIRCLE_TOKEN}}:\n{block}"
1376        );
1377    }
1378
1379    #[test]
1380    fn ensure_orb_registered_job_uses_idempotent_check() {
1381        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1382        let block = job_block(&output, "ensure-orb-registered-my-org");
1383        assert!(
1384            block.contains("circleci orb info my-org/mytool"),
1385            "ensure job must check the specific orb before creating:\n{block}"
1386        );
1387        assert!(
1388            block.contains("circleci orb create my-org/mytool --no-prompt"),
1389            "ensure job must create the specific orb with --no-prompt:\n{block}"
1390        );
1391        assert!(
1392            !block.contains("|| true"),
1393            "must not use `|| true` — real failures (wrong token, wrong namespace) must surface:\n{block}"
1394        );
1395    }
1396
1397    #[test]
1398    fn ensure_orb_registered_job_handles_already_exists_gracefully() {
1399        // `circleci orb info` can return non-zero even when the orb exists (e.g. auth scope,
1400        // CLI version quirk). The create command then fails with "already exists". The script
1401        // must treat that outcome as success rather than a hard failure.
1402        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1403        let block = job_block(&output, "ensure-orb-registered-my-org");
1404        assert!(
1405            block.contains("already exists"),
1406            "ensure job must handle 'already exists' error from circleci orb create:\n{block}"
1407        );
1408        assert!(
1409            !block.contains("|| \\"),
1410            "ensure job must not use single-line || fallback — use captured exit-code check instead:\n{block}"
1411        );
1412    }
1413
1414    #[test]
1415    fn release_workflow_has_ensure_orb_registered_step() {
1416        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1417        assert!(
1418            output.contains("      - ensure-orb-registered-my-org:"),
1419            "missing ensure-orb-registered-my-org workflow step:\n{output}"
1420        );
1421    }
1422
1423    #[test]
1424    fn release_workflow_ensure_orb_step_requires_approval_gate() {
1425        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1426        let after_step = output
1427            .split("      - ensure-orb-registered-my-org:")
1428            .nth(1)
1429            .expect("no ensure-orb-registered-my-org workflow step");
1430        let step_block = after_step.split("      - ").next().unwrap_or(after_step);
1431        assert!(
1432            step_block.contains("requires: [approve-release]"),
1433            "ensure-orb-registered-my-org must require the approval gate:\n{step_block}"
1434        );
1435    }
1436
1437    #[test]
1438    fn release_workflow_ensure_orb_step_uses_orb_publishing_context() {
1439        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1440        let after_step = output
1441            .split("      - ensure-orb-registered-my-org:")
1442            .nth(1)
1443            .expect("no ensure-orb-registered-my-org workflow step");
1444        let step_block = after_step.split("      - ").next().unwrap_or(after_step);
1445        assert!(
1446            step_block.contains("context: [orb-publishing]"),
1447            "ensure-orb-registered-my-org must run in the orb-publishing context:\n{step_block}"
1448        );
1449    }
1450
1451    #[test]
1452    fn release_workflow_publish_requires_ensure_orb_registered() {
1453        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1454        let after_publish = output
1455            .split("      - orb-tools/publish:")
1456            .nth(1)
1457            .expect("no orb-tools/publish step");
1458        // Split on "\n      - " (newline-anchored) so 12-space pre-step items
1459        // (which contain "      - " as a substring) do not cause a false cut.
1460        let publish_block = after_publish
1461            .split("\n      - ")
1462            .next()
1463            .unwrap_or(after_publish);
1464        assert!(
1465            publish_block.contains("ensure-orb-registered"),
1466            "orb-tools/publish must require ensure-orb-registered:\n{publish_block}"
1467        );
1468    }
1469
1470    #[test]
1471    fn release_workflow_publish_pre_steps_no_ensure_orb_step() {
1472        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1473        // The ensure step is now a separate job, not a pre-step. The pre-steps block
1474        // of orb-tools/publish should only contain workspace attachment and CIRCLE_TAG export.
1475        let after_publish = output
1476            .split("      - orb-tools/publish:")
1477            .nth(1)
1478            .expect("no orb-tools/publish step");
1479        let pre_steps_block = after_publish
1480            .split("          orb_name:")
1481            .next()
1482            .unwrap_or(after_publish);
1483        assert!(
1484            !pre_steps_block.contains("circleci orb info"),
1485            "orb info must not be in orb-tools/publish pre-steps — it belongs in the separate ensure-orb-registered job:\n{pre_steps_block}"
1486        );
1487        assert!(
1488            !pre_steps_block.contains("circleci orb create"),
1489            "orb create must not be in orb-tools/publish pre-steps — it belongs in the separate ensure-orb-registered job:\n{pre_steps_block}"
1490        );
1491    }
1492
1493    // ── release_crate rewiring ─────────────────────────────────────────────────
1494
1495    #[test]
1496    fn patch_release_does_not_rewire_toolkit_release_crate_requires() {
1497        // The pipeline topology — which jobs gate the crate release — is the caller's
1498        // responsibility, expressed via --release-after-job. The patcher must NOT
1499        // automatically rewire toolkit/release_crate to require publish-orb-<ns> because
1500        // that creates a cycle when the caller's topology already sequences the crate
1501        // release BEFORE the container build and orb publish.
1502        let (output, report) = patch_release(RELEASE_FIXTURE_WITH_RELEASE_CRATE, &make_opts());
1503        let wf_section = output.split("workflows:").nth(1).unwrap_or("");
1504        let after_rc = wf_section
1505            .split("- toolkit/release_crate:")
1506            .nth(1)
1507            .expect("no toolkit/release_crate step");
1508        let block = after_rc.split("\n      - ").next().unwrap_or(after_rc);
1509        assert!(
1510            block.contains("approve-release"),
1511            "toolkit/release_crate requires must remain [approve-release] — patcher must not rewire it:\n{block}"
1512        );
1513        assert!(
1514            !block.contains("publish-orb"),
1515            "patcher must not inject publish-orb into release_crate requires:\n{block}"
1516        );
1517        assert!(
1518            !report.insertions.iter().any(|s| s.contains("rewire")),
1519            "no rewire insertion should be reported:\n{:?}",
1520            report.insertions
1521        );
1522    }
1523
1524    // ── multi-namespace support ────────────────────────────────────────────────
1525    // When --orb-namespace / --public-orb-namespace / --private-orb-namespace is given
1526    // multiple times, each namespace gets its own ensure-orb-registered-<ns> job and
1527    // publish-orb-<ns> workflow step. toolkit/release_crate must require ALL publish jobs.
1528
1529    #[test]
1530    fn patch_release_adds_per_namespace_ensure_and_publish_jobs() {
1531        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts_two_ns());
1532        assert!(
1533            output.contains("  ensure-orb-registered-my-org:"),
1534            "missing ensure-orb-registered-my-org job:\n{output}"
1535        );
1536        assert!(
1537            output.contains("  ensure-orb-registered-other-org:"),
1538            "missing ensure-orb-registered-other-org job:\n{output}"
1539        );
1540        assert!(
1541            output.contains("name: publish-orb-my-org"),
1542            "missing publish-orb-my-org step:\n{output}"
1543        );
1544        assert!(
1545            output.contains("name: publish-orb-other-org"),
1546            "missing publish-orb-other-org step:\n{output}"
1547        );
1548    }
1549
1550    #[test]
1551    fn each_publish_job_requires_its_own_ensure_job() {
1552        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts_two_ns());
1553        // publish-orb-my-org must require ensure-orb-registered-my-org (not other-org).
1554        // Split on "\n      - " (6-space indent, workflow-level) to get just this job's block —
1555        // pre-steps use 12-space indent so they are not split on.
1556        let after_my_org = output
1557            .split("name: publish-orb-my-org")
1558            .nth(1)
1559            .expect("no publish-orb-my-org step");
1560        let my_org_block = after_my_org
1561            .split("\n      - ")
1562            .next()
1563            .unwrap_or(after_my_org);
1564        assert!(
1565            my_org_block.contains("ensure-orb-registered-my-org"),
1566            "publish-orb-my-org must require ensure-orb-registered-my-org:\n{my_org_block}"
1567        );
1568        assert!(
1569            !my_org_block.contains("ensure-orb-registered-other-org"),
1570            "publish-orb-my-org must NOT require other-org's ensure job:\n{my_org_block}"
1571        );
1572
1573        // publish-orb-other-org must require ensure-orb-registered-other-org
1574        let after_other_org = output
1575            .split("name: publish-orb-other-org")
1576            .nth(1)
1577            .expect("no publish-orb-other-org step");
1578        let other_org_block = after_other_org
1579            .split("\n      - ")
1580            .next()
1581            .unwrap_or(after_other_org);
1582        assert!(
1583            other_org_block.contains("ensure-orb-registered-other-org"),
1584            "publish-orb-other-org must require ensure-orb-registered-other-org:\n{other_org_block}"
1585        );
1586    }
1587
1588    #[test]
1589    fn multi_namespace_patch_release_is_idempotent() {
1590        let (first, _) = patch_release(RELEASE_FIXTURE, &make_opts_two_ns());
1591        let (second, second_report) = patch_release(&first, &make_opts_two_ns());
1592        assert_eq!(
1593            first, second,
1594            "second multi-namespace patch must not change content"
1595        );
1596        assert!(
1597            !second_report.skipped.is_empty(),
1598            "expected skipped entries on second run"
1599        );
1600    }
1601
1602    // ── orb visibility (public vs private, per namespace) ────────────────────
1603    // circleci orb create sets visibility at creation time — it cannot be changed
1604    // after the orb exists. --private must be passed when the orb is first registered.
1605    // Each namespace has independent visibility: a production namespace can be public
1606    // while a preprod/testing namespace is kept private.
1607
1608    #[test]
1609    fn ensure_orb_create_omits_private_flag_for_public_orb() {
1610        let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1611        let block = job_block(&output, "ensure-orb-registered-my-org");
1612        assert!(
1613            block.contains("circleci orb create my-org/mytool --no-prompt"),
1614            "public orb create must not have --private flag:\n{block}"
1615        );
1616        assert!(
1617            !block.contains("--private"),
1618            "public orb must not pass --private to circleci orb create:\n{block}"
1619        );
1620    }
1621
1622    #[test]
1623    fn ensure_orb_create_includes_private_flag_when_namespace_is_private() {
1624        let opts = PatchOpts {
1625            private_namespaces: vec!["my-org".to_string()],
1626            ..make_opts()
1627        };
1628        let (output, _) = patch_release(RELEASE_FIXTURE, &opts);
1629        let block = job_block(&output, "ensure-orb-registered-my-org");
1630        assert!(
1631            block.contains("circleci orb create my-org/mytool --private --no-prompt"),
1632            "private namespace orb create must include --private flag:\n{block}"
1633        );
1634    }
1635
1636    #[test]
1637    fn mixed_visibility_public_namespace_has_no_private_flag() {
1638        // my-org is public; other-org is private
1639        let opts = PatchOpts {
1640            namespaces: vec!["my-org".to_string(), "other-org".to_string()],
1641            private_namespaces: vec!["other-org".to_string()],
1642            ..make_opts()
1643        };
1644        let (output, _) = patch_release(RELEASE_FIXTURE, &opts);
1645        let my_org_block = job_block(&output, "ensure-orb-registered-my-org");
1646        assert!(
1647            !my_org_block.contains("--private"),
1648            "my-org is public — must not have --private:\n{my_org_block}"
1649        );
1650        assert!(
1651            my_org_block.contains("circleci orb create my-org/mytool --no-prompt"),
1652            "my-org create must be public:\n{my_org_block}"
1653        );
1654    }
1655
1656    #[test]
1657    fn mixed_visibility_private_namespace_has_private_flag() {
1658        // my-org is public; other-org is private
1659        let opts = PatchOpts {
1660            namespaces: vec!["my-org".to_string(), "other-org".to_string()],
1661            private_namespaces: vec!["other-org".to_string()],
1662            ..make_opts()
1663        };
1664        let (output, _) = patch_release(RELEASE_FIXTURE, &opts);
1665        let other_org_block = job_block(&output, "ensure-orb-registered-other-org");
1666        assert!(
1667            other_org_block.contains("circleci orb create other-org/mytool --private --no-prompt"),
1668            "other-org is private — create must have --private:\n{other_org_block}"
1669        );
1670    }
1671
1672    #[test]
1673    fn release_workflow_steps_always_requires_release_after_job() {
1674        // release_after_job is a required String field — no default, cannot be omitted.
1675        // Every generated workflow step must have a requires: clause referencing it.
1676        let opts = make_opts(); // release_after_job = "approve-release"
1677        let (output, _) = patch_release(RELEASE_FIXTURE, &opts);
1678        let wf = output.split("workflows:").nth(1).unwrap_or("");
1679        let after_build = wf
1680            .split("- build-binary-release:")
1681            .nth(1)
1682            .expect("no build-binary-release step");
1683        let block = after_build
1684            .split("\n      - ")
1685            .next()
1686            .unwrap_or(after_build);
1687        assert!(
1688            block.contains("requires: [approve-release]"),
1689            "build-binary-release must have requires: [approve-release]:\n{block}"
1690        );
1691    }
1692}