Skip to main content

gen_circleci_orb/commands/
init.rs

1use anyhow::Result;
2use indexmap::IndexMap;
3use std::path::PathBuf;
4
5use crate::{
6    ci_patcher,
7    commands::generate::Generate,
8    help_parser::types::CliDefinition,
9    orb_config::{CiSection, OrbConfig, OrbSection, RecordConfig, SubcommandConfig},
10};
11
12pub const DEFAULT_DOCKER_ORB_VERSION: &str = "3.0.1";
13pub const DEFAULT_DOCKER_CONTEXT: &str = "docker-credentials";
14pub const DEFAULT_ORB_CONTEXT: &str = "orb-publishing";
15pub const DEFAULT_MCP_CONTEXT: &str = "pcu-app";
16pub const DEFAULT_MCP_EARLIEST_VERSION: &str = "0.0.1";
17/// Default jerus-org/gen-orb-mcp orb version pinned for the build_mcp_server job
18/// (Mechanism A). Generator-owned (like the gen-circleci-orb pin) so the `update`
19/// gate stays authoritative; Renovate keeps this default current.
20pub const DEFAULT_GEN_ORB_MCP_ORB_VERSION: &str = "0.1.48";
21
22/// Values resolved by the interactive dialogue (or non-interactive fallback).
23/// These are used by both `PatchOpts` and the bootstrap config.
24pub(crate) struct GatheredExtras {
25    pub home_url: Option<String>,
26    pub source_url: Option<String>,
27    pub git_push_subcommands: Vec<String>,
28    pub docker_context: String,
29    pub orb_context: String,
30    pub mcp_context: Vec<String>,
31    pub mcp_earliest_version: String,
32    pub record: Option<RecordConfig>,
33}
34
35fn is_non_interactive(dry_run: bool) -> bool {
36    dry_run || std::env::var("CI").is_ok() || !console::Term::stderr().is_term()
37}
38
39/// Assemble the `[record]` config from explicit env-var names. Returns `Ok(None)`
40/// when auto-record is not enabled. When enabled, every name must be present and
41/// non-empty — there are no defaults, so the tool never imposes an env-var
42/// convention on the consumer. Errors naming the first missing flag otherwise.
43#[allow(clippy::too_many_arguments)]
44pub(crate) fn build_record_config(
45    enabled: bool,
46    gpg_key_env: Option<&str>,
47    gpg_trust_env: Option<&str>,
48    user_name_env: Option<&str>,
49    user_email_env: Option<&str>,
50    signing_key_env: Option<&str>,
51    push_ssh_fingerprint: Option<&str>,
52    contexts: &[String],
53) -> Result<Option<RecordConfig>> {
54    if !enabled {
55        return Ok(None);
56    }
57    let req = |v: Option<&str>, flag: &str| -> Result<String> {
58        v.map(str::trim)
59            .filter(|s| !s.is_empty())
60            .map(str::to_string)
61            .ok_or_else(|| {
62                anyhow::anyhow!(
63                    "auto-record is enabled but {flag} was not provided \
64                     (no default — supply the env-var name)"
65                )
66            })
67    };
68    let contexts: Vec<String> = contexts
69        .iter()
70        .map(|s| s.trim().to_string())
71        .filter(|s| !s.is_empty())
72        .collect();
73    if contexts.is_empty() {
74        anyhow::bail!(
75            "auto-record is enabled but no --record-context was provided \
76             (the record job needs the CircleCI context(s) that supply the GPG \
77             signing material)"
78        );
79    }
80    Ok(Some(RecordConfig {
81        enabled: true,
82        gpg_key_env: req(gpg_key_env, "--record-gpg-key-env")?,
83        gpg_trust_env: req(gpg_trust_env, "--record-gpg-trust-env")?,
84        user_name_env: req(user_name_env, "--record-user-name-env")?,
85        user_email_env: req(user_email_env, "--record-user-email-env")?,
86        signing_key_env: req(signing_key_env, "--record-signing-key-env")?,
87        // Optional: empty means the push falls back to ambient credentials.
88        push_ssh_fingerprint: push_ssh_fingerprint
89            .map(str::trim)
90            .unwrap_or("")
91            .to_string(),
92        contexts,
93    }))
94}
95
96/// Detect leaf subcommands that have a required `orb_path` parameter.
97/// These should receive `default = "src/@orb.yml"` in the config so
98/// orb consumers don't have to supply the path on every invocation.
99pub(crate) fn detect_orb_path_subcommands(cli: &CliDefinition) -> Vec<String> {
100    cli.subcommands
101        .iter()
102        .filter(|sub| {
103            sub.is_leaf
104                && sub
105                    .parameters
106                    .iter()
107                    .any(|p| p.long_name == "orb_path" && p.required)
108        })
109        .map(|sub| sub.name.clone())
110        .collect()
111}
112
113/// Add `[subcommand.<name>.param.orb_path] default = "src/@orb.yml"` for each
114/// detected subcommand.  Existing entries (e.g. help suppression) are preserved.
115pub(crate) fn populate_orb_path_defaults(
116    config: &mut crate::orb_config::OrbConfig,
117    subcommands: &[String],
118) {
119    use crate::orb_config::ParamOverride;
120    if subcommands.is_empty() {
121        return;
122    }
123    let sc_map = config
124        .subcommand
125        .get_or_insert_with(indexmap::IndexMap::new);
126    for name in subcommands {
127        let sc = sc_map.entry(name.clone()).or_default();
128        let params = sc.param.get_or_insert_with(indexmap::IndexMap::new);
129        params
130            .entry("orb_path".to_string())
131            .or_insert(ParamOverride {
132                default: Some("src/@orb.yml".to_string()),
133            });
134    }
135}
136
137/// Detect leaf subcommands that are likely to push to git, based on whether
138/// they have a `--push`, `--no-push`, or `--sign` parameter.
139pub(crate) fn detect_git_push_subcommands(cli: &CliDefinition) -> Vec<String> {
140    cli.subcommands
141        .iter()
142        .filter(|sub| {
143            sub.is_leaf
144                && sub
145                    .parameters
146                    .iter()
147                    .any(|p| matches!(p.long_name.as_str(), "push" | "no_push" | "sign"))
148        })
149        .map(|sub| sub.name.clone())
150        .collect()
151}
152
153/// Wire orb generation into an existing repo's CI configuration.
154#[derive(Debug, clap::Args)]
155pub struct Init {
156    /// Name of the binary to introspect (must be on PATH).
157    #[arg(long)]
158    pub binary: String,
159
160    /// CircleCI namespace(s) to publish the orb under as a public orb (repeatable).
161    /// Must be set correctly on first init — visibility cannot be changed after the orb is created.
162    #[arg(long = "public-orb-namespace")]
163    pub public_orb_namespaces: Vec<String>,
164
165    /// CircleCI namespace(s) to publish the orb under as a private orb (repeatable).
166    /// Each listed namespace gets `--private` in its `circleci orb create` command.
167    /// Must be set correctly on first init — visibility cannot be changed after the orb is created.
168    #[arg(long = "private-orb-namespace")]
169    pub private_orb_namespaces: Vec<String>,
170
171    /// Name of the build/validation workflow to patch.
172    #[arg(long)]
173    pub build_workflow: String,
174
175    /// Name of the release workflow to patch.
176    #[arg(long)]
177    pub release_workflow: String,
178
179    /// Job in the build workflow that regenerate-orb should require.
180    #[arg(long)]
181    pub requires_job: Option<String>,
182
183    /// Tag prefix used by `toolkit/release_crate` for the crate (e.g. `gen-orb-mcp-v`).
184    /// Used to filter the `orb-release:` workflow trigger in config.yml and to normalise
185    /// `CIRCLE_TAG` for `orb-tools/publish`.
186    #[arg(long)]
187    pub crate_tag_prefix: String,
188
189    /// Job in the release workflow after which the generated release jobs
190    /// (build-binary-release, pack-orb-release, build-container, ensure-orb-registered)
191    /// should be gated. This is the sole mechanism for specifying where the generated
192    /// jobs plug into the existing pipeline topology.
193    #[arg(long)]
194    pub release_after_job: String,
195
196    /// Output directory for the generated orb source (relative to repo root).
197    #[arg(long, default_value = "orb")]
198    pub orb_dir: String,
199
200    /// Path to the .circleci/ directory.
201    #[arg(long, default_value = ".circleci")]
202    pub ci_dir: PathBuf,
203
204    /// circleci/orb-tools version to pin in generated CI.
205    #[arg(long, default_value = "12.3.3")]
206    pub orb_tools_version: String,
207
208    /// circleci/docker orb version to pin in generated CI.
209    #[arg(long, default_value = DEFAULT_DOCKER_ORB_VERSION)]
210    pub docker_orb_version: String,
211
212    /// Docker Hub (or registry) namespace for the built container image.
213    #[arg(long)]
214    pub docker_namespace: String,
215
216    /// CircleCI context name holding Docker Hub credentials (DOCKER_LOGIN, DOCKER_PASSWORD).
217    /// Prompted interactively if not supplied.
218    #[arg(long)]
219    pub docker_context: Option<String>,
220
221    /// CircleCI context name holding orb publishing credentials (CIRCLECI_CLI_TOKEN).
222    /// Prompted interactively if not supplied.
223    #[arg(long)]
224    pub orb_context: Option<String>,
225
226    /// Version of the jerus-org/gen-circleci-orb orb to pin in generated CI.
227    /// Defaults to the version of this binary (orb and crate are released together).
228    #[arg(long, default_value = env!("CARGO_PKG_VERSION"))]
229    pub gen_circleci_orb_version: String,
230
231    /// Wire in gen-orb-mcp MCP server generation + publish after orb publish.
232    #[arg(long)]
233    pub mcp: bool,
234
235    /// Earliest orb version to include when priming prior-version snapshots.
236    /// Passed to gen-circleci-orb/build_mcp_server as `earliest_version`.
237    /// Only used when --mcp is enabled. Prompted interactively if not supplied.
238    #[arg(long)]
239    pub mcp_earliest_version: Option<String>,
240
241    /// CircleCI context name(s) for MCP server build + publish + save steps (repeatable or comma-separated).
242    /// Needs: GITHUB_TOKEN (GitHub App token, contents:write + bypass branch protection),
243    /// BOT_GPG_KEY, BOT_TRUST, BOT_USER_NAME, BOT_USER_EMAIL, BOT_SIGN_KEY.
244    /// Only used when --mcp is enabled. Prompted interactively if not supplied.
245    #[arg(long = "mcp-context", value_delimiter = ',')]
246    pub mcp_context: Vec<String>,
247
248    /// Subcommand names whose generated jobs should include a `set_https_remote` step
249    /// (repeatable). Use for subcommands that push to git (e.g. `save`).
250    #[arg(long, value_delimiter = ',')]
251    pub git_push_subcommands: Vec<String>,
252
253    /// Home URL for the orb (shown in the CircleCI registry).
254    #[arg(long)]
255    pub home_url: Option<String>,
256
257    /// Source URL for the orb (shown in the CircleCI registry).
258    #[arg(long)]
259    pub source_url: Option<String>,
260
261    /// Enable auto-record: after `generate`, the regenerate-orb CI job commits the
262    /// regenerated orb source back (GPG-signed) and pushes it, so the published orb
263    /// always reflects the CLI. When set, the `--record-*-env` flags name the
264    /// environment variables that hold the GPG signing material at runtime (no
265    /// defaults — they must be supplied). Prompted interactively if not set.
266    #[arg(long)]
267    pub record: bool,
268
269    /// Name of the env var holding the base64-encoded GPG private key (auto-record).
270    #[arg(long)]
271    pub record_gpg_key_env: Option<String>,
272
273    /// Name of the env var holding the GPG ownertrust export (auto-record).
274    #[arg(long)]
275    pub record_gpg_trust_env: Option<String>,
276
277    /// Name of the env var holding the committer name (auto-record).
278    #[arg(long)]
279    pub record_user_name_env: Option<String>,
280
281    /// Name of the env var holding the committer email (auto-record).
282    #[arg(long)]
283    pub record_user_email_env: Option<String>,
284
285    /// Name of the env var holding the GPG signing key id (auto-record).
286    #[arg(long)]
287    pub record_signing_key_env: Option<String>,
288
289    /// SSH key fingerprint (a public-key hash, not a secret) for the
290    /// end-of-workflow push job (auto-record). Optional: when set, the push job
291    /// loads this write key and drops the read-only checkout key; empty falls back
292    /// to ambient credentials. A value, not an env-var name — add_ssh_keys resolves
293    /// fingerprints at config-compile time and cannot read env vars.
294    #[arg(long)]
295    pub record_push_ssh_fingerprint: Option<String>,
296
297    /// CircleCI context(s) that supply the auto-record env-var values
298    /// (GPG signing material), repeatable or comma-separated.
299    /// The record CI job attaches these.
300    #[arg(long = "record-context", value_delimiter = ',')]
301    pub record_contexts: Vec<String>,
302
303    /// Show planned changes without modifying any files.
304    #[arg(long)]
305    pub dry_run: bool,
306}
307
308/// Subcommands present in the target binary that are interactive by default
309/// ([`DEFAULT_INTERACTIVE`]) — the ones `init` prompts about and scaffolds.
310pub(crate) fn present_default_interactive(cli: &CliDefinition) -> Vec<String> {
311    cli.subcommands
312        .iter()
313        .filter(|s| crate::orb_generator::render::DEFAULT_INTERACTIVE.contains(&s.name.as_str()))
314        .map(|s| s.name.clone())
315        .collect()
316}
317
318pub(crate) fn build_bootstrap_config(
319    binary: &str,
320    namespaces: &[String],
321    orb_dir: &str,
322    home_url: Option<&str>,
323    source_url: Option<&str>,
324    git_push_subcommands: &[String],
325    interactive: &[(String, bool)],
326) -> OrbConfig {
327    // `help` is reserved at the `--help` parser, so it needs no entry. Interactive
328    // (CLI-only) subcommands — `init`/`config` by default, as confirmed at init
329    // time — are fully excluded from the orb (job + command + script); a parent
330    // (`config`) cascades to its whole subtree, so no per-child entries are needed.
331    let mut subcommands = IndexMap::new();
332    for (name, is_interactive) in interactive {
333        subcommands.insert(
334            name.clone(),
335            SubcommandConfig {
336                interactive: Some(*is_interactive),
337                ..SubcommandConfig::default()
338            },
339        );
340    }
341    let subcommand = if subcommands.is_empty() {
342        None
343    } else {
344        Some(subcommands)
345    };
346    OrbConfig {
347        orb: Some(OrbSection {
348            binary: Some(binary.to_string()),
349            namespaces: Some(namespaces.to_vec()),
350            orb_dir: Some(orb_dir.to_string()),
351            base_image: None,
352            builder_image: None,
353            circleci_cli_version: None,
354            install_method: None,
355            apt_packages: None,
356            home_url: home_url.map(str::to_string),
357            source_url: source_url.map(str::to_string),
358            git_push_subcommands: if git_push_subcommands.is_empty() {
359                None
360            } else {
361                Some(git_push_subcommands.to_vec())
362            },
363            custom_files: None,
364        }),
365        ci: None, // populated by run() after gathering extras
366        orbs: None,
367        subcommand,
368        job_group: None,
369        extra_job: None,
370        record: None, // populated by run() after gathering extras
371    }
372}
373
374impl Init {
375    /// Gather the `[record]` config. Name resolution: CLI flag > existing config.
376    /// Non-interactive mode assembles from those sources (erroring if enabled but a
377    /// name is missing); interactive mode confirms the need then prompts for each
378    /// env-var name (no defaults beyond the user's own prior config).
379    fn gather_record(&self, existing: &OrbConfig) -> Result<Option<RecordConfig>> {
380        let ex = existing.record.as_ref();
381        let resolve = |cli: Option<&String>, prev: Option<&str>| -> Option<String> {
382            cli.filter(|s| !s.is_empty())
383                .cloned()
384                .or_else(|| prev.map(str::to_string))
385        };
386        let gpg_key = resolve(
387            self.record_gpg_key_env.as_ref(),
388            ex.map(|r| r.gpg_key_env.as_str()),
389        );
390        let gpg_trust = resolve(
391            self.record_gpg_trust_env.as_ref(),
392            ex.map(|r| r.gpg_trust_env.as_str()),
393        );
394        let user_name = resolve(
395            self.record_user_name_env.as_ref(),
396            ex.map(|r| r.user_name_env.as_str()),
397        );
398        let user_email = resolve(
399            self.record_user_email_env.as_ref(),
400            ex.map(|r| r.user_email_env.as_str()),
401        );
402        let sign_key = resolve(
403            self.record_signing_key_env.as_ref(),
404            ex.map(|r| r.signing_key_env.as_str()),
405        );
406        let push_fingerprint = resolve(
407            self.record_push_ssh_fingerprint.as_ref(),
408            ex.map(|r| r.push_ssh_fingerprint.as_str()),
409        );
410        let contexts: Vec<String> = if !self.record_contexts.is_empty() {
411            self.record_contexts.clone()
412        } else {
413            ex.map(|r| r.contexts.clone()).unwrap_or_default()
414        };
415
416        if is_non_interactive(self.dry_run) {
417            let enabled = self.record || ex.map(|r| r.enabled).unwrap_or(false);
418            return build_record_config(
419                enabled,
420                gpg_key.as_deref(),
421                gpg_trust.as_deref(),
422                user_name.as_deref(),
423                user_email.as_deref(),
424                sign_key.as_deref(),
425                push_fingerprint.as_deref(),
426                &contexts,
427            );
428        }
429
430        let enabled = if self.record {
431            true
432        } else {
433            dialoguer::Confirm::new()
434                .with_prompt("Enable auto-record (CI signs + pushes the regenerated orb)?")
435                .default(ex.map(|r| r.enabled).unwrap_or(false))
436                .interact()?
437        };
438        if !enabled {
439            return Ok(None);
440        }
441
442        use dialoguer::Input;
443        let prompt_name = |label: &str, current: Option<String>| -> Result<String> {
444            let mut input = Input::<String>::new().with_prompt(label);
445            if let Some(c) = current.filter(|s| !s.is_empty()) {
446                input = input.default(c);
447            }
448            Ok(input.interact_text()?)
449        };
450        let gpg_key = prompt_name("Env var name — base64 GPG private key", gpg_key)?;
451        let gpg_trust = prompt_name("Env var name — GPG ownertrust export", gpg_trust)?;
452        let user_name = prompt_name("Env var name — committer name", user_name)?;
453        let user_email = prompt_name("Env var name — committer email", user_email)?;
454        let sign_key = prompt_name("Env var name — GPG signing key id", sign_key)?;
455        // Optional: a fingerprint VALUE (public-key hash), or empty for ambient.
456        let push_fingerprint = prompt_name(
457            "SSH key fingerprint for the push job (optional; empty = ambient credentials)",
458            push_fingerprint,
459        )?;
460        let contexts_default = if contexts.is_empty() {
461            None
462        } else {
463            Some(contexts.join(","))
464        };
465        let contexts_raw = prompt_name(
466            "CircleCI context(s) supplying the GPG signing material, comma-separated",
467            contexts_default,
468        )?;
469        let contexts: Vec<String> = contexts_raw
470            .split(',')
471            .map(str::trim)
472            .filter(|s| !s.is_empty())
473            .map(str::to_string)
474            .collect();
475        build_record_config(
476            true,
477            Some(&gpg_key),
478            Some(&gpg_trust),
479            Some(&user_name),
480            Some(&user_email),
481            Some(&sign_key),
482            Some(&push_fingerprint),
483            &contexts,
484        )
485    }
486
487    pub(crate) fn gather_extras(
488        &self,
489        detected: &[String],
490        existing: &OrbConfig,
491    ) -> Result<GatheredExtras> {
492        // Resolution order: CLI flag > existing config > auto-detected / hardcoded default.
493        let existing_ci = existing.ci.as_ref();
494        let existing_orb = existing.orb.as_ref();
495        let record = self.gather_record(existing)?;
496
497        let effective_push = if !self.git_push_subcommands.is_empty() {
498            self.git_push_subcommands.clone()
499        } else {
500            existing_orb
501                .and_then(|o| o.git_push_subcommands.clone())
502                .filter(|v| !v.is_empty())
503                .unwrap_or_else(|| detected.to_vec())
504        };
505
506        if is_non_interactive(self.dry_run) {
507            return Ok(GatheredExtras {
508                home_url: self
509                    .home_url
510                    .clone()
511                    .or_else(|| existing_orb.and_then(|o| o.home_url.clone())),
512                source_url: self
513                    .source_url
514                    .clone()
515                    .or_else(|| existing_orb.and_then(|o| o.source_url.clone())),
516                git_push_subcommands: effective_push,
517                docker_context: self
518                    .docker_context
519                    .clone()
520                    .or_else(|| existing_ci.and_then(|ci| ci.docker_context.clone()))
521                    .unwrap_or_else(|| DEFAULT_DOCKER_CONTEXT.to_string()),
522                orb_context: self
523                    .orb_context
524                    .clone()
525                    .or_else(|| existing_ci.and_then(|ci| ci.orb_context.clone()))
526                    .unwrap_or_else(|| DEFAULT_ORB_CONTEXT.to_string()),
527                mcp_context: if !self.mcp_context.is_empty() {
528                    self.mcp_context.clone()
529                } else {
530                    existing_ci
531                        .and_then(|ci| ci.mcp_context.clone())
532                        .filter(|v| !v.is_empty())
533                        .unwrap_or_else(|| vec![DEFAULT_MCP_CONTEXT.to_string()])
534                },
535                mcp_earliest_version: self
536                    .mcp_earliest_version
537                    .clone()
538                    .or_else(|| existing_ci.and_then(|ci| ci.mcp_earliest_version.clone()))
539                    .unwrap_or_else(|| DEFAULT_MCP_EARLIEST_VERSION.to_string()),
540                record,
541            });
542        }
543
544        // Interactive mode — prompt only for fields not already provided via CLI flag.
545        // For un-set fields, the existing config value becomes the prompt default.
546        use dialoguer::Input;
547
548        let home_url = if let Some(v) = self.home_url.clone() {
549            Some(v).filter(|s| !s.is_empty())
550        } else {
551            let default = existing_orb
552                .and_then(|o| o.home_url.clone())
553                .unwrap_or_default();
554            let val = Input::<String>::new()
555                .with_prompt("Home URL for orb registry (Enter to skip)")
556                .default(default)
557                .allow_empty(true)
558                .interact_text()?;
559            if val.is_empty() {
560                None
561            } else {
562                Some(val)
563            }
564        };
565
566        let source_url = if let Some(v) = self.source_url.clone() {
567            Some(v).filter(|s| !s.is_empty())
568        } else {
569            let default = existing_orb
570                .and_then(|o| o.source_url.clone())
571                .unwrap_or_default();
572            let val = Input::<String>::new()
573                .with_prompt("Source URL for orb registry (Enter to skip)")
574                .default(default)
575                .allow_empty(true)
576                .interact_text()?;
577            if val.is_empty() {
578                None
579            } else {
580                Some(val)
581            }
582        };
583
584        let git_push_subcommands = if !self.git_push_subcommands.is_empty() {
585            effective_push
586        } else {
587            let cfg_push = existing_orb
588                .and_then(|o| o.git_push_subcommands.clone())
589                .unwrap_or_default();
590            let current = if !cfg_push.is_empty() {
591                cfg_push.join(",")
592            } else {
593                detected.join(",")
594            };
595            let prompt = if !detected.is_empty() && cfg_push.is_empty() {
596                format!(
597                    "Push-capable subcommands detected: {} — confirm or override (comma-separated)",
598                    detected.join(", ")
599                )
600            } else {
601                "Subcommands that push to git, comma-separated (e.g. save)".to_string()
602            };
603            let val = Input::<String>::new()
604                .with_prompt(prompt)
605                .default(current)
606                .allow_empty(true)
607                .interact_text()?;
608            val.split(',')
609                .map(str::trim)
610                .filter(|s| !s.is_empty())
611                .map(str::to_string)
612                .collect()
613        };
614
615        let docker_context = if let Some(v) = self.docker_context.clone() {
616            v
617        } else {
618            let default = existing_ci
619                .and_then(|ci| ci.docker_context.clone())
620                .unwrap_or_else(|| DEFAULT_DOCKER_CONTEXT.to_string());
621            Input::<String>::new()
622                .with_prompt("Docker context name (needs: DOCKER_LOGIN, DOCKER_PASSWORD)")
623                .default(default)
624                .interact_text()?
625        };
626
627        let orb_context = if let Some(v) = self.orb_context.clone() {
628            v
629        } else {
630            let default = existing_ci
631                .and_then(|ci| ci.orb_context.clone())
632                .unwrap_or_else(|| DEFAULT_ORB_CONTEXT.to_string());
633            Input::<String>::new()
634                .with_prompt("Orb publishing context name (needs: CIRCLECI_CLI_TOKEN)")
635                .default(default)
636                .interact_text()?
637        };
638
639        let mcp_context = if self.mcp {
640            if !self.mcp_context.is_empty() {
641                self.mcp_context.clone()
642            } else {
643                let default = existing_ci
644                    .and_then(|ci| ci.mcp_context.as_ref())
645                    .filter(|v| !v.is_empty())
646                    .map(|v| v.join(","))
647                    .unwrap_or_else(|| DEFAULT_MCP_CONTEXT.to_string());
648                let val = Input::<String>::new()
649                    .with_prompt(
650                        "MCP context names, comma-separated (needs: GITHUB_TOKEN with contents:write + bypass branch protection, BOT_GPG_KEY, BOT_TRUST, BOT_USER_NAME, BOT_USER_EMAIL, BOT_SIGN_KEY)",
651                    )
652                    .default(default)
653                    .interact_text()?;
654                val.split(',')
655                    .map(str::trim)
656                    .filter(|s| !s.is_empty())
657                    .map(str::to_string)
658                    .collect()
659            }
660        } else if !self.mcp_context.is_empty() {
661            self.mcp_context.clone()
662        } else {
663            existing_ci
664                .and_then(|ci| ci.mcp_context.clone())
665                .filter(|v| !v.is_empty())
666                .unwrap_or_else(|| vec![DEFAULT_MCP_CONTEXT.to_string()])
667        };
668
669        let mcp_earliest_version = if self.mcp {
670            if let Some(v) = self.mcp_earliest_version.clone() {
671                v
672            } else {
673                let default = existing_ci
674                    .and_then(|ci| ci.mcp_earliest_version.clone())
675                    .unwrap_or_else(|| DEFAULT_MCP_EARLIEST_VERSION.to_string());
676                Input::<String>::new()
677                    .with_prompt("Earliest orb version to include in MCP snapshots")
678                    .default(default)
679                    .interact_text()?
680            }
681        } else {
682            self.mcp_earliest_version
683                .clone()
684                .or_else(|| existing_ci.and_then(|ci| ci.mcp_earliest_version.clone()))
685                .unwrap_or_else(|| DEFAULT_MCP_EARLIEST_VERSION.to_string())
686        };
687
688        Ok(GatheredExtras {
689            home_url,
690            source_url,
691            git_push_subcommands,
692            docker_context,
693            orb_context,
694            mcp_context,
695            mcp_earliest_version,
696            record,
697        })
698    }
699
700    /// Decide which of the present default-interactive subcommands to reserve as
701    /// interactive/CLI-only (fully excluded from the orb). Interactive terminal:
702    /// prompt per subcommand, defaulting to reserved so the user confirms/overrides
703    /// in the initial scaffold. Non-interactive (CI/dry-run): reserve all (the safe
704    /// default), so the scaffold still records the choice explicitly.
705    fn resolve_interactive(&self, present: &[String]) -> Result<Vec<(String, bool)>> {
706        if is_non_interactive(self.dry_run) {
707            return Ok(present.iter().map(|n| (n.clone(), true)).collect());
708        }
709        present
710            .iter()
711            .map(|name| -> Result<(String, bool)> {
712                let reserve = dialoguer::Confirm::new()
713                    .with_prompt(format!(
714                        "Reserve `{name}` as interactive-only (CLI setup — excluded from the CI orb)?"
715                    ))
716                    .default(true)
717                    .interact()?;
718                Ok((name.clone(), reserve))
719            })
720            .collect()
721    }
722
723    pub fn run(&self) -> Result<()> {
724        // Parse binary early: detect push-capable subcommands (for dialogue default)
725        // and subcommands with a required orb_path param (for config defaults).
726        let (detected_push, detected_orb_path, present_interactive) =
727            match crate::help_parser::parse_binary(&self.binary) {
728                Ok(cli) => (
729                    detect_git_push_subcommands(&cli),
730                    detect_orb_path_subcommands(&cli),
731                    present_default_interactive(&cli),
732                ),
733                Err(_) => (vec![], vec![], vec![]),
734            };
735
736        let config_path = std::path::Path::new("gen-circleci-orb.toml");
737        let existing_config = crate::orb_config::load_config(config_path)?;
738        let extras = self.gather_extras(&detected_push, &existing_config)?;
739        let namespaces: Vec<String> = self
740            .public_orb_namespaces
741            .iter()
742            .chain(self.private_orb_namespaces.iter())
743            .cloned()
744            .collect();
745
746        // Step 1: generate orb source files
747        tracing::info!("Generating orb source into ./{}", self.orb_dir);
748        let gen = Generate {
749            binary: Some(self.binary.clone()),
750            namespaces: namespaces.clone(),
751            output: PathBuf::from("."),
752            orb_dir: Some(self.orb_dir.clone()),
753            install_method: None,
754            base_image: None,
755            home_url: extras.home_url.clone(),
756            source_url: extras.source_url.clone(),
757            git_push_subcommands: extras.git_push_subcommands.clone(),
758            circleci_cli_version: None,
759            apt_packages: vec![],
760            dry_run: self.dry_run,
761            config: None,
762            // init is a local bootstrap, not a CI run — never auto-record/push.
763            no_record: true,
764            // init writes the orb for real; verify-only check mode is off.
765            check: false,
766        };
767        gen.run()?;
768
769        // Step 2: patch CI configs
770        let opts = ci_patcher::PatchOpts {
771            binary: self.binary.clone(),
772            // Advanced knob — not gathered at init; set `[orb] rust_image` in the
773            // toml when the workspace needs a clang-equipped build image.
774            rust_image: String::new(),
775            namespaces,
776            docker_namespace: self.docker_namespace.clone(),
777            orb_dir: self.orb_dir.clone(),
778            build_workflow: self.build_workflow.clone(),
779            release_workflow: self.release_workflow.clone(),
780            requires_job: self.requires_job.clone(),
781            crate_tag_prefix: self.crate_tag_prefix.clone(),
782            release_after_job: self.release_after_job.clone(),
783            orb_tools_version: self.orb_tools_version.clone(),
784            docker_orb_version: self.docker_orb_version.clone(),
785            docker_context: extras.docker_context.clone(),
786            orb_context: extras.orb_context.clone(),
787            private_namespaces: self.private_orb_namespaces.clone(),
788            gen_circleci_orb_version: self.gen_circleci_orb_version.clone(),
789            mcp: self.mcp,
790            mcp_earliest_version: extras.mcp_earliest_version.clone(),
791            mcp_context: extras.mcp_context.clone(),
792            gen_orb_mcp_orb_version: DEFAULT_GEN_ORB_MCP_ORB_VERSION.to_string(),
793            record_contexts: extras
794                .record
795                .as_ref()
796                .map(|r| r.contexts.clone())
797                .unwrap_or_default(),
798            record_push_ssh_fingerprint: extras
799                .record
800                .as_ref()
801                .map(|r| r.push_ssh_fingerprint.clone())
802                .unwrap_or_default(),
803        };
804
805        let summary = ci_patcher::apply_patches(&self.ci_dir, &opts, self.dry_run)?;
806        for line in &summary {
807            println!("{line}");
808        }
809
810        // Step 3: write bootstrap gen-circleci-orb.toml
811        let config_path = std::path::Path::new("gen-circleci-orb.toml");
812        let interactive = self.resolve_interactive(&present_interactive)?;
813        let mut bootstrap = build_bootstrap_config(
814            &self.binary,
815            opts.namespaces.as_slice(),
816            &self.orb_dir,
817            extras.home_url.as_deref(),
818            extras.source_url.as_deref(),
819            &extras.git_push_subcommands,
820            &interactive,
821        );
822        populate_orb_path_defaults(&mut bootstrap, &detected_orb_path);
823        bootstrap.ci = Some(CiSection {
824            build_workflow: Some(self.build_workflow.clone()),
825            release_workflow: Some(self.release_workflow.clone()),
826            requires_job: self.requires_job.clone(),
827            release_after_job: Some(self.release_after_job.clone()),
828            crate_tag_prefix: Some(self.crate_tag_prefix.clone()),
829            docker_namespace: Some(self.docker_namespace.clone()),
830            docker_context: Some(extras.docker_context.clone()),
831            orb_context: Some(extras.orb_context.clone()),
832            mcp: Some(self.mcp),
833            mcp_context: Some(extras.mcp_context.clone()),
834            mcp_earliest_version: Some(extras.mcp_earliest_version.clone()),
835            // Left unset so the pin tracks the generator default (like the
836            // gen-circleci-orb pin); set it in the toml only to override.
837            gen_orb_mcp_orb_version: None,
838            // Advanced knob — not gathered at init; set `[ci] rust_image` in the
839            // toml when the workspace needs a clang-equipped build image.
840            rust_image: None,
841        });
842        bootstrap.record = extras.record.clone();
843        if self.dry_run {
844            let content = toml::to_string_pretty(&bootstrap)?;
845            println!("(dry-run) Would write {}", config_path.display());
846            println!("{content}");
847            println!("(dry-run: no files written)");
848        } else {
849            crate::orb_config::save_config(config_path, &bootstrap)?;
850            println!("Wrote {}", config_path.display());
851            println!("Done.");
852        }
853
854        Ok(())
855    }
856}
857
858#[cfg(test)]
859mod tests {
860    use super::*;
861
862    #[test]
863    fn default_docker_orb_version_matches_registry() {
864        // The CircleCI registry has circleci/docker@3.0.1 as latest.
865        // 3.2.0 does not exist and causes "Cannot find circleci/docker@3.2.0" errors.
866        assert_eq!(
867            DEFAULT_DOCKER_ORB_VERSION, "3.0.1",
868            "DEFAULT_DOCKER_ORB_VERSION must be the registry-available version"
869        );
870    }
871
872    // ── Phase 6: bootstrap config written by init ───────────────────────────
873
874    #[test]
875    fn bootstrap_config_has_orb_section_with_binary() {
876        let config = build_bootstrap_config(
877            "mytool",
878            &["my-org".to_string()],
879            "orb",
880            None,
881            None,
882            &[],
883            &[],
884        );
885        assert!(
886            config.orb.is_some(),
887            "bootstrap config must have [orb] section"
888        );
889        assert_eq!(
890            config.orb.as_ref().unwrap().binary.as_deref(),
891            Some("mytool")
892        );
893    }
894
895    #[test]
896    fn bootstrap_config_has_namespaces() {
897        let config = build_bootstrap_config(
898            "mytool",
899            &["ns1".to_string(), "ns2".to_string()],
900            "orb",
901            None,
902            None,
903            &[],
904            &[],
905        );
906        assert_eq!(
907            config.orb.as_ref().unwrap().namespaces.as_deref(),
908            Some(&["ns1".to_string(), "ns2".to_string()][..])
909        );
910    }
911
912    #[test]
913    fn bootstrap_config_scaffolds_interactive_decisions() {
914        // The bootstrap scaffolds the init-time interactive decisions explicitly
915        // (init reserved, config opted in) and does NOT emit a help entry — help is
916        // reserved at the --help parser.
917        let config = build_bootstrap_config(
918            "mytool",
919            &["my-org".to_string()],
920            "orb",
921            None,
922            None,
923            &[],
924            &[("init".to_string(), true), ("config".to_string(), false)],
925        );
926        let subcommands = config
927            .subcommand
928            .as_ref()
929            .expect("subcommand section missing");
930        assert_eq!(subcommands.get("init").unwrap().interactive, Some(true));
931        assert_eq!(subcommands.get("config").unwrap().interactive, Some(false));
932        assert!(
933            !subcommands.contains_key("help"),
934            "help is parser-reserved, not scaffolded into the toml"
935        );
936    }
937
938    #[test]
939    fn bootstrap_config_has_no_subcommand_section_without_interactive() {
940        let config = build_bootstrap_config(
941            "mytool",
942            &["my-org".to_string()],
943            "orb",
944            None,
945            None,
946            &[],
947            &[],
948        );
949        assert!(
950            config.subcommand.is_none(),
951            "no interactive decisions → no [subcommand] section"
952        );
953    }
954
955    #[test]
956    fn present_default_interactive_returns_only_present_defaults() {
957        use crate::help_parser::types::{CliDefinition, SubCommand};
958        let sub = |name: &str| SubCommand {
959            name: name.to_string(),
960            description: String::new(),
961            is_leaf: true,
962            parameters: vec![],
963            subcommands: vec![],
964        };
965        // `init` is a default-interactive name present here; `run` is not; `config`
966        // is absent → only `init` is returned (the prompt fires only for present names).
967        let cli = CliDefinition {
968            binary_name: "mytool".to_string(),
969            description: String::new(),
970            subcommands: vec![sub("init"), sub("run")],
971        };
972        assert_eq!(present_default_interactive(&cli), vec!["init".to_string()]);
973    }
974
975    #[test]
976    fn init_has_git_push_subcommands_field() {
977        // Init must expose --git-push-subcommands so the caller can name subcommands
978        // (e.g. "save") that need a set_https_remote step in their generated job.
979        let init = Init {
980            binary: "mytool".to_string(),
981            public_orb_namespaces: vec!["my-org".to_string()],
982            private_orb_namespaces: vec![],
983            build_workflow: "validation".to_string(),
984            release_workflow: "orb-release".to_string(),
985            requires_job: None,
986            crate_tag_prefix: "mytool-v".to_string(),
987            release_after_job: "publish-orb".to_string(),
988            orb_dir: "orb".to_string(),
989            ci_dir: std::path::PathBuf::from(".circleci"),
990            orb_tools_version: "12.3.3".to_string(),
991            docker_orb_version: "3.0.1".to_string(),
992            docker_namespace: "my-docker-ns".to_string(),
993            docker_context: None,
994            orb_context: None,
995            gen_circleci_orb_version: "0.0.1".to_string(),
996            mcp: false,
997            mcp_earliest_version: None,
998            mcp_context: vec![],
999            dry_run: false,
1000            git_push_subcommands: vec!["save".to_string()],
1001            home_url: None,
1002            source_url: None,
1003            record: false,
1004            record_gpg_key_env: None,
1005            record_gpg_trust_env: None,
1006            record_user_name_env: None,
1007            record_user_email_env: None,
1008            record_signing_key_env: None,
1009            record_push_ssh_fingerprint: None,
1010            record_contexts: vec![],
1011        };
1012        assert_eq!(
1013            init.git_push_subcommands,
1014            vec!["save".to_string()],
1015            "Init must hold git_push_subcommands and pass it through to Generate"
1016        );
1017    }
1018
1019    #[test]
1020    fn bootstrap_config_includes_git_push_subcommands() {
1021        let config = build_bootstrap_config(
1022            "mytool",
1023            &["my-org".to_string()],
1024            "orb",
1025            None,
1026            None,
1027            &["save".to_string()],
1028            &[],
1029        );
1030        assert_eq!(
1031            config.orb.as_ref().unwrap().git_push_subcommands.as_deref(),
1032            Some(&["save".to_string()][..])
1033        );
1034    }
1035
1036    #[test]
1037    fn bootstrap_config_git_push_subcommands_none_when_empty() {
1038        let config = build_bootstrap_config(
1039            "mytool",
1040            &["my-org".to_string()],
1041            "orb",
1042            None,
1043            None,
1044            &[],
1045            &[],
1046        );
1047        assert_eq!(
1048            config.orb.as_ref().unwrap().git_push_subcommands,
1049            None,
1050            "empty slice must produce None (not an empty list) to keep the TOML clean"
1051        );
1052    }
1053
1054    #[test]
1055    fn init_run_writes_ci_section_to_config() {
1056        let init = make_init(true);
1057        let extras = init.gather_extras(&[], &OrbConfig::default()).unwrap();
1058        let ci = CiSection {
1059            build_workflow: Some(init.build_workflow.clone()),
1060            release_workflow: Some(init.release_workflow.clone()),
1061            requires_job: init.requires_job.clone(),
1062            release_after_job: Some(init.release_after_job.clone()),
1063            crate_tag_prefix: Some(init.crate_tag_prefix.clone()),
1064            docker_namespace: Some(init.docker_namespace.clone()),
1065            docker_context: Some(extras.docker_context.clone()),
1066            orb_context: Some(extras.orb_context.clone()),
1067            mcp: Some(init.mcp),
1068            mcp_context: Some(extras.mcp_context.clone()),
1069            mcp_earliest_version: Some(extras.mcp_earliest_version.clone()),
1070            gen_orb_mcp_orb_version: None,
1071            rust_image: None,
1072        };
1073        assert_eq!(ci.build_workflow.as_deref(), Some("validation"));
1074        assert_eq!(ci.docker_context.as_deref(), Some(DEFAULT_DOCKER_CONTEXT));
1075        assert_eq!(ci.mcp, Some(false));
1076    }
1077
1078    #[test]
1079    fn bootstrap_config_includes_home_and_source_url() {
1080        let config = build_bootstrap_config(
1081            "mytool",
1082            &["my-org".to_string()],
1083            "orb",
1084            Some("https://example.com/home"),
1085            Some("https://example.com/source"),
1086            &[],
1087            &[],
1088        );
1089        assert_eq!(
1090            config.orb.as_ref().unwrap().home_url.as_deref(),
1091            Some("https://example.com/home")
1092        );
1093        assert_eq!(
1094            config.orb.as_ref().unwrap().source_url.as_deref(),
1095            Some("https://example.com/source")
1096        );
1097    }
1098
1099    // ── detect_orb_path_subcommands + populate_orb_path_defaults ───────────
1100
1101    fn make_cli_with_orb_path(
1102        sub_name: &str,
1103        required: bool,
1104    ) -> crate::help_parser::types::CliDefinition {
1105        use crate::help_parser::types::{CliDefinition, ParamType, Parameter, SubCommand};
1106        let p = Parameter {
1107            long_name: "orb_path".to_string(),
1108            short: Some('p'),
1109            param_type: ParamType::String,
1110            default: None,
1111            required,
1112            description: "Path to orb YAML".to_string(),
1113        };
1114        let sub = SubCommand {
1115            name: sub_name.to_string(),
1116            description: String::new(),
1117            is_leaf: true,
1118            parameters: vec![p],
1119            subcommands: vec![],
1120        };
1121        CliDefinition {
1122            binary_name: "mytool".to_string(),
1123            description: "My tool".to_string(),
1124            subcommands: vec![sub],
1125        }
1126    }
1127
1128    #[test]
1129    fn detect_required_orb_path_subcommand() {
1130        let cli = make_cli_with_orb_path("generate", true);
1131        let detected = detect_orb_path_subcommands(&cli);
1132        assert_eq!(detected, vec!["generate".to_string()]);
1133    }
1134
1135    #[test]
1136    fn optional_orb_path_not_detected() {
1137        let cli = make_cli_with_orb_path("generate", false);
1138        let detected = detect_orb_path_subcommands(&cli);
1139        assert!(
1140            detected.is_empty(),
1141            "optional orb_path must not trigger default injection"
1142        );
1143    }
1144
1145    #[test]
1146    fn populate_orb_path_defaults_adds_subcommand_entries() {
1147        let mut config = build_bootstrap_config(
1148            "mytool",
1149            &["my-org".to_string()],
1150            "orb",
1151            None,
1152            None,
1153            &[],
1154            &[],
1155        );
1156        populate_orb_path_defaults(
1157            &mut config,
1158            &["generate".to_string(), "validate".to_string()],
1159        );
1160        let subcommands = config.subcommand.as_ref().unwrap();
1161        let gen_params = subcommands.get("generate").unwrap().param.as_ref().unwrap();
1162        assert_eq!(
1163            gen_params.get("orb_path").unwrap().default.as_deref(),
1164            Some("src/@orb.yml")
1165        );
1166        let val_params = subcommands.get("validate").unwrap().param.as_ref().unwrap();
1167        assert_eq!(
1168            val_params.get("orb_path").unwrap().default.as_deref(),
1169            Some("src/@orb.yml")
1170        );
1171    }
1172
1173    #[test]
1174    fn populate_orb_path_defaults_preserves_existing_subcommand_entries() {
1175        // An existing interactive entry (init reserved) must not be disturbed when
1176        // populate adds orb_path param defaults for another subcommand.
1177        let mut config = build_bootstrap_config(
1178            "mytool",
1179            &["my-org".to_string()],
1180            "orb",
1181            None,
1182            None,
1183            &[],
1184            &[("init".to_string(), true)],
1185        );
1186        populate_orb_path_defaults(&mut config, &["generate".to_string()]);
1187        let subcommands = config.subcommand.as_ref().unwrap();
1188        assert_eq!(subcommands.get("init").unwrap().interactive, Some(true));
1189    }
1190
1191    #[test]
1192    fn populate_orb_path_defaults_noop_when_empty() {
1193        let mut config = build_bootstrap_config(
1194            "mytool",
1195            &["my-org".to_string()],
1196            "orb",
1197            None,
1198            None,
1199            &[],
1200            &[],
1201        );
1202        let before = config.subcommand.clone();
1203        populate_orb_path_defaults(&mut config, &[]);
1204        assert_eq!(
1205            config.subcommand, before,
1206            "no change when no subcommands detected"
1207        );
1208    }
1209
1210    // ── detect_git_push_subcommands ─────────────────────────────────────────
1211
1212    #[test]
1213    fn detect_push_subcommand_with_push_param() {
1214        use crate::help_parser::types::{CliDefinition, ParamType, Parameter, SubCommand};
1215        let push_param = Parameter {
1216            long_name: "push".to_string(),
1217            short: None,
1218            param_type: ParamType::Enum(vec!["true".to_string(), "false".to_string()]),
1219            default: Some("true".to_string()),
1220            required: false,
1221            description: "Push after committing".to_string(),
1222        };
1223        let sub = SubCommand {
1224            name: "save".to_string(),
1225            description: "Save artifacts".to_string(),
1226            is_leaf: true,
1227            parameters: vec![push_param],
1228            subcommands: vec![],
1229        };
1230        let cli = CliDefinition {
1231            binary_name: "mytool".to_string(),
1232            description: "My tool".to_string(),
1233            subcommands: vec![sub],
1234        };
1235        let detected = detect_git_push_subcommands(&cli);
1236        assert_eq!(detected, vec!["save".to_string()]);
1237    }
1238
1239    #[test]
1240    fn detect_push_subcommand_with_sign_param() {
1241        use crate::help_parser::types::{CliDefinition, ParamType, Parameter, SubCommand};
1242        let sign_param = Parameter {
1243            long_name: "sign".to_string(),
1244            short: None,
1245            param_type: ParamType::Boolean,
1246            default: None,
1247            required: false,
1248            description: "GPG sign".to_string(),
1249        };
1250        let sub = SubCommand {
1251            name: "commit".to_string(),
1252            description: "Commit".to_string(),
1253            is_leaf: true,
1254            parameters: vec![sign_param],
1255            subcommands: vec![],
1256        };
1257        let cli = CliDefinition {
1258            binary_name: "mytool".to_string(),
1259            description: "My tool".to_string(),
1260            subcommands: vec![sub],
1261        };
1262        let detected = detect_git_push_subcommands(&cli);
1263        assert_eq!(detected, vec!["commit".to_string()]);
1264    }
1265
1266    #[test]
1267    fn non_push_subcommand_not_detected() {
1268        use crate::help_parser::types::{CliDefinition, ParamType, Parameter, SubCommand};
1269        let other_param = Parameter {
1270            long_name: "output".to_string(),
1271            short: None,
1272            param_type: ParamType::String,
1273            default: Some("./dist".to_string()),
1274            required: false,
1275            description: "Output dir".to_string(),
1276        };
1277        let sub = SubCommand {
1278            name: "generate".to_string(),
1279            description: "Generate".to_string(),
1280            is_leaf: true,
1281            parameters: vec![other_param],
1282            subcommands: vec![],
1283        };
1284        let cli = CliDefinition {
1285            binary_name: "mytool".to_string(),
1286            description: "My tool".to_string(),
1287            subcommands: vec![sub],
1288        };
1289        let detected = detect_git_push_subcommands(&cli);
1290        assert!(detected.is_empty());
1291    }
1292
1293    #[test]
1294    fn gather_extras_uses_detected_when_cli_empty() {
1295        let init = make_init(true); // dry_run = true → non-interactive
1296        let extras = init
1297            .gather_extras(&["save".to_string()], &OrbConfig::default())
1298            .unwrap();
1299        assert_eq!(
1300            extras.git_push_subcommands,
1301            vec!["save".to_string()],
1302            "detected candidates must be used when --git-push-subcommands not set"
1303        );
1304    }
1305
1306    #[test]
1307    fn gather_extras_cli_overrides_detected() {
1308        let init = Init {
1309            git_push_subcommands: vec!["custom".to_string()],
1310            dry_run: true,
1311            ..make_init(true)
1312        };
1313        let extras = init
1314            .gather_extras(&["save".to_string()], &OrbConfig::default())
1315            .unwrap();
1316        assert_eq!(
1317            extras.git_push_subcommands,
1318            vec!["custom".to_string()],
1319            "explicit CLI value must override detected candidates"
1320        );
1321    }
1322
1323    // ── gather_extras / dialogue ────────────────────────────────────────────
1324
1325    fn make_init(dry_run: bool) -> Init {
1326        Init {
1327            binary: "mytool".to_string(),
1328            public_orb_namespaces: vec!["my-org".to_string()],
1329            private_orb_namespaces: vec![],
1330            build_workflow: "validation".to_string(),
1331            release_workflow: "orb-release".to_string(),
1332            requires_job: None,
1333            crate_tag_prefix: "mytool-v".to_string(),
1334            release_after_job: "publish-orb".to_string(),
1335            orb_dir: "orb".to_string(),
1336            ci_dir: std::path::PathBuf::from(".circleci"),
1337            orb_tools_version: "12.3.3".to_string(),
1338            docker_orb_version: "3.0.1".to_string(),
1339            docker_namespace: "my-docker-ns".to_string(),
1340            docker_context: None,
1341            orb_context: None,
1342            gen_circleci_orb_version: "0.0.1".to_string(),
1343            mcp: false,
1344            mcp_earliest_version: None,
1345            mcp_context: vec![],
1346            dry_run,
1347            git_push_subcommands: vec![],
1348            home_url: None,
1349            source_url: None,
1350            record: false,
1351            record_gpg_key_env: None,
1352            record_gpg_trust_env: None,
1353            record_user_name_env: None,
1354            record_user_email_env: None,
1355            record_signing_key_env: None,
1356            record_push_ssh_fingerprint: None,
1357            record_contexts: vec![],
1358        }
1359    }
1360
1361    // ── build_record_config ─────────────────────────────────────────────────
1362
1363    #[test]
1364    fn build_record_config_disabled_returns_none() {
1365        let rec = build_record_config(false, None, None, None, None, None, None, &[])
1366            .expect("disabled is ok");
1367        assert!(rec.is_none(), "disabled must yield no [record] section");
1368    }
1369
1370    #[test]
1371    fn build_record_config_collects_names_and_contexts() {
1372        let rec = build_record_config(
1373            true,
1374            Some("G_KEY"),
1375            Some("G_TRUST"),
1376            Some("G_NAME"),
1377            Some("G_EMAIL"),
1378            Some("G_SIGN"),
1379            None,
1380            &["release".to_string()],
1381        )
1382        .expect("all values present")
1383        .expect("enabled yields Some");
1384        assert!(rec.enabled);
1385        assert_eq!(rec.gpg_key_env, "G_KEY");
1386        assert_eq!(rec.signing_key_env, "G_SIGN");
1387        assert_eq!(rec.contexts, vec!["release"]);
1388    }
1389
1390    #[test]
1391    fn build_record_config_errors_when_enabled_without_name() {
1392        let err = build_record_config(
1393            true,
1394            None, // missing gpg key env name
1395            Some("G_TRUST"),
1396            Some("G_NAME"),
1397            Some("G_EMAIL"),
1398            Some("G_SIGN"),
1399            None,
1400            &["release".to_string()],
1401        )
1402        .unwrap_err()
1403        .to_string();
1404        assert!(err.contains("--record-gpg-key-env"), "unexpected: {err}");
1405    }
1406
1407    #[test]
1408    fn build_record_config_errors_when_enabled_without_context() {
1409        let err = build_record_config(
1410            true,
1411            Some("G_KEY"),
1412            Some("G_TRUST"),
1413            Some("G_NAME"),
1414            Some("G_EMAIL"),
1415            Some("G_SIGN"),
1416            None,
1417            &[], // no context supplied
1418        )
1419        .unwrap_err()
1420        .to_string();
1421        assert!(err.contains("--record-context"), "unexpected: {err}");
1422    }
1423
1424    #[test]
1425    fn gather_extras_non_interactive_uses_hardcoded_defaults() {
1426        let init = make_init(true); // dry_run=true → non-interactive
1427        let extras = init.gather_extras(&[], &OrbConfig::default()).unwrap();
1428        assert_eq!(extras.docker_context, DEFAULT_DOCKER_CONTEXT);
1429        assert_eq!(extras.orb_context, DEFAULT_ORB_CONTEXT);
1430        assert_eq!(extras.mcp_context, vec![DEFAULT_MCP_CONTEXT.to_string()]);
1431        assert_eq!(extras.mcp_earliest_version, DEFAULT_MCP_EARLIEST_VERSION);
1432        assert_eq!(extras.home_url, None);
1433        assert_eq!(extras.source_url, None);
1434        assert!(extras.git_push_subcommands.is_empty());
1435    }
1436
1437    #[test]
1438    fn gather_extras_cli_values_take_precedence_over_defaults() {
1439        let init = Init {
1440            docker_context: Some("my-docker".to_string()),
1441            orb_context: Some("my-orb-ctx".to_string()),
1442            mcp_context: vec!["my-mcp-ctx".to_string()],
1443            mcp_earliest_version: Some("1.2.3".to_string()),
1444            home_url: Some("https://example.com".to_string()),
1445            source_url: Some("https://src.example.com".to_string()),
1446            git_push_subcommands: vec!["save".to_string()],
1447            dry_run: true,
1448            ..make_init(true)
1449        };
1450        let extras = init.gather_extras(&[], &OrbConfig::default()).unwrap();
1451        assert_eq!(extras.docker_context, "my-docker");
1452        assert_eq!(extras.orb_context, "my-orb-ctx");
1453        assert_eq!(extras.mcp_context, vec!["my-mcp-ctx".to_string()]);
1454        assert_eq!(extras.mcp_earliest_version, "1.2.3");
1455        assert_eq!(extras.home_url.as_deref(), Some("https://example.com"));
1456        assert_eq!(
1457            extras.source_url.as_deref(),
1458            Some("https://src.example.com")
1459        );
1460        assert_eq!(extras.git_push_subcommands, vec!["save"]);
1461    }
1462
1463    #[test]
1464    fn gather_extras_ci_env_var_is_non_interactive() {
1465        // When $CI is set the dialogue must be skipped even without --dry-run
1466        std::env::set_var("CI", "true");
1467        let init = make_init(false);
1468        let extras = init.gather_extras(&[], &OrbConfig::default()).unwrap();
1469        std::env::remove_var("CI");
1470        assert_eq!(extras.docker_context, DEFAULT_DOCKER_CONTEXT);
1471    }
1472
1473    // ── gather_extras: skip prompts when field is explicitly set ───────────
1474
1475    #[test]
1476    fn gather_extras_skips_docker_context_prompt_when_set() {
1477        let init = Init {
1478            docker_context: Some("explicit-docker".to_string()),
1479            dry_run: true,
1480            ..make_init(true)
1481        };
1482        let extras = init.gather_extras(&[], &OrbConfig::default()).unwrap();
1483        assert_eq!(extras.docker_context, "explicit-docker");
1484    }
1485
1486    #[test]
1487    fn gather_extras_skips_orb_context_prompt_when_set() {
1488        let init = Init {
1489            orb_context: Some("explicit-orb".to_string()),
1490            dry_run: true,
1491            ..make_init(true)
1492        };
1493        let extras = init.gather_extras(&[], &OrbConfig::default()).unwrap();
1494        assert_eq!(extras.orb_context, "explicit-orb");
1495    }
1496
1497    #[test]
1498    fn gather_extras_skips_mcp_context_prompt_when_set() {
1499        let init = Init {
1500            mcp: true,
1501            mcp_context: vec!["ctx-a".to_string(), "ctx-b".to_string()],
1502            dry_run: true,
1503            ..make_init(true)
1504        };
1505        let extras = init.gather_extras(&[], &OrbConfig::default()).unwrap();
1506        assert_eq!(extras.mcp_context, vec!["ctx-a", "ctx-b"]);
1507    }
1508
1509    #[test]
1510    fn gather_extras_skips_mcp_earliest_version_prompt_when_set() {
1511        let init = Init {
1512            mcp: true,
1513            mcp_earliest_version: Some("3.0.0".to_string()),
1514            dry_run: true,
1515            ..make_init(true)
1516        };
1517        let extras = init.gather_extras(&[], &OrbConfig::default()).unwrap();
1518        assert_eq!(extras.mcp_earliest_version, "3.0.0");
1519    }
1520
1521    #[test]
1522    fn gather_extras_skips_git_push_subcommands_prompt_when_set() {
1523        let init = Init {
1524            git_push_subcommands: vec!["deploy".to_string()],
1525            dry_run: true,
1526            ..make_init(true)
1527        };
1528        // detected list is different — CLI must win without prompting
1529        let extras = init
1530            .gather_extras(&["save".to_string()], &OrbConfig::default())
1531            .unwrap();
1532        assert_eq!(extras.git_push_subcommands, vec!["deploy"]);
1533    }
1534
1535    #[test]
1536    fn gather_extras_skips_home_url_prompt_when_set() {
1537        let init = Init {
1538            home_url: Some("https://example.com/home".to_string()),
1539            dry_run: true,
1540            ..make_init(true)
1541        };
1542        let extras = init.gather_extras(&[], &OrbConfig::default()).unwrap();
1543        assert_eq!(extras.home_url.as_deref(), Some("https://example.com/home"));
1544    }
1545
1546    #[test]
1547    fn gather_extras_skips_source_url_prompt_when_set() {
1548        let init = Init {
1549            source_url: Some("https://example.com/src".to_string()),
1550            dry_run: true,
1551            ..make_init(true)
1552        };
1553        let extras = init.gather_extras(&[], &OrbConfig::default()).unwrap();
1554        assert_eq!(
1555            extras.source_url.as_deref(),
1556            Some("https://example.com/src")
1557        );
1558    }
1559
1560    // ── gather_extras: existing config as fallback ─────────────────────────
1561
1562    fn make_existing_config() -> OrbConfig {
1563        use crate::orb_config::CiSection;
1564        OrbConfig {
1565            orb: Some(OrbSection {
1566                home_url: Some("https://existing-home.example.com".to_string()),
1567                source_url: Some("https://existing-src.example.com".to_string()),
1568                git_push_subcommands: Some(vec!["existing-push".to_string()]),
1569                ..OrbSection::default()
1570            }),
1571            ci: Some(CiSection {
1572                docker_context: Some("existing-docker".to_string()),
1573                orb_context: Some("existing-orb".to_string()),
1574                mcp_context: Some(vec!["existing-mcp".to_string()]),
1575                mcp_earliest_version: Some("9.9.9".to_string()),
1576                ..CiSection::default()
1577            }),
1578            ..OrbConfig::default()
1579        }
1580    }
1581
1582    #[test]
1583    fn gather_extras_falls_back_to_existing_docker_context() {
1584        let init = make_init(true); // dry_run → non-interactive
1585        let extras = init.gather_extras(&[], &make_existing_config()).unwrap();
1586        assert_eq!(
1587            extras.docker_context, "existing-docker",
1588            "should use [ci].docker_context from existing config when CLI flag not set"
1589        );
1590    }
1591
1592    #[test]
1593    fn gather_extras_falls_back_to_existing_orb_context() {
1594        let init = make_init(true);
1595        let extras = init.gather_extras(&[], &make_existing_config()).unwrap();
1596        assert_eq!(extras.orb_context, "existing-orb");
1597    }
1598
1599    #[test]
1600    fn gather_extras_falls_back_to_existing_mcp_context() {
1601        let init = Init {
1602            mcp: true,
1603            dry_run: true,
1604            ..make_init(true)
1605        };
1606        let extras = init.gather_extras(&[], &make_existing_config()).unwrap();
1607        assert_eq!(extras.mcp_context, vec!["existing-mcp"]);
1608    }
1609
1610    #[test]
1611    fn gather_extras_falls_back_to_existing_mcp_earliest_version() {
1612        let init = make_init(true);
1613        let extras = init.gather_extras(&[], &make_existing_config()).unwrap();
1614        assert_eq!(extras.mcp_earliest_version, "9.9.9");
1615    }
1616
1617    #[test]
1618    fn gather_extras_falls_back_to_existing_home_url() {
1619        let init = make_init(true);
1620        let extras = init.gather_extras(&[], &make_existing_config()).unwrap();
1621        assert_eq!(
1622            extras.home_url.as_deref(),
1623            Some("https://existing-home.example.com")
1624        );
1625    }
1626
1627    #[test]
1628    fn gather_extras_falls_back_to_existing_source_url() {
1629        let init = make_init(true);
1630        let extras = init.gather_extras(&[], &make_existing_config()).unwrap();
1631        assert_eq!(
1632            extras.source_url.as_deref(),
1633            Some("https://existing-src.example.com")
1634        );
1635    }
1636
1637    #[test]
1638    fn gather_extras_falls_back_to_existing_git_push_subcommands() {
1639        let init = make_init(true);
1640        // No CLI flag, no detected — should fall back to existing config
1641        let extras = init.gather_extras(&[], &make_existing_config()).unwrap();
1642        assert_eq!(extras.git_push_subcommands, vec!["existing-push"]);
1643    }
1644
1645    #[test]
1646    fn gather_extras_cli_takes_precedence_over_existing_config() {
1647        let init = Init {
1648            docker_context: Some("cli-docker".to_string()),
1649            orb_context: Some("cli-orb".to_string()),
1650            dry_run: true,
1651            ..make_init(true)
1652        };
1653        let extras = init.gather_extras(&[], &make_existing_config()).unwrap();
1654        assert_eq!(extras.docker_context, "cli-docker");
1655        assert_eq!(extras.orb_context, "cli-orb");
1656    }
1657
1658    #[test]
1659    fn gather_extras_detected_used_when_neither_cli_nor_config_has_push_subcommands() {
1660        let init = make_init(true);
1661        let existing = OrbConfig::default(); // no git_push_subcommands in config
1662        let extras = init
1663            .gather_extras(&["detected-push".to_string()], &existing)
1664            .unwrap();
1665        assert_eq!(extras.git_push_subcommands, vec!["detected-push"]);
1666    }
1667
1668    #[test]
1669    fn is_non_interactive_reflects_tty_state() {
1670        // Verify that is_non_interactive() correctly responds to the TTY state
1671        // of the current process. CI environments may allocate a PTY; local
1672        // subprocess runs (e.g. cargo test piped) do not.
1673        let ci_was = std::env::var("CI").ok();
1674        std::env::remove_var("CI");
1675        let is_tty = console::Term::stderr().is_term();
1676        let result = is_non_interactive(false);
1677        if let Some(val) = ci_was {
1678            std::env::set_var("CI", val);
1679        }
1680        if is_tty {
1681            assert!(
1682                !result,
1683                "is_non_interactive must be false when stderr IS a terminal \
1684                 (and neither dry_run nor $CI is set)"
1685            );
1686        } else {
1687            assert!(
1688                result,
1689                "is_non_interactive must be true when stderr is NOT a terminal"
1690            );
1691        }
1692    }
1693
1694    #[test]
1695    fn init_docker_context_field_is_option() {
1696        // Compile-time guard: field must be Option<String> so we can distinguish
1697        // "explicitly set" from "not set (will prompt or use default)".
1698        let init = make_init(true);
1699        let _: Option<String> = init.docker_context;
1700    }
1701
1702    #[test]
1703    fn bootstrap_config_has_orb_dir() {
1704        let config = build_bootstrap_config(
1705            "mytool",
1706            &["my-org".to_string()],
1707            "custom-orb",
1708            None,
1709            None,
1710            &[],
1711            &[],
1712        );
1713        assert_eq!(
1714            config.orb.as_ref().unwrap().orb_dir.as_deref(),
1715            Some("custom-orb")
1716        );
1717    }
1718}